Files
calorie-counter/backend/src/test/java/com/caloriecounter/ProfileAndMealIntegrationTest.java
Andris Enins 8a031b30b6 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)
2026-05-18 22:44:53 +03:00

312 lines
13 KiB
Java

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