Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 방문 기록 생성 API 구현 #21 #64

Merged
merged 12 commits into from
Jul 24, 2024
2 changes: 2 additions & 0 deletions backend/src/main/java/com/staccato/pin/domain/Pin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -20,6 +26,12 @@
public class VisitController {
private final VisitService visitService;

@PostMapping
public ResponseEntity<Void> createVisit(@Valid @RequestBody VisitRequest visitRequest) {
long visitId = visitService.createVisit(visitRequest);
return ResponseEntity.created(URI.create("/visits/" + visitId)).build();
}

@DeleteMapping("/{visitId}")
public ResponseEntity<Void> deleteById(
@PathVariable @Min(value = 1L, message = "방문 기록 식별자는 양수로 이루어져야 합니다.") Long visitId) {
Expand Down
2 changes: 0 additions & 2 deletions backend/src/main/java/com/staccato/visit/domain/Visit.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,13 @@
import com.staccato.travel.domain.Travel;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;

@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SQLDelete(sql = "UPDATE visit SET is_deleted = true WHERE id = ?")
public class Visit extends BaseEntity {
Expand Down
12 changes: 9 additions & 3 deletions backend/src/main/java/com/staccato/visit/domain/VisitImage.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<VisitImage, Long> {
}
54 changes: 51 additions & 3 deletions backend/src/main/java/com/staccato/visit/service/VisitService.java
Original file line number Diff line number Diff line change
@@ -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<VisitImage> visitImages = makeVisitImages(visitRequest.visitedImages(), visit);
visitImageRepository.saveAll(visitImages);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


return visit.getId();
}

@Transactional
public void deleteById(Long visitId) {
visitLogRepository.deleteByVisitId(visitId);
visitRepository.deleteById(visitId);
}

private List<VisitImage> makeVisitImages(List<String> 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("요청하신 여행을 찾을 수 없어요."));
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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<Arguments> 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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

travelRepository에 의존하기보다는 여행 상세 생성 API가 구현되어 있으니 DynamicTest로 여행 상세 생성을 호출하고 필요한 테스트를 수행하면 어떨까 제안해봐요!(필수 사항은 아닙니다.)
통합 테스트에서 travelRepository를 직접 의존하면 나중에 로직이 복잡해졌을 때 구체적인 Repository 로직에 의존하게 될 것을 우려하여 말씀드려요!

return Stream.of(
        DynamicTest.dynamicTest("사용자가 새로운 여행 상세를 추가한다.",()->
            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/"))),
        DynamicTest.dynamicTest("사용자가 새로운 여행 상세를 추가한다.",()->
            RestAssured.given().log().all()
                .contentType(ContentType.JSON)
                .header(HttpHeaders.AUTHORIZATION,USER_AUTHORIZATION)
                .body(createTravelRequest(year))
                .when().log().all()
                .post("/travels")
                .then().log().all()
                .assertThat().statusCode(HttpStatus.CREATED.value())
                .header(HttpHeaders.LOCATION,containsString("/travels/")))
);

.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() {
Expand Down