Merge branch 'feature/ingredient-api' into 'main'

Ingredient controller and tests

See merge request cse1105/2025-2026/teams/csep-team-76!17
This commit is contained in:
Natalia Cholewa 2025-12-06 14:16:17 +01:00
commit f9d4901b6c
13 changed files with 507 additions and 10 deletions

View file

@ -33,10 +33,7 @@ public class Ingredient {
@Column(name = "carbs", nullable = false) @Column(name = "carbs", nullable = false)
public double carbsPer100g; public double carbsPer100g;
@SuppressWarnings("unused") public Ingredient() {}
protected Ingredient() {
// for object mapper says sebastian
}
public Ingredient(String name, public Ingredient(String name,
double proteinPer100g, double proteinPer100g,

View file

@ -2,4 +2,5 @@ package commons.ws;
public class Topics { public class Topics {
public static final String RECIPES = "/subscribe/recipe"; public static final String RECIPES = "/subscribe/recipe";
public static final String INGREDIENTS = "/subscribe/ingredient";
} }

View file

@ -0,0 +1,30 @@
package commons.ws.messages;
import commons.Ingredient;
/**
* Message sent when a new ingredient is created.
*
* @see commons.ws.messages.Message.Type#INGREDIENT_CREATE
*/
public class CreateIngredientMessage implements Message {
private Ingredient ingredient;
public CreateIngredientMessage(Ingredient ingredient) {
this.ingredient = ingredient;
}
@Override
public Type getType() {
return Type.INGREDIENT_CREATE;
}
/**
* Get the created ingredient.
*
* @return The created ingredient.
*/
public Ingredient getIngredient() {
return ingredient;
}
}

View file

@ -0,0 +1,28 @@
package commons.ws.messages;
/**
* Message sent when an ingredient is deleted.
*
* @see commons.ws.messages.Message.Type#INGREDIENT_DELETE
*/
public class DeleteIngredientMessage implements Message {
private Long ingredientId;
public DeleteIngredientMessage(Long ingredientId) {
this.ingredientId = ingredientId;
}
@Override
public Type getType() {
return Type.INGREDIENT_DELETE;
}
/**
* Get the deleted ingredient's id.
*
* @return The deleted ingredient's id.
*/
public Long getIngredientId() {
return ingredientId;
}
}

View file

@ -21,7 +21,28 @@ public interface Message {
* *
* @see commons.ws.messages.DeleteRecipeMessage * @see commons.ws.messages.DeleteRecipeMessage
*/ */
RECIPE_DELETE RECIPE_DELETE,
/**
* Message sent when a new ingredient is created.
*
* @see commons.ws.messages.CreateIngredientMessage
*/
INGREDIENT_CREATE,
/**
* Message sent when an existing ingredient is updated.
*
* @see commons.ws.messages.UpdateIngredientMessage
*/
INGREDIENT_UPDATE,
/**
* Message sent when an ingredient is deleted.
*
* @see commons.ws.messages.DeleteIngredientMessage
*/
INGREDIENT_DELETE
} }
/** /**

View file

@ -0,0 +1,30 @@
package commons.ws.messages;
import commons.Ingredient;
/**
* Message sent when an ingredient is updated.
*
* @see commons.ws.messages.Message.Type#INGREDIENT_UPDATAE
*/
public class UpdateIngredientMessage implements Message {
private Ingredient ingredient;
public UpdateIngredientMessage(Ingredient ingredient) {
this.ingredient = ingredient;
}
@Override
public Type getType() {
return Type.INGREDIENT_UPDATE;
}
/**
* Get the updated ingredient.
*
* @return The updated ingredient.
*/
public Ingredient getIngredient() {
return ingredient;
}
}

View file

@ -47,7 +47,6 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
</dependencies> </dependencies>
<build> <build>

View file

@ -0,0 +1,206 @@
package server.api;
import commons.Ingredient;
import commons.ws.Topics;
import commons.ws.messages.CreateIngredientMessage;
import commons.ws.messages.DeleteIngredientMessage;
import commons.ws.messages.UpdateIngredientMessage;
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.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import server.database.IngredientRepository;
import java.util.Optional;
import java.util.List;
/**
* REST controller for managing ingredients.
* <p>
* Exposes the following endpoints:
* <ul>
* <li>GET /api/ingredients - Get a list of ingredients, optionally paginated.</li>
* <li>GET /api/ingredients/{id} - Get a specific ingredient by its ID.</li>
* <li>POST /api/ingredients - Create a new ingredient.</li>
* <li>PATCH /api/ingredients/{id} - Update an existing ingredient by its ID.</li>
* <li>DELETE /api/ingredients/{id} - Delete an ingredient by its ID.</li>
* </ul>
* </p>
*
* @see Ingredient
* @see IngredientRepository
*/
@RestController
@RequestMapping("/api")
public class IngredientController {
private final IngredientRepository ingredientRepository;
private final SimpMessagingTemplate messagingTemplate;
public IngredientController(IngredientRepository ingredientRepository,
SimpMessagingTemplate messagingTemplate) {
this.ingredientRepository = ingredientRepository;
this.messagingTemplate = messagingTemplate;
}
/**
* Get a list of ingredients, optionally paginated. <br />
* Maps to <code>GET /api/ingredients(?page=&limit=)</code>
* <p>
* If no limit is specified, all ingredients are returned sorted by name ascending.
* When calling this function, consider using pagination to avoid large responses.
* </p>
*
* <p>
* When using pagination, you should provide a limit.
* The page defaults to 0 if not provided.
* </p>
*
* @param page The page number to retrieve (0-indexed). Optional.
* @param limit The maximum number of ingredients to return. Optional.
* @return A ResponseEntity containing the list of ingredients.
*
* @see Ingredient
*/
@GetMapping("/ingredients")
public ResponseEntity<List<Ingredient>> getIngredients(
@RequestParam Optional<Integer> page,
@RequestParam Optional<Integer> limit
) {
List<Ingredient> ingredients = limit
.map(l -> {
return ingredientRepository.findAllByOrderByNameAsc(
PageRequest.of(page.orElse(0), l)
).toList();
})
.orElseGet(ingredientRepository::findAllByOrderByNameAsc);
return ResponseEntity.ok(ingredients);
}
/**
* Get a specific ingredient by its ID.
* Maps to <code>GET /api/ingredients/{id}</code>
*
* <p>
* Returns 200 OK with the ingredient if found,
* or 404 Not Found if the ingredient does not exist.
* </p>
*
* @param id The ID of the ingredient to retrieve.
* @return The ingredient wrapped in a ResponseEntity.
*
* @see Ingredient
*/
@GetMapping("/ingredients/{id}")
public ResponseEntity<Ingredient> getIngredientById(@PathVariable Long id) {
return ingredientRepository.findById(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
/**
* Update an existing ingredient by its ID.
* Maps to <code>PATCH /api/ingredients/{id}</code>
*
* <p>
* If the ingredient with the specified ID does not exist,
* returns 404 Not Found.
* <p>
* If the ingredient exists, updates it with the provided data
* and returns the updated ingredient with 200 OK.
* </p>
*
* @param id The ID of the ingredient to update.
* @param updated The updated ingredient data.
* @return The updated ingredient wrapped in a ResponseEntity.
*
* @see Ingredient
*/
@PatchMapping("/ingredients/{id}")
public ResponseEntity<Ingredient> updateIngredient(
@PathVariable Long id,
@RequestBody Ingredient updated
) {
if (!ingredientRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
// TODO: Refactor to use setters
updated.id = id;
Ingredient savedIngredient = ingredientRepository.save(updated);
messagingTemplate.convertAndSend(Topics.INGREDIENTS, new CreateIngredientMessage(savedIngredient));
return ResponseEntity.ok(savedIngredient);
}
/**
* Create a new ingredient.
* Maps to <code>POST /api/ingredients</code>
*
* <p>
* If an ingredient with the same name already exists,
* returns 400 Bad Request.
*
* If the ingredient is created successfully,
* returns the created ingredient with 200 OK.
* </p>
*
* @param ingredient The ingredient to create.
* @return The created ingredient wrapped in a ResponseEntity.
*
* @see Ingredient
*/
@PostMapping("/ingredients")
public ResponseEntity<Ingredient> createIngredient(@RequestBody Ingredient ingredient) {
if (ingredient.name == null || ingredient.name.isEmpty()) {
return ResponseEntity.badRequest().build();
}
Ingredient example = new Ingredient();
example.name = ingredient.name;
if (ingredientRepository.exists(Example.of(example))) {
return ResponseEntity.badRequest().build();
}
Ingredient saved = ingredientRepository.save(ingredient);
messagingTemplate.convertAndSend(Topics.INGREDIENTS, new UpdateIngredientMessage(saved));
return ResponseEntity.ok(saved);
}
/**
* Delete an ingredient by its ID.
* Maps to <code>DELETE /api/ingredients/{id}</code>
*
* <p>
* Returns 404 Not Found if the ingredient does not exist.
* If the ingredient is deleted successfully, returns 200 OK with true.
* </p>
*
* @param id The ID of the ingredient to delete.
* @return A ResponseEntity indicating the result of the operation.
*
* @see Ingredient
*/
@DeleteMapping("/ingredients/{id}")
public ResponseEntity<Boolean> deleteIngredient(@PathVariable Long id) {
if (!ingredientRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
ingredientRepository.deleteById(id);
messagingTemplate.convertAndSend(Topics.INGREDIENTS, new DeleteIngredientMessage(id));
return ResponseEntity.ok(true);
}
}

View file

@ -32,7 +32,8 @@ public class RecipeController {
private final RecipeRepository recipeRepository; // JPA repository used in this controller private final RecipeRepository recipeRepository; // JPA repository used in this controller
private final SimpMessagingTemplate messagingTemplate; private final SimpMessagingTemplate messagingTemplate;
public RecipeController(RecipeRepository recipeRepository, SimpMessagingTemplate messagingTemplate) { public RecipeController(RecipeRepository recipeRepository,
SimpMessagingTemplate messagingTemplate) {
this.recipeRepository = recipeRepository; this.recipeRepository = recipeRepository;
this.messagingTemplate = messagingTemplate; this.messagingTemplate = messagingTemplate;
} }

View file

@ -1,6 +1,7 @@
package server.api; package server.api;
import commons.Recipe; import commons.Recipe;
import commons.ws.Topics;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.MessageMapping;
@ -22,7 +23,7 @@ public class UpdateMessagingController {
* @return The updated {@link Recipe} object wrapped in a {@link org.springframework.http.ResponseEntity}. * @return The updated {@link Recipe} object wrapped in a {@link org.springframework.http.ResponseEntity}.
*/ */
@MessageMapping("/updates/recipe") @MessageMapping("/updates/recipe")
@SendTo("/subscribe/recipe") @SendTo(Topics.RECIPES)
public ResponseEntity<Recipe> broadcastRecipeUpdate(Recipe recipe) { public ResponseEntity<Recipe> broadcastRecipeUpdate(Recipe recipe) {
return recipeController.updateRecipe(recipe.getId(), recipe); return recipeController.updateRecipe(recipe.getId(), recipe);
} }

View file

@ -1,11 +1,14 @@
package server; package server.database;
import commons.Ingredient; import commons.Ingredient;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
public interface IngredientRepository extends JpaRepository<Ingredient, Long> { public interface IngredientRepository extends JpaRepository<Ingredient, Long> {
List<Ingredient> findAllByOrderByNameAsc(); List<Ingredient> findAllByOrderByNameAsc();
Page<Ingredient> findAllByOrderByNameAsc(Pageable pageable);
} }

View file

@ -1,4 +1,4 @@
package server; package server.database;
import commons.RecipeIngredient; import commons.RecipeIngredient;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;

View file

@ -0,0 +1,180 @@
package server.api;
import commons.Ingredient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.HttpStatusCode;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.test.context.ActiveProfiles;
import server.database.IngredientRepository;
import server.WebSocketConfig;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
@DataJpaTest
@ActiveProfiles("mock-data-test")
@Import(WebSocketConfig.class)
public class IngredientControllerTest {
private final SimpMessagingTemplate template;
private final IngredientRepository ingredientRepository;
private IngredientController controller;
private static final double PROTEIN_BASE = 1.0;
private static final double FAT_BASE = 2.0;
private static final double CARBS_BASE = 2.0;
private static final double PROTEIN_ALT = 0.5;
private static final double FAT_ALT = 1.0;
private static final double CARBS_ALT = 1.5;
private static final int OK_STATUS = 200;
private static final int BAD_REQUEST_STATUS = 400;
private static final int NOT_FOUND_STATUS = 404;
private static final Long INVALID_ID = 2137L;
@Autowired
public IngredientControllerTest(IngredientRepository ingredientRepository,
SimpMessagingTemplate template) {
this.ingredientRepository = ingredientRepository;
this.template = template;
}
private void createInitialIngredients() {
ingredientRepository.deleteAll();
Stream.of("Salt", "Sugar", "Flour", "Eggs", "Milk")
.map(name -> {
return new Ingredient(name, PROTEIN_BASE, FAT_BASE, CARBS_BASE);
})
.forEach(ingredientRepository::save);
}
@BeforeEach
public void setup() {
controller = new IngredientController(ingredientRepository, template);
this.createInitialIngredients();
}
@Test
public void testGetAllIngredients() {
List<Ingredient> ingredients = controller.getIngredients(Optional.empty(), Optional.empty()).getBody();
assertNotNull(ingredients);
final int expectedCount = 5;
assertEquals(expectedCount, ingredients.size());
assertEquals("Eggs", ingredients.getFirst().name);
}
@Test
public void testGetPaginatedIngredients() {
final int limit = 2;
List<Ingredient> ingredients = controller.getIngredients(Optional.of(0), Optional.of(limit)).getBody();
assertNotNull(ingredients);
final int expectedCount = 2;
assertEquals(expectedCount, ingredients.size());
assertEquals("Eggs", ingredients.get(0).name);
assertEquals("Flour", ingredients.get(1).name);
}
@Test
public void testGetIngredientById() {
Ingredient ingredient = ingredientRepository.findAll().getFirst();
Long id = ingredient.id;
Ingredient fetchedIngredient = controller.getIngredientById(id).getBody();
assertNotNull(fetchedIngredient);
assertEquals(ingredient.name, fetchedIngredient.name);
}
@Test
public void testGetIngredientByInvalidId() {
var response = controller.getIngredientById(INVALID_ID);
assertEquals(HttpStatusCode.valueOf(NOT_FOUND_STATUS), response.getStatusCode());
}
@Test
public void testCreateIngredient() {
Ingredient newIngredient = new Ingredient("Butter", PROTEIN_ALT, FAT_ALT, CARBS_ALT);
Ingredient createdIngredient = controller.createIngredient(newIngredient).getBody();
final int expectedCount = 6;
assertNotNull(createdIngredient);
assertEquals("Butter", createdIngredient.name);
assertEquals(expectedCount, ingredientRepository.count());
}
@Test
public void testCreateIngredientMissingName() {
Ingredient newIngredient = new Ingredient(null, PROTEIN_ALT, FAT_ALT, CARBS_ALT);
var response = controller.createIngredient(newIngredient);
assertEquals(HttpStatusCode.valueOf(BAD_REQUEST_STATUS), response.getStatusCode());
}
@Test
public void testUpdateIngredient() {
Ingredient ingredient = ingredientRepository.findAll()
.stream()
.filter(i -> i.name.equals("Salt"))
.findFirst()
.get(); // Should exist, no need for a check
Long id = ingredient.id;
Ingredient updatedData = new Ingredient("Sea Salt", PROTEIN_ALT, FAT_ALT, CARBS_ALT);
Ingredient updatedIngredient = controller.updateIngredient(id, updatedData).getBody();
assertNotNull(updatedIngredient);
assertEquals("Sea Salt", updatedIngredient.name);
assertEquals(PROTEIN_ALT, updatedIngredient.proteinPer100g);
}
@Test
public void testUpdateMissingIngredient() {
Ingredient updatedData = new Ingredient("Sea Salt", PROTEIN_ALT, FAT_ALT, CARBS_ALT);
var response = controller.updateIngredient(INVALID_ID, updatedData);
assertEquals(HttpStatusCode.valueOf(NOT_FOUND_STATUS), response.getStatusCode());
}
@Test
public void testDeleteIngredient() {
Ingredient ingredient = ingredientRepository.findAll()
.stream()
.filter(i -> i.name.equals("Sugar"))
.findFirst()
.get(); // Should exist, no need for a check
Long id = ingredient.id;
var response = controller.deleteIngredient(id);
final int expectedCount = 4;
assertEquals(HttpStatusCode.valueOf(OK_STATUS), response.getStatusCode());
assertEquals(expectedCount, ingredientRepository.count());
}
@Test
public void testDeleteMissingIngredient() {
var response = controller.deleteIngredient(INVALID_ID);
assertEquals(HttpStatusCode.valueOf(NOT_FOUND_STATUS), response.getStatusCode());
}
}