From 24209e779776cf4207c2697064617986c56ec33f Mon Sep 17 00:00:00 2001 From: Hyunjoon Choi Date: Sun, 6 Oct 2024 23:01:35 +0900 Subject: [PATCH] =?UTF-8?q?[#20]=20=EC=9D=8C=EC=8B=9D=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EA=B5=AC=ED=98=84=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Food 도메인 작성 - Food 도메인 작성 - 하위 VO 작성 - 필요한 예외 등록 * feat: Food 컨트롤러/서비스/리포지터리 작성 - Food 컨트롤러 코드 작성 - Food 서비스 코드 작성 - Food 리포지터리 코드 작성 - 인터셉터 등록 - FoodServiceTest 코드 문제 수정 - FakeFoodRepository 초안 작성 - 요청 dto, 응답 dto 작성 - createdAt 추가 * test: Food 테스트 작성 - Food 단위 테스트 작성 - 하위 VO 테스트 작성 * test: FoodService 음식 생성 테스트 작성 - FoodService 음식 생성 테스트 작성 - FakeFoodRepository 기능 작성 - SuperBuilder 적용 * test: FoodController 통합 테스트 추가 작성 - FoodController 음식 생성 테스트 작성 - API 문서화 반영 - 필요한 fixture 등록 * test: FoodJpaRepository 테스트 작성 - FoodJpaRepository 테스트 작성 * docs: food.sql 작성 - Food DDL 작성 * fix: url 정보 추가 - 음식 url 정보 추가 * refactor: FoodServiceTest 중복 제거 - FoodServiceTest 중복 제거 및 fixture 등록 * refactor: FoodControllerWebMvcTest 픽스처 이용 - FoodControllerWebMvcTest에 fixture 이용되도록 수정 * refactor: FoodCreateRequestFixture 중복 제거 - FoodCreateRequestFixture 중복 제거하고 다른 방식으로 작성 --- src/docs/asciidoc/food.adoc | 12 ++++ .../food/application/FoodService.java | 26 ++++++- .../application/dto/FoodCreateRequest.java | 43 ++++++++++++ .../com/flab/eattofit/food/domain/Food.java | 59 ++++++++++++++++ .../eattofit/food/domain/FoodRepository.java | 6 ++ .../eattofit/food/domain/vo/FoodNutrient.java | 50 ++++++++++++++ .../eattofit/food/domain/vo/FoodUnit.java | 29 ++++++++ .../eattofit/food/domain/vo/FoodWeight.java | 30 +++++++++ .../exception/FoodUnitNotFoundException.java | 11 +++ .../infrastructure/FoodJpaRepository.java | 9 +++ .../infrastructure/FoodRepositoryImpl.java | 18 +++++ .../flab/eattofit/food/ui/FoodController.java | 18 +++++ .../food/ui/dto/FoodCreateResponse.java | 42 ++++++++++++ .../eattofit/global/domain/BaseEntity.java | 6 ++ .../member/config/auth/MemberAuthConfig.java | 2 +- .../resources/db/migration/V7__Add_Food.sql | 14 ++++ .../food/application/FoodServiceTest.java | 53 ++++++++++++++- .../flab/eattofit/food/domain/FoodTest.java | 51 ++++++++++++++ .../food/domain/vo/FoodNutrientTest.java | 40 +++++++++++ .../eattofit/food/domain/vo/FoodUnitTest.java | 44 ++++++++++++ .../food/domain/vo/FoodWeightTest.java | 47 +++++++++++++ .../fixture/FoodCreateRequestFixture.java | 51 ++++++++++++++ .../eattofit/food/fixture/FoodFixture.java | 36 ++++++++++ .../infrastructure/FakeFoodRepository.java | 28 ++++++++ .../infrastructure/FoodJpaRepositoryTest.java | 47 +++++++++++++ .../food/ui/FoodControllerWebMvcTest.java | 67 ++++++++++++++++++- 26 files changed, 834 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/flab/eattofit/food/application/dto/FoodCreateRequest.java create mode 100644 src/main/java/com/flab/eattofit/food/domain/Food.java create mode 100644 src/main/java/com/flab/eattofit/food/domain/FoodRepository.java create mode 100644 src/main/java/com/flab/eattofit/food/domain/vo/FoodNutrient.java create mode 100644 src/main/java/com/flab/eattofit/food/domain/vo/FoodUnit.java create mode 100644 src/main/java/com/flab/eattofit/food/domain/vo/FoodWeight.java create mode 100644 src/main/java/com/flab/eattofit/food/exception/FoodUnitNotFoundException.java create mode 100644 src/main/java/com/flab/eattofit/food/infrastructure/FoodJpaRepository.java create mode 100644 src/main/java/com/flab/eattofit/food/infrastructure/FoodRepositoryImpl.java create mode 100644 src/main/java/com/flab/eattofit/food/ui/dto/FoodCreateResponse.java create mode 100644 src/main/resources/db/migration/V7__Add_Food.sql create mode 100644 src/test/java/com/flab/eattofit/food/domain/FoodTest.java create mode 100644 src/test/java/com/flab/eattofit/food/domain/vo/FoodNutrientTest.java create mode 100644 src/test/java/com/flab/eattofit/food/domain/vo/FoodUnitTest.java create mode 100644 src/test/java/com/flab/eattofit/food/domain/vo/FoodWeightTest.java create mode 100644 src/test/java/com/flab/eattofit/food/fixture/FoodCreateRequestFixture.java create mode 100644 src/test/java/com/flab/eattofit/food/fixture/FoodFixture.java create mode 100644 src/test/java/com/flab/eattofit/food/infrastructure/FakeFoodRepository.java create mode 100644 src/test/java/com/flab/eattofit/food/infrastructure/FoodJpaRepositoryTest.java diff --git a/src/docs/asciidoc/food.adoc b/src/docs/asciidoc/food.adoc index eafb28b..0e0d173 100644 --- a/src/docs/asciidoc/food.adoc +++ b/src/docs/asciidoc/food.adoc @@ -7,6 +7,18 @@ == Food +=== 음식 생성 (POST /api/foods) + +==== 요청 +include::{snippets}/food-controller-web-mvc-test/음식 생성/http-request.adoc[] +include::{snippets}/food-controller-web-mvc-test/음식 생성/request-headers.adoc[] +include::{snippets}/food-controller-web-mvc-test/음식 생성/request-fields.adoc[] + +==== 응답 +include::{snippets}/food-controller-web-mvc-test/음식 생성/http-response.adoc[] +include::{snippets}/food-controller-web-mvc-test/음식 생성/response-headers.adoc[] +include::{snippets}/food-controller-web-mvc-test/음식 생성/response-fields.adoc[] + === 음식 검색 (GET /api/foods/search) ==== 요청 diff --git a/src/main/java/com/flab/eattofit/food/application/FoodService.java b/src/main/java/com/flab/eattofit/food/application/FoodService.java index 2c7a54d..f92eb29 100644 --- a/src/main/java/com/flab/eattofit/food/application/FoodService.java +++ b/src/main/java/com/flab/eattofit/food/application/FoodService.java @@ -1,6 +1,11 @@ package com.flab.eattofit.food.application; +import com.flab.eattofit.food.application.dto.FoodCreateRequest; +import com.flab.eattofit.food.domain.Food; +import com.flab.eattofit.food.domain.FoodRepository; import com.flab.eattofit.food.domain.FoodSearchManager; +import com.flab.eattofit.food.domain.vo.FoodNutrient; +import com.flab.eattofit.food.domain.vo.FoodWeight; import com.flab.eattofit.food.infrastructure.dto.PredictFoodSearchResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; @@ -12,12 +17,31 @@ public class FoodService { private static final String FOOD_SEARCH_MANAGER = "doinglabFoodSearchManager"; + private final FoodRepository foodRepository; private final FoodSearchManager foodSearchManager; - public FoodService(@Qualifier(value = FOOD_SEARCH_MANAGER) final FoodSearchManager foodSearchManager) { + public FoodService( + final FoodRepository foodRepository, + @Qualifier(value = FOOD_SEARCH_MANAGER) final FoodSearchManager foodSearchManager + ) { + this.foodRepository = foodRepository; this.foodSearchManager = foodSearchManager; } + public Food createFood(final FoodCreateRequest request, final Long memberId) { + FoodWeight weight = FoodWeight.createWith(request.servingSize(), request.unit()); + FoodNutrient nutrient = FoodNutrient.builder() + .kcal(request.kcal()) + .carbohydrate(request.carbohydrate()) + .protein(request.protein()) + .fat(request.fat()) + .sodium(request.sodium()) + .build(); + Food food = Food.createWith(request.name(), weight, nutrient, request.url(), memberId); + + return foodRepository.save(food); + } + public PredictFoodSearchResponse foodSearch(final Long memberId, final String url) { log.info("{} 회원이 {}로 음식 검색 요청", memberId, url); return foodSearchManager.search(url); diff --git a/src/main/java/com/flab/eattofit/food/application/dto/FoodCreateRequest.java b/src/main/java/com/flab/eattofit/food/application/dto/FoodCreateRequest.java new file mode 100644 index 0000000..98c94ea --- /dev/null +++ b/src/main/java/com/flab/eattofit/food/application/dto/FoodCreateRequest.java @@ -0,0 +1,43 @@ +package com.flab.eattofit.food.application.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; + +public record FoodCreateRequest( + @NotEmpty(message = "음식 이름이 있어야 합니다.") + String name, + + @NotNull(message = "음식 사이즈가 있어야 합니다.") + @DecimalMin(value = "0", inclusive = false, message = "음식 사이즈는 0보다 커야 합니다.") + BigDecimal servingSize, + + @NotEmpty(message = "음식 사이즈 단위가 있어야 합니다.") + String unit, + + @NotNull(message = "음식 칼로리가 있어야 합니다.") + @DecimalMin(value = "0", message = "음식 칼로리는 0 이상이어야 합니다.") + BigDecimal kcal, + + @NotNull(message = "음식 탄수화물이 있어야 합니다.") + @DecimalMin(value = "0", message = "음식 탄수화물은 0 이상이어야 합니다.") + BigDecimal carbohydrate, + + @NotNull(message = "음식 단백질이 있어야 합니다.") + @DecimalMin(value = "0", message = "음식 단백질은 0 이상이어야 합니다.") + BigDecimal protein, + + @NotNull(message = "음식 지방이 있어야 합니다.") + @DecimalMin(value = "0", message = "음식 지방은 0 이상이어야 합니다.") + BigDecimal fat, + + @NotNull(message = "음식 나트륨이 있어야 합니다.") + @DecimalMin(value = "0", message = "음식 나트륨은 0 이상이어야 합니다.") + BigDecimal sodium, + + @NotEmpty(message = "음식 이미지 주소가 있어야 합니다.") + String url +) { +} diff --git a/src/main/java/com/flab/eattofit/food/domain/Food.java b/src/main/java/com/flab/eattofit/food/domain/Food.java new file mode 100644 index 0000000..2f7eea6 --- /dev/null +++ b/src/main/java/com/flab/eattofit/food/domain/Food.java @@ -0,0 +1,59 @@ +package com.flab.eattofit.food.domain; + +import com.flab.eattofit.food.domain.vo.FoodNutrient; +import com.flab.eattofit.food.domain.vo.FoodWeight; +import com.flab.eattofit.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SuperBuilder +@Entity +public class Food extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Embedded + private FoodWeight weight; + + @Embedded + private FoodNutrient nutrient; + + @Column(nullable = false) + private String url; + + @Column(nullable = false) + private Long memberId; + + private Food(final String name, final FoodWeight weight, final FoodNutrient nutrient, final String url, final Long memberId) { + this.name = name; + this.weight = weight; + this.nutrient = nutrient; + this.url = url; + this.memberId = memberId; + } + + public static Food createWith( + final String name, + final FoodWeight weight, + final FoodNutrient nutrient, + final String url, + final Long memberId + ) { + return new Food(name, weight, nutrient, url, memberId); + } +} diff --git a/src/main/java/com/flab/eattofit/food/domain/FoodRepository.java b/src/main/java/com/flab/eattofit/food/domain/FoodRepository.java new file mode 100644 index 0000000..30b6452 --- /dev/null +++ b/src/main/java/com/flab/eattofit/food/domain/FoodRepository.java @@ -0,0 +1,6 @@ +package com.flab.eattofit.food.domain; + +public interface FoodRepository { + + Food save(Food food); +} diff --git a/src/main/java/com/flab/eattofit/food/domain/vo/FoodNutrient.java b/src/main/java/com/flab/eattofit/food/domain/vo/FoodNutrient.java new file mode 100644 index 0000000..83ef70b --- /dev/null +++ b/src/main/java/com/flab/eattofit/food/domain/vo/FoodNutrient.java @@ -0,0 +1,50 @@ +package com.flab.eattofit.food.domain.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Getter +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@Embeddable +public class FoodNutrient { + + @Column(nullable = false) + private BigDecimal kcal; + + @Column(nullable = false) + private BigDecimal carbohydrate; + + @Column(nullable = false) + private BigDecimal protein; + + @Column(nullable = false) + private BigDecimal fat; + + @Column(nullable = false) + private BigDecimal sodium; + + public static FoodNutrient createWith( + final BigDecimal kcal, + final BigDecimal carbohydrate, + final BigDecimal protein, + final BigDecimal fat, + final BigDecimal sodium + ) { + return FoodNutrient.builder() + .kcal(kcal) + .carbohydrate(carbohydrate) + .protein(protein) + .fat(fat) + .sodium(sodium) + .build(); + } +} diff --git a/src/main/java/com/flab/eattofit/food/domain/vo/FoodUnit.java b/src/main/java/com/flab/eattofit/food/domain/vo/FoodUnit.java new file mode 100644 index 0000000..168912d --- /dev/null +++ b/src/main/java/com/flab/eattofit/food/domain/vo/FoodUnit.java @@ -0,0 +1,29 @@ +package com.flab.eattofit.food.domain.vo; + +import com.flab.eattofit.food.exception.FoodUnitNotFoundException; +import lombok.Getter; + +import java.util.Arrays; + +@Getter +public enum FoodUnit { + GRAM("g"), + KILOGRAM("kg"); + + private final String name; + + FoodUnit(final String name) { + this.name = name; + } + + public static FoodUnit findByName(final String name) { + return Arrays.stream(values()) + .filter(unit -> unit.isSame(name)) + .findAny() + .orElseThrow(FoodUnitNotFoundException::new); + } + + private boolean isSame(final String name) { + return name.equals(this.name); + } +} diff --git a/src/main/java/com/flab/eattofit/food/domain/vo/FoodWeight.java b/src/main/java/com/flab/eattofit/food/domain/vo/FoodWeight.java new file mode 100644 index 0000000..eb5cd89 --- /dev/null +++ b/src/main/java/com/flab/eattofit/food/domain/vo/FoodWeight.java @@ -0,0 +1,30 @@ +package com.flab.eattofit.food.domain.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Getter +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Embeddable +public class FoodWeight { + + @Column(nullable = false) + private BigDecimal servingSize; + + @Enumerated(value = EnumType.STRING) + @Column(nullable = false) + private FoodUnit unit; + + public static FoodWeight createWith(final BigDecimal servingSize, final String unit) { + return new FoodWeight(servingSize, FoodUnit.findByName(unit)); + } +} diff --git a/src/main/java/com/flab/eattofit/food/exception/FoodUnitNotFoundException.java b/src/main/java/com/flab/eattofit/food/exception/FoodUnitNotFoundException.java new file mode 100644 index 0000000..a52b135 --- /dev/null +++ b/src/main/java/com/flab/eattofit/food/exception/FoodUnitNotFoundException.java @@ -0,0 +1,11 @@ +package com.flab.eattofit.food.exception; + +import com.flab.eattofit.global.exception.GlobalException; +import org.springframework.http.HttpStatus; + +public class FoodUnitNotFoundException extends GlobalException { + + public FoodUnitNotFoundException() { + super(HttpStatus.NOT_FOUND, "FOOD_UNIT_NOT_FOUND", "이용 가능한 음식 단위 타입이 아닙니다."); + } +} diff --git a/src/main/java/com/flab/eattofit/food/infrastructure/FoodJpaRepository.java b/src/main/java/com/flab/eattofit/food/infrastructure/FoodJpaRepository.java new file mode 100644 index 0000000..307510e --- /dev/null +++ b/src/main/java/com/flab/eattofit/food/infrastructure/FoodJpaRepository.java @@ -0,0 +1,9 @@ +package com.flab.eattofit.food.infrastructure; + +import com.flab.eattofit.food.domain.Food; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FoodJpaRepository extends JpaRepository { + + Food save(Food food); +} diff --git a/src/main/java/com/flab/eattofit/food/infrastructure/FoodRepositoryImpl.java b/src/main/java/com/flab/eattofit/food/infrastructure/FoodRepositoryImpl.java new file mode 100644 index 0000000..d544580 --- /dev/null +++ b/src/main/java/com/flab/eattofit/food/infrastructure/FoodRepositoryImpl.java @@ -0,0 +1,18 @@ +package com.flab.eattofit.food.infrastructure; + +import com.flab.eattofit.food.domain.Food; +import com.flab.eattofit.food.domain.FoodRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class FoodRepositoryImpl implements FoodRepository { + + private final FoodJpaRepository foodJpaRepository; + + @Override + public Food save(final Food food) { + return foodJpaRepository.save(food); + } +} diff --git a/src/main/java/com/flab/eattofit/food/ui/FoodController.java b/src/main/java/com/flab/eattofit/food/ui/FoodController.java index b9bd7e3..53ebe20 100644 --- a/src/main/java/com/flab/eattofit/food/ui/FoodController.java +++ b/src/main/java/com/flab/eattofit/food/ui/FoodController.java @@ -1,15 +1,23 @@ package com.flab.eattofit.food.ui; import com.flab.eattofit.food.application.FoodService; +import com.flab.eattofit.food.application.dto.FoodCreateRequest; +import com.flab.eattofit.food.domain.Food; import com.flab.eattofit.food.infrastructure.dto.PredictFoodSearchResponse; +import com.flab.eattofit.food.ui.dto.FoodCreateResponse; import com.flab.eattofit.member.ui.auth.support.annotations.AuthMember; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.net.URI; + @RequiredArgsConstructor @RequestMapping("/api/foods") @RestController @@ -19,6 +27,16 @@ public class FoodController { private final FoodService foodService; + @PostMapping + public ResponseEntity createFood( + @RequestBody @Valid final FoodCreateRequest request, + @AuthMember final Long memberId + ) { + Food food = foodService.createFood(request, memberId); + return ResponseEntity.created(URI.create("/foods/" + food.getId())) + .body(FoodCreateResponse.from(food)); + } + @GetMapping("/search") public ResponseEntity search( final @AuthMember Long memberId, diff --git a/src/main/java/com/flab/eattofit/food/ui/dto/FoodCreateResponse.java b/src/main/java/com/flab/eattofit/food/ui/dto/FoodCreateResponse.java new file mode 100644 index 0000000..9d1230e --- /dev/null +++ b/src/main/java/com/flab/eattofit/food/ui/dto/FoodCreateResponse.java @@ -0,0 +1,42 @@ +package com.flab.eattofit.food.ui.dto; + +import com.flab.eattofit.food.domain.Food; +import com.flab.eattofit.food.domain.vo.FoodNutrient; +import com.flab.eattofit.food.domain.vo.FoodWeight; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record FoodCreateResponse( + String name, + BigDecimal servingSize, + String unit, + BigDecimal kcal, + BigDecimal carbohydrate, + BigDecimal protein, + BigDecimal fat, + BigDecimal sodium, + String url, + Long memberId, + LocalDateTime createdAt +) { + + public static FoodCreateResponse from(final Food food) { + FoodWeight weight = food.getWeight(); + FoodNutrient nutrient = food.getNutrient(); + + return new FoodCreateResponse( + food.getName(), + weight.getServingSize(), + weight.getUnit().getName(), + nutrient.getKcal(), + nutrient.getCarbohydrate(), + nutrient.getProtein(), + nutrient.getFat(), + nutrient.getSodium(), + food.getUrl(), + food.getMemberId(), + food.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/flab/eattofit/global/domain/BaseEntity.java b/src/main/java/com/flab/eattofit/global/domain/BaseEntity.java index 18c9ad3..21cdcfa 100644 --- a/src/main/java/com/flab/eattofit/global/domain/BaseEntity.java +++ b/src/main/java/com/flab/eattofit/global/domain/BaseEntity.java @@ -3,13 +3,19 @@ import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; @Getter +@AllArgsConstructor +@NoArgsConstructor +@SuperBuilder @MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class BaseEntity { diff --git a/src/main/java/com/flab/eattofit/member/config/auth/MemberAuthConfig.java b/src/main/java/com/flab/eattofit/member/config/auth/MemberAuthConfig.java index cff91ec..f58402d 100644 --- a/src/main/java/com/flab/eattofit/member/config/auth/MemberAuthConfig.java +++ b/src/main/java/com/flab/eattofit/member/config/auth/MemberAuthConfig.java @@ -41,7 +41,7 @@ private HandlerInterceptor loginValidCheckerInterceptor() { return new PathMatcherInterceptor(memberLoginValidCheckerInterceptor) .excludePathPatterns("/api/auth/login/**", POST) .addPathPatterns("/api/storage/**", GET) - .addPathPatterns("/api/foods/**", GET) + .addPathPatterns("/api/foods/**", GET, POST) .addPathPatterns("/api/members/**", GET, POST, PATCH, DELETE); } diff --git a/src/main/resources/db/migration/V7__Add_Food.sql b/src/main/resources/db/migration/V7__Add_Food.sql new file mode 100644 index 0000000..a0fa59d --- /dev/null +++ b/src/main/resources/db/migration/V7__Add_Food.sql @@ -0,0 +1,14 @@ +CREATE TABLE food( + id BIGINT NOT NULL AUTO_INCREMENT, + name CHAR(20) NOT NULL, + serving_size DECIMAL(5, 2) NOT NULL, + unit ENUM('GRAM', 'KILOGRAM') NOT NULL, + kcal DECIMAL(5, 2) NOT NULL, + carbohydrate DECIMAL(5, 2) NOT NULL, + protein DECIMAL(5, 2) NOT NULL, + fat DECIMAL(5, 2) NOT NULL, + sodium DECIMAL(5, 2) NOT NULL, + url VARCHAR(255) NOT NULL, + member_id BIGINT NOT NULL, + PRIMARY KEY (id) +); diff --git a/src/test/java/com/flab/eattofit/food/application/FoodServiceTest.java b/src/test/java/com/flab/eattofit/food/application/FoodServiceTest.java index 05be310..35cff6f 100644 --- a/src/test/java/com/flab/eattofit/food/application/FoodServiceTest.java +++ b/src/test/java/com/flab/eattofit/food/application/FoodServiceTest.java @@ -1,8 +1,15 @@ package com.flab.eattofit.food.application; +import com.flab.eattofit.food.application.dto.FoodCreateRequest; +import com.flab.eattofit.food.domain.Food; +import com.flab.eattofit.food.domain.FoodRepository; import com.flab.eattofit.food.domain.FoodSearchManager; +import com.flab.eattofit.food.domain.vo.FoodNutrient; +import com.flab.eattofit.food.domain.vo.FoodWeight; import com.flab.eattofit.food.exception.BadImageUrlException; import com.flab.eattofit.food.exception.FoodSearchJsonParseException; +import com.flab.eattofit.food.exception.FoodUnitNotFoundException; +import com.flab.eattofit.food.infrastructure.FakeFoodRepository; import com.flab.eattofit.food.infrastructure.dto.FoodSearchResponse; import com.flab.eattofit.food.infrastructure.dto.PredictFoodSearchResponse; import org.junit.jupiter.api.BeforeEach; @@ -14,6 +21,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import static com.flab.eattofit.food.fixture.FoodCreateRequestFixture.음식_생성_요청_햄버거; +import static com.flab.eattofit.food.fixture.FoodCreateRequestFixture.음식_생성_요청_햄버거_단위예외; import static com.flab.eattofit.food.fixture.FoodSearchResponseFixture.음식_아님_빈_목록; import static com.flab.eattofit.food.fixture.FoodSearchResponseFixture.음식_응답_비빔밥; import static org.assertj.core.api.Assertions.assertThat; @@ -29,11 +38,53 @@ class FoodServiceTest { @Mock private FoodSearchManager foodSearchManager; + private FoodRepository foodRepository; + private FoodService foodService; @BeforeEach void init() { - foodService = new FoodService(foodSearchManager); + foodRepository = new FakeFoodRepository(); + foodService = new FoodService(foodRepository, foodSearchManager); + } + + @Nested + class 음식_생성 { + + @Test + void 음식을_생성한다() { + // given + FoodCreateRequest request = 음식_생성_요청_햄버거(); + Long memberId = 1L; + + // when + Food food = foodService.createFood(request, memberId); + FoodWeight weight = food.getWeight(); + FoodNutrient nutrient = food.getNutrient(); + + // then + assertSoftly(softly -> { + softly.assertThat(food.getName()).isEqualTo(request.name()); + softly.assertThat(weight.getServingSize()).isEqualTo(request.servingSize()); + softly.assertThat(weight.getUnit().getName()).isEqualTo(request.unit()); + softly.assertThat(nutrient.getKcal()).isEqualTo(request.kcal()); + softly.assertThat(nutrient.getCarbohydrate()).isEqualTo(request.carbohydrate()); + softly.assertThat(nutrient.getProtein()).isEqualTo(request.protein()); + softly.assertThat(nutrient.getFat()).isEqualTo(request.fat()); + softly.assertThat(nutrient.getSodium()).isEqualTo(request.sodium()); + }); + } + + @Test + void 잘못된_음식_무게_단위를_이용하면_예외가_발생한다() { + // given + FoodCreateRequest request = 음식_생성_요청_햄버거_단위예외(); + Long memberId = 1L; + + // when & then + assertThatThrownBy(() -> foodService.createFood(request, memberId)) + .isInstanceOf(FoodUnitNotFoundException.class); + } } @Nested diff --git a/src/test/java/com/flab/eattofit/food/domain/FoodTest.java b/src/test/java/com/flab/eattofit/food/domain/FoodTest.java new file mode 100644 index 0000000..016ba68 --- /dev/null +++ b/src/test/java/com/flab/eattofit/food/domain/FoodTest.java @@ -0,0 +1,51 @@ +package com.flab.eattofit.food.domain; + +import com.flab.eattofit.food.domain.vo.FoodNutrient; +import com.flab.eattofit.food.domain.vo.FoodWeight; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FoodTest { + + @Nested + class 음식_생성 { + + @Test + void 음식을_생성한다() { + // given + String name = "햄버거"; + BigDecimal servingSize = BigDecimal.valueOf(123.45); + String unit = "g"; + BigDecimal kcal = BigDecimal.valueOf(230.4); + BigDecimal carbohydrate = BigDecimal.valueOf(250); + BigDecimal protein = BigDecimal.valueOf(120); + BigDecimal fat = BigDecimal.valueOf(10); + BigDecimal sodium = BigDecimal.valueOf(20); + Long memberId = 1L; + String url = "burger.jpg"; + + FoodWeight weight = FoodWeight.createWith(servingSize, unit); + FoodNutrient nutrient = FoodNutrient.createWith(kcal, carbohydrate, protein, fat, sodium); + + // when + Food food = Food.createWith(name, weight, nutrient, url, memberId); + + // then + assertSoftly(softly -> { + softly.assertThat(food.getName()).isEqualTo(name); + softly.assertThat(food.getWeight()).usingRecursiveComparison() + .isEqualTo(weight); + softly.assertThat(food.getNutrient()).usingRecursiveComparison() + .isEqualTo(nutrient); + softly.assertThat(food.getMemberId()).isEqualTo(memberId); + }); + } + } +} diff --git a/src/test/java/com/flab/eattofit/food/domain/vo/FoodNutrientTest.java b/src/test/java/com/flab/eattofit/food/domain/vo/FoodNutrientTest.java new file mode 100644 index 0000000..ad50301 --- /dev/null +++ b/src/test/java/com/flab/eattofit/food/domain/vo/FoodNutrientTest.java @@ -0,0 +1,40 @@ +package com.flab.eattofit.food.domain.vo; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FoodNutrientTest { + + @Nested + class 음식_영양성분_생성 { + + @Test + void 음식_영양성분을_생성한다() { + // given + BigDecimal kcal = BigDecimal.valueOf(635.31); + BigDecimal carbohydrate = BigDecimal.valueOf(97.13); + BigDecimal protein = BigDecimal.valueOf(24.21); + BigDecimal fat = BigDecimal.valueOf(16.23); + BigDecimal sodium = BigDecimal.valueOf(1248.24); + + // when + FoodNutrient nutrient = FoodNutrient.createWith(kcal, carbohydrate, protein, fat, sodium); + + // then + assertSoftly(softly -> { + softly.assertThat(nutrient.getKcal()).isEqualTo(kcal); + softly.assertThat(nutrient.getCarbohydrate()).isEqualTo(carbohydrate); + softly.assertThat(nutrient.getProtein()).isEqualTo(protein); + softly.assertThat(nutrient.getFat()).isEqualTo(fat); + softly.assertThat(nutrient.getSodium()).isEqualTo(sodium); + }); + } + } +} diff --git a/src/test/java/com/flab/eattofit/food/domain/vo/FoodUnitTest.java b/src/test/java/com/flab/eattofit/food/domain/vo/FoodUnitTest.java new file mode 100644 index 0000000..8f33b20 --- /dev/null +++ b/src/test/java/com/flab/eattofit/food/domain/vo/FoodUnitTest.java @@ -0,0 +1,44 @@ +package com.flab.eattofit.food.domain.vo; + +import com.flab.eattofit.food.exception.FoodUnitNotFoundException; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FoodUnitTest { + + @Nested + class 음식_무게_단위_조회 { + + @Test + void 음식_무게_단위를_조회한다() { + // given + String value = "g"; + + // when + FoodUnit unit = FoodUnit.findByName(value); + + // then + assertSoftly(softly -> { + softly.assertThat(unit).isEqualTo(FoodUnit.GRAM); + softly.assertThat(unit.getName()).isEqualTo(value); + }); + } + + @Test + void 없는_음식_무게_단위를_조회면_예외가_발생한다() { + // given + String value = "abc"; + + // when & then + assertThatThrownBy(() -> FoodUnit.findByName(value)) + .isInstanceOf(FoodUnitNotFoundException.class); + } + } +} diff --git a/src/test/java/com/flab/eattofit/food/domain/vo/FoodWeightTest.java b/src/test/java/com/flab/eattofit/food/domain/vo/FoodWeightTest.java new file mode 100644 index 0000000..3378bd7 --- /dev/null +++ b/src/test/java/com/flab/eattofit/food/domain/vo/FoodWeightTest.java @@ -0,0 +1,47 @@ +package com.flab.eattofit.food.domain.vo; + +import com.flab.eattofit.food.exception.FoodUnitNotFoundException; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FoodWeightTest { + + @Nested + class 음식_무게_생성 { + + @Test + void 음식_무게를_단위와_함께_생성한다() { + // given + BigDecimal servingSize = BigDecimal.valueOf(123.45); + String unit = "g"; + + // when + FoodWeight weight = FoodWeight.createWith(servingSize, unit); + + // then + assertSoftly(softly -> { + softly.assertThat(weight.getServingSize()).isEqualTo(servingSize); + softly.assertThat(weight.getUnit()).isEqualTo(FoodUnit.GRAM); + }); + } + + @Test + void 없는_무게_단위를_사용하면_예외가_발생한다() { + // given + BigDecimal servingSize = BigDecimal.valueOf(123.45); + String unit = "abc"; + + // when & then + assertThatThrownBy(() -> FoodWeight.createWith(servingSize, unit)) + .isInstanceOf(FoodUnitNotFoundException.class); + } + } +} diff --git a/src/test/java/com/flab/eattofit/food/fixture/FoodCreateRequestFixture.java b/src/test/java/com/flab/eattofit/food/fixture/FoodCreateRequestFixture.java new file mode 100644 index 0000000..5db0a7b --- /dev/null +++ b/src/test/java/com/flab/eattofit/food/fixture/FoodCreateRequestFixture.java @@ -0,0 +1,51 @@ +package com.flab.eattofit.food.fixture; + +import com.flab.eattofit.food.application.dto.FoodCreateRequest; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; + +import java.math.BigDecimal; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +public class FoodCreateRequestFixture { + + public static FoodCreateRequest 음식_생성_요청_햄버거() { + String name = "햄버거"; + BigDecimal servingSize = BigDecimal.valueOf(150.0); + String unit = "g"; + BigDecimal kcal = BigDecimal.valueOf(430.0); + BigDecimal carbohydrate = BigDecimal.valueOf(36.0); + BigDecimal protein = BigDecimal.valueOf(25.0); + BigDecimal fat = BigDecimal.valueOf(21.0); + BigDecimal sodium = BigDecimal.valueOf(636.0); + String url = "burger.jpg"; + + return new FoodCreateRequest( + name, + servingSize, + unit, + kcal, + carbohydrate, + protein, + fat, + sodium, + url + ); + } + + public static FoodCreateRequest 음식_생성_요청_햄버거_단위예외() { + FoodCreateRequest request = 음식_생성_요청_햄버거(); + return new FoodCreateRequest( + request.name(), + request.servingSize(), + "abc", + request.kcal(), + request.carbohydrate(), + request.protein(), + request.fat(), + request.sodium(), + request.url() + ); + } +} diff --git a/src/test/java/com/flab/eattofit/food/fixture/FoodFixture.java b/src/test/java/com/flab/eattofit/food/fixture/FoodFixture.java new file mode 100644 index 0000000..8e90718 --- /dev/null +++ b/src/test/java/com/flab/eattofit/food/fixture/FoodFixture.java @@ -0,0 +1,36 @@ +package com.flab.eattofit.food.fixture; + +import com.flab.eattofit.food.application.dto.FoodCreateRequest; +import com.flab.eattofit.food.domain.Food; +import com.flab.eattofit.food.domain.vo.FoodNutrient; +import com.flab.eattofit.food.domain.vo.FoodWeight; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; + +import java.time.LocalDateTime; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +public class FoodFixture { + + public static Food 음식_생성_응답_id있음(final FoodCreateRequest request, final Long memberId) { + FoodWeight weight = FoodWeight.createWith(request.servingSize(), request.unit()); + FoodNutrient nutrient = FoodNutrient.createWith( + request.kcal(), + request.kcal(), + request.protein(), + request.fat(), + request.sodium() + ); + + return Food.builder() + .id(1L) + .name(request.name()) + .weight(weight) + .nutrient(nutrient) + .url(request.url()) + .memberId(memberId) + .createdAt(LocalDateTime.now()) + .build(); + } +} diff --git a/src/test/java/com/flab/eattofit/food/infrastructure/FakeFoodRepository.java b/src/test/java/com/flab/eattofit/food/infrastructure/FakeFoodRepository.java new file mode 100644 index 0000000..c4db474 --- /dev/null +++ b/src/test/java/com/flab/eattofit/food/infrastructure/FakeFoodRepository.java @@ -0,0 +1,28 @@ +package com.flab.eattofit.food.infrastructure; + +import com.flab.eattofit.food.domain.Food; +import com.flab.eattofit.food.domain.FoodRepository; + +import java.util.HashMap; +import java.util.Map; + +public class FakeFoodRepository implements FoodRepository { + + private final Map map = new HashMap<>(); + private Long id = 1L; + + @Override + public Food save(final Food food) { + Food savedFood = Food.builder() + .id(id) + .name(food.getName()) + .weight(food.getWeight()) + .nutrient(food.getNutrient()) + .memberId(food.getMemberId()) + .createdAt(food.getCreatedAt()) + .build(); + + map.put(id++, savedFood); + return savedFood; + } +} diff --git a/src/test/java/com/flab/eattofit/food/infrastructure/FoodJpaRepositoryTest.java b/src/test/java/com/flab/eattofit/food/infrastructure/FoodJpaRepositoryTest.java new file mode 100644 index 0000000..8ec3279 --- /dev/null +++ b/src/test/java/com/flab/eattofit/food/infrastructure/FoodJpaRepositoryTest.java @@ -0,0 +1,47 @@ +package com.flab.eattofit.food.infrastructure; + +import com.flab.eattofit.food.domain.Food; +import com.flab.eattofit.food.domain.vo.FoodNutrient; +import com.flab.eattofit.food.domain.vo.FoodWeight; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.math.BigDecimal; +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@DataJpaTest +class FoodJpaRepositoryTest { + + @Autowired + private FoodJpaRepository foodJpaRepository; + + @Test + void 음식을_생성한다() { + // given + FoodWeight weight = FoodWeight.createWith(BigDecimal.valueOf(150.0), "g"); + FoodNutrient nutrient = FoodNutrient.createWith( + BigDecimal.valueOf(430.0), + BigDecimal.valueOf(36.0), + BigDecimal.valueOf(25.0), + BigDecimal.valueOf(21.0), + BigDecimal.valueOf(636.0) + ); + Long memberId = 1L; + String url = "burger.jpg"; + + Food food = Food.createWith("햄버거", weight, nutrient, url, memberId); + + // when + Food savedFood = foodJpaRepository.save(food); + + // then + assertThat(savedFood).usingRecursiveComparison() + .ignoringFields("id", "createdAt") + .isEqualTo(food); + } +} diff --git a/src/test/java/com/flab/eattofit/food/ui/FoodControllerWebMvcTest.java b/src/test/java/com/flab/eattofit/food/ui/FoodControllerWebMvcTest.java index 46520de..c4bb765 100644 --- a/src/test/java/com/flab/eattofit/food/ui/FoodControllerWebMvcTest.java +++ b/src/test/java/com/flab/eattofit/food/ui/FoodControllerWebMvcTest.java @@ -1,5 +1,8 @@ package com.flab.eattofit.food.ui; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flab.eattofit.food.application.dto.FoodCreateRequest; +import com.flab.eattofit.food.domain.Food; import com.flab.eattofit.food.infrastructure.dto.PredictFoodSearchResponse; import com.flab.eattofit.helper.MockBeanInjection; import org.junit.jupiter.api.DisplayNameGeneration; @@ -10,19 +13,26 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.test.web.servlet.MockMvc; +import static com.flab.eattofit.food.fixture.FoodCreateRequestFixture.음식_생성_요청_햄버거; +import static com.flab.eattofit.food.fixture.FoodFixture.음식_생성_응답_id있음; +import static com.flab.eattofit.food.fixture.FoodSearchResponseFixture.음식_응답_비빔밥; import static com.flab.eattofit.helper.RestDocsHelper.customDocument; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; -import static com.flab.eattofit.food.fixture.FoodSearchResponseFixture.음식_응답_비빔밥; -import static org.mockito.Mockito.when; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) @@ -36,6 +46,59 @@ class FoodControllerWebMvcTest extends MockBeanInjection { @Autowired private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + + @Test + void 음식을_생성한다() throws Exception { + // given + FoodCreateRequest request = 음식_생성_요청_햄버거(); + Long memberId = 1L; + Food food = 음식_생성_응답_id있음(request, memberId); + when(foodService.createFood(any(), any())).thenReturn(food); + + // when & then + mockMvc.perform(post("/api/foods") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .header(AUTHORIZATION, BEARER_TOKEN)) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/foods/" + food.getId())) + .andDo(print()) + .andDo(customDocument("음식 생성", + requestHeaders( + headerWithName(AUTHORIZATION).description("유저 토큰 정보") + ), + requestFields( + fieldWithPath("name").description("음식 이름"), + fieldWithPath("servingSize").description("음식 사이즈"), + fieldWithPath("unit").description("음식 사이즈 단위"), + fieldWithPath("kcal").description("음식 칼로리"), + fieldWithPath("carbohydrate").description("음식 탄수화물"), + fieldWithPath("protein").description("음식 단백질"), + fieldWithPath("fat").description("음식 지방"), + fieldWithPath("sodium").description("음식 나트륨"), + fieldWithPath("url").description("음식 이미지 주소") + ), + responseHeaders( + headerWithName("Location").description("등록된 음식 경로 (id 포함)") + ), + responseFields( + fieldWithPath("name").description("음식 이름"), + fieldWithPath("servingSize").description("음식 사이즈"), + fieldWithPath("unit").description("음식 사이즈 단위"), + fieldWithPath("kcal").description("음식 칼로리"), + fieldWithPath("carbohydrate").description("음식 탄수화물"), + fieldWithPath("protein").description("음식 단백질"), + fieldWithPath("fat").description("음식 지방"), + fieldWithPath("sodium").description("음식 나트륨"), + fieldWithPath("memberId").description("생성한 회원 id"), + fieldWithPath("url").description("음식 이미지 주소"), + fieldWithPath("createdAt").description("음식 생성 시각") + ) + )); + } + @Test void 음식을_검색한다() throws Exception { // given