diff --git a/backend/src/main/java/com/caloriecounter/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/caloriecounter/exception/GlobalExceptionHandler.java index 0d8b4aa..37b1a3f 100644 --- a/backend/src/main/java/com/caloriecounter/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/caloriecounter/exception/GlobalExceptionHandler.java @@ -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 handleConstraintViolation(ConstraintViolationException ex) { + Map 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 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 handleGeneric(Exception ex) { log.error("Unexpected error", ex); diff --git a/backend/src/main/java/com/caloriecounter/service/AiService.java b/backend/src/main/java/com/caloriecounter/service/AiService.java index 26a5c6d..835d074 100644 --- a/backend/src/main/java/com/caloriecounter/service/AiService.java +++ b/backend/src/main/java/com/caloriecounter/service/AiService.java @@ -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; diff --git a/backend/src/test/java/com/caloriecounter/ProfileAndMealIntegrationTest.java b/backend/src/test/java/com/caloriecounter/ProfileAndMealIntegrationTest.java new file mode 100644 index 0000000..107f2c9 --- /dev/null +++ b/backend/src/test/java/com/caloriecounter/ProfileAndMealIntegrationTest.java @@ -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")); + } +} diff --git a/backend/src/test/java/com/caloriecounter/ValidationIntegrationTest.java b/backend/src/test/java/com/caloriecounter/ValidationIntegrationTest.java new file mode 100644 index 0000000..5c8fcf9 --- /dev/null +++ b/backend/src/test/java/com/caloriecounter/ValidationIntegrationTest.java @@ -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 ") 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"); + } +} diff --git a/backend/src/test/java/com/caloriecounter/security/JwtTokenProviderTest.java b/backend/src/test/java/com/caloriecounter/security/JwtTokenProviderTest.java new file mode 100644 index 0000000..40b54d5 --- /dev/null +++ b/backend/src/test/java/com/caloriecounter/security/JwtTokenProviderTest.java @@ -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(); + } +} diff --git a/backend/src/test/java/com/caloriecounter/service/MealServiceTest.java b/backend/src/test/java/com/caloriecounter/service/MealServiceTest.java new file mode 100644 index 0000000..abfef3b --- /dev/null +++ b/backend/src/test/java/com/caloriecounter/service/MealServiceTest.java @@ -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 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); + } +} diff --git a/backend/src/test/java/com/caloriecounter/service/UserServiceTest.java b/backend/src/test/java/com/caloriecounter/service/UserServiceTest.java new file mode 100644 index 0000000..71c2dab --- /dev/null +++ b/backend/src/test/java/com/caloriecounter/service/UserServiceTest.java @@ -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); + } +}