diff --git a/server/build.gradle b/server/build.gradle index 43598d638..26f6ec262 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -26,6 +26,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/server/src/main/java/server/haengdong/application/BillActionService.java b/server/src/main/java/server/haengdong/application/BillActionService.java new file mode 100644 index 000000000..0aec7f6bd --- /dev/null +++ b/server/src/main/java/server/haengdong/application/BillActionService.java @@ -0,0 +1,48 @@ +package server.haengdong.application; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import server.haengdong.application.request.BillActionAppRequest; +import server.haengdong.domain.Action; +import server.haengdong.domain.BillAction; +import server.haengdong.domain.Event; +import server.haengdong.persistence.ActionRepository; +import server.haengdong.persistence.BillActionRepository; +import server.haengdong.persistence.EventRepository; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class BillActionService { + + private final EventRepository eventRepository; + private final ActionRepository actionRepository; + private final BillActionRepository billActionRepository; + + @Transactional + public void saveAllBillAction(String eventToken, List requests) { + Event event = eventRepository.findByToken(eventToken) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 이벤트 토큰입니다.")); + long lastSequence = getLastSequence(event); + + List billActions = new ArrayList<>(); + for (BillActionAppRequest request : requests) { + Action action = new Action(event, ++lastSequence); + BillAction billAction = request.toBillAction(action); + billActions.add(billAction); + } + billActionRepository.saveAll(billActions); + } + + private long getLastSequence(Event event) { + Optional lastAction = actionRepository.findLastByEvent(event); + if (lastAction.isPresent()) { + return lastAction.get().getSequence(); + } + return 0L; + } +} diff --git a/server/src/main/java/server/haengdong/application/EventService.java b/server/src/main/java/server/haengdong/application/EventService.java index deac413a4..6a5304e32 100644 --- a/server/src/main/java/server/haengdong/application/EventService.java +++ b/server/src/main/java/server/haengdong/application/EventService.java @@ -20,6 +20,6 @@ public EventAppResponse saveEvent(EventAppRequest request) { Event event = request.toEvent(token); eventRepository.save(event); - return EventAppResponse.of(event); + return EventAppResponse.of(event); } } diff --git a/server/src/main/java/server/haengdong/application/request/BillActionAppRequest.java b/server/src/main/java/server/haengdong/application/request/BillActionAppRequest.java new file mode 100644 index 000000000..33c335737 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/request/BillActionAppRequest.java @@ -0,0 +1,14 @@ +package server.haengdong.application.request; + +import server.haengdong.domain.Action; +import server.haengdong.domain.BillAction; + +public record BillActionAppRequest( + String title, + Long price +) { + + public BillAction toBillAction(Action action) { + return new BillAction(action, title, price); + } +} diff --git a/server/src/main/java/server/haengdong/domain/Action.java b/server/src/main/java/server/haengdong/domain/Action.java index 7f782a1ac..7a47b1de5 100644 --- a/server/src/main/java/server/haengdong/domain/Action.java +++ b/server/src/main/java/server/haengdong/domain/Action.java @@ -23,4 +23,9 @@ public class Action { private Event event; private Long sequence; + + public Action(Event event, Long sequence) { + this.event = event; + this.sequence = sequence; + } } diff --git a/server/src/main/java/server/haengdong/domain/BillAction.java b/server/src/main/java/server/haengdong/domain/BillAction.java index e70abd644..8c0827445 100644 --- a/server/src/main/java/server/haengdong/domain/BillAction.java +++ b/server/src/main/java/server/haengdong/domain/BillAction.java @@ -1,12 +1,11 @@ package server.haengdong.domain; +import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToOne; -import java.math.BigDecimal; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -20,10 +19,20 @@ public class BillAction { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @OneToOne(fetch = FetchType.LAZY) + @OneToOne(cascade = CascadeType.PERSIST) private Action action; private String title; - private BigDecimal price; + private Long price; + + public BillAction(Action action, String title, Long price) { + this.action = action; + this.title = title; + this.price = price; + } + + public Long getSequence() { + return action.getSequence(); + } } diff --git a/server/src/main/java/server/haengdong/persistence/ActionRepository.java b/server/src/main/java/server/haengdong/persistence/ActionRepository.java new file mode 100644 index 000000000..72c573d91 --- /dev/null +++ b/server/src/main/java/server/haengdong/persistence/ActionRepository.java @@ -0,0 +1,21 @@ +package server.haengdong.persistence; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import server.haengdong.domain.Action; +import server.haengdong.domain.Event; + +@Repository +public interface ActionRepository extends JpaRepository { + + @Query(""" + SELECT a + FROM Action a + WHERE a.event = :event + ORDER BY a.sequence DESC + LIMIT 1 + """) + Optional findLastByEvent(Event event); +} diff --git a/server/src/main/java/server/haengdong/persistence/BillActionRepository.java b/server/src/main/java/server/haengdong/persistence/BillActionRepository.java new file mode 100644 index 000000000..96e47237a --- /dev/null +++ b/server/src/main/java/server/haengdong/persistence/BillActionRepository.java @@ -0,0 +1,13 @@ +package server.haengdong.persistence; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import server.haengdong.domain.BillAction; +import server.haengdong.domain.Event; + +@Repository +public interface BillActionRepository extends JpaRepository { + + List findByAction_Event(Event event); +} diff --git a/server/src/main/java/server/haengdong/persistence/EventRepository.java b/server/src/main/java/server/haengdong/persistence/EventRepository.java index 855fc80ad..8a83d751f 100644 --- a/server/src/main/java/server/haengdong/persistence/EventRepository.java +++ b/server/src/main/java/server/haengdong/persistence/EventRepository.java @@ -1,9 +1,12 @@ package server.haengdong.persistence; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import server.haengdong.domain.Event; @Repository public interface EventRepository extends JpaRepository { + + Optional findByToken(String token); } diff --git a/server/src/main/java/server/haengdong/presentation/BillActionController.java b/server/src/main/java/server/haengdong/presentation/BillActionController.java new file mode 100644 index 000000000..139d6a470 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/BillActionController.java @@ -0,0 +1,29 @@ +package server.haengdong.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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.RestController; +import server.haengdong.application.BillActionService; +import server.haengdong.presentation.request.BillActionsSaveRequest; + +@RequiredArgsConstructor +@RestController +public class BillActionController { + + private final BillActionService billActionService; + + @PostMapping("/api/events/{token}/actions/bills") + public ResponseEntity saveAllBillAction( + @PathVariable String token, + @RequestBody @Valid BillActionsSaveRequest request + ) { + billActionService.saveAllBillAction(token, request.toAppRequests()); + + return ResponseEntity.ok() + .build(); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/request/BillActionSaveRequest.java b/server/src/main/java/server/haengdong/presentation/request/BillActionSaveRequest.java new file mode 100644 index 000000000..8d662050f --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/request/BillActionSaveRequest.java @@ -0,0 +1,22 @@ +package server.haengdong.presentation.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import server.haengdong.application.request.BillActionAppRequest; + +public record BillActionSaveRequest( + + @NotNull + @Size(min = 2, max = 30) + String title, + + @NotNull + @Positive + Long price +) { + + public BillActionAppRequest toAppRequest() { + return new BillActionAppRequest(title, price); + } +} diff --git a/server/src/main/java/server/haengdong/presentation/request/BillActionsSaveRequest.java b/server/src/main/java/server/haengdong/presentation/request/BillActionsSaveRequest.java new file mode 100644 index 000000000..bb16dfda0 --- /dev/null +++ b/server/src/main/java/server/haengdong/presentation/request/BillActionsSaveRequest.java @@ -0,0 +1,13 @@ +package server.haengdong.presentation.request; + +import java.util.List; +import server.haengdong.application.request.BillActionAppRequest; + +public record BillActionsSaveRequest(List actions) { + + public List toAppRequests() { + return actions.stream() + .map(BillActionSaveRequest::toAppRequest) + .toList(); + } +} diff --git a/server/src/main/resources/application.properties b/server/src/main/resources/application.properties deleted file mode 100644 index df243b894..000000000 --- a/server/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=server diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml new file mode 100644 index 000000000..49c481eaa --- /dev/null +++ b/server/src/main/resources/application.yml @@ -0,0 +1,15 @@ +spring: + h2: + console: + enabled: true + path: /h2-console + datasource: + url: jdbc:h2:mem:database + jpa: + defer-datasource-initialization: true + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: create-drop diff --git a/server/src/test/java/server/haengdong/application/BillActionServiceTest.java b/server/src/test/java/server/haengdong/application/BillActionServiceTest.java new file mode 100644 index 000000000..ba0cfc41a --- /dev/null +++ b/server/src/test/java/server/haengdong/application/BillActionServiceTest.java @@ -0,0 +1,69 @@ +package server.haengdong.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; + +import java.util.Comparator; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import server.haengdong.application.request.BillActionAppRequest; +import server.haengdong.domain.BillAction; +import server.haengdong.domain.Event; +import server.haengdong.persistence.BillActionRepository; +import server.haengdong.persistence.EventRepository; + +@SpringBootTest +class BillActionServiceTest { + + @Autowired + private BillActionService billActionService; + + @Autowired + private EventRepository eventRepository; + + @Autowired + private BillActionRepository billActionRepository; + + @DisplayName("지출 내역을 생성한다.") + @Test + void saveAllBillAction() { + String token = "TOKEN"; + Event event = eventRepository.save(new Event("감자", token)); + + List requests = List.of( + new BillActionAppRequest("뽕족", 10_000L), + new BillActionAppRequest("인생맥주", 15_000L) + ); + + billActionService.saveAllBillAction(token, requests); + + List actions = billActionRepository.findByAction_Event(event) + .stream() + .sorted(Comparator.comparing(BillAction::getSequence).reversed()) + .limit(requests.size()) + .toList(); + + assertThat(actions).extracting("title", "price") + .containsExactly( + tuple("인생맥주", 15_000L), + tuple("뽕족", 10_000L) + ); + } + + @DisplayName("이벤트가 존재하지 않으면 지출 내역을 생성할 수 없다.") + @Test + void saveAllBillAction1() { + List requests = List.of( + new BillActionAppRequest("뽕족", 10_000L), + new BillActionAppRequest("인생맥주", 15_000L) + ); + + assertThatThrownBy(() -> billActionService.saveAllBillAction("token", requests)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 이벤트 토큰입니다."); + } +} diff --git a/server/src/test/java/server/haengdong/presentation/BillActionControllerTest.java b/server/src/test/java/server/haengdong/presentation/BillActionControllerTest.java new file mode 100644 index 000000000..a3ae3d5c8 --- /dev/null +++ b/server/src/test/java/server/haengdong/presentation/BillActionControllerTest.java @@ -0,0 +1,53 @@ +package server.haengdong.presentation; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import server.haengdong.application.BillActionService; +import server.haengdong.presentation.request.BillActionSaveRequest; +import server.haengdong.presentation.request.BillActionsSaveRequest; + +@WebMvcTest(BillActionController.class) +class BillActionControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private BillActionService billActionService; + + @DisplayName("지출 내역을 생성한다.") + @Test + void saveAllBillAction() throws Exception { + BillActionsSaveRequest request = new BillActionsSaveRequest( + List.of( + new BillActionSaveRequest("뽕족", 10_000L), + new BillActionSaveRequest("인생맥주", 10_000L) + ) + ); + String requestBody = objectMapper.writeValueAsString(request); + String token = "TOKEN"; + doNothing().when(billActionService).saveAllBillAction(any(), any()); + + mockMvc.perform(post("/api/events/{token}/actions/bills", token) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andDo(print()) + .andExpect(status().isOk()); + } +}