diff --git a/sunpick/build.gradle b/sunpick/build.gradle index b95c37e..930375f 100644 --- a/sunpick/build.gradle +++ b/sunpick/build.gradle @@ -27,6 +27,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.4' implementation 'mysql:mysql-connector-java:8.0.33' compileOnly 'org.projectlombok:lombok' diff --git a/sunpick/src/main/java/com/backend/sunpick/domain/store/controller/StoreController.java b/sunpick/src/main/java/com/backend/sunpick/domain/store/controller/StoreController.java new file mode 100644 index 0000000..cba377c --- /dev/null +++ b/sunpick/src/main/java/com/backend/sunpick/domain/store/controller/StoreController.java @@ -0,0 +1,57 @@ +package com.backend.sunpick.domain.store.controller; + +import com.backend.sunpick.domain.store.dto.request.StoreCreateRequest; +import com.backend.sunpick.domain.store.dto.request.StoreModifyRequest; +import com.backend.sunpick.domain.store.dto.response.StoreResponse; +import com.backend.sunpick.domain.store.service.StoreService; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/store") +public class StoreController { + + private final StoreService storeService; + + @PostMapping + public ResponseEntity createStore(@RequestBody @Valid StoreCreateRequest request) { + storeService.createStore(request); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @GetMapping + public ResponseEntity> getStoreAll() { + List response = storeService.getStoreAll(); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + @GetMapping("/{storeId}") + public ResponseEntity getStoreById(@PathVariable Integer storeId) { + StoreResponse response = storeService.getStoreById(storeId); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + @PatchMapping("/{storeId}") + public ResponseEntity modifyStore(@PathVariable Integer storeId, @RequestBody @Valid StoreModifyRequest request) { + storeService.modifyStore(storeId, request); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @DeleteMapping("/{storeId}") + public ResponseEntity deleteStore(@PathVariable Integer storeId) { + storeService.deleteStore(storeId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } +} diff --git a/sunpick/src/main/java/com/backend/sunpick/domain/store/dto/request/StoreCreateRequest.java b/sunpick/src/main/java/com/backend/sunpick/domain/store/dto/request/StoreCreateRequest.java new file mode 100644 index 0000000..fdac600 --- /dev/null +++ b/sunpick/src/main/java/com/backend/sunpick/domain/store/dto/request/StoreCreateRequest.java @@ -0,0 +1,21 @@ +package com.backend.sunpick.domain.store.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +public record StoreCreateRequest( + @NotNull(message = "회원 ID는 필수입니다.") + @Positive(message = "회원 ID는 1 이상의 값이어야 합니다.") + Integer memberId, + + @NotBlank(message = "상점명은 필수입니다.") + @Size(max = 20, message = "상점명은 20자 이하로 입력해 주세요.") + String name, + + @Size(max = 100, message = "상점 설명은 100자 이하로 입력해 주세요.") + String description +) { + +} diff --git a/sunpick/src/main/java/com/backend/sunpick/domain/store/dto/request/StoreModifyRequest.java b/sunpick/src/main/java/com/backend/sunpick/domain/store/dto/request/StoreModifyRequest.java new file mode 100644 index 0000000..ba757ba --- /dev/null +++ b/sunpick/src/main/java/com/backend/sunpick/domain/store/dto/request/StoreModifyRequest.java @@ -0,0 +1,17 @@ +package com.backend.sunpick.domain.store.dto.request; + +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +public record StoreModifyRequest( + @Positive(message = "회원 ID는 1 이상의 값이어야 합니다.") + Integer memberId, + + @Size(max = 20, message = "상점명은 20자 이하로 입력해 주세요.") + String name, + + @Size(max = 100, message = "상점 설명은 100자 이하로 입력해 주세요.") + String description +) { + +} diff --git a/sunpick/src/main/java/com/backend/sunpick/domain/store/dto/response/StoreResponse.java b/sunpick/src/main/java/com/backend/sunpick/domain/store/dto/response/StoreResponse.java new file mode 100644 index 0000000..0012a62 --- /dev/null +++ b/sunpick/src/main/java/com/backend/sunpick/domain/store/dto/response/StoreResponse.java @@ -0,0 +1,11 @@ +package com.backend.sunpick.domain.store.dto.response; + +public record StoreResponse( + Integer id, + String name, + String description, + Integer ownerId, + String ownerName +) { + +} diff --git a/sunpick/src/main/java/com/backend/sunpick/domain/store/entity/Store.java b/sunpick/src/main/java/com/backend/sunpick/domain/store/entity/Store.java index fea0bb3..575d072 100644 --- a/sunpick/src/main/java/com/backend/sunpick/domain/store/entity/Store.java +++ b/sunpick/src/main/java/com/backend/sunpick/domain/store/entity/Store.java @@ -11,6 +11,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import java.util.NoSuchElementException; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -40,4 +41,32 @@ public class Store extends BaseEntity { @Column(name = "owner_name", length = 6, nullable = false) private String ownerName; + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted; + + public void changeName(String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("상점명은 비어있을 수 없습니다."); + } + this.name = name; + } + + public void changeDescription(String description) { + this.description = description; + } + + public void changeOwner(Member member) { + if (member == null || member.getName() == null || member.getName().isBlank()) { + throw new IllegalArgumentException("회원과 회원명은 비어있을 수 없습니다."); + } + this.member = member; + this.ownerName = member.getName(); + } + + public void delete() { + if (isDeleted) { + throw new NoSuchElementException("상점 ID: " + id + "는 이미 삭제되었습니다."); + } + isDeleted = true; + } } diff --git a/sunpick/src/main/java/com/backend/sunpick/domain/store/service/StoreService.java b/sunpick/src/main/java/com/backend/sunpick/domain/store/service/StoreService.java new file mode 100644 index 0000000..b6ee1ff --- /dev/null +++ b/sunpick/src/main/java/com/backend/sunpick/domain/store/service/StoreService.java @@ -0,0 +1,81 @@ +package com.backend.sunpick.domain.store.service; + +import com.backend.sunpick.domain.member.entity.Member; +import com.backend.sunpick.domain.member.repository.MemberRepository; +import com.backend.sunpick.domain.store.dto.request.StoreCreateRequest; +import com.backend.sunpick.domain.store.dto.request.StoreModifyRequest; +import com.backend.sunpick.domain.store.dto.response.StoreResponse; +import com.backend.sunpick.domain.store.entity.Store; +import com.backend.sunpick.domain.store.repository.StoreRepository; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class StoreService { + + private final StoreRepository storeRepository; + private final MemberRepository memberRepository; + + @Transactional + public void createStore(StoreCreateRequest request) { + Member member = memberRepository.findById(request.memberId()) + .orElseThrow(() -> new NoSuchElementException( + "회원 ID: " + request.memberId() + "가 존재하지 않습니다.")); + storeRepository.save(Store.builder() + .member(member) + .name(request.name()) + .description(request.description()) + .ownerName(member.getName()) + .build()); + } + + @Transactional(readOnly = true) + public List getStoreAll() { + return storeRepository.findAll().stream() + .map(this::toResponse) + .toList(); + } + + @Transactional(readOnly = true) + public StoreResponse getStoreById(Integer storeId) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new NoSuchElementException("상점 ID: " + storeId + "가 존재하지 않습니다.")); + return toResponse(store); + } + + @Transactional + public void modifyStore(Integer storeId, StoreModifyRequest request) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new NoSuchElementException("상점 ID: " + storeId + "가 존재하지 않습니다.")); + + if (store.isDeleted()) { + throw new NoSuchElementException("상점 ID: " + storeId + "는 삭제되었습니다."); + } + + Optional.ofNullable(request.memberId()).ifPresent(memberId -> { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new NoSuchElementException( + "회원 ID: " + memberId + "가 존재하지 않습니다.")); + store.changeOwner(member); + }); + Optional.ofNullable(request.name()).ifPresent(store::changeName); + Optional.ofNullable(request.description()).ifPresent(store::changeDescription); + } + + @Transactional + public void deleteStore(Integer storeId) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new NoSuchElementException("상점 ID: " + storeId + "가 존재하지 않습니다.")); + store.delete(); + } + + private StoreResponse toResponse(Store store) { + return new StoreResponse(store.getId(), store.getName(), store.getDescription(), + store.getMember().getId(), store.getOwnerName()); + } +} diff --git a/sunpick/src/main/java/com/backend/sunpick/global/exception/GlobalExceptionHandler.java b/sunpick/src/main/java/com/backend/sunpick/global/exception/GlobalExceptionHandler.java index c567a45..49f6e5e 100644 --- a/sunpick/src/main/java/com/backend/sunpick/global/exception/GlobalExceptionHandler.java +++ b/sunpick/src/main/java/com/backend/sunpick/global/exception/GlobalExceptionHandler.java @@ -10,6 +10,14 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +/** + * 전역 예외 처리 + *

