diff --git a/.github/workflows/automatic-erd.yml b/.github/workflows/automatic-erd.yml index 309bd73c..ea555611 100644 --- a/.github/workflows/automatic-erd.yml +++ b/.github/workflows/automatic-erd.yml @@ -2,8 +2,8 @@ name: ERD to GitHub Pages on: pull_request: - branches: [ "**" ] - paths: [ "**" ] + branches: [ "main", "develop" ] + paths: [ "src/main/resources/db/migration/**" ] concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 8fc80f97..23f69f5e 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -19,6 +19,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Java uses: actions/setup-java@v3 diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index e5c898ec..1a33236e 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -2,9 +2,8 @@ name: Terraform Plan on PR on: pull_request: - branches: - - main - - develop + branches: [ "main", "develop" ] + paths: [ "terraform/**", "terraform-bootstrap/**" ] permissions: contents: read diff --git a/src/main/java/eatda/controller/story/FilteredSearchResult.java b/src/main/java/eatda/controller/story/FilteredSearchResult.java index 89d94efe..13da5f5b 100644 --- a/src/main/java/eatda/controller/story/FilteredSearchResult.java +++ b/src/main/java/eatda/controller/story/FilteredSearchResult.java @@ -3,7 +3,8 @@ public record FilteredSearchResult( String kakaoId, String name, - String address, + String roadAddress, + String lotNumberAddress, String category ) { } diff --git a/src/main/java/eatda/controller/story/StoryController.java b/src/main/java/eatda/controller/story/StoryController.java index 23495b0f..82c3e409 100644 --- a/src/main/java/eatda/controller/story/StoryController.java +++ b/src/main/java/eatda/controller/story/StoryController.java @@ -2,11 +2,15 @@ import eatda.controller.web.auth.LoginMember; import eatda.service.story.StoryService; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; @@ -28,8 +32,14 @@ public ResponseEntity registerStory( } @GetMapping("api/stories") - public ResponseEntity getStories() { + public ResponseEntity getStories(@RequestParam(defaultValue = "5") @Min(1) @Max(50) int size) { return ResponseEntity.status(HttpStatus.OK) - .body(storyService.getPagedStoryPreviews()); + .body(storyService.getPagedStoryPreviews(size)); + } + + @GetMapping("/api/stories/{storyId}") + public ResponseEntity getStory(@PathVariable long storyId) { + return ResponseEntity.status(HttpStatus.OK) + .body(storyService.getStory(storyId)); } } diff --git a/src/main/java/eatda/controller/story/StoryResponse.java b/src/main/java/eatda/controller/story/StoryResponse.java index df46d476..2e1faa12 100644 --- a/src/main/java/eatda/controller/story/StoryResponse.java +++ b/src/main/java/eatda/controller/story/StoryResponse.java @@ -4,7 +4,8 @@ public record StoryResponse( String storeKakaoId, String category, String storeName, - String storeAddress, + String storeDistrict, + String storeNeighborhood, String description, String imageUrl ) { diff --git a/src/main/java/eatda/domain/story/Story.java b/src/main/java/eatda/domain/story/Story.java index 97817acf..eb46f1e8 100644 --- a/src/main/java/eatda/domain/story/Story.java +++ b/src/main/java/eatda/domain/story/Story.java @@ -38,8 +38,11 @@ public class Story extends AuditingEntity { @Column(name = "store_name", nullable = false) private String storeName; - @Column(name = "store_address", nullable = false) - private String storeAddress; + @Column(name = "store_road_address", nullable = false) + private String storeRoadAddress; + + @Column(name = "store_lot_number_address", nullable = false) + private String storeLotNumberAddress; @Column(name = "store_category", nullable = false) private String storeCategory; @@ -54,21 +57,23 @@ public class Story extends AuditingEntity { private Story( Member member, String storeKakaoId, - String storeName, - String storeAddress, String storeCategory, + String storeName, + String storeRoadAddress, + String storeLotNumberAddress, String description, String imageKey ) { validateMember(member); - validateStore(storeKakaoId, storeName, storeAddress, storeCategory); + validateStore(storeKakaoId, storeCategory, storeName, storeRoadAddress, storeLotNumberAddress); validateStory(description, imageKey); this.member = member; this.storeKakaoId = storeKakaoId; - this.storeName = storeName; - this.storeAddress = storeAddress; this.storeCategory = storeCategory; + this.storeName = storeName; + this.storeRoadAddress = storeRoadAddress; + this.storeLotNumberAddress = storeLotNumberAddress; this.description = description; this.imageKey = imageKey; } @@ -79,11 +84,18 @@ private void validateMember(Member member) { } } - private void validateStore(String storeKakaoId, String storeName, String storeAddress, String storeCategory) { + private void validateStore( + String storeKakaoId, + String storeCategory, + String storeName, + String roadAddress, + String lotNumberAddress + ) { validateStoreKakaoId(storeKakaoId); - validateStoreName(storeName); - validateStoreAddress(storeAddress); validateStoreCategory(storeCategory); + validateStoreName(storeName); + validateStoreRoadAddress(roadAddress); + validateStoreLotNumberAddress(lotNumberAddress); } private void validateStory(String description, String imageKey) { @@ -103,8 +115,14 @@ private void validateStoreName(String storeName) { } } - private void validateStoreAddress(String storeAddress) { - if (storeAddress == null || storeAddress.isBlank()) { + private void validateStoreRoadAddress(String roadAddress) { + if (roadAddress == null || roadAddress.isBlank()) { + throw new BusinessException(BusinessErrorCode.INVALID_STORE_ADDRESS); + } + } + + private void validateStoreLotNumberAddress(String lotNumberAddress) { + if (lotNumberAddress == null || lotNumberAddress.isBlank()) { throw new BusinessException(BusinessErrorCode.INVALID_STORE_ADDRESS); } } @@ -126,4 +144,20 @@ private void validateImage(String imageKey) { throw new BusinessException(BusinessErrorCode.INVALID_STORY_IMAGE_KEY); } } + + public String getAddressDistrict() { + String[] addressParts = storeLotNumberAddress.split(" "); + if (addressParts.length < 2) { + return ""; + } + return addressParts[1]; + } + + public String getAddressNeighborhood() { + String[] addressParts = storeLotNumberAddress.split(" "); + if (addressParts.length < 3) { + return ""; + } + return addressParts[2]; + } } diff --git a/src/main/java/eatda/exception/BusinessErrorCode.java b/src/main/java/eatda/exception/BusinessErrorCode.java index fea49646..94a75b76 100644 --- a/src/main/java/eatda/exception/BusinessErrorCode.java +++ b/src/main/java/eatda/exception/BusinessErrorCode.java @@ -47,7 +47,7 @@ public enum BusinessErrorCode { INVALID_STORY_IMAGE_KEY("STY002", "스토리 이미지 Key는 필수입니다."), STORY_MEMBER_REQUIRED("STY003", "스토리 작성 시 회원 정보는 필수입니다."), STORY_STORE_REQUIRED("STY004", "스토리 작성 시 가게 정보는 필수입니다."), - STORY_NOT_FOUND("STY005", "스토리를 찾을 수 없습니다."), + STORY_NOT_FOUND("STY005", "스토리를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), INVALID_STORE_ID("STY006", "유효하지 않은 가게 ID입니다."), INVALID_STORE_KAKAO_ID("STY007", "스토어 Kakao ID는 필수입니다."), INVALID_STORE_NAME("STY008", "스토어 이름은 필수입니다."), diff --git a/src/main/java/eatda/service/story/StoryService.java b/src/main/java/eatda/service/story/StoryService.java index 17168edd..2e97eb12 100644 --- a/src/main/java/eatda/service/story/StoryService.java +++ b/src/main/java/eatda/service/story/StoryService.java @@ -4,6 +4,7 @@ import eatda.controller.story.FilteredSearchResult; import eatda.controller.story.StoriesResponse; import eatda.controller.story.StoryRegisterRequest; +import eatda.controller.story.StoryResponse; import eatda.domain.member.Member; import eatda.domain.story.Story; import eatda.exception.BusinessErrorCode; @@ -26,7 +27,6 @@ @RequiredArgsConstructor public class StoryService { private static final int PAGE_START_NUMBER = 0; - private static final int PAGE_SIZE = 5; private final StoreService storeService; private final ImageService imageService; @@ -44,7 +44,8 @@ public void registerStory(StoryRegisterRequest request, MultipartFile image, Lon .member(member) .storeKakaoId(matchedStore.kakaoId()) .storeName(matchedStore.name()) - .storeAddress(matchedStore.address()) + .storeRoadAddress(matchedStore.roadAddress()) + .storeLotNumberAddress(matchedStore.lotNumberAddress()) .storeCategory(matchedStore.category()) .description(request.description()) .imageKey(imageKey) @@ -61,14 +62,15 @@ private FilteredSearchResult filteredSearchResponse(List resp store.kakaoId(), store.name(), store.roadAddress(), + store.lotNumberAddress(), store.categoryName() )) .orElseThrow(() -> new BusinessException(BusinessErrorCode.STORE_NOT_FOUND)); } @Transactional(readOnly = true) - public StoriesResponse getPagedStoryPreviews() { - Pageable pageable = PageRequest.of(PAGE_START_NUMBER, PAGE_SIZE); + public StoriesResponse getPagedStoryPreviews(int size) { + Pageable pageable = PageRequest.of(PAGE_START_NUMBER, size); Page orderByPage = storyRepository.findAllByOrderByCreatedAtDesc(pageable); return new StoriesResponse( @@ -80,4 +82,20 @@ public StoriesResponse getPagedStoryPreviews() { .toList() ); } + + @Transactional(readOnly = true) + public StoryResponse getStory(long storyId) { + Story story = storyRepository.findById(storyId) + .orElseThrow(() -> new BusinessException(BusinessErrorCode.STORY_NOT_FOUND)); + + return new StoryResponse( + story.getStoreKakaoId(), + story.getStoreCategory(), + story.getStoreName(), + story.getAddressDistrict(), + story.getAddressNeighborhood(), + story.getDescription(), + imageService.getPresignedUrl(story.getImageKey()) + ); + } } diff --git a/src/main/resources/db/migration/V3__add_story_table.sql b/src/main/resources/db/migration/V3__add_story_table.sql index 32c7e6ad..397e930a 100644 --- a/src/main/resources/db/migration/V3__add_story_table.sql +++ b/src/main/resources/db/migration/V3__add_story_table.sql @@ -1,13 +1,14 @@ 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, + `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_road_address` VARCHAR(255) NOT NULL, + `store_lot_number_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/main/resources/db/seed/dev/V2__dev_init_data.sql b/src/main/resources/db/seed/dev/V2__dev_init_data.sql index b9fa13ed..9f9e024c 100644 --- a/src/main/resources/db/seed/dev/V2__dev_init_data.sql +++ b/src/main/resources/db/seed/dev/V2__dev_init_data.sql @@ -25,15 +25,15 @@ VALUES (1, '99999999999', 'KOREAN', '01012345678', '맛있는 한식집', 'https '서울시 강남구 역삼동 222-324', 37.5036675804016, 127.05305858911); INSERT INTO cheer (id, member_id, store_id, description, image_key, is_admin) -VALUES (1, 1, 1, '정말 맛있어요! 강추합니다!', 'default.jpg', true), - (2, 2, 2, '서비스가 훌륭해요!', 'default.jpg', true), - (3, 3, 3, '여기 음식이 정말 맛있어요!', 'default.jpg', true), - (4, 4, 4, '분위기가 너무 좋아요!', 'default.jpg', true), - (5, 5, 5, '디저트가 정말 맛있어요!', 'default.jpg', true), - (6, 6, 6, '커피가 정말 맛있어요!', 'default.jpg', false), - (7, 7, 7, '패스트푸드가 빠르고 맛있어요!', 'default.jpg', false); +VALUES (1, 1, 1, '정말 맛있어요! 강추합니다!', 'cheer/dummy/1.jpg', true), + (2, 2, 2, '서비스가 훌륭해요!', 'cheer/dummy/2.jpg', true), + (3, 3, 3, '여기 음식이 정말 맛있어요!', 'cheer/dummy/3.jpg', true), + (4, 4, 4, '분위기가 너무 좋아요!', 'cheer/dummy/4.jpg', true), + (5, 5, 5, '디저트가 정말 맛있어요!', 'cheer/dummy/5.jpg', true), + (6, 6, 6, '커피가 정말 맛있어요!', 'cheer/dummy/6.jpg', false), + (7, 7, 7, '패스트푸드가 빠르고 맛있어요!', 'cheer/dummy/7.jpg', false); INSERT INTO article (id, title, subtitle, article_url, image_key) -VALUES (1, '첫 번째 기사', '서브타이틀 1', 'https://example.com/article1', 'default.jpg'), - (2, '두 번째 기사', '서브타이틀 2', 'https://example.com/article2', 'default.jpg'), - (3, '세 번째 기사', '서브타이틀 3', 'https://example.com/article3', 'default.jpg'); +VALUES (1, '첫 번째 기사', '서브타이틀 1', 'https://example.com/article1', 'article/dummy/1.jpg'), + (2, '두 번째 기사', '서브타이틀 2', 'https://example.com/article2', 'article/dummy/2.jpg'), + (3, '세 번째 기사', '서브타이틀 3', 'https://example.com/article3', 'article/dummy/3.jpg'); diff --git a/src/main/resources/db/seed/dev/V4__dev_add_story_data.sql b/src/main/resources/db/seed/dev/V4__dev_add_story_data.sql index e5dc1300..2822120d 100644 --- a/src/main/resources/db/seed/dev/V4__dev_add_story_data.sql +++ b/src/main/resources/db/seed/dev/V4__dev_add_story_data.sql @@ -1,5 +1,14 @@ -INSERT INTO story (member_id, store_kakao_id, store_name, store_address, - store_category, description, image_key) -VALUES (1, '99999999999', '맛있는 한식집', '서울시 강남구 역삼동 123-45', 'KOREAN', '진짜 여기 곱창 맛집임. 다시 또 갈 듯!', 'story/preview/1.jpg'), - (2, '99999999998', '아름다운 양식집', '서울시 강남구 역삼동 67-89', 'WESTERN', '스테이크가 부드럽고 서비스도 좋아요.', 'story/preview/2.jpg'), - (3, '99999999997', '정통 중식당', '서울시 강남구 역삼동 101-112', 'CHINESE', '짜장면이 정통의 맛. 강력 추천.', 'story/preview/3.jpg'); +INSERT INTO story (member_id, + store_kakao_id, + store_name, + store_road_address, + store_lot_number_address, + store_category, + description, + image_key) +VALUES (1, '99999999999', '맛있는 한식집', '서울시 강남구 테헤란로 123', '서울시 강남구 역삼동 123-45', 'KOREAN', '진짜 여기 곱창 맛집임. 다시 또 갈 듯!', + 'story/dummy/1.jpg'), + (2, '99999999998', '아름다운 양식집', '서울시 강남구 테헤란로 67', '서울시 강남구 역삼동 67-89', 'WESTERN', '스테이크가 부드럽고 서비스도 좋아요.', + 'story/dummy/2.jpg'), + (3, '99999999997', '정통 중식당', '서울시 강남구 봉은사로 101', '서울시 강남구 역삼동 101-112', 'CHINESE', '짜장면이 정통의 맛. 강력 추천.', + 'story/dummy/3.jpg'); diff --git a/src/main/resources/db/seed/local/V2__local_init_data.sql b/src/main/resources/db/seed/local/V2__local_init_data.sql index b9fa13ed..9f9e024c 100644 --- a/src/main/resources/db/seed/local/V2__local_init_data.sql +++ b/src/main/resources/db/seed/local/V2__local_init_data.sql @@ -25,15 +25,15 @@ VALUES (1, '99999999999', 'KOREAN', '01012345678', '맛있는 한식집', 'https '서울시 강남구 역삼동 222-324', 37.5036675804016, 127.05305858911); INSERT INTO cheer (id, member_id, store_id, description, image_key, is_admin) -VALUES (1, 1, 1, '정말 맛있어요! 강추합니다!', 'default.jpg', true), - (2, 2, 2, '서비스가 훌륭해요!', 'default.jpg', true), - (3, 3, 3, '여기 음식이 정말 맛있어요!', 'default.jpg', true), - (4, 4, 4, '분위기가 너무 좋아요!', 'default.jpg', true), - (5, 5, 5, '디저트가 정말 맛있어요!', 'default.jpg', true), - (6, 6, 6, '커피가 정말 맛있어요!', 'default.jpg', false), - (7, 7, 7, '패스트푸드가 빠르고 맛있어요!', 'default.jpg', false); +VALUES (1, 1, 1, '정말 맛있어요! 강추합니다!', 'cheer/dummy/1.jpg', true), + (2, 2, 2, '서비스가 훌륭해요!', 'cheer/dummy/2.jpg', true), + (3, 3, 3, '여기 음식이 정말 맛있어요!', 'cheer/dummy/3.jpg', true), + (4, 4, 4, '분위기가 너무 좋아요!', 'cheer/dummy/4.jpg', true), + (5, 5, 5, '디저트가 정말 맛있어요!', 'cheer/dummy/5.jpg', true), + (6, 6, 6, '커피가 정말 맛있어요!', 'cheer/dummy/6.jpg', false), + (7, 7, 7, '패스트푸드가 빠르고 맛있어요!', 'cheer/dummy/7.jpg', false); INSERT INTO article (id, title, subtitle, article_url, image_key) -VALUES (1, '첫 번째 기사', '서브타이틀 1', 'https://example.com/article1', 'default.jpg'), - (2, '두 번째 기사', '서브타이틀 2', 'https://example.com/article2', 'default.jpg'), - (3, '세 번째 기사', '서브타이틀 3', 'https://example.com/article3', 'default.jpg'); +VALUES (1, '첫 번째 기사', '서브타이틀 1', 'https://example.com/article1', 'article/dummy/1.jpg'), + (2, '두 번째 기사', '서브타이틀 2', 'https://example.com/article2', 'article/dummy/2.jpg'), + (3, '세 번째 기사', '서브타이틀 3', 'https://example.com/article3', 'article/dummy/3.jpg'); diff --git a/src/main/resources/db/seed/local/V4__local_add_story_data.sql b/src/main/resources/db/seed/local/V4__local_add_story_data.sql index 47300db8..d9d29a87 100644 --- a/src/main/resources/db/seed/local/V4__local_add_story_data.sql +++ b/src/main/resources/db/seed/local/V4__local_add_story_data.sql @@ -1,9 +1,22 @@ -INSERT INTO story (member_id, store_kakao_id, store_name, store_address, - store_category, description, image_key) -VALUES (1, '99999999999', '맛있는 한식집', '서울시 강남구 역삼동 123-45', 'KOREAN', '진짜 여기 곱창 맛집임. 다시 또 갈 듯!', 'story/preview/1.jpg'), - (2, '99999999998', '아름다운 양식집', '서울시 강남구 역삼동 67-89', 'WESTERN', '스테이크가 부드럽고 서비스도 좋아요.', 'story/preview/2.jpg'), - (3, '99999999997', '정통 중식당', '서울시 강남구 역삼동 101-112', 'CHINESE', '짜장면이 정통의 맛. 강력 추천.', 'story/preview/3.jpg'), - (4, '99999999996', '고급 양식 레스토랑', '서울시 강남구 역삼동 131-415', 'WESTERN', '분위기가 연인 데이트하기 좋아요.', 'story/preview/4.jpg'), - (5, '99999999995', '달콤한 디저트 카페', '서울시 강남구 역삼동 161-718', 'ETC', '케이크가 촉촉하고 맛있어요.', 'story/preview/5.jpg'), - (6, '99999999994', '아늑한 카페', '서울시 강남구 역삼동 192-021', 'ETC', '조용해서 공부하기 좋아요.', 'story/preview/6.jpg'), - (7, '99999999993', '빠른 패스트푸드점', '서울시 강남구 역삼동 222-324', 'ETC', '햄버거 나오는데 3분도 안 걸림. 굿.', 'story/preview/7.jpg'); +INSERT INTO story (member_id, + store_kakao_id, + store_name, + store_road_address, + store_lot_number_address, + store_category, + description, + image_key) +VALUES (1, '99999999999', '맛있는 한식집', '서울시 강남구 테헤란로 123', '서울시 강남구 역삼동 123-45', 'KOREAN', '진짜 여기 곱창 맛집임. 다시 또 갈 듯!', + 'story/dummy/1.jpg'), + (2, '99999999998', '아름다운 양식집', '서울시 강남구 테헤란로 67', '서울시 강남구 역삼동 67-89', 'WESTERN', '스테이크가 부드럽고 서비스도 좋아요.', + 'story/dummy/2.jpg'), + (3, '99999999997', '정통 중식당', '서울시 강남구 봉은사로 101', '서울시 강남구 역삼동 101-112', 'CHINESE', '짜장면이 정통의 맛. 강력 추천.', + 'story/dummy/3.jpg'), + (4, '99999999996', '고급 양식 레스토랑', '서울시 강남구 언주로 131', '서울시 강남구 역삼동 131-415', 'WESTERN', '분위기가 연인 데이트하기 좋아요.', + 'story/dummy/4.jpg'), + (5, '99999999995', '달콤한 디저트 카페', '서울시 강남구 논현로 161', '서울시 강남구 역삼동 161-718', 'ETC', '케이크가 촉촉하고 맛있어요.', + 'story/dummy/5.jpg'), + (6, '99999999994', '아늑한 카페', '서울시 강남구 선릉로 192', '서울시 강남구 역삼동 192-021', 'ETC', '조용해서 공부하기 좋아요.', + 'story/dummy/6.jpg'), + (7, '99999999993', '빠른 패스트푸드점', '서울시 강남구 도산대로 222', '서울시 강남구 역삼동 222-324', 'ETC', '햄버거 나오는데 3분도 안 걸림. 굿.', + 'story/dummy/7.jpg'); diff --git a/src/test/java/eatda/controller/story/StoryControllerTest.java b/src/test/java/eatda/controller/story/StoryControllerTest.java index d1c77ceb..9a3f4767 100644 --- a/src/test/java/eatda/controller/story/StoryControllerTest.java +++ b/src/test/java/eatda/controller/story/StoryControllerTest.java @@ -1,13 +1,16 @@ package eatda.controller.story; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertAll; 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 static org.mockito.Mockito.doThrow; import eatda.controller.BaseControllerTest; -import eatda.service.common.ImageDomain; +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; import io.restassured.response.Response; import java.nio.charset.StandardCharsets; import java.util.List; @@ -25,7 +28,7 @@ void setUpMock() { } @Nested - class SearchStores { + class RegisterStory { @Test void 스토리를_등록할_수_있다() { @@ -51,25 +54,84 @@ class SearchStores { } } - @Test - void 스토리_목록을_조회할_수_있다() { - StoriesResponse mockResponse = new StoriesResponse(List.of( - new StoriesResponse.StoryPreview(1L, "https://dummy-s3.com/story1.png"), - new StoriesResponse.StoryPreview(2L, "https://dummy-s3.com/story2.png") - )); + @Nested + class GetStories { - doReturn(mockResponse) - .when(storyService) - .getPagedStoryPreviews(); + @Test + void 스토리_목록을_조회할_수_있다() { + StoriesResponse mockResponse = new StoriesResponse(List.of( + new StoriesResponse.StoryPreview(1L, "https://s3.bucket.com/story/dummy/1.jpg"), + new StoriesResponse.StoryPreview(2L, "https://s3.bucket.com/story/dummy/2.jpg") + )); + + doReturn(mockResponse) + .when(storyService) + .getPagedStoryPreviews(5); + + StoriesResponse response = given() + .queryParam("size", 5) + .when() + .get("/api/stories") + .then().statusCode(200) + .extract().as(StoriesResponse.class); + + assertAll( + () -> assertThat(response.stories()).hasSize(2), + () -> assertThat(response.stories().getFirst().storyId()).isEqualTo(1L), + () -> assertThat(response.stories().getFirst().imageUrl()).isEqualTo("https://s3.bucket.com/story/dummy/1.jpg") + ); + } + } + + @Nested + class GetStory { + + @Test + void 해당_스토리를_상세_조회할_수_있다() { + long storyId = 1L; - Response response = given() - .when() - .get("/api/stories"); + doReturn(new StoryResponse( + "123456", + "한식", + "진또곱창집", + "성동구", + "성수동", + "곱창은 여기", + "https://s3.bucket.com/story1.jpg" + )).when(storyService).getStory(storyId); - response.then() - .statusCode(200) - .body("stories.size()", equalTo(2)) - .body("stories[0].storyId", equalTo(1)) - .body("stories[0].imageUrl", equalTo("https://dummy-s3.com/story1.png")); + Response response = given() + .pathParam("storyId", storyId) + .when() + .get("/api/stories/{storyId}"); + + response.then() + .statusCode(200) + .body("storeKakaoId", equalTo("123456")) + .body("category", equalTo("한식")) + .body("storeName", equalTo("진또곱창집")) + .body("storeDistrict", equalTo("성동구")) + .body("storeNeighborhood", equalTo("성수동")) + .body("description", equalTo("곱창은 여기")) + .body("imageUrl", equalTo("https://s3.bucket.com/story1.jpg")); + } + + @Test + void 존재하지_않는_스토리를_조회하면_404_응답한다() { + long nonexistentId = 999L; + + doThrow(new BusinessException(BusinessErrorCode.STORY_NOT_FOUND)) + .when(storyService).getStory(nonexistentId); + + Response response = given() + .pathParam("storyId", nonexistentId) + .when() + .get("/api/stories/{storyId}"); + + response.then() + .statusCode(404) + .body("errorCode", equalTo(BusinessErrorCode.STORY_NOT_FOUND.getCode())) + .body("message", equalTo(BusinessErrorCode.STORY_NOT_FOUND.getMessage())); + } } } diff --git a/src/test/java/eatda/document/story/StoryDocumentTest.java b/src/test/java/eatda/document/story/StoryDocumentTest.java index 07058347..c861bf06 100644 --- a/src/test/java/eatda/document/story/StoryDocumentTest.java +++ b/src/test/java/eatda/document/story/StoryDocumentTest.java @@ -1,5 +1,6 @@ package eatda.document.story; +import static org.hamcrest.Matchers.equalTo; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doReturn; @@ -8,6 +9,7 @@ import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import eatda.controller.story.StoriesResponse; +import eatda.controller.story.StoryResponse; import eatda.document.BaseDocumentTest; import eatda.document.RestDocsRequest; import eatda.document.RestDocsResponse; @@ -158,7 +160,7 @@ class GetStories { doReturn(mockResponse) .when(storyService) - .getPagedStoryPreviews(); + .getPagedStoryPreviews(5); RestDocumentationFilter document = document("story/get-stories", 200) .request(requestDocument) @@ -166,10 +168,89 @@ class GetStories { .build(); Response response = given(document) + .queryParam("size", 5) .header(HttpHeaders.AUTHORIZATION, accessToken()) .when().get("/api/stories"); response.then().statusCode(200); } } + + @Nested + class GetStory { + + RestDocsRequest requestDocument = request() + .tag(Tag.STORY_API) + .summary("스토리 상세 조회") + .description("스토리 ID를 기반으로 상세 정보를 조회합니다."); + + RestDocsResponse responseDocument = response() + .responseBodyField( + fieldWithPath("storeKakaoId").description("가게의 카카오 ID"), + fieldWithPath("category").description("가게 카테고리"), + fieldWithPath("storeName").description("가게 이름"), + fieldWithPath("storeDistrict").description("가게 주소의 구"), + fieldWithPath("storeNeighborhood").description("가게 주소의 동"), + fieldWithPath("description").description("스토리 내용"), + fieldWithPath("imageUrl").description("스토리 이미지 URL") + ); + + @Test + void 스토리_상세_조회_성공() { + long storyId = 1L; + + doReturn(new StoryResponse( + "123456", + "한식", + "진또곱창집", + "성동구", + "성수동", + "곱창은 여기", + "https://s3.bucket.com/story1.jpg" + )).when(storyService).getStory(storyId); + + RestDocumentationFilter document = document("story/get-story", 200) + .request(requestDocument) + .response(responseDocument) + .build(); + + Response response = given(document) + .pathParam("storyId", storyId) + .when() + .get("/api/stories/{storyId}"); + + response.then() + .statusCode(200) + .body("storeKakaoId", equalTo("123456")) + .body("category", equalTo("한식")) + .body("storeName", equalTo("진또곱창집")) + .body("storeDistrict", equalTo("성동구")) + .body("storeNeighborhood", equalTo("성수동")) + .body("description", equalTo("곱창은 여기")) + .body("imageUrl", equalTo("https://s3.bucket.com/story1.jpg")); + } + + @Test + void 스토리_상세_조회_실패_존재하지_않는_스토리() { + long nonexistentId = 999L; + + doThrow(new BusinessException(BusinessErrorCode.STORY_NOT_FOUND)) + .when(storyService).getStory(nonexistentId); + + RestDocumentationFilter document = document("story/get-story", BusinessErrorCode.STORY_NOT_FOUND) + .request(requestDocument) + .response(ERROR_RESPONSE) + .build(); + + Response response = given(document) + .pathParam("storyId", nonexistentId) + .when() + .get("/api/stories/{storyId}"); + + response.then() + .statusCode(BusinessErrorCode.STORY_NOT_FOUND.getStatus().value()) + .body("errorCode", equalTo(BusinessErrorCode.STORY_NOT_FOUND.getCode())) + .body("message", equalTo(BusinessErrorCode.STORY_NOT_FOUND.getMessage())); + } + } } diff --git a/src/test/java/eatda/domain/story/StoryTest.java b/src/test/java/eatda/domain/story/StoryTest.java index 572f0601..3f4d9dc3 100644 --- a/src/test/java/eatda/domain/story/StoryTest.java +++ b/src/test/java/eatda/domain/story/StoryTest.java @@ -23,7 +23,8 @@ class RegisterStory { .member(MEMBER) .storeKakaoId("123") .storeName("곱창집") - .storeAddress("서울시 성동구") + .storeRoadAddress("서울시 성동구 왕십리로 1길 12") + .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") .storeCategory("한식") .description("정말 맛있어요") .imageKey("story/image.jpg") @@ -44,7 +45,8 @@ class ValidateMember { .member(null) .storeKakaoId("123") .storeName("곱창집") - .storeAddress("서울시 성동구") + .storeRoadAddress("서울시 성동구 왕십리로 1길 12") + .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") .storeCategory("한식") .description("정말 맛있어요") .imageKey("story/image.jpg") @@ -64,7 +66,8 @@ class ValidateStore { .member(MEMBER) .storeKakaoId(" ") .storeName("곱창집") - .storeAddress("서울시") + .storeRoadAddress("서울시 성동구 왕십리로 1길 12") + .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") .storeCategory("한식") .description("맛있음") .imageKey("story/image.jpg") @@ -79,9 +82,10 @@ class ValidateStore { Story.builder() .member(MEMBER) .storeKakaoId("123") - .storeName(" ") - .storeAddress("서울시") .storeCategory("한식") + .storeName(" ") + .storeRoadAddress("서울시 성동구 왕십리로 1길 12") + .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") .description("맛있음") .imageKey("story/image.jpg") .build() @@ -90,14 +94,32 @@ class ValidateStore { } @Test - void 가게_주소가_비어있으면_예외가_발생한다() { + void 도로명_주소가_비어있으면_예외가_발생한다() { assertThatThrownBy(() -> Story.builder() .member(MEMBER) .storeKakaoId("123") + .storeCategory("한식") .storeName("곱창집") - .storeAddress(" ") + .storeRoadAddress(" ") + .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") + .description("맛있음") + .imageKey("story/image.jpg") + .build() + ).isInstanceOf(BusinessException.class) + .hasMessage(BusinessErrorCode.INVALID_STORE_ADDRESS.getMessage()); + } + + @Test + void 지번_주소가_비어있으면_예외가_발생한다() { + assertThatThrownBy(() -> + Story.builder() + .member(MEMBER) + .storeKakaoId("123") .storeCategory("한식") + .storeName("곱창집") + .storeRoadAddress("서울시 성동구 왕십리로 1길 12") + .storeLotNumberAddress(" ") .description("맛있음") .imageKey("story/image.jpg") .build() @@ -111,9 +133,10 @@ class ValidateStore { Story.builder() .member(MEMBER) .storeKakaoId("123") - .storeName("곱창집") - .storeAddress("서울시") .storeCategory(" ") + .storeName("곱창집") + .storeRoadAddress("서울시 성동구 왕십리로 1길 12") + .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") .description("맛있음") .imageKey("story/image.jpg") .build() @@ -132,7 +155,8 @@ class ValidateStory { .member(MEMBER) .storeKakaoId("123") .storeName("곱창집") - .storeAddress("서울시") + .storeRoadAddress("서울시 성동구 왕십리로 1길 12") + .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") .storeCategory("한식") .description(" ") .imageKey("story/image.jpg") @@ -148,7 +172,8 @@ class ValidateStory { .member(MEMBER) .storeKakaoId("123") .storeName("곱창집") - .storeAddress("서울시") + .storeRoadAddress("서울시 성동구 왕십리로 1길 12") + .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") .storeCategory("한식") .description("맛있음") .imageKey(" ") diff --git a/src/test/java/eatda/service/story/StoryServiceTest.java b/src/test/java/eatda/service/story/StoryServiceTest.java index 82f1664a..d12d043b 100644 --- a/src/test/java/eatda/service/story/StoryServiceTest.java +++ b/src/test/java/eatda/service/story/StoryServiceTest.java @@ -9,6 +9,7 @@ import eatda.client.map.StoreSearchResult; import eatda.controller.story.StoryRegisterRequest; +import eatda.controller.story.StoryResponse; import eatda.domain.member.Member; import eatda.domain.story.Story; import eatda.exception.BusinessErrorCode; @@ -16,10 +17,8 @@ import eatda.repository.story.StoryRepository; import eatda.service.BaseServiceTest; import eatda.service.common.ImageDomain; -import eatda.service.store.StoreService; import java.util.Collections; 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.beans.factory.annotation.Autowired; @@ -31,8 +30,6 @@ public class StoryServiceTest extends BaseServiceTest { private StoryService storyService; @Autowired private StoryRepository storyRepository; - @Autowired - private StoreService storeService; @Nested class RegisterStory { @@ -69,14 +66,7 @@ class RegisterStory { } @Nested - class GetPagedStoryPreviews extends BaseServiceTest { - - private StoryService storyService; - - @BeforeEach - void setUp() { - storyService = new StoryService(storeService, imageService, storyRepository, memberRepository); - } + class GetPagedStoryPreviews { @Test void 스토리_목록을_조회할_수_있다() { @@ -86,7 +76,8 @@ void setUp() { .member(member) .storeKakaoId("1") .storeName("곱창집") - .storeAddress("서울시") + .storeRoadAddress("서울시 성동구 왕십리로 1길 12") + .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") .storeCategory("한식") .description("미쳤다 진짜") .imageKey("image-key-1") @@ -96,7 +87,8 @@ void setUp() { .member(member) .storeKakaoId("2") .storeName("순대국밥집") - .storeAddress("부산시") + .storeRoadAddress("서울시 성동구 왕십리로 1길 12") + .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") .storeCategory("한식") .description("뜨끈한 국밥 최고") .imageKey("image-key-2") @@ -104,18 +96,62 @@ void setUp() { storyRepository.saveAll(List.of(story1, story2)); - when(imageService.getPresignedUrl("image-key-1")).thenReturn("https://s3.com/story1.jpg"); - when(imageService.getPresignedUrl("image-key-2")).thenReturn("https://s3.com/story2.jpg"); + when(imageService.getPresignedUrl("image-key-1")).thenReturn("https://s3.bucket.com/story/dummy/1.jpg"); + when(imageService.getPresignedUrl("image-key-2")).thenReturn("https://s3.bucket.com/story/dummy/2.jpg"); - var response = storyService.getPagedStoryPreviews(); + var response = storyService.getPagedStoryPreviews(5); assertThat(response.stories()).hasSize(2); assertThat(response.stories()) .extracting("imageUrl") - .containsExactly( - "https://s3.com/story2.jpg", - "https://s3.com/story1.jpg" + .containsExactlyInAnyOrder( + "https://s3.bucket.com/story/dummy/2.jpg", + "https://s3.bucket.com/story/dummy/1.jpg" ); } } + + @Nested + class GetStory { + + @Test + void 스토리_상세_정보를_조회할_수_있다() { + Member member = memberGenerator.generate("99999"); + + Story story = Story.builder() + .member(member) + .storeKakaoId("123456") + .storeName("진또곱창집") + .storeRoadAddress("서울시 성동구 왕십리로 1길 12") + .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") + .storeCategory("한식") + .description("곱창은 여기") + .imageKey("story-image-key") + .build(); + + storyRepository.save(story); + + when(imageService.getPresignedUrl("story-image-key")) + .thenReturn("https://s3.bucket.com/story/dummy/1.jpg"); + + StoryResponse response = storyService.getStory(story.getId()); + + assertThat(response.storeKakaoId()).isEqualTo("123456"); + assertThat(response.category()).isEqualTo("한식"); + assertThat(response.storeName()).isEqualTo("진또곱창집"); + assertThat(response.storeDistrict()).isEqualTo("성동구"); + assertThat(response.storeNeighborhood()).isEqualTo("성수동1가"); + assertThat(response.description()).isEqualTo("곱창은 여기"); + assertThat(response.imageUrl()).isEqualTo("https://s3.bucket.com/story/dummy/1.jpg"); + } + + @Test + void 존재하지_않는_스토리ID를_조회하면_예외가_발생한다() { + long invalidId = 999L; + + assertThatThrownBy(() -> storyService.getStory(invalidId)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(BusinessErrorCode.STORY_NOT_FOUND.getMessage()); + } + } }