test: add high & medium priority tests — 48 tests total, 0 failures
Unit tests: - JwtTokenProviderTest (7): round-trip, tampered sig, expired, wrong secret - UserServiceTest (6): Mifflin-St Jeor BMR for lose/maintain/gain, manual override - MealServiceTest (6): calorie calc, food-memory create/avg, IDOR ownership Integration tests: - ValidationIntegrationTest (12): 400s for invalid email, short password, barcode pattern, food query length, history range >90 days - ProfileAndMealIntegrationTest (11): profile CRUD, meal get/delete lifecycle, history, cross-user IDOR (403 on GET and DELETE), data-leakage check Fixes bundled: - GlobalExceptionHandler: add ConstraintViolationException (400) and MissingServletRequestParameterException (400) handlers - AiService: remove unused spring-ai import (compile fix)
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
// Generated by GitHub Copilot
|
||||
package com.caloriecounter.exception;
|
||||
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@@ -53,6 +55,33 @@ public class GlobalExceptionHandler {
|
||||
return ResponseEntity.badRequest().body(detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles @Validated constraint violations on controller method parameters
|
||||
* (e.g. @Size, @Pattern on @RequestParam and @PathVariable).
|
||||
*/
|
||||
@ExceptionHandler(ConstraintViolationException.class)
|
||||
public ResponseEntity<ProblemDetail> handleConstraintViolation(ConstraintViolationException ex) {
|
||||
Map<String, String> errors = ex.getConstraintViolations().stream()
|
||||
.collect(Collectors.toMap(
|
||||
v -> v.getPropertyPath().toString(),
|
||||
v -> v.getMessage(),
|
||||
(a, b) -> a));
|
||||
ProblemDetail detail = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.BAD_REQUEST, "Validation failed");
|
||||
detail.setProperty("errors", errors);
|
||||
return ResponseEntity.badRequest().body(detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles missing required @RequestParam — returns 400 rather than 500.
|
||||
*/
|
||||
@ExceptionHandler(MissingServletRequestParameterException.class)
|
||||
public ResponseEntity<ProblemDetail> handleMissingParam(MissingServletRequestParameterException ex) {
|
||||
ProblemDetail detail = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.BAD_REQUEST, "Required parameter '" + ex.getParameterName() + "' is missing");
|
||||
return ResponseEntity.badRequest().body(detail);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ProblemDetail> handleGeneric(Exception ex) {
|
||||
log.error("Unexpected error", ex);
|
||||
|
||||
@@ -12,7 +12,6 @@ import com.caloriecounter.repository.PhotoAnalysisRepository;
|
||||
import com.caloriecounter.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.openai.OpenAiChatModel;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
// Generated by GitHub Copilot
|
||||
package com.caloriecounter;
|
||||
|
||||
import com.caloriecounter.dto.auth.RegisterRequest;
|
||||
import com.caloriecounter.dto.user.UserProfileDto;
|
||||
import com.caloriecounter.entity.FoodItem;
|
||||
import com.caloriecounter.entity.UserProfile;
|
||||
import com.caloriecounter.repository.FoodItemRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
/**
|
||||
* Integration tests covering:
|
||||
* 1. User profile CRUD (REQ-PRF-001, REQ-PRF-002)
|
||||
* 2. Meal get / delete lifecycle (REQ-MEAL-003)
|
||||
* 3. Meal history (REQ-HIST-001)
|
||||
* 4. Cross-user IDOR protection — user A must not access user B's meals (REQ-SEC-003)
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
class ProfileAndMealIntegrationTest {
|
||||
|
||||
@Autowired MockMvc mvc;
|
||||
@Autowired ObjectMapper objectMapper;
|
||||
@Autowired FoodItemRepository foodItemRepository;
|
||||
|
||||
// ─── Profile ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /user/profile before any profile is saved must return 200 with all-null fields.
|
||||
*/
|
||||
@Test
|
||||
void getProfile_noneSet_returns200WithNullFields() throws Exception {
|
||||
String token = registerAndGetToken("profile-empty@example.com");
|
||||
|
||||
mvc.perform(get("/user/profile")
|
||||
.header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.age").doesNotExist())
|
||||
.andExpect(jsonPath("$.weightKg").doesNotExist())
|
||||
.andExpect(jsonPath("$.dailyCaloriesTarget").doesNotExist());
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /user/profile with full biometrics must return a non-null calculated
|
||||
* dailyCaloriesTarget and store the correct goal.
|
||||
*/
|
||||
@Test
|
||||
void updateProfile_fullBiometrics_returnCalculatedTarget() throws Exception {
|
||||
String token = registerAndGetToken("profile-full@example.com");
|
||||
|
||||
UserProfileDto dto = new UserProfileDto(
|
||||
30,
|
||||
new BigDecimal("80"),
|
||||
new BigDecimal("175"),
|
||||
UserProfile.Goal.maintain,
|
||||
null);
|
||||
|
||||
mvc.perform(put("/user/profile")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(dto)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.goal").value("maintain"))
|
||||
.andExpect(jsonPath("$.dailyCaloriesTarget").isNumber());
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT followed by GET must persist the profile.
|
||||
*/
|
||||
@Test
|
||||
void updateThenGetProfile_persistsChanges() throws Exception {
|
||||
String token = registerAndGetToken("profile-persist@example.com");
|
||||
|
||||
UserProfileDto dto = new UserProfileDto(
|
||||
25,
|
||||
new BigDecimal("70"),
|
||||
new BigDecimal("170"),
|
||||
UserProfile.Goal.lose,
|
||||
null);
|
||||
|
||||
mvc.perform(put("/user/profile")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(dto)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mvc.perform(get("/user/profile")
|
||||
.header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.age").value(25))
|
||||
.andExpect(jsonPath("$.goal").value("lose"))
|
||||
.andExpect(jsonPath("$.dailyCaloriesTarget").isNumber());
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual calorie override (null biometrics) must be persisted as-is.
|
||||
*/
|
||||
@Test
|
||||
void updateProfile_manualCalorieOverride_persistsValue() throws Exception {
|
||||
String token = registerAndGetToken("profile-manual@example.com");
|
||||
|
||||
UserProfileDto dto = new UserProfileDto(null, null, null, null, 1800);
|
||||
|
||||
mvc.perform(put("/user/profile")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(dto)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.dailyCaloriesTarget").value(1800));
|
||||
}
|
||||
|
||||
// ─── Meal lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /meals/{id} must return the meal that was just created with correct totals.
|
||||
*/
|
||||
@Test
|
||||
void createThenGetMeal_returnsCorrectMeal() throws Exception {
|
||||
String token = registerAndGetToken("get-meal@example.com");
|
||||
FoodItem food = seedFood("Get Meal Food", new BigDecimal("100"));
|
||||
|
||||
UUID mealId = createMealAndGetId(token, food.getId(), new BigDecimal("150"));
|
||||
|
||||
mvc.perform(get("/meals/" + mealId)
|
||||
.header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(mealId.toString()))
|
||||
.andExpect(jsonPath("$.items[0].calories").value(150.00));
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /meals/{id} must return 204, and a subsequent GET must return 404.
|
||||
*/
|
||||
@Test
|
||||
void deleteMeal_returns204_thenGetReturns404() throws Exception {
|
||||
String token = registerAndGetToken("delete-meal@example.com");
|
||||
FoodItem food = seedFood("Delete Meal Food", new BigDecimal("200"));
|
||||
|
||||
UUID mealId = createMealAndGetId(token, food.getId(), new BigDecimal("100"));
|
||||
|
||||
mvc.perform(delete("/meals/" + mealId)
|
||||
.header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
mvc.perform(get("/meals/" + mealId)
|
||||
.header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /meals/history with a valid 30-day range must return a list (possibly empty).
|
||||
*/
|
||||
@Test
|
||||
void mealHistory_validRange_returns200() throws Exception {
|
||||
String token = registerAndGetToken("history-valid@example.com");
|
||||
LocalDate to = LocalDate.now();
|
||||
LocalDate from = to.minusDays(30);
|
||||
|
||||
mvc.perform(get("/meals/history")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.param("from", from.toString())
|
||||
.param("to", to.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Created meals appear in the history response for the correct date range.
|
||||
*/
|
||||
@Test
|
||||
void mealHistory_afterCreatingMeal_mealAppearsInHistory() throws Exception {
|
||||
String token = registerAndGetToken("history-populated@example.com");
|
||||
FoodItem food = seedFood("History Food", new BigDecimal("100"));
|
||||
|
||||
createMealAndGetId(token, food.getId(), new BigDecimal("100"));
|
||||
|
||||
LocalDate today = LocalDate.now();
|
||||
mvc.perform(get("/meals/history")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.param("from", today.toString())
|
||||
.param("to", today.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].mealType").value("lunch"));
|
||||
}
|
||||
|
||||
// ─── IDOR: cross-user data isolation ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* User B must receive 403 when attempting to GET User A's meal.
|
||||
* Verifies the ownership check in MealService.findAndCheckOwnership() at the HTTP layer.
|
||||
*/
|
||||
@Test
|
||||
void getMeal_otherUsersToken_returns403() throws Exception {
|
||||
String tokenA = registerAndGetToken("idor-a@example.com");
|
||||
String tokenB = registerAndGetToken("idor-b@example.com");
|
||||
FoodItem food = seedFood("IDOR Food GET", new BigDecimal("100"));
|
||||
|
||||
UUID mealIdOfA = createMealAndGetId(tokenA, food.getId(), new BigDecimal("100"));
|
||||
|
||||
mvc.perform(get("/meals/" + mealIdOfA)
|
||||
.header("Authorization", "Bearer " + tokenB))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.detail").value("Access denied"));
|
||||
}
|
||||
|
||||
/**
|
||||
* User B must receive 403 when attempting to DELETE User A's meal.
|
||||
*/
|
||||
@Test
|
||||
void deleteMeal_otherUsersToken_returns403() throws Exception {
|
||||
String tokenA = registerAndGetToken("idor-del-a@example.com");
|
||||
String tokenB = registerAndGetToken("idor-del-b@example.com");
|
||||
FoodItem food = seedFood("IDOR Food DEL", new BigDecimal("100"));
|
||||
|
||||
UUID mealIdOfA = createMealAndGetId(tokenA, food.getId(), new BigDecimal("100"));
|
||||
|
||||
mvc.perform(delete("/meals/" + mealIdOfA)
|
||||
.header("Authorization", "Bearer " + tokenB))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.detail").value("Access denied"));
|
||||
|
||||
// Verify the meal was NOT deleted — owner can still access it
|
||||
mvc.perform(get("/meals/" + mealIdOfA)
|
||||
.header("Authorization", "Bearer " + tokenA))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
/**
|
||||
* User B's daily overview must not include meals from User A on the same date.
|
||||
*/
|
||||
@Test
|
||||
void dailyOverview_doesNotLeakOtherUsersMeals() throws Exception {
|
||||
String tokenA = registerAndGetToken("leak-a@example.com");
|
||||
String tokenB = registerAndGetToken("leak-b@example.com");
|
||||
FoodItem food = seedFood("Leak Food", new BigDecimal("500"));
|
||||
|
||||
// User A logs a large meal (500 kcal)
|
||||
createMealAndGetId(tokenA, food.getId(), new BigDecimal("100"));
|
||||
|
||||
// User B's overview for the same day must show 0 calories
|
||||
mvc.perform(get("/meals/daily")
|
||||
.header("Authorization", "Bearer " + tokenB)
|
||||
.param("date", LocalDate.now().toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.totalCalories").value(0));
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private String registerAndGetToken(String email) throws Exception {
|
||||
MvcResult result = mvc.perform(post("/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(
|
||||
new RegisterRequest(email, "password123"))))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
Map<?, ?> body = objectMapper.readValue(
|
||||
result.getResponse().getContentAsString(), Map.class);
|
||||
return (String) body.get("token");
|
||||
}
|
||||
|
||||
private FoodItem seedFood(String name, BigDecimal caloriesPer100g) {
|
||||
return foodItemRepository.save(FoodItem.builder()
|
||||
.name(name)
|
||||
.source(FoodItem.Source.custom)
|
||||
.caloriesPer100g(caloriesPer100g)
|
||||
.proteinG(BigDecimal.ZERO)
|
||||
.fatG(BigDecimal.ZERO)
|
||||
.carbsG(BigDecimal.ZERO)
|
||||
.build());
|
||||
}
|
||||
|
||||
private UUID createMealAndGetId(String token, UUID foodId, BigDecimal grams) throws Exception {
|
||||
String payload = objectMapper.writeValueAsString(Map.of(
|
||||
"date", LocalDate.now().toString(),
|
||||
"mealType", "lunch",
|
||||
"source", "manual",
|
||||
"items", List.of(Map.of("foodItemId", foodId, "grams", grams))
|
||||
));
|
||||
|
||||
MvcResult result = mvc.perform(post("/meals")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
|
||||
Map<?, ?> body = objectMapper.readValue(
|
||||
result.getResponse().getContentAsString(), Map.class);
|
||||
return UUID.fromString((String) body.get("id"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Generated by GitHub Copilot
|
||||
package com.caloriecounter;
|
||||
|
||||
import com.caloriecounter.dto.auth.RegisterRequest;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
/**
|
||||
* Integration tests verifying that all controller-layer input validation
|
||||
* correctly rejects invalid inputs with HTTP 400 and RFC-7807 error bodies.
|
||||
*
|
||||
* Coverage: REQ-SEC-004 (input validation at system boundary)
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
class ValidationIntegrationTest {
|
||||
|
||||
@Autowired MockMvc mvc;
|
||||
@Autowired ObjectMapper objectMapper;
|
||||
|
||||
// ─── POST /auth/register ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Email field must be a valid RFC-5321 address.
|
||||
*/
|
||||
@Test
|
||||
void register_invalidEmail_returns400WithFieldError() throws Exception {
|
||||
mvc.perform(post("/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(
|
||||
new RegisterRequest("not-an-email", "password123"))))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.errors.email").exists());
|
||||
}
|
||||
|
||||
/**
|
||||
* Password shorter than 8 characters must be rejected.
|
||||
*/
|
||||
@Test
|
||||
void register_shortPassword_returns400WithFieldError() throws Exception {
|
||||
mvc.perform(post("/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(
|
||||
new RegisterRequest("user@example.com", "short"))))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.errors.password").exists());
|
||||
}
|
||||
|
||||
/**
|
||||
* A blank email must be rejected (covers @NotBlank).
|
||||
*/
|
||||
@Test
|
||||
void register_blankEmail_returns400() throws Exception {
|
||||
mvc.perform(post("/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"\",\"password\":\"password123\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.errors.email").exists());
|
||||
}
|
||||
|
||||
/**
|
||||
* A blank password must be rejected (covers @NotBlank).
|
||||
*/
|
||||
@Test
|
||||
void register_blankPassword_returns400() throws Exception {
|
||||
mvc.perform(post("/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"user@example.com\",\"password\":\"\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.errors.password").exists());
|
||||
}
|
||||
|
||||
// ─── GET /foods ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Food search query must be at least 2 characters.
|
||||
* A 1-character query must return 400 to prevent excessive broad searches.
|
||||
*/
|
||||
@Test
|
||||
void foodSearch_oneCharQuery_returns400() throws Exception {
|
||||
String token = registerAndGetToken("search-valid@example.com");
|
||||
|
||||
mvc.perform(get("/foods")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.param("query", "a"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing query parameter entirely must return 400.
|
||||
*/
|
||||
@Test
|
||||
void foodSearch_missingQuery_returns400() throws Exception {
|
||||
String token = registerAndGetToken("search-missing@example.com");
|
||||
|
||||
mvc.perform(get("/foods")
|
||||
.header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// ─── GET /foods/barcode/{code} ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Non-numeric barcode must be rejected by the @Pattern constraint.
|
||||
*/
|
||||
@Test
|
||||
void barcodeSearch_nonNumericBarcode_returns400() throws Exception {
|
||||
String token = registerAndGetToken("barcode-alpha@example.com");
|
||||
|
||||
mvc.perform(get("/foods/barcode/ABCDEFGH")
|
||||
.header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
/**
|
||||
* A barcode shorter than 8 digits must be rejected.
|
||||
*/
|
||||
@Test
|
||||
void barcodeSearch_tooShortBarcode_returns400() throws Exception {
|
||||
String token = registerAndGetToken("barcode-short@example.com");
|
||||
|
||||
mvc.perform(get("/foods/barcode/1234567")
|
||||
.header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
/**
|
||||
* A barcode longer than 14 digits must be rejected.
|
||||
*/
|
||||
@Test
|
||||
void barcodeSearch_tooLongBarcode_returns400() throws Exception {
|
||||
String token = registerAndGetToken("barcode-long@example.com");
|
||||
|
||||
mvc.perform(get("/foods/barcode/123456789012345")
|
||||
.header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// ─── GET /meals/history ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A date range exceeding 90 days must be rejected to prevent excessive data fetches.
|
||||
*/
|
||||
@Test
|
||||
void mealHistory_rangeExceeds90Days_returns400() throws Exception {
|
||||
String token = registerAndGetToken("history-range@example.com");
|
||||
LocalDate from = LocalDate.now().minusDays(91);
|
||||
LocalDate to = LocalDate.now();
|
||||
|
||||
mvc.perform(get("/meals/history")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.param("from", from.toString())
|
||||
.param("to", to.toString()))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
/**
|
||||
* A reversed date range (from > to) must be rejected.
|
||||
*/
|
||||
@Test
|
||||
void mealHistory_fromAfterTo_returns400() throws Exception {
|
||||
String token = registerAndGetToken("history-reversed@example.com");
|
||||
|
||||
mvc.perform(get("/meals/history")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.param("from", LocalDate.now().toString())
|
||||
.param("to", LocalDate.now().minusDays(1).toString()))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// ─── Security: unauthenticated access ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A structurally malformed Authorization header (not "Bearer <token>") must
|
||||
* result in 403, not a 500.
|
||||
*/
|
||||
@Test
|
||||
void request_malformedAuthorizationHeader_returns403() throws Exception {
|
||||
mvc.perform(get("/meals/daily")
|
||||
.header("Authorization", "InvalidScheme xyz")
|
||||
.param("date", LocalDate.now().toString()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registers a fresh user and returns the JWT token from the 201 response.
|
||||
*/
|
||||
private String registerAndGetToken(String email) throws Exception {
|
||||
var result = mvc.perform(post("/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(
|
||||
new RegisterRequest(email, "password123"))))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
|
||||
var body = objectMapper.readValue(
|
||||
result.getResponse().getContentAsString(), java.util.Map.class);
|
||||
return (String) body.get("token");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Generated by GitHub Copilot
|
||||
package com.caloriecounter.security;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link JwtTokenProvider}.
|
||||
* No Spring context — provider is instantiated directly with test values via ReflectionTestUtils.
|
||||
*
|
||||
* Security coverage: REQ-SEC-001, REQ-SEC-002
|
||||
*/
|
||||
class JwtTokenProviderTest {
|
||||
|
||||
private static final String TEST_SECRET =
|
||||
"test-secret-key-that-is-at-least-256-bits-long-for-hs256-algorithm";
|
||||
private static final long ONE_HOUR_MS = 3_600_000L;
|
||||
|
||||
private JwtTokenProvider provider;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
provider = new JwtTokenProvider();
|
||||
ReflectionTestUtils.setField(provider, "jwtSecret", TEST_SECRET);
|
||||
ReflectionTestUtils.setField(provider, "expirationMs", ONE_HOUR_MS);
|
||||
ReflectionTestUtils.invokeMethod(provider, "init");
|
||||
}
|
||||
|
||||
/**
|
||||
* Round-trip: a generated token must parse back to the same user ID.
|
||||
*/
|
||||
@Test
|
||||
void generateToken_thenValidate_returnsSameUserId() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
|
||||
String token = provider.generateToken(userId);
|
||||
|
||||
assertThat(token).isNotBlank();
|
||||
assertThat(provider.validateToken(token)).isTrue();
|
||||
assertThat(provider.getUserIdFromToken(token)).isEqualTo(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two distinct users must never receive the same token.
|
||||
*/
|
||||
@Test
|
||||
void generateToken_differentUsers_produceDifferentTokens() {
|
||||
String token1 = provider.generateToken(UUID.randomUUID());
|
||||
String token2 = provider.generateToken(UUID.randomUUID());
|
||||
|
||||
assertThat(token1).isNotEqualTo(token2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flipping characters in the signature portion must invalidate the token.
|
||||
* Guards against signature-stripping / alg:none attacks.
|
||||
*/
|
||||
@Test
|
||||
void validateToken_tamperedSignature_returnsFalse() {
|
||||
String token = provider.generateToken(UUID.randomUUID());
|
||||
// Corrupt the last 4 characters of the Base64-encoded signature
|
||||
String tampered = token.substring(0, token.length() - 4) + "XXXX";
|
||||
|
||||
assertThat(provider.validateToken(tampered)).isFalse();
|
||||
assertThat(provider.getUserIdFromToken(tampered)).isNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* A token created with a negative expiry (already in the past) must be rejected.
|
||||
*/
|
||||
@Test
|
||||
void validateToken_expiredToken_returnsFalse() {
|
||||
JwtTokenProvider expiredProvider = new JwtTokenProvider();
|
||||
ReflectionTestUtils.setField(expiredProvider, "jwtSecret", TEST_SECRET);
|
||||
ReflectionTestUtils.setField(expiredProvider, "expirationMs", -1_000L);
|
||||
ReflectionTestUtils.invokeMethod(expiredProvider, "init");
|
||||
|
||||
String expiredToken = expiredProvider.generateToken(UUID.randomUUID());
|
||||
|
||||
assertThat(expiredProvider.validateToken(expiredToken)).isFalse();
|
||||
}
|
||||
|
||||
/**
|
||||
* A random string that is not a JWT must not throw — must return null.
|
||||
*/
|
||||
@Test
|
||||
void getUserIdFromToken_randomString_returnsNull() {
|
||||
assertThat(provider.getUserIdFromToken("not.a.valid.jwt")).isNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* An empty string must not throw — must return null.
|
||||
*/
|
||||
@Test
|
||||
void getUserIdFromToken_emptyString_returnsNull() {
|
||||
assertThat(provider.getUserIdFromToken("")).isNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* A token signed with a different secret must be rejected by this provider.
|
||||
*/
|
||||
@Test
|
||||
void validateToken_wrongSecret_returnsFalse() {
|
||||
JwtTokenProvider otherProvider = new JwtTokenProvider();
|
||||
ReflectionTestUtils.setField(otherProvider, "jwtSecret",
|
||||
"completely-different-secret-key-256-bits-long-enough-for-test");
|
||||
ReflectionTestUtils.setField(otherProvider, "expirationMs", ONE_HOUR_MS);
|
||||
ReflectionTestUtils.invokeMethod(otherProvider, "init");
|
||||
|
||||
String foreignToken = otherProvider.generateToken(UUID.randomUUID());
|
||||
|
||||
assertThat(provider.validateToken(foreignToken)).isFalse();
|
||||
assertThat(provider.getUserIdFromToken(foreignToken)).isNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Generated by GitHub Copilot
|
||||
package com.caloriecounter.service;
|
||||
|
||||
import com.caloriecounter.dto.food.FoodItemDto;
|
||||
import com.caloriecounter.dto.meal.CreateMealRequest;
|
||||
import com.caloriecounter.entity.*;
|
||||
import com.caloriecounter.repository.MealEntryRepository;
|
||||
import com.caloriecounter.repository.UserFoodMemoryRepository;
|
||||
import com.caloriecounter.repository.UserRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link MealService}.
|
||||
*
|
||||
* Coverage:
|
||||
* - Calorie calculation: kcal_per_100g × grams / 100 (REQ-MEAL-001)
|
||||
* - Food-memory creation on first use (REQ-AI-005)
|
||||
* - Running-average update on subsequent uses (REQ-AI-005)
|
||||
* - Ownership enforcement: cross-user meal access throws ForbiddenException (REQ-SEC-003)
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class MealServiceTest {
|
||||
|
||||
@Mock MealEntryRepository mealEntryRepository;
|
||||
@Mock UserRepository userRepository;
|
||||
@Mock FoodService foodService;
|
||||
@Mock UserFoodMemoryRepository userFoodMemoryRepository;
|
||||
|
||||
@InjectMocks MealService mealService;
|
||||
|
||||
private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
private static final UUID FOOD_ID = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||
|
||||
private User user;
|
||||
private FoodItem food;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
user = new User();
|
||||
user.setId(USER_ID);
|
||||
|
||||
food = FoodItem.builder()
|
||||
.name("Test Food")
|
||||
.source(FoodItem.Source.custom)
|
||||
.caloriesPer100g(new BigDecimal("200"))
|
||||
.proteinG(BigDecimal.ZERO)
|
||||
.fatG(BigDecimal.ZERO)
|
||||
.carbsG(BigDecimal.ZERO)
|
||||
.build();
|
||||
ReflectionTestUtils.setField(food, "id", FOOD_ID);
|
||||
|
||||
when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user));
|
||||
when(userRepository.getReferenceById(USER_ID)).thenReturn(user);
|
||||
when(foodService.getEntityById(FOOD_ID)).thenReturn(food);
|
||||
when(mealEntryRepository.save(any(MealEntry.class))).thenAnswer(inv -> {
|
||||
MealEntry e = inv.getArgument(0);
|
||||
ReflectionTestUtils.setField(e, "id", UUID.randomUUID());
|
||||
return e;
|
||||
});
|
||||
when(foodService.toDto(food)).thenReturn(new FoodItemDto(
|
||||
FOOD_ID, "Test Food", "custom", null,
|
||||
new BigDecimal("200"),
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO));
|
||||
}
|
||||
|
||||
// --- calorie calculation ---
|
||||
|
||||
/**
|
||||
* 250 g of a food with 200 kcal/100 g → 500 kcal.
|
||||
* Verifies the core formula: caloriesPer100g × grams / 100
|
||||
*/
|
||||
@Test
|
||||
void createMeal_caloriesCalculatedCorrectly() {
|
||||
when(userFoodMemoryRepository.findByUserIdAndFoodName(any(), any()))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
var result = createMeal(new BigDecimal("250"));
|
||||
|
||||
assertThat(result.items()).hasSize(1);
|
||||
assertThat(result.items().get(0).calories())
|
||||
.isEqualByComparingTo("500.00");
|
||||
}
|
||||
|
||||
// --- food-memory (personalisation) ---
|
||||
|
||||
/**
|
||||
* First time a food is logged: a new UserFoodMemory record must be saved
|
||||
* with avgPortionGrams equal to the submitted grams.
|
||||
*/
|
||||
@Test
|
||||
void createMeal_firstTimeFood_createsNewMemoryEntry() {
|
||||
when(userFoodMemoryRepository.findByUserIdAndFoodName(USER_ID, "Test Food"))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
createMeal(new BigDecimal("200"));
|
||||
|
||||
ArgumentCaptor<UserFoodMemory> captor = ArgumentCaptor.forClass(UserFoodMemory.class);
|
||||
verify(userFoodMemoryRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getAvgPortionGrams())
|
||||
.isEqualByComparingTo("200.00");
|
||||
assertThat(captor.getValue().getFoodName()).isEqualTo("Test Food");
|
||||
}
|
||||
|
||||
/**
|
||||
* When an existing memory exists (avg = 100 g), logging 300 g must update
|
||||
* the running average to (100 + 300) / 2 = 200 g.
|
||||
*/
|
||||
@Test
|
||||
void createMeal_existingMemory_updatesRunningAverage() {
|
||||
UserFoodMemory existing = UserFoodMemory.builder()
|
||||
.user(user)
|
||||
.foodName("Test Food")
|
||||
.avgPortionGrams(new BigDecimal("100.00"))
|
||||
.lastUsed(OffsetDateTime.now())
|
||||
.build();
|
||||
when(userFoodMemoryRepository.findByUserIdAndFoodName(USER_ID, "Test Food"))
|
||||
.thenReturn(Optional.of(existing));
|
||||
when(userFoodMemoryRepository.save(existing)).thenReturn(existing);
|
||||
|
||||
createMeal(new BigDecimal("300"));
|
||||
|
||||
// (100 + 300) / 2 = 200
|
||||
assertThat(existing.getAvgPortionGrams()).isEqualByComparingTo("200.00");
|
||||
}
|
||||
|
||||
/**
|
||||
* Running average converges across three consecutive log events:
|
||||
* initial=100, log 300 → avg=200, log 100 → avg=150
|
||||
*/
|
||||
@Test
|
||||
void createMeal_threeConsecutiveLogs_averageConverges() {
|
||||
UserFoodMemory memory = UserFoodMemory.builder()
|
||||
.user(user)
|
||||
.foodName("Test Food")
|
||||
.avgPortionGrams(new BigDecimal("100.00"))
|
||||
.lastUsed(OffsetDateTime.now())
|
||||
.build();
|
||||
when(userFoodMemoryRepository.findByUserIdAndFoodName(USER_ID, "Test Food"))
|
||||
.thenReturn(Optional.of(memory));
|
||||
when(userFoodMemoryRepository.save(memory)).thenReturn(memory);
|
||||
|
||||
createMeal(new BigDecimal("300")); // avg: (100 + 300)/2 = 200
|
||||
createMeal(new BigDecimal("100")); // avg: (200 + 100)/2 = 150
|
||||
|
||||
assertThat(memory.getAvgPortionGrams()).isEqualByComparingTo("150.00");
|
||||
}
|
||||
|
||||
// --- ownership check ---
|
||||
|
||||
/**
|
||||
* Attempting to fetch a meal owned by a different user must throw ForbiddenException.
|
||||
* Guards against IDOR at the service layer (REQ-SEC-003).
|
||||
*/
|
||||
@Test
|
||||
void getMeal_wrongUser_throwsForbiddenException() {
|
||||
UUID otherUserId = UUID.randomUUID();
|
||||
UUID mealId = UUID.randomUUID();
|
||||
|
||||
MealEntry entry = MealEntry.builder()
|
||||
.date(LocalDate.now())
|
||||
.mealType(MealEntry.MealType.lunch)
|
||||
.source(MealEntry.LogSource.manual)
|
||||
.build();
|
||||
ReflectionTestUtils.setField(entry, "id", mealId);
|
||||
entry.setUser(user); // owned by USER_ID
|
||||
|
||||
when(mealEntryRepository.findById(mealId)).thenReturn(Optional.of(entry));
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
com.caloriecounter.exception.ForbiddenException.class,
|
||||
() -> mealService.getMeal(otherUserId, mealId));
|
||||
}
|
||||
|
||||
/**
|
||||
* deleteMeal with a different user must also throw ForbiddenException.
|
||||
*/
|
||||
@Test
|
||||
void deleteMeal_wrongUser_throwsForbiddenException() {
|
||||
UUID otherUserId = UUID.randomUUID();
|
||||
UUID mealId = UUID.randomUUID();
|
||||
|
||||
MealEntry entry = MealEntry.builder()
|
||||
.date(LocalDate.now())
|
||||
.mealType(MealEntry.MealType.dinner)
|
||||
.source(MealEntry.LogSource.manual)
|
||||
.build();
|
||||
ReflectionTestUtils.setField(entry, "id", mealId);
|
||||
entry.setUser(user);
|
||||
|
||||
when(mealEntryRepository.findById(mealId)).thenReturn(Optional.of(entry));
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
com.caloriecounter.exception.ForbiddenException.class,
|
||||
() -> mealService.deleteMeal(otherUserId, mealId));
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private com.caloriecounter.dto.meal.MealEntryDto createMeal(BigDecimal grams) {
|
||||
CreateMealRequest request = new CreateMealRequest(
|
||||
LocalDate.now(),
|
||||
MealEntry.MealType.lunch,
|
||||
MealEntry.LogSource.manual,
|
||||
List.of(new CreateMealRequest.MealItemRequest(FOOD_ID, grams)));
|
||||
return mealService.createMeal(USER_ID, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Generated by GitHub Copilot
|
||||
package com.caloriecounter.service;
|
||||
|
||||
import com.caloriecounter.dto.user.UserProfileDto;
|
||||
import com.caloriecounter.entity.User;
|
||||
import com.caloriecounter.entity.UserProfile;
|
||||
import com.caloriecounter.repository.UserRepository;
|
||||
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 java.math.BigDecimal;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link UserService} — focuses on the Mifflin-St Jeor BMR formula
|
||||
* and the daily calorie target calculation used in REQ-PRF-002.
|
||||
*
|
||||
* Test data: male, 30 years old, 80 kg, 175 cm
|
||||
* BMR = (10 × 80) + (6.25 × 175) − (5 × 30) + 5 = 1748.75
|
||||
* TDEE = 1748.75 × 1.375 (lightly active) = 2404.53...
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceTest {
|
||||
|
||||
@Mock
|
||||
UserRepository userRepository;
|
||||
|
||||
@InjectMocks
|
||||
UserService userService;
|
||||
|
||||
private static final UUID USER_ID =
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
// Shared biometrics for all goal-variant tests
|
||||
private static final int AGE = 30;
|
||||
private static final BigDecimal WEIGHT = new BigDecimal("80");
|
||||
private static final BigDecimal HEIGHT = new BigDecimal("175");
|
||||
|
||||
@BeforeEach
|
||||
void stubRepository() {
|
||||
User user = new User();
|
||||
user.setId(USER_ID);
|
||||
when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user));
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
}
|
||||
|
||||
// --- BMR × goal modifier ---
|
||||
|
||||
/**
|
||||
* TDEE − 500 kcal for weight-loss goal.
|
||||
* Expected: round(2404.53 − 500) = 1905
|
||||
*/
|
||||
@Test
|
||||
void updateProfile_loseGoal_targetIsTdeeMinusFiveHundred() {
|
||||
UserProfileDto dto = profileDto(UserProfile.Goal.lose, null);
|
||||
|
||||
UserProfileDto result = userService.updateProfile(USER_ID, dto);
|
||||
|
||||
assertThat(result.dailyCaloriesTarget()).isEqualTo(1905);
|
||||
}
|
||||
|
||||
/**
|
||||
* TDEE rounded for maintenance goal.
|
||||
* Expected: round(2404.53) = 2405
|
||||
*/
|
||||
@Test
|
||||
void updateProfile_maintainGoal_targetIsTdeeRounded() {
|
||||
UserProfileDto dto = profileDto(UserProfile.Goal.maintain, null);
|
||||
|
||||
UserProfileDto result = userService.updateProfile(USER_ID, dto);
|
||||
|
||||
assertThat(result.dailyCaloriesTarget()).isEqualTo(2405);
|
||||
}
|
||||
|
||||
/**
|
||||
* TDEE + 300 kcal for muscle-gain goal.
|
||||
* Expected: round(2404.53 + 300) = 2705
|
||||
*/
|
||||
@Test
|
||||
void updateProfile_gainGoal_targetIsTdeePlusThreeHundred() {
|
||||
UserProfileDto dto = profileDto(UserProfile.Goal.gain, null);
|
||||
|
||||
UserProfileDto result = userService.updateProfile(USER_ID, dto);
|
||||
|
||||
assertThat(result.dailyCaloriesTarget()).isEqualTo(2705);
|
||||
}
|
||||
|
||||
// --- Edge cases ---
|
||||
|
||||
/**
|
||||
* When biometrics are absent but a manual override is provided, the manual value
|
||||
* must be stored as-is without attempting BMR calculation.
|
||||
*/
|
||||
@Test
|
||||
void updateProfile_manualCalorieOverride_usesProvidedValue() {
|
||||
UserProfileDto dto = new UserProfileDto(null, null, null, null, 1600);
|
||||
|
||||
UserProfileDto result = userService.updateProfile(USER_ID, dto);
|
||||
|
||||
assertThat(result.dailyCaloriesTarget()).isEqualTo(1600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial biometrics (no goal) must not trigger BMR calculation and must not throw.
|
||||
* The target should remain null if no manual override was given.
|
||||
*/
|
||||
@Test
|
||||
void updateProfile_partialBiometricsNoManualOverride_targetRemainsNull() {
|
||||
UserProfileDto dto = new UserProfileDto(AGE, WEIGHT, HEIGHT, null, null);
|
||||
|
||||
UserProfileDto result = userService.updateProfile(USER_ID, dto);
|
||||
|
||||
assertThat(result.dailyCaloriesTarget()).isNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updating an existing profile preserves continuity — the returned DTO's
|
||||
* goal field must match what was submitted.
|
||||
*/
|
||||
@Test
|
||||
void updateProfile_existingProfile_goalIsUpdated() {
|
||||
// First save: set gain goal
|
||||
userService.updateProfile(USER_ID, profileDto(UserProfile.Goal.gain, null));
|
||||
|
||||
// Second save: switch to lose
|
||||
UserProfileDto result = userService.updateProfile(USER_ID, profileDto(UserProfile.Goal.lose, null));
|
||||
|
||||
assertThat(result.goal()).isEqualTo(UserProfile.Goal.lose);
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private UserProfileDto profileDto(UserProfile.Goal goal, Integer manualTarget) {
|
||||
return new UserProfileDto(AGE, WEIGHT, HEIGHT, goal, manualTarget);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user