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