diff --git a/backend/src/main/java/net/pengcook/category/controller/CategoryController.java b/backend/src/main/java/net/pengcook/category/controller/CategoryController.java new file mode 100644 index 00000000..fe944801 --- /dev/null +++ b/backend/src/main/java/net/pengcook/category/controller/CategoryController.java @@ -0,0 +1,28 @@ +package net.pengcook.category.controller; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import net.pengcook.category.dto.RecipeOfCategoryRequest; +import net.pengcook.category.service.CategoryService; +import net.pengcook.recipe.dto.MainRecipeResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/categories") +public class CategoryController { + + private final CategoryService categoryService; + + @GetMapping + public List readRecipesOfCategory( + @RequestParam String category, + @RequestParam int pageNumber, + @RequestParam int pageSize) { + RecipeOfCategoryRequest request = new RecipeOfCategoryRequest(category, pageNumber, pageSize); + return categoryService.readRecipesOfCategory(request); + } +} diff --git a/backend/src/main/java/net/pengcook/category/dto/RecipeOfCategoryRequest.java b/backend/src/main/java/net/pengcook/category/dto/RecipeOfCategoryRequest.java new file mode 100644 index 00000000..206fa4f8 --- /dev/null +++ b/backend/src/main/java/net/pengcook/category/dto/RecipeOfCategoryRequest.java @@ -0,0 +1,4 @@ +package net.pengcook.category.dto; + +public record RecipeOfCategoryRequest(String category, int pageNumber, int pageSize) { +} diff --git a/backend/src/main/java/net/pengcook/category/repository/CategoryRecipeRepository.java b/backend/src/main/java/net/pengcook/category/repository/CategoryRecipeRepository.java index e13df319..e5216455 100644 --- a/backend/src/main/java/net/pengcook/category/repository/CategoryRecipeRepository.java +++ b/backend/src/main/java/net/pengcook/category/repository/CategoryRecipeRepository.java @@ -1,7 +1,18 @@ package net.pengcook.category.repository; +import java.util.List; import net.pengcook.category.domain.CategoryRecipe; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface CategoryRecipeRepository extends JpaRepository { + + @Query(""" + SELECT cr.recipe.id + FROM CategoryRecipe cr + JOIN Category c ON cr.category.id = c.id + WHERE c.name = :categoryName + """) + List findRecipeIdsByCategoryName(String categoryName, Pageable pageable); } diff --git a/backend/src/main/java/net/pengcook/category/service/CategoryService.java b/backend/src/main/java/net/pengcook/category/service/CategoryService.java index 01e9f994..535d85f4 100644 --- a/backend/src/main/java/net/pengcook/category/service/CategoryService.java +++ b/backend/src/main/java/net/pengcook/category/service/CategoryService.java @@ -4,9 +4,16 @@ import lombok.RequiredArgsConstructor; import net.pengcook.category.domain.Category; import net.pengcook.category.domain.CategoryRecipe; +import net.pengcook.category.dto.RecipeOfCategoryRequest; import net.pengcook.category.repository.CategoryRecipeRepository; import net.pengcook.category.repository.CategoryRepository; import net.pengcook.recipe.domain.Recipe; +import net.pengcook.recipe.dto.MainRecipeResponse; +import net.pengcook.recipe.dto.RecipeDataResponse; +import net.pengcook.recipe.repository.RecipeRepository; +import net.pengcook.recipe.service.RecipeService; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @Service @@ -15,11 +22,22 @@ public class CategoryService { private final CategoryRepository categoryRepository; private final CategoryRecipeRepository categoryRecipeRepository; + private final RecipeRepository recipeRepository; + private final RecipeService recipeService; public void saveCategories(Recipe recipe, List categories) { categories.forEach(category -> saveCategoryRecipe(recipe, category)); } + public List readRecipesOfCategory(RecipeOfCategoryRequest request) { + String categoryName = request.category(); + Pageable pageable = PageRequest.of(request.pageNumber(), request.pageSize()); + List recipeIds = categoryRecipeRepository.findRecipeIdsByCategoryName(categoryName, pageable); + + List recipeDataResponses = recipeRepository.findRecipeData(recipeIds); + return recipeService.convertToMainRecipeResponses(recipeDataResponses); + } + private void saveCategoryRecipe(Recipe recipe, String name) { Category category = categoryRepository.findByName(name) .orElseGet(() -> categoryRepository.save(new Category(name))); diff --git a/backend/src/test/java/net/pengcook/category/controller/CategoryControllerTest.java b/backend/src/test/java/net/pengcook/category/controller/CategoryControllerTest.java new file mode 100644 index 00000000..f07fd3f3 --- /dev/null +++ b/backend/src/test/java/net/pengcook/category/controller/CategoryControllerTest.java @@ -0,0 +1,38 @@ +package net.pengcook.category.controller; + +import static org.hamcrest.Matchers.is; + +import io.restassured.RestAssured; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.jdbc.Sql; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@Sql(value = "/data/category.sql") +class CategoryControllerTest { + + @LocalServerPort + private int port; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @Test + @DisplayName("레시피 개요 목록을 조회한다.") + void readRecipes() { + RestAssured.given().log().all() + .queryParam("category", "한식") + .queryParam("pageNumber", 0) + .queryParam("pageSize", 3) + .when() + .get("/api/categories") + .then().log().all() + .body("size()", is(3)); + } +} diff --git a/backend/src/test/java/net/pengcook/category/repository/CategoryRecipeRepositoryTest.java b/backend/src/test/java/net/pengcook/category/repository/CategoryRecipeRepositoryTest.java new file mode 100644 index 00000000..97bfbb6c --- /dev/null +++ b/backend/src/test/java/net/pengcook/category/repository/CategoryRecipeRepositoryTest.java @@ -0,0 +1,30 @@ +package net.pengcook.category.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.jdbc.Sql; + +@DataJpaTest +@Sql("/data/category.sql") +class CategoryRecipeRepositoryTest { + + @Autowired + private CategoryRecipeRepository repository; + + @Test + @DisplayName("요청한 카테고리와 페이지에 해당하는 레시피 id 목록을 반환한다.") + void findRecipeIds() { + Pageable pageable = PageRequest.of(0, 3); + + List recipeIds = repository.findRecipeIdsByCategoryName("한식", pageable); + + assertThat(recipeIds).containsExactlyInAnyOrder(2L, 3L, 7L); + } +} diff --git a/backend/src/test/java/net/pengcook/category/service/CategoryServiceTest.java b/backend/src/test/java/net/pengcook/category/service/CategoryServiceTest.java index 116db5ca..839fa0d3 100644 --- a/backend/src/test/java/net/pengcook/category/service/CategoryServiceTest.java +++ b/backend/src/test/java/net/pengcook/category/service/CategoryServiceTest.java @@ -6,25 +6,31 @@ import java.time.LocalDate; import java.time.LocalTime; import java.util.List; +import java.util.stream.Stream; +import net.pengcook.category.dto.RecipeOfCategoryRequest; import net.pengcook.category.repository.CategoryRecipeRepository; import net.pengcook.category.repository.CategoryRepository; import net.pengcook.recipe.domain.Recipe; +import net.pengcook.recipe.dto.MainRecipeResponse; +import net.pengcook.recipe.service.RecipeService; import net.pengcook.user.domain.User; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.jdbc.Sql; @DataJpaTest -@Import(CategoryService.class) +@Import({CategoryService.class, RecipeService.class}) @Sql(scripts = "/data/category.sql") class CategoryServiceTest { - private final long INITIAL_CATEGORY_COUNT = 5; - private final long INITIAL_CATEGORY_RECIPE_COUNT = 0; + private final long INITIAL_CATEGORY_COUNT = 10; + private final long INITIAL_CATEGORY_RECIPE_COUNT = 25; @Autowired private CategoryService categoryService; @@ -39,11 +45,34 @@ void saveCategories() { User author = new User("ela@pengcook.net", "ela", "엘라", "ela.jpg", LocalDate.of(2024, 7, 22), "KOREA"); Recipe recipe = new Recipe(1L, "김치볶음밥", author, LocalTime.of(0, 30, 0), "김치볶음밥이미지.jpg", 3, 2, "김치볶음밥 조리법"); - categoryService.saveCategories(recipe, List.of("한식", "매운음식")); + categoryService.saveCategories(recipe, List.of("건강식", "매운음식")); assertAll( () -> assertThat(categoryRepository.count()).isEqualTo(INITIAL_CATEGORY_COUNT + 1), () -> assertThat(categoryRecipeRepository.count()).isEqualTo(INITIAL_CATEGORY_RECIPE_COUNT + 2) ); } + + @ParameterizedTest + @MethodSource("provideParameters") + @DisplayName("특정 카테고리의 레시피를 찾는다.") + void readRecipesOfCategory(int pageNumber, int pageSize, List expected) { + RecipeOfCategoryRequest request = new RecipeOfCategoryRequest("한식", pageNumber, pageSize); + + List mainRecipeResponses = categoryService.readRecipesOfCategory(request); + List actual = mainRecipeResponses.stream().map(MainRecipeResponse::recipeId).toList(); + + assertAll( + () -> assertThat(actual.size()).isEqualTo(pageSize), + () -> assertThat(actual).containsAll(expected) + ); + } + + static Stream provideParameters() { + return Stream.of( + Arguments.of(0, 2, List.of(2L, 3L)), + Arguments.of(1, 2, List.of(7L, 9L)), + Arguments.of(1, 3, List.of(9L, 14L, 15L)) + ); + } } diff --git a/backend/src/test/resources/data/category.sql b/backend/src/test/resources/data/category.sql index beb15140..10138fce 100644 --- a/backend/src/test/resources/data/category.sql +++ b/backend/src/test/resources/data/category.sql @@ -1,24 +1,32 @@ -SET REFERENTIAL_INTEGRITY FALSE; +SET +REFERENTIAL_INTEGRITY FALSE; TRUNCATE TABLE users; -ALTER TABLE users ALTER COLUMN id RESTART; +ALTER TABLE users + ALTER COLUMN id RESTART; TRUNCATE TABLE category; -ALTER TABLE category ALTER COLUMN id RESTART; +ALTER TABLE category + ALTER COLUMN id RESTART; TRUNCATE TABLE ingredient; -ALTER TABLE ingredient ALTER COLUMN id RESTART; +ALTER TABLE ingredient + ALTER COLUMN id RESTART; TRUNCATE TABLE recipe; -ALTER TABLE recipe ALTER COLUMN id RESTART; +ALTER TABLE recipe + ALTER COLUMN id RESTART; TRUNCATE TABLE category_recipe; -ALTER TABLE category_recipe ALTER COLUMN id RESTART; +ALTER TABLE category_recipe + ALTER COLUMN id RESTART; TRUNCATE TABLE ingredient_recipe; -ALTER TABLE ingredient_recipe ALTER COLUMN id RESTART; +ALTER TABLE ingredient_recipe + ALTER COLUMN id RESTART; -SET REFERENTIAL_INTEGRITY TRUE; +SET +REFERENTIAL_INTEGRITY TRUE; INSERT INTO users (email, username, nickname, image, birth, region) VALUES ('ela@pengcook.net', 'ela', '엘라', 'ela.jpg', '2024-07-22', 'KOREA'); @@ -28,7 +36,108 @@ VALUES ('한식'), ('양식'), ('채식'), ('건강식'), - ('간편식'); + ('간편식'), + ('디저트'), + ('해산물'), + ('면요리'), + ('샐러드'), + ('스프'); INSERT INTO recipe (title, author_id, cooking_time, thumbnail, difficulty, like_count, description) -VALUES ('김치볶음밥', 1, '00:30:00', '김치볶음밥이미지.jpg', 3, 2, '김치볶음밥 조리법'); +VALUES ('김치볶음밥', 1, '00:30:00', '김치볶음밥이미지.jpg', 3, 2, '김치볶음밥 조리법'), + ('김밥', 1, '01:00:00', '김밥이미지.jpg', 8, 1, '김밥 조리법'), + ('김치찌개', 1, '00:30:00', '김치찌개이미지.jpg', 3, 2, '김치찌개 조리법'), + ('토마토스파게티', 1, '00:30:00', '토마토스파게티이미지.jpg', 3, 2, '토마토스파게티 조리법'), + ('간장계란밥', 1, '00:10:00', '간장계란밥이미지.jpg', 1, 3, '간장계란밥 조리법'), + ('피자', 1, '00:30:00', '피자이미지.jpg', 3, 2, '피자 조리법'), + ('된장찌개', 1, '00:30:00', '된장찌개이미지.jpg', 3, 2, '된장찌개 조리법'), + ('햄버거', 1, '00:30:00', '햄버거이미지.jpg', 3, 2, '햄버거 조리법'), + ('흰쌀밥', 1, '00:40:00', '흰쌀밥이미지.jpg', 2, 4, '흰쌀밥 조리법'), + ('샐러드', 1, '00:15:00', '샐러드이미지.jpg', 1, 5, '샐러드 조리법'), + ('연어스테이크', 1, '00:45:00', '연어스테이크이미지.jpg', 4, 3, '연어스테이크 조리법'), + ('초콜릿 케이크', 1, '01:20:00', '초콜릿케이크이미지.jpg', 5, 6, '초콜릿 케이크 조리법'), + ('베지터블 스프', 1, '00:50:00', '베지터블스프이미지.jpg', 3, 2, '베지터블 스프 조리법'), + ('카레라이스', 1, '00:30:00', '카레라이스이미지.jpg', 3, 2, '카레라이스 조리법'), + ('새우볶음밥', 1, '00:25:00', '새우볶음밥이미지.jpg', 2, 3, '새우볶음밥 조리법'); + +INSERT INTO category_recipe (category_id, recipe_id) +VALUES (1, 2), -- 김밥은 한식 + (3, 2), -- 김밥은 채식 + (1, 3), -- 김치찌개는 한식 + (5, 3), -- 김치찌개는 간편식 + (2, 4), -- 토마토스파게티는 양식 + (4, 5), -- 간장계란밥은 건강식 + (5, 5), -- 간장계란밥은 간편식 + (2, 6), -- 피자는 양식 + (5, 6), -- 피자는 간편식 + (1, 7), -- 된장찌개는 한식 + (5, 7), -- 된장찌개는 간편식 + (2, 8), -- 햄버거는 양식 + (5, 8), -- 햄버거는 간편식 + (1, 9), -- 흰쌀밥은 한식 + (9, 10), -- 샐러드는 샐러드 + (4, 10), -- 샐러드는 건강식 + (7, 11), -- 연어스테이크는 해산물 + (2, 11), -- 연어스테이크는 양식 + (6, 12), -- 초콜릿 케이크는 디저트 + (10, 13),-- 베지터블 스프는 스프 + (3, 13), -- 베지터블 스프는 채식 + (1, 14), -- 카레라이스는 한식 + (5, 14), -- 카레라이스는 간편식 + (7, 15), -- 새우볶음밥은 해산물 + (1, 15); -- 새우볶음밥은 한식 + +INSERT INTO ingredient (name) +VALUES ('김'), + ('쌀'), + ('계란'), + ('김치'), + ('오이'), + ('후추'), + ('간장'), + ('소금'), + ('햄'), + ('토마토'), + ('밀가루'), + ('새우'), + ('버터'), + ('설탕'), + ('초콜릿'), + ('버섯'), + ('양파'), + ('피망'); + +INSERT INTO ingredient_recipe (ingredient_id, recipe_id, requirement) +VALUES (1, 1, 'REQUIRED'), -- 김치볶음밥 + (2, 1, 'REQUIRED'), -- 김치볶음밥 + (3, 1, 'ALTERNATIVE'), -- 김치볶음밥 + (4, 1, 'OPTIONAL'), -- 김치볶음밥 + (2, 2, 'REQUIRED'), -- 김밥 + (3, 2, 'OPTIONAL'), -- 김밥 + (4, 2, 'REQUIRED'), -- 김밥 + (2, 3, 'REQUIRED'), -- 김치찌개 + (3, 3, 'REQUIRED'), -- 김치찌개 + (7, 3, 'REQUIRED'), -- 김치찌개 + (2, 4, 'REQUIRED'), -- 토마토스파게티 + (5, 4, 'OPTIONAL'), -- 토마토스파게티 + (8, 5, 'REQUIRED'), -- 간장계란밥 + (3, 5, 'REQUIRED'), -- 간장계란밥 + (9, 6, 'REQUIRED'), -- 피자 + (6, 6, 'OPTIONAL'), -- 피자 + (4, 7, 'REQUIRED'), -- 된장찌개 + (2, 7, 'REQUIRED'), -- 된장찌개 + (8, 8, 'OPTIONAL'), -- 햄버거 + (9, 8, 'REQUIRED'), -- 햄버거 + (2, 9, 'REQUIRED'), -- 흰쌀밥 + (1, 9, 'OPTIONAL'), -- 흰쌀밥 + (5, 10, 'REQUIRED'), -- 샐러드 + (8, 10, 'OPTIONAL'), -- 샐러드 + (10, 4, 'REQUIRED'), -- 토마토스파게티 + (11, 6, 'REQUIRED'), -- 피자 + (12, 15, 'REQUIRED'), -- 새우볶음밥 + (13, 15, 'OPTIONAL'), -- 새우볶음밥 + (14, 12, 'REQUIRED'), -- 초콜릿 케이크 + (15, 12, 'REQUIRED'), -- 초콜릿 케이크 + (16, 13, 'REQUIRED'), -- 베지터블 스프 + (17, 13, 'REQUIRED'), -- 베지터블 스프 + (18, 14, 'OPTIONAL'); -- 카레라이스