From aef9127ce8d242a6c3a2f1909b3b9fbfd21534d4 Mon Sep 17 00:00:00 2001 From: rover0811 Date: Tue, 6 Oct 2026 14:44:17 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=8B=9D=EB=8B=A8=20=EC=8A=AC?= =?UTF-8?q?=EB=A1=AF=20=EC=A1=B0=EC=A0=95=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/ssu/eatssu/domain/menu/entity/Meal.java | 4 ++++ .../dto/response/MealSlotReconcileResult.java | 11 +++++++++++ .../domain/review/repository/ReviewRepository.java | 2 ++ 3 files changed, 17 insertions(+) create mode 100644 src/main/java/ssu/eatssu/domain/menu/presentation/dto/response/MealSlotReconcileResult.java diff --git a/src/main/java/ssu/eatssu/domain/menu/entity/Meal.java b/src/main/java/ssu/eatssu/domain/menu/entity/Meal.java index fa7069bd..72762f76 100644 --- a/src/main/java/ssu/eatssu/domain/menu/entity/Meal.java +++ b/src/main/java/ssu/eatssu/domain/menu/entity/Meal.java @@ -65,6 +65,10 @@ public void addMealMenu(MealMenu mealMenu) { mealMenus.add(mealMenu); } + public void updatePrice(Integer price) { + this.price = price; + } + public List getMenus() { return mealMenus.stream() .map(MealMenu::getMenu) diff --git a/src/main/java/ssu/eatssu/domain/menu/presentation/dto/response/MealSlotReconcileResult.java b/src/main/java/ssu/eatssu/domain/menu/presentation/dto/response/MealSlotReconcileResult.java new file mode 100644 index 00000000..d40f31d6 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/menu/presentation/dto/response/MealSlotReconcileResult.java @@ -0,0 +1,11 @@ +package ssu.eatssu.domain.menu.presentation.dto.response; + +import java.util.List; + +public record MealSlotReconcileResult( + List mealIds, + List> unmatchedMainMenus, + List deletedMealIds, + List keptWithReviews +) { +} diff --git a/src/main/java/ssu/eatssu/domain/review/repository/ReviewRepository.java b/src/main/java/ssu/eatssu/domain/review/repository/ReviewRepository.java index 0f08ab08..2e9cebb3 100644 --- a/src/main/java/ssu/eatssu/domain/review/repository/ReviewRepository.java +++ b/src/main/java/ssu/eatssu/domain/review/repository/ReviewRepository.java @@ -19,6 +19,8 @@ public interface ReviewRepository extends JpaRepository, ReviewRep List findAllByMeal(Meal meal); + boolean existsByMeal_Id(Long mealId); + /** * dto projection */ From bf4c95e7c5234a6d651708ccfcd9153ba88dcfdf Mon Sep 17 00:00:00 2001 From: rover0811 Date: Tue, 6 Oct 2026 14:44:24 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=8B=9D=EB=8B=A8=20=EC=8A=AC?= =?UTF-8?q?=EB=A1=AF=20=EC=A1=B0=EC=A0=95=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/menu/service/MealService.java | 93 ++++++- .../menu/service/MealServiceBranchTest.java | 5 +- .../service/MealSlotReconcileServiceTest.java | 228 ++++++++++++++++++ 3 files changed, 316 insertions(+), 10 deletions(-) create mode 100644 src/test/java/ssu/eatssu/domain/menu/service/MealSlotReconcileServiceTest.java diff --git a/src/main/java/ssu/eatssu/domain/menu/service/MealService.java b/src/main/java/ssu/eatssu/domain/menu/service/MealService.java index 1f53e769..ee02f6bd 100644 --- a/src/main/java/ssu/eatssu/domain/menu/service/MealService.java +++ b/src/main/java/ssu/eatssu/domain/menu/service/MealService.java @@ -17,18 +17,22 @@ import ssu.eatssu.domain.menu.presentation.dto.request.MealCreateWithPriceRequest; import ssu.eatssu.domain.menu.presentation.dto.response.MealCreateResult; import ssu.eatssu.domain.menu.presentation.dto.response.MealDetailResponse; +import ssu.eatssu.domain.menu.presentation.dto.response.MealSlotReconcileResult; import ssu.eatssu.domain.menu.presentation.dto.response.MenusInMealResponse; import ssu.eatssu.domain.restaurant.entity.Restaurant; import ssu.eatssu.domain.restaurant.entity.RestaurantType; +import ssu.eatssu.domain.review.repository.ReviewRepository; import ssu.eatssu.domain.user.entity.Language; import ssu.eatssu.global.handler.response.BaseException; import ssu.eatssu.global.handler.response.BaseResponseStatus; import java.util.ArrayList; import java.util.Date; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_SUPPORT_RESTAURANT; @@ -42,6 +46,7 @@ public class MealService { private final MealRepository mealRepository; private final MealMenuRepository mealMenuRepository; private final MealMainMenuRepository mealMainMenuRepository; + private final ReviewRepository reviewRepository; private final MealRatingService mealRatingService; private final MenuService menuService; @@ -102,11 +107,87 @@ public MealCreateResult createMeal(Date date, Restaurant restaurant, TimePart ti @Transactional public MealCreateResult createMealWithPrice(Date date, Restaurant restaurant, TimePart timePart, - MealCreateWithPriceRequest request) { + MealCreateWithPriceRequest request) { return createMealWithOptionalPrice(date, restaurant, timePart, request.menuNames(), request.price(), request.mainMenus()); } + @Transactional + public MealSlotReconcileResult reconcileMealSlot(Date date, Restaurant restaurant, TimePart timePart, + List requests) { + validateMealRestaurant(restaurant); + validateSlotRequests(requests); + + List existingMeals = mealRepository.findAllByDateAndTimePartAndRestaurant(date, timePart, restaurant); + Set matchedMealIds = new HashSet<>(); + List mealIds = new ArrayList<>(); + List> unmatchedMainMenus = new ArrayList<>(); + + for (MealCreateWithPriceRequest request : requests) { + Meal existingMeal = findMatchingMeal(existingMeals, matchedMealIds, request.menuNames()).orElse(null); + Long mealId; + if (existingMeal != null) { + existingMeal.updatePrice(request.price()); + mealId = existingMeal.getId(); + matchedMealIds.add(mealId); + } else { + mealId = createNewMeal(date, restaurant, timePart, request.menuNames(), request.price()); + } + + mealIds.add(mealId); + unmatchedMainMenus.add(upsertMainMenus(mealId, request.menuNames(), request.mainMenus())); + } + + List deletedMealIds = new ArrayList<>(); + List keptWithReviews = new ArrayList<>(); + for (Meal existingMeal : existingMeals) { + Long mealId = existingMeal.getId(); + if (matchedMealIds.contains(mealId)) { + continue; + } + if (reviewRepository.existsByMeal_Id(mealId)) { + keptWithReviews.add(mealId); + continue; + } + + mealMainMenuRepository.deleteAllByMeal_Id(mealId); + mealMainMenuRepository.flush(); + mealRepository.delete(existingMeal); + deletedMealIds.add(mealId); + } + + return new MealSlotReconcileResult(mealIds, unmatchedMainMenus, deletedMealIds, keptWithReviews); + } + + private void validateSlotRequests(List requests) { + if (requests == null || requests.isEmpty()) { + throw new BaseException(BaseResponseStatus.BAD_REQUEST); + } + + Set> menuNameSets = new HashSet<>(); + for (MealCreateWithPriceRequest request : requests) { + if (request == null || request.menuNames() == null || request.menuNames().isEmpty() + || request.menuNames().stream().anyMatch(name -> name == null || name.isBlank())) { + throw new BaseException(BaseResponseStatus.BAD_REQUEST); + } + if (!menuNameSets.add(sortedMenuNames(request.menuNames()))) { + throw new BaseException(BaseResponseStatus.BAD_REQUEST); + } + } + } + + private Optional findMatchingMeal(List meals, Set matchedMealIds, List menuNames) { + List sortedRequestMenuNames = sortedMenuNames(menuNames); + return meals.stream() + .filter(meal -> !matchedMealIds.contains(meal.getId())) + .filter(meal -> sortedMenuNames(meal.getMenuNames()).equals(sortedRequestMenuNames)) + .findFirst(); + } + + private List sortedMenuNames(List menuNames) { + return menuNames.stream().sorted().toList(); + } + private MealCreateResult createMealWithOptionalPrice(Date date, Restaurant restaurant, TimePart timePart, List menuNames, Integer price, List mainMenus) { @@ -159,16 +240,12 @@ private Optional getExistingMealId(Date date, TimePart timePart, Restauran List menuNames) { List meals = mealRepository.findAllByDateAndTimePartAndRestaurant(date, timePart, restaurant); - List sortedRequestMenuNames = menuNames.stream() - .sorted() - .toList(); + List sortedRequestMenuNames = sortedMenuNames(menuNames); return meals.stream() .filter(meal -> { - List sortedMenuNames = meal.getMenuNames().stream() - .sorted() - .toList(); - return sortedMenuNames.equals(sortedRequestMenuNames); + List existingMenuNames = sortedMenuNames(meal.getMenuNames()); + return existingMenuNames.equals(sortedRequestMenuNames); }) .findFirst() .map(Meal::getId); diff --git a/src/test/java/ssu/eatssu/domain/menu/service/MealServiceBranchTest.java b/src/test/java/ssu/eatssu/domain/menu/service/MealServiceBranchTest.java index 7cd83b9b..ea723a58 100644 --- a/src/test/java/ssu/eatssu/domain/menu/service/MealServiceBranchTest.java +++ b/src/test/java/ssu/eatssu/domain/menu/service/MealServiceBranchTest.java @@ -6,6 +6,7 @@ import ssu.eatssu.domain.menu.persistence.MealMenuRepository; import ssu.eatssu.domain.menu.persistence.MealRepository; import ssu.eatssu.domain.restaurant.entity.Restaurant; +import ssu.eatssu.domain.review.repository.ReviewRepository; import ssu.eatssu.global.handler.response.BaseException; import java.util.Date; @@ -18,8 +19,8 @@ class MealServiceBranchTest { @Test void 고정_식당의_식단은_조회하지_않는다() { MealService service = new MealService(mock(MealRepository.class), mock(MealMenuRepository.class), - mock(MealMainMenuRepository.class), mock(MealRatingService.class), - mock(MenuService.class)); + mock(MealMainMenuRepository.class), mock(ReviewRepository.class), + mock(MealRatingService.class), mock(MenuService.class)); assertThatThrownBy(() -> service.getMealDetailsByDateAndRestaurantAndTimePart(new Date(), Restaurant.FOOD_COURT, TimePart.LUNCH, null)) diff --git a/src/test/java/ssu/eatssu/domain/menu/service/MealSlotReconcileServiceTest.java b/src/test/java/ssu/eatssu/domain/menu/service/MealSlotReconcileServiceTest.java new file mode 100644 index 00000000..7422ae6e --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/menu/service/MealSlotReconcileServiceTest.java @@ -0,0 +1,228 @@ +package ssu.eatssu.domain.menu.service; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import ssu.eatssu.domain.menu.entity.Meal; +import ssu.eatssu.domain.menu.entity.MealMainMenu; +import ssu.eatssu.domain.menu.entity.constants.TimePart; +import ssu.eatssu.domain.menu.persistence.MealMainMenuRepository; +import ssu.eatssu.domain.menu.persistence.MealMenuRepository; +import ssu.eatssu.domain.menu.persistence.MealRepository; +import ssu.eatssu.domain.menu.persistence.MenuRepository; +import ssu.eatssu.domain.menu.presentation.dto.request.MainMenuRequest; +import ssu.eatssu.domain.menu.presentation.dto.request.MealCreateWithPriceRequest; +import ssu.eatssu.domain.menu.presentation.dto.response.MealSlotReconcileResult; +import ssu.eatssu.domain.restaurant.entity.Restaurant; +import ssu.eatssu.domain.review.entity.Review; +import ssu.eatssu.domain.review.repository.ReviewRepository; +import ssu.eatssu.global.handler.response.BaseException; + +import java.sql.Date; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +class MealSlotReconcileServiceTest { + + private static final Date DATE = Date.valueOf("2026-01-01"); + private static final Restaurant RESTAURANT = Restaurant.HAKSIK; + private static final TimePart TIME_PART = TimePart.LUNCH; + + @Autowired + private MealService mealService; + + @Autowired + private MealRepository mealRepository; + + @Autowired + private MealMenuRepository mealMenuRepository; + + @Autowired + private MealMainMenuRepository mealMainMenuRepository; + + @Autowired + private MenuRepository menuRepository; + + @Autowired + private ReviewRepository reviewRepository; + + @BeforeEach + void setUp() { + cleanUp(); + } + + @AfterEach + void tearDown() { + cleanUp(); + } + + private void cleanUp() { + reviewRepository.deleteAll(); + mealMainMenuRepository.deleteAll(); + mealMenuRepository.deleteAll(); + mealRepository.deleteAll(); + menuRepository.deleteAll(); + } + + @Test + void createsEveryMealInAnEmptySlot() { + // given + List requests = List.of( + request(List.of("돈까스", "김치"), 5000, List.of()), + request(List.of("제육볶음", "된장국"), 5500, List.of())); + + // when + MealSlotReconcileResult result = mealService.reconcileMealSlot(DATE, RESTAURANT, TIME_PART, requests); + + // then + assertThat(result.mealIds()).hasSize(2).doesNotHaveDuplicates(); + assertThat(result.unmatchedMainMenus()).containsExactly(List.of(), List.of()); + assertThat(result.deletedMealIds()).isEmpty(); + assertThat(result.keptWithReviews()).isEmpty(); + assertThat(mealRepository.findAll()).hasSize(2); + } + + @Test + void keepsIdenticalMealAndUpdatesItsPriceWithoutCreatingRows() { + // given + Long mealId = createMeal(request(List.of("돈까스", "김치"), 5000, null)); + long mealMenuCount = mealMenuRepository.count(); + + // when + MealSlotReconcileResult result = mealService.reconcileMealSlot(DATE, RESTAURANT, TIME_PART, + List.of(request(List.of("김치", "돈까스"), 6500, null))); + + // then + assertThat(result.mealIds()).containsExactly(mealId); + assertThat(mealRepository.count()).isOne(); + assertThat(mealMenuRepository.count()).isEqualTo(mealMenuCount); + assertThat(mealRepository.findById(mealId).orElseThrow().getPrice()).isEqualTo(6500); + } + + @Test + void replacesChangedMealAndKeepsMenuRows() { + // given + Long oldMealId = createMeal(request(List.of("돈까스"), 5000, null)); + + // when + MealSlotReconcileResult result = mealService.reconcileMealSlot(DATE, RESTAURANT, TIME_PART, + List.of(request(List.of("제육볶음"), 5500, null))); + + // then + assertThat(result.deletedMealIds()).containsExactly(oldMealId); + assertThat(result.mealIds()).hasSize(1).doesNotContain(oldMealId); + assertThat(mealRepository.findById(oldMealId)).isEmpty(); + assertThat(menuRepository.findAll()).extracting("name").containsExactlyInAnyOrder("돈까스", "제육볶음"); + } + + @Test + void keepsUnmatchedMealWhenItHasReview() { + // given + Long reviewedMealId = createMeal(request(List.of("돈까스"), 5000, null)); + Meal reviewedMeal = mealRepository.findById(reviewedMealId).orElseThrow(); + reviewRepository.save(Review.builder().meal(reviewedMeal).rating(5).content("맛있어요").build()); + + // when + MealSlotReconcileResult result = mealService.reconcileMealSlot(DATE, RESTAURANT, TIME_PART, + List.of(request(List.of("제육볶음"), 5500, null))); + + // then + assertThat(result.keptWithReviews()).containsExactly(reviewedMealId); + assertThat(result.deletedMealIds()).isEmpty(); + assertThat(mealRepository.findById(reviewedMealId)).isPresent(); + assertThat(mealRepository.count()).isEqualTo(2); + } + + @Test + void nullMainMenusKeepsExistingRowsAndEmptyMainMenusClearsThem() { + // given + Long mealId = createMeal(request(List.of("돈까스"), 5000, + List.of(new MainMenuRequest("돈까스", "Pork Cutlet")))); + + // when + mealService.reconcileMealSlot(DATE, RESTAURANT, TIME_PART, + List.of(request(List.of("돈까스"), 5000, null))); + + // then + assertThat(mealMainMenuRepository.findAllByMeal_Id(mealId)) + .extracting(MealMainMenu::getNameKo) + .containsExactly("돈까스"); + + // when + mealService.reconcileMealSlot(DATE, RESTAURANT, TIME_PART, + List.of(request(List.of("돈까스"), 5000, List.of()))); + + // then + assertThat(mealMainMenuRepository.findAllByMeal_Id(mealId)).isEmpty(); + } + + @Test + void returnsUnmatchedMainMenusForEachRequestInOrder() { + // given + List requests = List.of( + request(List.of("돈까스"), 5000, + List.of(new MainMenuRequest("없는 메뉴", "Unknown"))), + request(List.of("제육볶음"), 5500, + List.of(new MainMenuRequest("제육볶음", "Spicy Pork")))); + + // when + MealSlotReconcileResult result = mealService.reconcileMealSlot(DATE, RESTAURANT, TIME_PART, requests); + + // then + assertThat(result.unmatchedMainMenus()).containsExactly(List.of("없는 메뉴"), List.of()); + } + + @Test + void rejectsDuplicateMealsRegardlessOfMenuOrder() { + // given + List requests = List.of( + request(List.of("돈까스", "김치"), 5000, null), + request(List.of("김치", "돈까스"), 5500, null)); + + // when & then + assertThatThrownBy(() -> mealService.reconcileMealSlot(DATE, RESTAURANT, TIME_PART, requests)) + .isInstanceOf(BaseException.class); + assertThat(mealRepository.findAll()).isEmpty(); + } + + @Test + void rejectsEmptyOrBlankMenuNames() { + // given + List> invalidRequests = List.of( + List.of(), + List.of(request(List.of(), 5000, null)), + List.of(request(List.of(" "), 5000, null)), + List.of(request(Arrays.asList("돈까스", null), 5000, null))); + + // when & then + for (List requests : invalidRequests) { + assertThatThrownBy(() -> mealService.reconcileMealSlot(DATE, RESTAURANT, TIME_PART, requests)) + .isInstanceOf(BaseException.class); + } + } + + @Test + void rejectsFixedRestaurant() { + // given + List requests = List.of(request(List.of("돈까스"), 5000, null)); + + // when & then + assertThatThrownBy(() -> mealService.reconcileMealSlot(DATE, Restaurant.FOOD_COURT, TIME_PART, requests)) + .isInstanceOf(BaseException.class); + } + + private Long createMeal(MealCreateWithPriceRequest request) { + return mealService.createMealWithPrice(DATE, RESTAURANT, TIME_PART, request).mealId(); + } + + private MealCreateWithPriceRequest request(List menuNames, Integer price, + List mainMenus) { + return new MealCreateWithPriceRequest(menuNames, price, mainMenus); + } +} From fd594124605c2635e42276d459c7896805efbefe Mon Sep 17 00:00:00 2001 From: rover0811 Date: Tue, 6 Oct 2026 14:44:31 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EC=8B=9D=EB=8B=A8=20=EC=8A=AC?= =?UTF-8?q?=EB=A1=AF=20=EC=A1=B0=EC=A0=95=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/rest/MealController.java | 16 +++++ .../rest/docs/MealControllerDocs.java | 21 ++++++ .../presentation/rest/MealControllerTest.java | 69 +++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/src/main/java/ssu/eatssu/domain/menu/presentation/rest/MealController.java b/src/main/java/ssu/eatssu/domain/menu/presentation/rest/MealController.java index efac5fbf..3d1f0941 100644 --- a/src/main/java/ssu/eatssu/domain/menu/presentation/rest/MealController.java +++ b/src/main/java/ssu/eatssu/domain/menu/presentation/rest/MealController.java @@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -15,6 +16,7 @@ import ssu.eatssu.domain.menu.presentation.dto.request.MealCreateWithPriceRequest; import ssu.eatssu.domain.menu.presentation.dto.response.MealCreateResult; import ssu.eatssu.domain.menu.presentation.dto.response.MealDetailResponse; +import ssu.eatssu.domain.menu.presentation.dto.response.MealSlotReconcileResult; import ssu.eatssu.domain.menu.presentation.dto.response.MenusInMealResponse; import ssu.eatssu.domain.menu.presentation.rest.docs.MealControllerDocs; import ssu.eatssu.domain.menu.service.MealService; @@ -64,6 +66,20 @@ public BaseResponse createMealWithPrice( return BaseResponse.success(mealService.createMealWithPrice(date, restaurant, timePart, request)); } + @Override + @PutMapping("/with-price/slot") + public BaseResponse reconcileMealSlot( + @RequestParam("date") @DateTimeFormat(pattern = "yyyyMMdd") Date date, + @RequestParam("restaurant") Restaurant restaurant, + @RequestParam("time") TimePart timePart, + @RequestBody List requests) { + if (RestaurantType.isFixedType(restaurant)) { + throw new BaseException(NOT_SUPPORT_RESTAURANT); + } + + return BaseResponse.success(mealService.reconcileMealSlot(date, restaurant, timePart, requests)); + } + @Override @GetMapping("") public BaseResponse> getMealDetail( diff --git a/src/main/java/ssu/eatssu/domain/menu/presentation/rest/docs/MealControllerDocs.java b/src/main/java/ssu/eatssu/domain/menu/presentation/rest/docs/MealControllerDocs.java index cbc3fbcf..f89549d1 100644 --- a/src/main/java/ssu/eatssu/domain/menu/presentation/rest/docs/MealControllerDocs.java +++ b/src/main/java/ssu/eatssu/domain/menu/presentation/rest/docs/MealControllerDocs.java @@ -12,6 +12,7 @@ import ssu.eatssu.domain.menu.presentation.dto.request.MealCreateWithPriceRequest; import ssu.eatssu.domain.menu.presentation.dto.response.MealCreateResult; import ssu.eatssu.domain.menu.presentation.dto.response.MealDetailResponse; +import ssu.eatssu.domain.menu.presentation.dto.response.MealSlotReconcileResult; import ssu.eatssu.domain.menu.presentation.dto.response.MenusInMealResponse; import ssu.eatssu.domain.restaurant.entity.Restaurant; import ssu.eatssu.domain.user.entity.Language; @@ -64,6 +65,26 @@ BaseResponse createMealWithPrice( @Parameter(description = "시간대") TimePart timePart, MealCreateWithPriceRequest request); + @Operation(summary = "식단 슬롯 조정 [인증 토큰 필요 X]", description = """ + 특정 날짜, 식당, 시간대의 식단 목록을 요청 본문과 일치하도록 조정합니다.

+ 같은 메뉴명 목록의 식단은 유지하고 가격과 대표메뉴를 갱신합니다.

+ 요청에서 빠진 식단은 리뷰가 없을 때만 삭제합니다. + """) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "식단 슬롯 조정 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 또는 지원하지 않는 식당", content = + @Content(schema = @Schema(implementation = BaseResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 날짜형식", content = @Content(schema = + @Schema(implementation = BaseResponse.class))), + @ApiResponse(responseCode = "404", description = "존재하지 않는 식당", content = @Content(schema = + @Schema(implementation = BaseResponse.class))) + }) + BaseResponse reconcileMealSlot( + @Parameter(schema = @Schema(type = "string", format = "date", example = "20240101")) Date date, + @Parameter(description = "식당이름") Restaurant restaurant, + @Parameter(description = "시간대") TimePart timePart, + List requests); + @Operation(summary = "변동 메뉴 식단 리스트 조회 [인증 토큰 필요 X]", description = """ 변동 메뉴 식단 리스트를 조회하는 API 입니다. 변동 메뉴 식당 (학생 식당, 도담, 기숙사 식당) 의 특정날짜(yyyyMMdd), 특정시간대(아침/점심/저녁)에 해당하는 식단 목록을 조회합니다. diff --git a/src/test/java/ssu/eatssu/domain/menu/presentation/rest/MealControllerTest.java b/src/test/java/ssu/eatssu/domain/menu/presentation/rest/MealControllerTest.java index 5827fb87..dd0ae8ff 100644 --- a/src/test/java/ssu/eatssu/domain/menu/presentation/rest/MealControllerTest.java +++ b/src/test/java/ssu/eatssu/domain/menu/presentation/rest/MealControllerTest.java @@ -9,11 +9,14 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import ssu.eatssu.domain.menu.entity.constants.TimePart; +import ssu.eatssu.domain.menu.presentation.dto.response.MealSlotReconcileResult; import ssu.eatssu.domain.menu.presentation.dto.response.MealCreateResult; import ssu.eatssu.domain.menu.presentation.dto.response.MenusInMealResponse; import ssu.eatssu.domain.menu.service.MealService; import ssu.eatssu.domain.restaurant.entity.Restaurant; import ssu.eatssu.global.handler.GlobalExceptionHandler; +import ssu.eatssu.global.handler.response.BaseException; +import ssu.eatssu.global.handler.response.BaseResponseStatus; import java.util.List; @@ -26,6 +29,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; 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.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -104,6 +108,71 @@ void returnsBadRequestWhenCreatingMealWithPriceForFixedRestaurant() throws Excep verifyNoInteractions(mealService); } + @Test + void reconcilesMealSlot() throws Exception { + // given + when(mealService.reconcileMealSlot(any(), eq(Restaurant.DODAM), eq(TimePart.LUNCH), any())) + .thenReturn(new MealSlotReconcileResult(List.of(1L, 2L), List.of(List.of(), List.of("김치")), + List.of(3L), List.of(4L))); + + // when & then + mockMvc.perform(put("/meals/with-price/slot") + .param("date", "20260101") + .param("restaurant", Restaurant.DODAM.name()) + .param("time", TimePart.LUNCH.name()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + [ + {"menuNames":["돈까스"],"price":3000,"mainMenus":[]}, + {"menuNames":["제육볶음"],"price":3500,"mainMenus":[]} + ] + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.mealIds[0]").value(1)) + .andExpect(jsonPath("$.result.mealIds[1]").value(2)) + .andExpect(jsonPath("$.result.unmatchedMainMenus[1][0]").value("김치")) + .andExpect(jsonPath("$.result.deletedMealIds[0]").value(3)) + .andExpect(jsonPath("$.result.keptWithReviews[0]").value(4)); + } + + @Test + void returnsBadRequestWhenReconcilingDuplicateMeals() throws Exception { + // given + when(mealService.reconcileMealSlot(any(), eq(Restaurant.DODAM), eq(TimePart.LUNCH), any())) + .thenThrow(new BaseException(BaseResponseStatus.BAD_REQUEST)); + + // when & then + mockMvc.perform(put("/meals/with-price/slot") + .param("date", "20260101") + .param("restaurant", Restaurant.DODAM.name()) + .param("time", TimePart.LUNCH.name()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + [ + {"menuNames":["돈까스","김치"],"price":3000}, + {"menuNames":["김치","돈까스"],"price":3000} + ] + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)); + } + + @Test + void returnsBadRequestWhenReconcilingFixedRestaurant() throws Exception { + // when & then + mockMvc.perform(put("/meals/with-price/slot") + .param("date", "20260101") + .param("restaurant", Restaurant.FOOD_COURT.name()) + .param("time", TimePart.LUNCH.name()) + .contentType(MediaType.APPLICATION_JSON) + .content("[{\"menuNames\":[\"돈까스\"],\"price\":3000}]")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)); + + verifyNoInteractions(mealService); + } + @Test void returnsMealDetails() throws Exception { when(mealService.getMealDetailsByDateAndRestaurantAndTimePart(any(), eq(Restaurant.DODAM),