diff --git a/backend/src/main/java/com/staccato/pin/domain/Pin.java b/backend/src/main/java/com/staccato/pin/domain/Pin.java index 3185f7168..8bfab64eb 100644 --- a/backend/src/main/java/com/staccato/pin/domain/Pin.java +++ b/backend/src/main/java/com/staccato/pin/domain/Pin.java @@ -12,10 +12,12 @@ import lombok.AccessLevel; import lombok.Builder; +import lombok.Getter; import lombok.NoArgsConstructor; import lombok.NonNull; @Entity +@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @SQLDelete(sql = "UPDATE pin SET is_deleted = true WHERE id = ?") public class Pin extends BaseEntity { diff --git a/backend/src/main/java/com/staccato/visit/controller/VisitController.java b/backend/src/main/java/com/staccato/visit/controller/VisitController.java index 488f6a9d2..e517ff607 100644 --- a/backend/src/main/java/com/staccato/visit/controller/VisitController.java +++ b/backend/src/main/java/com/staccato/visit/controller/VisitController.java @@ -1,15 +1,21 @@ package com.staccato.visit.controller; +import java.net.URI; + +import jakarta.validation.Valid; import jakarta.validation.constraints.Min; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; 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; import com.staccato.visit.service.VisitService; +import com.staccato.visit.service.dto.request.VisitRequest; import lombok.RequiredArgsConstructor; @@ -20,6 +26,12 @@ public class VisitController { private final VisitService visitService; + @PostMapping + public ResponseEntity createVisit(@Valid @RequestBody VisitRequest visitRequest) { + long visitId = visitService.createVisit(visitRequest); + return ResponseEntity.created(URI.create("/visits/" + visitId)).build(); + } + @DeleteMapping("/{visitId}") public ResponseEntity deleteById( @PathVariable @Min(value = 1L, message = "방문 기록 식별자는 양수로 이루어져야 합니다.") Long visitId) { diff --git a/backend/src/main/java/com/staccato/visit/domain/Visit.java b/backend/src/main/java/com/staccato/visit/domain/Visit.java index 31ab6ca2b..072fbdad7 100644 --- a/backend/src/main/java/com/staccato/visit/domain/Visit.java +++ b/backend/src/main/java/com/staccato/visit/domain/Visit.java @@ -18,7 +18,6 @@ import com.staccato.travel.domain.Travel; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -26,7 +25,6 @@ @Entity @Getter -@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) @SQLDelete(sql = "UPDATE visit SET is_deleted = true WHERE id = ?") public class Visit extends BaseEntity { diff --git a/backend/src/main/java/com/staccato/visit/domain/VisitImage.java b/backend/src/main/java/com/staccato/visit/domain/VisitImage.java index 6bbc7ad0d..a91123c16 100644 --- a/backend/src/main/java/com/staccato/visit/domain/VisitImage.java +++ b/backend/src/main/java/com/staccato/visit/domain/VisitImage.java @@ -1,5 +1,6 @@ package com.staccato.visit.domain; +import jakarta.annotation.Nonnull; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -14,13 +15,12 @@ import com.staccato.config.domain.BaseEntity; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.Getter; import lombok.NoArgsConstructor; @Entity -@Builder -@AllArgsConstructor +@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @SQLDelete(sql = "UPDATE visit_image SET is_deleted = true WHERE id = ?") public class VisitImage extends BaseEntity { @@ -32,4 +32,10 @@ public class VisitImage extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "visit_id", nullable = false) private Visit visit; + + @Builder + public VisitImage(@Nonnull String imageUrl, @Nonnull Visit visit) { + this.imageUrl = imageUrl; + this.visit = visit; + } } diff --git a/backend/src/main/java/com/staccato/visit/repository/VisitImageRepository.java b/backend/src/main/java/com/staccato/visit/repository/VisitImageRepository.java new file mode 100644 index 000000000..df3f6b753 --- /dev/null +++ b/backend/src/main/java/com/staccato/visit/repository/VisitImageRepository.java @@ -0,0 +1,8 @@ +package com.staccato.visit.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.staccato.visit.domain.VisitImage; + +public interface VisitImageRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/staccato/visit/service/VisitService.java b/backend/src/main/java/com/staccato/visit/service/VisitService.java index eae0ce334..6f4730223 100644 --- a/backend/src/main/java/com/staccato/visit/service/VisitService.java +++ b/backend/src/main/java/com/staccato/visit/service/VisitService.java @@ -1,20 +1,68 @@ package com.staccato.visit.service; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.staccato.exception.StaccatoException; +import com.staccato.pin.domain.Pin; +import com.staccato.pin.repository.PinRepository; +import com.staccato.travel.domain.Travel; +import com.staccato.travel.repository.TravelRepository; +import com.staccato.visit.domain.Visit; +import com.staccato.visit.domain.VisitImage; +import com.staccato.visit.repository.VisitImageRepository; import com.staccato.visit.repository.VisitLogRepository; import com.staccato.visit.repository.VisitRepository; +import com.staccato.visit.service.dto.request.VisitRequest; + import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service -@Transactional +@Transactional(readOnly = true) @RequiredArgsConstructor public class VisitService { private final VisitRepository visitRepository; + private final PinRepository pinRepository; + private final TravelRepository travelRepository; + private final VisitImageRepository visitImageRepository; private final VisitLogRepository visitLogRepository; + @Transactional + public long createVisit(VisitRequest visitRequest) { + Pin pin = getPinById(visitRequest.pinId()); + Travel travel = getTravelById(visitRequest.travelId()); + Visit visit = visitRepository.save(visitRequest.toVisit(pin, travel)); + + List visitImages = makeVisitImages(visitRequest.visitedImages(), visit); + visitImageRepository.saveAll(visitImages); + + return visit.getId(); + } + + @Transactional public void deleteById(Long visitId) { visitLogRepository.deleteByVisitId(visitId); visitRepository.deleteById(visitId); } + + private List makeVisitImages(List visitedImages, Visit visit) { + return visitedImages.stream() + .map(visitImage -> VisitImage.builder() + .imageUrl(visitImage) + .visit(visit) + .build()) + .toList(); + } + + private Pin getPinById(long pinId) { + return pinRepository.findById(pinId) + .orElseThrow(() -> new StaccatoException("요청하신 핀을 찾을 수 없어요.")); + } + + private Travel getTravelById(long travelId) { + return travelRepository.findById(travelId) + .orElseThrow(() -> new StaccatoException("요청하신 여행을 찾을 수 없어요.")); + } } diff --git a/backend/src/main/java/com/staccato/visit/service/dto/request/VisitRequest.java b/backend/src/main/java/com/staccato/visit/service/dto/request/VisitRequest.java new file mode 100644 index 000000000..07833a3bf --- /dev/null +++ b/backend/src/main/java/com/staccato/visit/service/dto/request/VisitRequest.java @@ -0,0 +1,30 @@ +package com.staccato.visit.service.dto.request; + +import java.time.LocalDate; +import java.util.List; + +import jakarta.validation.constraints.NotNull; + +import org.springframework.format.annotation.DateTimeFormat; + +import com.staccato.pin.domain.Pin; +import com.staccato.travel.domain.Travel; +import com.staccato.visit.domain.Visit; + +public record VisitRequest( + @NotNull(message = "핀을 선택해주세요.") + Long pinId, + List visitedImages, + @NotNull(message = "방문 날짜를 입력해주세요.") + @DateTimeFormat(pattern = "yyyy-MM-dd") + LocalDate visitedAt, + @NotNull(message = "여행 상세를 선택해주세요.") + Long travelId) { + public Visit toVisit(Pin pin, Travel travel) { + return Visit.builder() + .visitedAt(visitedAt) + .pin(pin) + .travel(travel) + .build(); + } +} diff --git a/backend/src/test/java/com/staccato/visit/controller/VisitIntegrationTest.java b/backend/src/test/java/com/staccato/visit/controller/VisitIntegrationTest.java index 7c64de7a6..3f5822b2f 100644 --- a/backend/src/test/java/com/staccato/visit/controller/VisitIntegrationTest.java +++ b/backend/src/test/java/com/staccato/visit/controller/VisitIntegrationTest.java @@ -1,20 +1,104 @@ package com.staccato.visit.controller; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; -import org.springframework.test.annotation.DirtiesContext; + +import com.staccato.IntegrationTest; +import com.staccato.pin.domain.Pin; +import com.staccato.pin.repository.PinRepository; +import com.staccato.travel.domain.Travel; +import com.staccato.travel.repository.TravelRepository; +import com.staccato.visit.service.dto.request.VisitRequest; import io.restassured.RestAssured; import io.restassured.http.ContentType; -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) -class VisitIntegrationTest { +class VisitIntegrationTest extends IntegrationTest { + private static final String USER_AUTHORIZATION = "1"; + + @Autowired + private PinRepository pinRepository; + @Autowired + private TravelRepository travelRepository; + + static Stream invalidVisitRequestProvider() { + return Stream.of( + Arguments.of( + new VisitRequest(null, List.of("https://example1.com.jpg"), LocalDate.of(2023, 7, 1), 1L), + "핀을 선택해주세요." + ), + Arguments.of( + new VisitRequest(1L, List.of("https://example1.com.jpg"), null, 1L), + "방문 날짜를 입력해주세요." + ), + Arguments.of( + new VisitRequest(1L, List.of("https://example1.com.jpg"), LocalDate.of(2023, 7, 1), null), + "여행 상세를 선택해주세요." + ) + ); + } + + @BeforeEach + void init() { + pinRepository.save(Pin.builder().place("장소").address("주소").build()); + travelRepository.save(Travel.builder() + .thumbnailUrl("https://example1.com.jpg") + .title("2023 여름 휴가") + .description("친구들과 함께한 여름 휴가 여행") + .startAt(LocalDate.of(2023, 7, 1)) + .endAt(LocalDate.of(2023, 7, 10)) + .build()); + } + + @DisplayName("사용자가 방문 기록 정보를 입력하면, 새로운 방문 기록을 생성한다.") + @Test + void createVisit() { + // given + VisitRequest visitRequest = new VisitRequest(1L, List.of("https://example1.com.jpg"), + LocalDate.of(2023, 7, 1), 1L); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, USER_AUTHORIZATION) + .body(visitRequest) + .when().log().all() + .post("/visits") + .then().log().all() + .assertThat().statusCode(HttpStatus.CREATED.value()) + .header(HttpHeaders.LOCATION, containsString("/visits/")); + } + + @DisplayName("사용자가 잘못된 방식으로 정보를 입력하면, 방문 기록을 생성할 수 없다.") + @ParameterizedTest + @MethodSource("invalidVisitRequestProvider") + void failCreateTravel(VisitRequest visitRequest, String expectedMessage) { + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, USER_AUTHORIZATION) + .body(visitRequest) + .when().log().all() + .post("/visits") + .then().log().all() + .assertThat().statusCode(HttpStatus.BAD_REQUEST.value()) + .body("message", is(expectedMessage)) + .body("status", is(HttpStatus.BAD_REQUEST.toString())); + } + @DisplayName("Visit을 삭제한다.") @Test void deleteById() {