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:
2026-05-18 22:44:53 +03:00
parent 91cd18aec6
commit 8a031b30b6
7 changed files with 1049 additions and 1 deletions

View File

@@ -1,12 +1,14 @@
// Generated by GitHub Copilot // Generated by GitHub Copilot
package com.caloriecounter.exception; package com.caloriecounter.exception;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail; import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError; import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException; 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.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -53,6 +55,33 @@ public class GlobalExceptionHandler {
return ResponseEntity.badRequest().body(detail); 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) @ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleGeneric(Exception ex) { public ResponseEntity<ProblemDetail> handleGeneric(Exception ex) {
log.error("Unexpected error", ex); log.error("Unexpected error", ex);

View File

@@ -12,7 +12,6 @@ import com.caloriecounter.repository.PhotoAnalysisRepository;
import com.caloriecounter.repository.UserRepository; import com.caloriecounter.repository.UserRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;

View File

@@ -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"));
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}