diff --git a/src/main/java/eatda/controller/story/FilteredSearchResult.java b/src/main/java/eatda/controller/story/FilteredSearchResult.java new file mode 100644 index 00000000..89d94efe --- /dev/null +++ b/src/main/java/eatda/controller/story/FilteredSearchResult.java @@ -0,0 +1,9 @@ +package eatda.controller.story; + +public record FilteredSearchResult( + String kakaoId, + String name, + String address, + String category +) { +} diff --git a/src/main/java/eatda/controller/story/StoriesResponse.java b/src/main/java/eatda/controller/story/StoriesResponse.java new file mode 100644 index 00000000..8829449e --- /dev/null +++ b/src/main/java/eatda/controller/story/StoriesResponse.java @@ -0,0 +1,13 @@ +package eatda.controller.story; + +import java.util.List; + +public record StoriesResponse( + List stories +) { + public record StoryPreview( + Long storyId, + String imageUrl + ) { + } +} diff --git a/src/main/java/eatda/controller/story/StoryController.java b/src/main/java/eatda/controller/story/StoryController.java new file mode 100644 index 00000000..382ae3d4 --- /dev/null +++ b/src/main/java/eatda/controller/story/StoryController.java @@ -0,0 +1,28 @@ +package eatda.controller.story; + +import eatda.controller.web.auth.LoginMember; +import eatda.service.story.StoryService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +public class StoryController { + + private final StoryService storyService; + + @PostMapping("/api/stories") + public ResponseEntity registerStory( + @RequestPart("request") StoryRegisterRequest request, + @RequestPart("image") MultipartFile image, + LoginMember member + ) { + storyService.registerStory(request, image, member.id()); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } +} diff --git a/src/main/java/eatda/controller/story/StoryRegisterRequest.java b/src/main/java/eatda/controller/story/StoryRegisterRequest.java new file mode 100644 index 00000000..aeebfd4e --- /dev/null +++ b/src/main/java/eatda/controller/story/StoryRegisterRequest.java @@ -0,0 +1,8 @@ +package eatda.controller.story; + +public record StoryRegisterRequest( + String query, + String storeKakaoId, + String description +) { +} diff --git a/src/main/java/eatda/controller/story/StoryResponse.java b/src/main/java/eatda/controller/story/StoryResponse.java new file mode 100644 index 00000000..df46d476 --- /dev/null +++ b/src/main/java/eatda/controller/story/StoryResponse.java @@ -0,0 +1,11 @@ +package eatda.controller.story; + +public record StoryResponse( + String storeKakaoId, + String category, + String storeName, + String storeAddress, + String description, + String imageUrl +) { +} diff --git a/src/main/java/eatda/domain/story/Story.java b/src/main/java/eatda/domain/story/Story.java new file mode 100644 index 00000000..97817acf --- /dev/null +++ b/src/main/java/eatda/domain/story/Story.java @@ -0,0 +1,129 @@ +package eatda.domain.story; + +import eatda.domain.AuditingEntity; +import eatda.domain.member.Member; +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "story") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Story extends AuditingEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Column(name = "store_kakao_id", nullable = false) + private String storeKakaoId; + + @Column(name = "store_name", nullable = false) + private String storeName; + + @Column(name = "store_address", nullable = false) + private String storeAddress; + + @Column(name = "store_category", nullable = false) + private String storeCategory; + + @Column(name = "description", nullable = false) + private String description; + + @Column(name = "image_key", nullable = false) + private String imageKey; + + @Builder + private Story( + Member member, + String storeKakaoId, + String storeName, + String storeAddress, + String storeCategory, + String description, + String imageKey + ) { + validateMember(member); + validateStore(storeKakaoId, storeName, storeAddress, storeCategory); + validateStory(description, imageKey); + + this.member = member; + this.storeKakaoId = storeKakaoId; + this.storeName = storeName; + this.storeAddress = storeAddress; + this.storeCategory = storeCategory; + this.description = description; + this.imageKey = imageKey; + } + + private void validateMember(Member member) { + if (member == null) { + throw new BusinessException(BusinessErrorCode.STORY_MEMBER_REQUIRED); + } + } + + private void validateStore(String storeKakaoId, String storeName, String storeAddress, String storeCategory) { + validateStoreKakaoId(storeKakaoId); + validateStoreName(storeName); + validateStoreAddress(storeAddress); + validateStoreCategory(storeCategory); + } + + private void validateStory(String description, String imageKey) { + validateDescription(description); + validateImage(imageKey); + } + + private void validateStoreKakaoId(String storeKakaoId) { + if (storeKakaoId == null || storeKakaoId.isBlank()) { + throw new BusinessException(BusinessErrorCode.INVALID_STORE_KAKAO_ID); + } + } + + private void validateStoreName(String storeName) { + if (storeName == null || storeName.isBlank()) { + throw new BusinessException(BusinessErrorCode.INVALID_STORE_NAME); + } + } + + private void validateStoreAddress(String storeAddress) { + if (storeAddress == null || storeAddress.isBlank()) { + throw new BusinessException(BusinessErrorCode.INVALID_STORE_ADDRESS); + } + } + + private void validateStoreCategory(String storeCategory) { + if (storeCategory == null || storeCategory.isBlank()) { + throw new BusinessException(BusinessErrorCode.INVALID_STORE_CATEGORY); + } + } + + private void validateDescription(String description) { + if (description == null || description.isBlank()) { + throw new BusinessException(BusinessErrorCode.INVALID_STORY_DESCRIPTION); + } + } + + private void validateImage(String imageKey) { + if (imageKey == null || imageKey.isBlank()) { + throw new BusinessException(BusinessErrorCode.INVALID_STORY_IMAGE_KEY); + } + } +} diff --git a/src/main/java/eatda/exception/BusinessErrorCode.java b/src/main/java/eatda/exception/BusinessErrorCode.java index 3b4a6fcb..fea49646 100644 --- a/src/main/java/eatda/exception/BusinessErrorCode.java +++ b/src/main/java/eatda/exception/BusinessErrorCode.java @@ -21,6 +21,7 @@ public enum BusinessErrorCode { INVALID_STORE_COORDINATES_NULL("STO007", "좌표 값은 필수입니다."), OUT_OF_SEOUL_LATITUDE_RANGE("STO010", "서비스 지역(서울)을 벗어난 위도 값입니다."), OUT_OF_SEOUL_LONGITUDE_RANGE("STO011", "서비스 지역(서울)을 벗어난 경도 값입니다."), + STORE_NOT_FOUND("ST0012", "해당 가게 정보를 찾을수 없습니다."), // Cheer INVALID_CHEER_DESCRIPTION("CHE001", "응원 메시지는 필수입니다."), @@ -40,7 +41,17 @@ public enum BusinessErrorCode { FILE_UPLOAD_FAILED("SERVER002", "파일 업로드에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), FILE_URL_GENERATION_FAILED("SERVER003", "파일 URL 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), PRESIGNED_URL_GENERATION_FAILED("SERVER004", "Presigned URL 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), - ; + + //story + INVALID_STORY_DESCRIPTION("STY001", "스토리 본문은 필수입니다."), + INVALID_STORY_IMAGE_KEY("STY002", "스토리 이미지 Key는 필수입니다."), + STORY_MEMBER_REQUIRED("STY003", "스토리 작성 시 회원 정보는 필수입니다."), + STORY_STORE_REQUIRED("STY004", "스토리 작성 시 가게 정보는 필수입니다."), + STORY_NOT_FOUND("STY005", "스토리를 찾을 수 없습니다."), + INVALID_STORE_ID("STY006", "유효하지 않은 가게 ID입니다."), + INVALID_STORE_KAKAO_ID("STY007", "스토어 Kakao ID는 필수입니다."), + INVALID_STORE_NAME("STY008", "스토어 이름은 필수입니다."), + INVALID_STORE_ADDRESS("STY009", "스토어 주소는 필수입니다."); private final String code; private final String message; diff --git a/src/main/java/eatda/exception/GlobalExceptionHandler.java b/src/main/java/eatda/exception/GlobalExceptionHandler.java index fcde8d70..e597be1d 100644 --- a/src/main/java/eatda/exception/GlobalExceptionHandler.java +++ b/src/main/java/eatda/exception/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ import jakarta.validation.ConstraintViolationException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.catalina.connector.ClientAbortException; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindException; @@ -15,6 +16,7 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.resource.NoResourceFoundException; +@Slf4j @RestControllerAdvice @RequiredArgsConstructor public class GlobalExceptionHandler { @@ -77,6 +79,7 @@ public ResponseEntity handleMissingServletRequestParameterExcepti @ExceptionHandler(BusinessException.class) public ResponseEntity handleBusinessException(BusinessException exception) { + log.error("[BusinessException] handled: {}", exception.getErrorCode()); ErrorResponse response = new ErrorResponse(exception.getErrorCode()); return ResponseEntity.status(exception.getStatus()) .body(response); @@ -84,6 +87,7 @@ public ResponseEntity handleBusinessException(BusinessException e @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception exception) { + log.error("[Unhandled Exception] {}: {}", exception.getClass().getSimpleName(), exception.getMessage(), exception); return toErrorResponse(EtcErrorCode.INTERNAL_SERVER_ERROR); } diff --git a/src/main/java/eatda/repository/story/StoryRepository.java b/src/main/java/eatda/repository/story/StoryRepository.java new file mode 100644 index 00000000..f17c9e12 --- /dev/null +++ b/src/main/java/eatda/repository/story/StoryRepository.java @@ -0,0 +1,19 @@ +package eatda.repository.story; + +import eatda.domain.story.Story; +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; +import java.util.Optional; +import org.springframework.data.repository.Repository; + +public interface StoryRepository extends Repository { + + Story save(Story story); + + Optional findById(Long id); + + default Story getById(Long id) { + return findById(id) + .orElseThrow(() -> new BusinessException(BusinessErrorCode.STORY_NOT_FOUND)); + } +} diff --git a/src/main/java/eatda/service/common/ImageDomain.java b/src/main/java/eatda/service/common/ImageDomain.java new file mode 100644 index 00000000..5d155c0b --- /dev/null +++ b/src/main/java/eatda/service/common/ImageDomain.java @@ -0,0 +1,15 @@ +package eatda.service.common; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ImageDomain { + ARTICLE("article"), + STORE("store"), + MEMBER("member"), + STORY("story"); + + private final String name; +} diff --git a/src/main/java/eatda/service/common/ImageService.java b/src/main/java/eatda/service/common/ImageService.java index dce319ca..8972ae18 100644 --- a/src/main/java/eatda/service/common/ImageService.java +++ b/src/main/java/eatda/service/common/ImageService.java @@ -20,6 +20,7 @@ public class ImageService { private static final Set ALLOWED_CONTENT_TYPES = Set.of("image/jpg", "image/jpeg", "image/png"); + private static final String DEFAULT_CONTENT_TYPE = "bin"; private static final String PATH_DELIMITER = "/"; private static final String EXTENSION_DELIMITER = "."; private static final Duration PRESIGNED_URL_DURATION = Duration.ofMinutes(30); @@ -37,11 +38,11 @@ public ImageService( this.s3Presigner = s3Presigner; } - public String upload(MultipartFile file, String domain) { + public String upload(MultipartFile file, ImageDomain domain) { validateContentType(file); String extension = getExtension(file.getOriginalFilename()); String uuid = UUID.randomUUID().toString(); - String key = domain + PATH_DELIMITER + uuid + EXTENSION_DELIMITER + extension; + String key = domain.getName() + PATH_DELIMITER + uuid + EXTENSION_DELIMITER + extension; try { PutObjectRequest request = PutObjectRequest.builder() @@ -66,7 +67,7 @@ private void validateContentType(MultipartFile file) { private String getExtension(String filename) { if (filename == null || filename.lastIndexOf(EXTENSION_DELIMITER) == -1 || filename.startsWith(EXTENSION_DELIMITER)) { - return "bin"; + return DEFAULT_CONTENT_TYPE; } return filename.substring(filename.lastIndexOf(EXTENSION_DELIMITER) + 1); } diff --git a/src/main/java/eatda/service/store/StoreService.java b/src/main/java/eatda/service/store/StoreService.java index 468101ad..a9096e9c 100644 --- a/src/main/java/eatda/service/store/StoreService.java +++ b/src/main/java/eatda/service/store/StoreService.java @@ -19,4 +19,9 @@ public StoreSearchResponses searchStores(String query) { List filteredResults = storeSearchFilter.filterSearchedStores(searchResults); return StoreSearchResponses.from(filteredResults); } + + public List searchStoreResults(String query) { + List searchResults = mapClient.searchShops(query); + return storeSearchFilter.filterSearchedStores(searchResults); + } } diff --git a/src/main/java/eatda/service/story/StoryService.java b/src/main/java/eatda/service/story/StoryService.java new file mode 100644 index 00000000..7724b1cb --- /dev/null +++ b/src/main/java/eatda/service/story/StoryService.java @@ -0,0 +1,62 @@ +package eatda.service.story; + +import eatda.client.map.StoreSearchResult; +import eatda.controller.story.FilteredSearchResult; +import eatda.controller.story.StoryRegisterRequest; +import eatda.domain.member.Member; +import eatda.domain.story.Story; +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; +import eatda.repository.member.MemberRepository; +import eatda.repository.story.StoryRepository; +import eatda.service.common.ImageDomain; +import eatda.service.common.ImageService; +import eatda.service.store.StoreService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class StoryService { + + private final StoreService storeService; + private final ImageService imageService; + private final StoryRepository storyRepository; + private final MemberRepository memberRepository; + + @Transactional + public void registerStory(StoryRegisterRequest request, MultipartFile image, Long memberId) { + Member member = memberRepository.getById(memberId); + List searchResponses = storeService.searchStoreResults(request.query()); + FilteredSearchResult matchedStore = filteredSearchResponse(searchResponses, request.storeKakaoId()); + String imageKey = imageService.upload(image, ImageDomain.STORY); + + Story story = Story.builder() + .member(member) + .storeKakaoId(matchedStore.kakaoId()) + .storeName(matchedStore.name()) + .storeAddress(matchedStore.address()) + .storeCategory(matchedStore.category()) + .description(request.description()) + .imageKey(imageKey) + .build(); + + storyRepository.save(story); + } + + private FilteredSearchResult filteredSearchResponse(List responses, String storeKakaoId) { + return responses.stream() + .filter(store -> store.kakaoId().equals(storeKakaoId)) + .findFirst() + .map(store -> new FilteredSearchResult( + store.kakaoId(), + store.name(), + store.roadAddress(), + store.categoryName() + )) + .orElseThrow(() -> new BusinessException(BusinessErrorCode.STORE_NOT_FOUND)); + } +} diff --git a/src/main/resources/db/migration/V3__add_story_table.sql b/src/main/resources/db/migration/V3__add_story_table.sql new file mode 100644 index 00000000..32c7e6ad --- /dev/null +++ b/src/main/resources/db/migration/V3__add_story_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE `story` +( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `member_id` BIGINT NOT NULL, + `store_kakao_id` VARCHAR(255) NOT NULL, + `store_name` VARCHAR(255) NOT NULL, + `store_address` VARCHAR(255) NOT NULL, + `store_category` VARCHAR(50) NOT NULL, + `description` TEXT NOT NULL, + `image_key` VARCHAR(511) NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +); diff --git a/src/test/java/eatda/controller/BaseControllerTest.java b/src/test/java/eatda/controller/BaseControllerTest.java index 24b8396d..d4c27827 100644 --- a/src/test/java/eatda/controller/BaseControllerTest.java +++ b/src/test/java/eatda/controller/BaseControllerTest.java @@ -13,6 +13,8 @@ import eatda.domain.member.Member; import eatda.fixture.MemberGenerator; import eatda.repository.member.MemberRepository; +import eatda.service.common.ImageService; +import eatda.service.story.StoryService; import io.restassured.RestAssured; import io.restassured.builder.RequestSpecBuilder; import io.restassured.filter.Filter; @@ -54,6 +56,12 @@ public class BaseControllerTest { @MockitoBean private MapClient mapClient; + @MockitoBean + protected StoryService storyService; + + @MockitoBean + protected ImageService imageService; + @LocalServerPort private int port; diff --git a/src/test/java/eatda/controller/story/StoryControllerTest.java b/src/test/java/eatda/controller/story/StoryControllerTest.java new file mode 100644 index 00000000..dc251b05 --- /dev/null +++ b/src/test/java/eatda/controller/story/StoryControllerTest.java @@ -0,0 +1,55 @@ +package eatda.controller.story; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; + +import eatda.controller.BaseControllerTest; +import eatda.service.common.ImageDomain; +import io.restassured.response.Response; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class StoryControllerTest extends BaseControllerTest { + + @BeforeEach + void setUpMock() { + doReturn("https://dummy-s3.com/story.png") + .when(imageService) + .upload(any(), eq(ImageDomain.STORY)); + + doNothing() + .when(storyService) + .registerStory(any(), any(), any()); + } + + @Nested + class SearchStores { + + @Test + void 스토리를_등록할_수_있다() { + String requestJson = """ + { + "query": "농민백암순대", + "storeKakaoId": "123", + "description": "여기 진짜 맛있어요!" + } + """; + + byte[] imageBytes = "dummy image content".getBytes(StandardCharsets.UTF_8); + + Response response = given() + .contentType("multipart/form-data") + .header("Authorization", accessToken()) + .multiPart("request", "request.json", requestJson.getBytes(StandardCharsets.UTF_8), "application/json") + .multiPart("image", "image.png", imageBytes, "image/png") + .when() + .post("/api/stories"); + + response.then().statusCode(201); + } + } +} diff --git a/src/test/java/eatda/document/BaseDocumentTest.java b/src/test/java/eatda/document/BaseDocumentTest.java index 97498e68..a1bf0ce2 100644 --- a/src/test/java/eatda/document/BaseDocumentTest.java +++ b/src/test/java/eatda/document/BaseDocumentTest.java @@ -6,9 +6,12 @@ import eatda.controller.web.jwt.JwtManager; import eatda.exception.BusinessErrorCode; +import eatda.exception.EtcErrorCode; import eatda.service.auth.AuthService; +import eatda.service.common.ImageService; import eatda.service.member.MemberService; import eatda.service.store.StoreService; +import eatda.service.story.StoryService; import io.restassured.RestAssured; import io.restassured.builder.RequestSpecBuilder; import io.restassured.specification.RequestSpecification; @@ -38,12 +41,22 @@ public abstract class BaseDocumentTest { @MockitoBean protected AuthService authService; + @MockitoBean protected MemberService memberService; + @MockitoBean protected StoreService storeService; + + @MockitoBean + protected StoryService storyService; + + @MockitoBean + protected ImageService imageService; + @MockitoBean protected JwtManager jwtManager; + @LocalServerPort private int port; @@ -88,6 +101,10 @@ protected final RequestSpecification given(RestDocumentationFilter documentation .filter(documentationFilter); } + protected final RestDocsFilterBuilder document(String identifierPrefix, EtcErrorCode errorCode) { + return new RestDocsFilterBuilder(identifierPrefix, errorCode.name()); + } + protected final String accessToken() { return MOCKED_ACCESS_TOKEN; } diff --git a/src/test/java/eatda/document/Tag.java b/src/test/java/eatda/document/Tag.java index 35d594bc..633a987c 100644 --- a/src/test/java/eatda/document/Tag.java +++ b/src/test/java/eatda/document/Tag.java @@ -4,7 +4,8 @@ public enum Tag { AUTH_API("Auth API"), MEMBER_API("Member API"), - STORE_API("Store API"),; + STORE_API("Store API"), + STORY_API("Story API"); private final String displayName; diff --git a/src/test/java/eatda/document/story/StoryDocumentTest.java b/src/test/java/eatda/document/story/StoryDocumentTest.java new file mode 100644 index 00000000..e88838b7 --- /dev/null +++ b/src/test/java/eatda/document/story/StoryDocumentTest.java @@ -0,0 +1,136 @@ +package eatda.document.story; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; + +import eatda.document.BaseDocumentTest; +import eatda.document.RestDocsRequest; +import eatda.document.RestDocsResponse; +import eatda.document.Tag; +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; +import eatda.exception.EtcErrorCode; +import eatda.service.common.ImageDomain; +import io.restassured.response.Response; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.restdocs.restassured.RestDocumentationFilter; + +public class StoryDocumentTest extends BaseDocumentTest { + + @Nested + class RegisterStory { + + RestDocsRequest requestDocument = request() + .tag(Tag.STORY_API) + .summary("스토리 등록") + .description("스토리와 이미지를 multipart/form-data로 등록합니다.") + .requestHeader( + headerWithName(HttpHeaders.AUTHORIZATION).description("액세스 토큰") + ); + + RestDocsResponse responseDocument = response(); + + @Test + void 스토리_등록_성공() { + doReturn("https://dummy-s3.com/story.png") + .when(imageService) + .upload(any(), org.mockito.ArgumentMatchers.eq(ImageDomain.STORY)); + + doNothing().when(storyService) + .registerStory(any(), any(), any()); + + String requestJson = """ + { + "query": "농민백암순대", + "storeKakaoId": "123", + "description": "여기 진짜 맛있어요!" + } + """; + + byte[] imageBytes = "dummy image content".getBytes(StandardCharsets.UTF_8); + + RestDocumentationFilter document = document("story/register", 201) + .request(requestDocument) + .response(responseDocument) + .build(); + + Response response = given(document) + .contentType("multipart/form-data") + .header(HttpHeaders.AUTHORIZATION, accessToken()) + .multiPart("request", "request.json", requestJson.getBytes(StandardCharsets.UTF_8), "application/json") + .multiPart("image", "image.png", imageBytes, "image/png") + .when().post("/api/stories"); + + response.then().statusCode(201); + } + + @Test + void 스토리_등록_실패_필수값_누락() { + String invalidJson = """ + { + "query": "농민백암순대", + "storeKakaoId": "123" + } + """; + + byte[] imageBytes = "dummy image content".getBytes(StandardCharsets.UTF_8); + + doThrow(new BusinessException(BusinessErrorCode.INVALID_STORY_DESCRIPTION)) + .when(storyService) + .registerStory(any(), any(), any()); + + var document = document("story/register", EtcErrorCode.CLIENT_REQUEST_ERROR) + .request(requestDocument) + .response(ERROR_RESPONSE) + .build(); + + given(document) + .contentType("multipart/form-data") + .header(HttpHeaders.AUTHORIZATION, accessToken()) + .multiPart("request", "request.json", invalidJson.getBytes(StandardCharsets.UTF_8), "application/json") + .multiPart("image", "image.png", imageBytes, "image/png") + .when().post("/api/stories") + .then().statusCode(EtcErrorCode.CLIENT_REQUEST_ERROR.getStatus().value()); + } + + @Test + void 스토리_등록_실패_이미지_형식_오류() { + String requestJson = """ + { + "query": "농민백암순대", + "storeKakaoId": "123", + "description": "여기 진짜 맛있어요!" + } + """; + + byte[] invalidImage = "not an image".getBytes(StandardCharsets.UTF_8); + + doThrow(new BusinessException(BusinessErrorCode.INVALID_IMAGE_TYPE)) + .when(storyService) + .registerStory(any(), any(), any()); + + var document = document("story/register", BusinessErrorCode.INVALID_IMAGE_TYPE) + .request(requestDocument) + .response(ERROR_RESPONSE) + .build(); + + Response response = given(document) + .contentType("multipart/form-data") + .header(HttpHeaders.AUTHORIZATION, accessToken()) + .multiPart("request", "request.json", requestJson.getBytes(StandardCharsets.UTF_8), "application/json") + .multiPart("image", "image.txt", invalidImage, "text/plain") + .when().post("/api/stories"); + + System.out.println("응답 상태코드 >>> " + response.statusCode()); + System.out.println("응답 바디 >>> " + response.asString()); + + response.then().statusCode(BusinessErrorCode.INVALID_IMAGE_TYPE.getStatus().value()); + } + } +} diff --git a/src/test/java/eatda/domain/story/StoryTest.java b/src/test/java/eatda/domain/story/StoryTest.java new file mode 100644 index 00000000..572f0601 --- /dev/null +++ b/src/test/java/eatda/domain/story/StoryTest.java @@ -0,0 +1,160 @@ +package eatda.domain.story; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import eatda.domain.member.Member; +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class StoryTest { + + private static final Member MEMBER = Mockito.mock(Member.class); + + @Nested + class RegisterStory { + + @Test + void 스토리를_정상적으로_생성한다() { + Story story = Story.builder() + .member(MEMBER) + .storeKakaoId("123") + .storeName("곱창집") + .storeAddress("서울시 성동구") + .storeCategory("한식") + .description("정말 맛있어요") + .imageKey("story/image.jpg") + .build(); + + assertThat(story.getStoreName()).isEqualTo("곱창집"); + assertThat(story.getDescription()).isEqualTo("정말 맛있어요"); + } + } + + @Nested + class ValidateMember { + + @Test + void 회원이_null이면_예외가_발생한다() { + assertThatThrownBy(() -> + Story.builder() + .member(null) + .storeKakaoId("123") + .storeName("곱창집") + .storeAddress("서울시 성동구") + .storeCategory("한식") + .description("정말 맛있어요") + .imageKey("story/image.jpg") + .build() + ).isInstanceOf(BusinessException.class) + .hasMessage(BusinessErrorCode.STORY_MEMBER_REQUIRED.getMessage()); + } + } + + @Nested + class ValidateStore { + + @Test + void 가게_ID가_비어있으면_예외가_발생한다() { + assertThatThrownBy(() -> + Story.builder() + .member(MEMBER) + .storeKakaoId(" ") + .storeName("곱창집") + .storeAddress("서울시") + .storeCategory("한식") + .description("맛있음") + .imageKey("story/image.jpg") + .build() + ).isInstanceOf(BusinessException.class) + .hasMessage(BusinessErrorCode.INVALID_STORE_KAKAO_ID.getMessage()); + } + + @Test + void 가게_이름이_비어있으면_예외가_발생한다() { + assertThatThrownBy(() -> + Story.builder() + .member(MEMBER) + .storeKakaoId("123") + .storeName(" ") + .storeAddress("서울시") + .storeCategory("한식") + .description("맛있음") + .imageKey("story/image.jpg") + .build() + ).isInstanceOf(BusinessException.class) + .hasMessage(BusinessErrorCode.INVALID_STORE_NAME.getMessage()); + } + + @Test + void 가게_주소가_비어있으면_예외가_발생한다() { + assertThatThrownBy(() -> + Story.builder() + .member(MEMBER) + .storeKakaoId("123") + .storeName("곱창집") + .storeAddress(" ") + .storeCategory("한식") + .description("맛있음") + .imageKey("story/image.jpg") + .build() + ).isInstanceOf(BusinessException.class) + .hasMessage(BusinessErrorCode.INVALID_STORE_ADDRESS.getMessage()); + } + + @Test + void 가게_카테고리가_비어있으면_예외가_발생한다() { + assertThatThrownBy(() -> + Story.builder() + .member(MEMBER) + .storeKakaoId("123") + .storeName("곱창집") + .storeAddress("서울시") + .storeCategory(" ") + .description("맛있음") + .imageKey("story/image.jpg") + .build() + ).isInstanceOf(BusinessException.class) + .hasMessage(BusinessErrorCode.INVALID_STORE_CATEGORY.getMessage()); + } + } + + @Nested + class ValidateStory { + + @Test + void 설명이_비어있으면_예외가_발생한다() { + assertThatThrownBy(() -> + Story.builder() + .member(MEMBER) + .storeKakaoId("123") + .storeName("곱창집") + .storeAddress("서울시") + .storeCategory("한식") + .description(" ") + .imageKey("story/image.jpg") + .build() + ).isInstanceOf(BusinessException.class) + .hasMessage(BusinessErrorCode.INVALID_STORY_DESCRIPTION.getMessage()); + } + + @Test + void 이미지가_비어있으면_예외가_발생한다() { + assertThatThrownBy(() -> + Story.builder() + .member(MEMBER) + .storeKakaoId("123") + .storeName("곱창집") + .storeAddress("서울시") + .storeCategory("한식") + .description("맛있음") + .imageKey(" ") + .build() + ).isInstanceOf(BusinessException.class) + .hasMessage(BusinessErrorCode.INVALID_STORY_IMAGE_KEY.getMessage()); + } + } +} diff --git a/src/test/java/eatda/service/BaseServiceTest.java b/src/test/java/eatda/service/BaseServiceTest.java index 09082e6b..620301a9 100644 --- a/src/test/java/eatda/service/BaseServiceTest.java +++ b/src/test/java/eatda/service/BaseServiceTest.java @@ -5,6 +5,9 @@ import eatda.client.oauth.OauthClient; import eatda.fixture.MemberGenerator; import eatda.repository.member.MemberRepository; +import eatda.repository.story.StoryRepository; +import eatda.service.common.ImageService; +import eatda.service.store.StoreService; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -25,4 +28,13 @@ public abstract class BaseServiceTest { @Autowired protected MemberRepository memberRepository; + + @MockitoBean + protected StoreService storeService; + + @MockitoBean + protected ImageService imageService; + + @Autowired + protected StoryRepository storyRepository; } diff --git a/src/test/java/eatda/service/common/ImageServiceTest.java b/src/test/java/eatda/service/common/ImageServiceTest.java index bbe652c4..ec551d27 100644 --- a/src/test/java/eatda/service/common/ImageServiceTest.java +++ b/src/test/java/eatda/service/common/ImageServiceTest.java @@ -10,13 +10,14 @@ import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; -import java.io.IOException; import java.net.URL; import java.time.Duration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -49,23 +50,26 @@ void setUp() { @Nested class FileUpload { - @Test - void 허용된_이미지_타입이면_정상적으로_업로드되고_생성된_Key를_반환한다() throws IOException { + @ParameterizedTest + @EnumSource(ImageDomain.class) + void 허용된_이미지_타입이면_정상적으로_업로드되고_생성된_Key를_반환한다(ImageDomain imageDomain) { String originalFilename = "test-image.jpg"; String contentType = "image/jpeg"; - String domain = "stores"; + MockMultipartFile file = new MockMultipartFile( "image", originalFilename, contentType, "image-content".getBytes() ); - String key = imageService.upload(file, domain); + String key = imageService.upload(file, imageDomain); ArgumentCaptor putObjectRequestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); verify(s3Client).putObject(putObjectRequestCaptor.capture(), any(RequestBody.class)); PutObjectRequest capturedRequest = putObjectRequestCaptor.getValue(); + String expectedPattern = imageDomain.getName() + "/[a-f0-9\\-]{36}\\.jpg"; + assertAll( - () -> assertThat(key).matches(domain + "/[a-f0-9\\-]{36}\\.jpg"), + () -> assertThat(key).matches(expectedPattern), () -> assertThat(capturedRequest.key()).isEqualTo(key), () -> assertThat(capturedRequest.bucket()).isEqualTo(TEST_BUCKET), () -> assertThat(capturedRequest.contentType()).isEqualTo(contentType) @@ -79,7 +83,7 @@ class FileUpload { ); BusinessException exception = assertThrows(BusinessException.class, - () -> imageService.upload(file, "etc")); + () -> imageService.upload(file, ImageDomain.STORY)); assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.INVALID_IMAGE_TYPE); } diff --git a/src/test/java/eatda/service/story/StoryServiceTest.java b/src/test/java/eatda/service/story/StoryServiceTest.java new file mode 100644 index 00000000..b5e05fdd --- /dev/null +++ b/src/test/java/eatda/service/story/StoryServiceTest.java @@ -0,0 +1,68 @@ +package eatda.service.story; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import eatda.client.map.StoreSearchResult; +import eatda.controller.story.StoryRegisterRequest; +import eatda.domain.member.Member; +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; +import eatda.service.BaseServiceTest; +import eatda.service.common.ImageDomain; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.web.multipart.MultipartFile; + +public class StoryServiceTest { + + @Nested + class RegisterStory extends BaseServiceTest { + + private StoryService storyService; + + @BeforeEach + void setUp() { + storyService = new StoryService(storeService, imageService, storyRepository, memberRepository); + } + + @Test + void 스토리_등록에_성공한다() { + Member member = memberGenerator.generate("12345"); + StoryRegisterRequest request = new StoryRegisterRequest("곱창", "123", "미쳤다 여기"); + + MultipartFile image = mock(MultipartFile.class); + when(imageService.upload(image, ImageDomain.STORY)).thenReturn("image-key"); + + StoreSearchResult store = new StoreSearchResult( + "123", "FD6", "음식점 > 한식", "010-1234-5678", + "곱창집", "http://example.com", + "서울 강남구", "서울 강남구", 37.0, 127.0 + ); + + when(storeService.searchStoreResults(request.query())).thenReturn(List.of(store)); + + assertDoesNotThrow(() -> + storyService.registerStory(request, image, member.getId()) + ); + } + + @Test + void 클라이언트_요청과_일치하는_가게가_없으면_실패한다() { + Member member = memberGenerator.generate("12345"); + StoryRegisterRequest request = new StoryRegisterRequest("곱창", "999", "미쳤다 여기"); + + MultipartFile image = mock(MultipartFile.class); + when(storeService.searchStoreResults(request.query())).thenReturn(List.of()); + + assertThatThrownBy(() -> + storyService.registerStory(request, image, member.getId()) + ).isInstanceOf(BusinessException.class) + .hasMessageContaining(BusinessErrorCode.STORE_NOT_FOUND.getMessage()); + } + } +}