+ * 컨트롤러에서 발생한 예외를 가로채 간단한 HTTP 상태 코드와 메시지로 변환 + *

+ * + * @author haazz + */ @RestControllerAdvice public class GlobalExceptionHandler { @@ -30,7 +38,7 @@ public ResponseEntity handleIllegalArgument(IllegalArgumentException e) @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException e) { Optional fieldMsg = e.getBindingResult().getFieldErrors().stream() - .map(fe -> fe.getField() + ": " + messageOrDefault(fe.getDefaultMessage(), "유효성 검증 오류")) + .map(fe -> messageOrDefault(fe.getDefaultMessage(), "유효성 검증 오류")) .findFirst(); Optional globalMsg = e.getBindingResult().getGlobalErrors().stream() diff --git a/sunpick/src/test/java/com/backend/sunpick/global/config/TestSecurityConfig.java b/sunpick/src/test/java/com/backend/sunpick/global/config/TestSecurityConfig.java new file mode 100644 index 0000000..8d1ef93 --- /dev/null +++ b/sunpick/src/test/java/com/backend/sunpick/global/config/TestSecurityConfig.java @@ -0,0 +1,21 @@ +package com.backend.sunpick.global.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@TestConfiguration +public class TestSecurityConfig { + + @Bean + public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/store/**").permitAll() + .anyRequest().authenticated() + ); + return http.build(); + } +} diff --git a/sunpick/src/test/java/com/backend/sunpick/store/StoreControllerTest.java b/sunpick/src/test/java/com/backend/sunpick/store/StoreControllerTest.java new file mode 100644 index 0000000..1972d33 --- /dev/null +++ b/sunpick/src/test/java/com/backend/sunpick/store/StoreControllerTest.java @@ -0,0 +1,211 @@ +package com.backend.sunpick.store; + +import com.backend.sunpick.domain.store.dto.request.StoreModifyRequest; +import com.backend.sunpick.domain.store.dto.response.StoreResponse; +import com.backend.sunpick.global.config.TestSecurityConfig; +import com.backend.sunpick.domain.store.controller.StoreController; +import com.backend.sunpick.domain.store.dto.request.StoreCreateRequest; +import com.backend.sunpick.domain.store.service.StoreService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.NoSuchElementException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(StoreController.class) +@Import(TestSecurityConfig.class) +public class StoreControllerTest { + + @MockitoBean + private StoreService storeService; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("POST /api/store - 상점 등록 201 Created") + void createStore_success() throws Exception { + + StoreCreateRequest request = new StoreCreateRequest(1, "storeName", "description"); + + mockMvc.perform(post("/api/store") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + Mockito.verify(storeService, Mockito.times(1)).createStore(any(StoreCreateRequest.class)); + } + + @Test + @DisplayName("POST /api/store - 상점 등록 memberId가 null이면 400 Bad Request") + void createStore_fail_memberIdNull() throws Exception { + StoreCreateRequest request = new StoreCreateRequest(null, "storeName", "description"); + + mockMvc.perform(post("/api/store") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(content().string("회원 ID는 필수입니다.")); + + Mockito.verify(storeService, Mockito.never()).createStore(any()); + } + + @Test + @DisplayName("POST /api/store - 상점 등록 memberId가 0이면 400 Bad Request") + void createStore_fail_memberIdZero() throws Exception { + StoreCreateRequest request = new StoreCreateRequest(0, "storeName", "description"); + + mockMvc.perform(post("/api/store") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(content().string("회원 ID는 1 이상의 값이어야 합니다.")); + + Mockito.verify(storeService, Mockito.never()).createStore(any()); + } + + @Test + @DisplayName("POST /api/store - 상점 등록 name이 비어있으면 400 Bad Request") + void createStore_fail_nameBlank() throws Exception { + StoreCreateRequest request = new StoreCreateRequest(1, "", "description"); + + mockMvc.perform(post("/api/store") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(content().string("상점명은 필수입니다.")); + + Mockito.verify(storeService, Mockito.never()).createStore(any()); + } + + @Test + @DisplayName("POST /api/store - 상점 등록 name이 20자 초과면 400 Bad Request") + void createStore_fail_nameTooLong() throws Exception { + String longName = "a".repeat(21); + StoreCreateRequest request = new StoreCreateRequest(1, longName, "description"); + + mockMvc.perform(post("/api/store") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(content().string("상점명은 20자 이하로 입력해 주세요.")); + + Mockito.verify(storeService, Mockito.never()).createStore(any()); + } + + @Test + @DisplayName("GET /api/store - 상점 목록 조회 200 OK") + void getStoreAll_success() throws Exception { + List responses = List.of( + new StoreResponse(1, "store1", "description1", 1, "owner1"), + new StoreResponse(2, "store2", "description2", 2, "owner2") + ); + when(storeService.getStoreAll()).thenReturn(responses); + + mockMvc.perform(get("/api/store")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1)) + .andExpect(jsonPath("$[0].name").value("store1")) + .andExpect(jsonPath("$[0].description").value("description1")) + .andExpect(jsonPath("$[0].ownerId").value(1)) + .andExpect(jsonPath("$[0].ownerName").value("owner1")) + .andExpect(jsonPath("$[1].id").value(2)) + .andExpect(jsonPath("$[1].name").value("store2")) + .andExpect(jsonPath("$[1].description").value("description2")) + .andExpect(jsonPath("$[1].ownerId").value(2)) + .andExpect(jsonPath("$[1].ownerName").value("owner2")); + + Mockito.verify(storeService, Mockito.times(1)).getStoreAll(); + } + + @Test + @DisplayName("GET /api/store/{storeId} - 상점 단건 조회 200 OK") + void getStoreById_success() throws Exception { + int storeId = 1; + StoreResponse response = new StoreResponse(storeId, "store", "description", 1, "owner"); + + when(storeService.getStoreById(storeId)).thenReturn(response); + + mockMvc.perform(get("/api/store/{storeId}", storeId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(storeId)) + .andExpect(jsonPath("$.name").value("store")) + .andExpect(jsonPath("$.description").value("description")) + .andExpect(jsonPath("$.ownerName").value("owner")); + + Mockito.verify(storeService, Mockito.times(1)).getStoreById(storeId); + } + + @Test + @DisplayName("PATCH /api/store/{id} - 상점 수정 204 No Content") + void modifyStore_success() throws Exception { + StoreModifyRequest request = new StoreModifyRequest(1, "new store", "new description"); + + mockMvc.perform(patch("/api/store/{storeId}", 1) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNoContent()); + + Mockito.verify(storeService, Mockito.times(1)) + .modifyStore(any(Integer.class), any(StoreModifyRequest.class)); + } + + @Test + @DisplayName("POST /api/store - 상점 수정 memberId가 0이면 400 Bad Request") + void modifyStore_fail_memberIdZero() throws Exception { + StoreModifyRequest request = new StoreModifyRequest(0, "storeName", "description"); + + mockMvc.perform(patch("/api/store/{storeId}", 1) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(content().string("회원 ID는 1 이상의 값이어야 합니다.")); + + Mockito.verify(storeService, Mockito.never()) + .modifyStore(any(Integer.class), any(StoreModifyRequest.class)); + } + + @Test + @DisplayName("PATCH /api/store/{id} - 상점 수정 name이 20자 초과면 400 Bad Request") + void modifyStore_fail_nameTooLong() throws Exception { + String longName = "a".repeat(21); + StoreModifyRequest request = new StoreModifyRequest(1, longName, "description"); + + mockMvc.perform(patch("/api/store/{storeId}", 1) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(content().string("상점명은 20자 이하로 입력해 주세요.")); + + Mockito.verify(storeService, Mockito.never()) + .modifyStore(any(Integer.class), any(StoreModifyRequest.class)); + } + + @Test + @DisplayName("DELETE /api/store/{id} - 상점 삭제 204 No Content") + void deleteStore_success() throws Exception { + mockMvc.perform(delete("/api/store/{storeId}", 1)) + .andExpect(status().isNoContent()); + + Mockito.verify(storeService, Mockito.times(1)).deleteStore(any(Integer.class)); + } +} diff --git a/sunpick/src/test/java/com/backend/sunpick/store/StoreServiceTest.java b/sunpick/src/test/java/com/backend/sunpick/store/StoreServiceTest.java new file mode 100644 index 0000000..967ac92 --- /dev/null +++ b/sunpick/src/test/java/com/backend/sunpick/store/StoreServiceTest.java @@ -0,0 +1,249 @@ +package com.backend.sunpick.store; + +import com.backend.sunpick.domain.member.entity.Member; +import com.backend.sunpick.domain.member.repository.MemberRepository; +import com.backend.sunpick.domain.store.dto.request.StoreCreateRequest; +import com.backend.sunpick.domain.store.dto.request.StoreModifyRequest; +import com.backend.sunpick.domain.store.dto.response.StoreResponse; +import com.backend.sunpick.domain.store.entity.Store; +import com.backend.sunpick.domain.store.repository.StoreRepository; +import com.backend.sunpick.domain.store.service.StoreService; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + + +@ExtendWith(MockitoExtension.class) +public class StoreServiceTest { + + @Mock + private StoreRepository storeRepository; + + @Mock + private MemberRepository memberRepository; + + @InjectMocks + private StoreService storeService; + + @Test + @DisplayName("createStore() - 성공") + void createStore_success() { + StoreCreateRequest request = new StoreCreateRequest(1, "storeName", "description"); + Member member = mock(Member.class); + when(member.getName()).thenReturn("memberName"); + when(memberRepository.findById(1)).thenReturn(Optional.of(member)); + + storeService.createStore(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Store.class); + verify(storeRepository, times(1)).save(captor.capture()); + Store saved = captor.getValue(); + + assertThat(saved.getName()).isEqualTo("storeName"); + assertThat(saved.getDescription()).isEqualTo("description"); + assertThat(saved.getMember()).isEqualTo(member); + assertThat(saved.getOwnerName()).isEqualTo("memberName"); + verify(storeRepository, times(1)).save(any(Store.class)); + } + + @Test + @DisplayName("createStore() - 실패 memberId가 존재하지 않는 경우") + void createStore_fail_memberNotFound() { + StoreCreateRequest request = new StoreCreateRequest(1, "storeName", + "description"); + when(memberRepository.findById(1)).thenReturn(Optional.empty()); + + NoSuchElementException exception = assertThrows(NoSuchElementException.class, + () -> storeService.createStore(request)); + assertEquals("회원 ID: " + 1 + "가 존재하지 않습니다.", exception.getMessage()); + verify(storeRepository, never()).save(any(Store.class)); + } + + @Test + @DisplayName("modifyStore() - 성공") + void modifyStore_success() { + Member oldOwner = mock(Member.class); + Store store = Store.builder() + .name("name") + .description("description") + .ownerName("ownerName") + .member(oldOwner) + .build(); + when(storeRepository.findById(1)).thenReturn(Optional.of(store)); + + Member newOwner = mock(Member.class); + when(newOwner.getName()).thenReturn("newOwnerName"); + when(memberRepository.findById(2)).thenReturn(Optional.of(newOwner)); + + StoreModifyRequest request = new StoreModifyRequest(2, "newName", "newDescription"); + + storeService.modifyStore(1, request); + + assertEquals("newName", store.getName()); + assertEquals("newDescription", store.getDescription()); + assertEquals(newOwner, store.getMember()); + assertEquals("newOwnerName", store.getOwnerName()); + } + + @Test + @DisplayName("modifyStore() - 실패 storeId가 존재하지 않는 경우") + void modifyStore_fail_storeNotFound() { + StoreModifyRequest request = new StoreModifyRequest(1, "newName", "newDescription"); + when(storeRepository.findById(any(Integer.class))).thenReturn(Optional.empty()); + + NoSuchElementException exception = assertThrows(NoSuchElementException.class, + () -> storeService.modifyStore(1, request)); + + assertEquals("상점 ID: " + 1 + "가 존재하지 않습니다.", exception.getMessage()); + } + + @Test + @DisplayName("modifyStore() - 실패 storeId가 삭제된 경우") + void modifyStore_fail_deletedStore() { + Store store = mock(Store.class); + when(store.isDeleted()).thenReturn(true); + when(storeRepository.findById(1)).thenReturn(Optional.of(store)); + StoreModifyRequest request = new StoreModifyRequest(1, "newName", "newDescription"); + + NoSuchElementException exception = assertThrows(NoSuchElementException.class, + () -> storeService.modifyStore(1, request)); + assertEquals("상점 ID: " + 1 + "는 삭제되었습니다.", exception.getMessage()); + } + + @Test + @DisplayName("modifyStore() - 실패 memberId가 존재하지 않는 경우") + void modifyStore_fail_memberNotFound() { + Store store = mock(Store.class); + when(store.isDeleted()).thenReturn(false); + when(storeRepository.findById(1)).thenReturn(Optional.of(store)); + when(memberRepository.findById(1)).thenReturn(Optional.empty()); + + StoreModifyRequest request = new StoreModifyRequest(1, "newName", + "newDescription"); + + NoSuchElementException exception = assertThrows(NoSuchElementException.class, + () -> storeService.modifyStore(1, request)); + assertEquals("회원 ID: " + 1 + "가 존재하지 않습니다.", exception.getMessage()); + } + + @Test + @DisplayName("deleteStore() - 성공") + void deleteStore_success() { + Store store = Store.builder() + .name("name") + .description("description") + .ownerName("ownerName") + .build(); + when(storeRepository.findById(1)).thenReturn(Optional.of(store)); + + storeService.deleteStore(1); + + assertTrue(store.isDeleted()); + } + + @Test + @DisplayName("deleteStore() - 실패 storeId가 존재하지 않는 경우") + void deleteStore_fail_storeNotFound() { + when(storeRepository.findById(any(Integer.class))).thenReturn(Optional.empty()); + + NoSuchElementException exception = assertThrows(NoSuchElementException.class, + () -> storeService.deleteStore(1)); + + assertEquals("상점 ID: " + 1 + "가 존재하지 않습니다.", exception.getMessage()); + } + + @Test + @DisplayName("getStoreAll() - 성공") + void getStoreAll_success() { + Member member1 = mock(Member.class); + when(member1.getId()).thenReturn(1); + + Store store1 = mock(Store.class); + when(store1.getId()).thenReturn(1); + when(store1.getName()).thenReturn("store1"); + when(store1.getDescription()).thenReturn("description1"); + when(store1.getMember()).thenReturn(member1); + when(store1.getOwnerName()).thenReturn("ownerName1"); + + Member member2 = mock(Member.class); + when(member2.getId()).thenReturn(2); + + Store store2 = mock(Store.class); + when(store2.getId()).thenReturn(2); + when(store2.getName()).thenReturn("store2"); + when(store2.getDescription()).thenReturn("description2"); + when(store2.getMember()).thenReturn(member2); + when(store2.getOwnerName()).thenReturn("ownerName2"); + + when(storeRepository.findAll()).thenReturn(List.of(store1, store2)); + + List response = storeService.getStoreAll(); + + assertEquals(2, response.size()); + + assertEquals(1, response.get(0).id()); + assertEquals("store1", response.get(0).name()); + assertEquals("description1", response.get(0).description()); + assertEquals(1, response.get(0).ownerId()); + assertEquals("ownerName1", response.get(0).ownerName()); + + assertEquals(2, response.get(1).id()); + assertEquals("store2", response.get(1).name()); + assertEquals("description2", response.get(1).description()); + assertEquals(2, response.get(1).ownerId()); + assertEquals("ownerName2", response.get(1).ownerName()); + } + + @Test + @DisplayName("getStoreById() - 성공") + void getStoreById_success() { + Member member = mock(Member.class); + when(member.getId()).thenReturn(1); + + Store store = mock(Store.class); + when(store.getId()).thenReturn(1); + when(store.getName()).thenReturn("store"); + when(store.getDescription()).thenReturn("description"); + when(store.getMember()).thenReturn(member); + when(store.getOwnerName()).thenReturn("ownerName"); + + when(storeRepository.findById(1)).thenReturn(Optional.of(store)); + + StoreResponse response = storeService.getStoreById(1); + + assertEquals(1, response.id()); + assertEquals("store", response.name()); + assertEquals("description", response.description()); + assertEquals(1, response.ownerId()); + assertEquals("ownerName", response.ownerName()); + } + + @Test + @DisplayName("getStoreById() - 실패 storeId가 존재하지 않는 경우") + void getStoreById_fail_storeNotFound() { + when(storeRepository.findById(1)).thenReturn(Optional.empty()); + + NoSuchElementException exception = assertThrows(NoSuchElementException.class, + () -> storeService.getStoreById(1)); + + assertEquals("상점 ID: " + 1 + "가 존재하지 않습니다.", exception.getMessage()); + } +}