From 7346e49167705c40e494a590e85407f106b2ce97 Mon Sep 17 00:00:00 2001 From: Seongju Lee Date: Sun, 19 Jan 2025 19:48:24 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=EB=AA=85=20=EB=B3=80=EA=B2=BD=20(memory=20->=20category,=20mom?= =?UTF-8?q?ent=20->=20staccato)=20#594=20(#596)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: memory -> category 변경 * refactor: memory -> category 변경 * test: CategoryController 테스트 작성 * refactor: v2를 RequestMapping에서 한번에 처리 * refactor: moment -> staccato 변경 * test: StaccatoController 테스트 작성 * refactor: 주석 삭제 * refactor: 코드 축약 * refactor: v2 삭제 * refactor: 접근제어자 private으로 변경 * refactor: swagger 설명의 추억 -> 스타카토 추가 변경 * refactor: swagger 설명의 추억 -> 스타카토 추가 변경 * fix: 이전 PR 수정사항 반영 * refactor: ControllerTest extend 적용 * refactor: MemoryReadRequest -> CategoryReadRequest 변경 및 적용 --- .../memory/controller/CategoryController.java | 100 ++++ .../memory/controller/CategoryDtoMapper.java | 98 ++++ .../docs/CategoryControllerDocs.java | 121 +++++ .../dto/request/CategoryReadRequest.java | 38 ++ .../service/dto/request/CategoryRequest.java | 47 ++ .../dto/response/CategoryDetailResponse.java | 49 ++ .../dto/response/CategoryIdResponse.java | 10 + .../dto/response/CategoryNameResponse.java | 17 + .../dto/response/CategoryNameResponses.java | 18 + .../dto/response/CategoryResponse.java | 35 ++ .../dto/response/CategoryResponses.java | 18 + .../dto/response/StaccatoResponse.java | 25 + .../moment/controller/StaccatoController.java | 99 ++++ .../moment/controller/StaccatoDtoMapper.java | 66 +++ .../docs/StaccatoControllerDocs.java | 128 +++++ .../service/dto/request/StaccatoRequest.java | 71 +++ .../dto/response/StaccatoDetailResponse.java | 63 +++ .../dto/response/StaccatoIdResponse.java | 10 + .../response/StaccatoLocationResponse.java | 21 + .../response/StaccatoLocationResponses.java | 9 + .../controller/CategoryControllerTest.java | 442 ++++++++++++++++++ .../dto/request/CategoryReadRequestTest.java | 56 +++ .../dto/request/CategoryRequestTest.java | 30 ++ .../controller/StaccatoControllerTest.java | 365 +++++++++++++++ 24 files changed, 1936 insertions(+) create mode 100644 backend/src/main/java/com/staccato/memory/controller/CategoryController.java create mode 100644 backend/src/main/java/com/staccato/memory/controller/CategoryDtoMapper.java create mode 100644 backend/src/main/java/com/staccato/memory/controller/docs/CategoryControllerDocs.java create mode 100644 backend/src/main/java/com/staccato/memory/service/dto/request/CategoryReadRequest.java create mode 100644 backend/src/main/java/com/staccato/memory/service/dto/request/CategoryRequest.java create mode 100644 backend/src/main/java/com/staccato/memory/service/dto/response/CategoryDetailResponse.java create mode 100644 backend/src/main/java/com/staccato/memory/service/dto/response/CategoryIdResponse.java create mode 100644 backend/src/main/java/com/staccato/memory/service/dto/response/CategoryNameResponse.java create mode 100644 backend/src/main/java/com/staccato/memory/service/dto/response/CategoryNameResponses.java create mode 100644 backend/src/main/java/com/staccato/memory/service/dto/response/CategoryResponse.java create mode 100644 backend/src/main/java/com/staccato/memory/service/dto/response/CategoryResponses.java create mode 100644 backend/src/main/java/com/staccato/memory/service/dto/response/StaccatoResponse.java create mode 100644 backend/src/main/java/com/staccato/moment/controller/StaccatoController.java create mode 100644 backend/src/main/java/com/staccato/moment/controller/StaccatoDtoMapper.java create mode 100644 backend/src/main/java/com/staccato/moment/controller/docs/StaccatoControllerDocs.java create mode 100644 backend/src/main/java/com/staccato/moment/service/dto/request/StaccatoRequest.java create mode 100644 backend/src/main/java/com/staccato/moment/service/dto/response/StaccatoDetailResponse.java create mode 100644 backend/src/main/java/com/staccato/moment/service/dto/response/StaccatoIdResponse.java create mode 100644 backend/src/main/java/com/staccato/moment/service/dto/response/StaccatoLocationResponse.java create mode 100644 backend/src/main/java/com/staccato/moment/service/dto/response/StaccatoLocationResponses.java create mode 100644 backend/src/test/java/com/staccato/memory/controller/CategoryControllerTest.java create mode 100644 backend/src/test/java/com/staccato/memory/service/dto/request/CategoryReadRequestTest.java create mode 100644 backend/src/test/java/com/staccato/memory/service/dto/request/CategoryRequestTest.java create mode 100644 backend/src/test/java/com/staccato/moment/controller/StaccatoControllerTest.java diff --git a/backend/src/main/java/com/staccato/memory/controller/CategoryController.java b/backend/src/main/java/com/staccato/memory/controller/CategoryController.java new file mode 100644 index 000000000..425adae13 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/controller/CategoryController.java @@ -0,0 +1,100 @@ +package com.staccato.memory.controller; + +import java.net.URI; +import java.time.LocalDate; + +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.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.staccato.config.auth.LoginMember; +import com.staccato.config.log.annotation.Trace; +import com.staccato.member.domain.Member; +import com.staccato.memory.controller.docs.CategoryControllerDocs; +import com.staccato.memory.service.MemoryService; +import com.staccato.memory.service.dto.request.CategoryReadRequest; +import com.staccato.memory.service.dto.request.CategoryRequest; +import com.staccato.memory.service.dto.request.MemoryReadRequest; +import com.staccato.memory.service.dto.response.CategoryDetailResponse; +import com.staccato.memory.service.dto.response.CategoryIdResponse; +import com.staccato.memory.service.dto.response.CategoryNameResponses; +import com.staccato.memory.service.dto.response.CategoryResponses; +import com.staccato.memory.service.dto.response.MemoryDetailResponse; +import com.staccato.memory.service.dto.response.MemoryIdResponse; +import com.staccato.memory.service.dto.response.MemoryNameResponses; +import com.staccato.memory.service.dto.response.MemoryResponses; + +import lombok.RequiredArgsConstructor; + +@Trace +@Validated +@RestController +@RequestMapping("/categories") +@RequiredArgsConstructor +public class CategoryController implements CategoryControllerDocs { + private final MemoryService memoryService; + + @PostMapping + public ResponseEntity createCategory( + @Valid @RequestBody CategoryRequest categoryRequest, + @LoginMember Member member + ) { + MemoryIdResponse memoryIdResponse = memoryService.createMemory(CategoryDtoMapper.toMemoryRequest(categoryRequest), member); + return ResponseEntity.created(URI.create("/categories/" + memoryIdResponse.memoryId())).body(CategoryDtoMapper.toCategoryIdResponse(memoryIdResponse)); + } + + @GetMapping + public ResponseEntity readAllCategories( + @LoginMember Member member, + @ModelAttribute("CategoryReadRequest") CategoryReadRequest categoryReadRequest + ) { + MemoryResponses memoryResponses = memoryService.readAllMemories(member, CategoryDtoMapper.toMemoryReadRequest(categoryReadRequest)); + return ResponseEntity.ok(CategoryDtoMapper.toCategoryResponses(memoryResponses)); + } + + @GetMapping("/candidates") + public ResponseEntity readAllCandidateCategories( + @LoginMember Member member, + @RequestParam(value = "currentDate") LocalDate currentDate + ) { + MemoryNameResponses memoryNameResponses = memoryService.readAllMemoriesByDate(member, currentDate); + return ResponseEntity.ok(CategoryDtoMapper.toCategoryNameResponses(memoryNameResponses)); + } + + @GetMapping("/{categoryId}") + public ResponseEntity readCategory( + @LoginMember Member member, + @PathVariable @Min(value = 1L, message = "카테고리 식별자는 양수로 이루어져야 합니다.") long categoryId) { + MemoryDetailResponse memoryDetailResponse = memoryService.readMemoryById(categoryId, member); + return ResponseEntity.ok(CategoryDtoMapper.toCategoryDetailResponse(memoryDetailResponse)); + } + + @PutMapping(path = "/{categoryId}") + public ResponseEntity updateCategory( + @PathVariable @Min(value = 1L, message = "카테고리 식별자는 양수로 이루어져야 합니다.") long categoryId, + @Valid @RequestBody CategoryRequest categoryRequest, + @LoginMember Member member) { + memoryService.updateMemory(CategoryDtoMapper.toMemoryRequest(categoryRequest), categoryId, member); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{categoryId}") + public ResponseEntity deleteCategory( + @PathVariable @Min(value = 1L, message = "카테고리 식별자는 양수로 이루어져야 합니다.") long categoryId, + @LoginMember Member member) { + memoryService.deleteMemory(categoryId, member); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/staccato/memory/controller/CategoryDtoMapper.java b/backend/src/main/java/com/staccato/memory/controller/CategoryDtoMapper.java new file mode 100644 index 000000000..280d5315a --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/controller/CategoryDtoMapper.java @@ -0,0 +1,98 @@ +package com.staccato.memory.controller; + +import java.util.List; + +import com.staccato.memory.service.dto.request.CategoryReadRequest; +import com.staccato.memory.service.dto.request.CategoryRequest; +import com.staccato.memory.service.dto.request.MemoryReadRequest; +import com.staccato.memory.service.dto.request.MemoryRequest; +import com.staccato.memory.service.dto.response.CategoryDetailResponse; +import com.staccato.memory.service.dto.response.CategoryIdResponse; +import com.staccato.memory.service.dto.response.CategoryNameResponse; +import com.staccato.memory.service.dto.response.CategoryNameResponses; +import com.staccato.memory.service.dto.response.CategoryResponse; +import com.staccato.memory.service.dto.response.CategoryResponses; +import com.staccato.memory.service.dto.response.MemoryDetailResponse; +import com.staccato.memory.service.dto.response.MemoryIdResponse; +import com.staccato.memory.service.dto.response.MemoryNameResponse; +import com.staccato.memory.service.dto.response.MemoryNameResponses; +import com.staccato.memory.service.dto.response.MemoryResponse; +import com.staccato.memory.service.dto.response.MemoryResponses; +import com.staccato.memory.service.dto.response.MomentResponse; +import com.staccato.memory.service.dto.response.StaccatoResponse; + +public class CategoryDtoMapper { + public static MemoryRequest toMemoryRequest(CategoryRequest categoryRequest) { + return new MemoryRequest( + categoryRequest.categoryThumbnailUrl(), + categoryRequest.categoryTitle(), + categoryRequest.description(), + categoryRequest.startAt(), + categoryRequest.endAt() + ); + } + + public static CategoryIdResponse toCategoryIdResponse(MemoryIdResponse memoryIdResponse) { + return new CategoryIdResponse(memoryIdResponse.memoryId()); + } + + public static CategoryResponse toCategoryResponse(MemoryResponse memoryResponse) { + return new CategoryResponse( + memoryResponse.memoryId(), + memoryResponse.memoryThumbnailUrl(), + memoryResponse.memoryTitle(), + memoryResponse.startAt(), + memoryResponse.endAt() + ); + } + + public static CategoryResponses toCategoryResponses(MemoryResponses memoryResponses) { + List categoryResponses = memoryResponses.memories().stream() + .map(CategoryDtoMapper::toCategoryResponse) + .toList(); + return new CategoryResponses(categoryResponses); + } + + public static CategoryNameResponse toCategoryNameResponse(MemoryNameResponse memoryNameResponse) { + return new CategoryNameResponse(memoryNameResponse.memoryId(), memoryNameResponse.memoryTitle()); + } + + public static CategoryNameResponses toCategoryNameResponses(MemoryNameResponses memoryNameResponses) { + List categoryNameResponses = memoryNameResponses.memories().stream() + .map(CategoryDtoMapper::toCategoryNameResponse) + .toList(); + return new CategoryNameResponses(categoryNameResponses); + } + + public static CategoryDetailResponse toCategoryDetailResponse(MemoryDetailResponse memoryDetailResponse) { + List staccatoResponses = memoryDetailResponse.moments().stream() + .map(CategoryDtoMapper::toStaccatoResponse) + .toList(); + return new CategoryDetailResponse( + memoryDetailResponse.memoryId(), + memoryDetailResponse.memoryThumbnailUrl(), + memoryDetailResponse.memoryTitle(), + memoryDetailResponse.description(), + memoryDetailResponse.startAt(), + memoryDetailResponse.endAt(), + memoryDetailResponse.mates(), + staccatoResponses + ); + } + + private static StaccatoResponse toStaccatoResponse(MomentResponse momentResponse) { + return new StaccatoResponse( + momentResponse.momentId(), + momentResponse.staccatoTitle(), + momentResponse.momentImageUrl(), + momentResponse.visitedAt() + ); + } + + public static MemoryReadRequest toMemoryReadRequest(CategoryReadRequest categoryReadRequest) { + return new MemoryReadRequest( + categoryReadRequest.filters(), + categoryReadRequest.sort() + ); + } +} diff --git a/backend/src/main/java/com/staccato/memory/controller/docs/CategoryControllerDocs.java b/backend/src/main/java/com/staccato/memory/controller/docs/CategoryControllerDocs.java new file mode 100644 index 000000000..28b640cfb --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/controller/docs/CategoryControllerDocs.java @@ -0,0 +1,121 @@ +package com.staccato.memory.controller.docs; + +import java.time.LocalDate; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; + +import org.springframework.http.ResponseEntity; + +import com.staccato.member.domain.Member; +import com.staccato.memory.service.dto.request.CategoryReadRequest; +import com.staccato.memory.service.dto.request.CategoryRequest; +import com.staccato.memory.service.dto.request.MemoryReadRequest; +import com.staccato.memory.service.dto.response.CategoryDetailResponse; +import com.staccato.memory.service.dto.response.CategoryIdResponse; +import com.staccato.memory.service.dto.response.CategoryNameResponses; +import com.staccato.memory.service.dto.response.CategoryResponses; +import com.staccato.memory.service.dto.response.MemoryResponses; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Category", description = "Category API") +public interface CategoryControllerDocs { + @Operation(summary = "카테고리 생성", description = "카테고리(썸네일, 제목, 내용, 기간)을 생성합니다.") + @ApiResponses(value = { + @ApiResponse(description = "카테고리 생성 성공", responseCode = "201"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 필수 값(카테고리 제목)이 누락되었을 때 + + (2) 날짜 형식(yyyy-MM-dd)이 잘못되었을 때 + + (3) 제목이 공백 포함 30자를 초과했을 때 + + (4) 내용이 공백 포함 500자를 초과했을 때 + + (5) 기간 설정이 잘못되었을 때 (시작 날짜와 끝날짜 중 하나만 설정할 수 없음) + + (6) 이미 존재하는 카테고리 이름일 때 + """, + responseCode = "400") + }) + ResponseEntity createCategory( + @Parameter(required = true) @Valid CategoryRequest categoryRequest, + @Parameter(hidden = true) Member member); + + @Operation(summary = "카테고리 목록 조회", description = "사용자의 모든 카테고리 목록을 조회합니다.") + @ApiResponse(description = "카테고리 목록 조회 성공", responseCode = "200") + ResponseEntity readAllCategories( + @Parameter(hidden = true) Member member, + @Parameter(description = "정렬 기준은 생략하거나 유효하지 않은 값에 대해서는 최근 수정 순(UPDATED)이 기본 정렬로 적용됩니다. 필터링 조건은 생략하거나 유효하지 않은 값이 들어오면 적용되지 않습니다.") CategoryReadRequest categoryReadRequest + ); + + @Operation(summary = "특정 날짜를 포함하는 사용자의 모든 카테고리 목록 조회", description = "특정 날짜를 포함하는 사용자의 모든 카테고리 목록을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(description = "카테고리 목록 조회 성공", responseCode = "200"), + @ApiResponse(description = "입력받은 현재 날짜가 유효하지 않을 때 발생", responseCode = "400") + }) + ResponseEntity readAllCandidateCategories( + @Parameter(hidden = true) Member member, + @Parameter(description = "현재 날짜", example = "2024-08-21") LocalDate currentDate); + + @Operation(summary = "카테고리 조회", description = "사용자의 카테고리을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(description = "카테고리 조회 성공", responseCode = "200"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 존재하지 않는 카테고리을 조회하려고 했을 때 + + (2) Path Variable 형식이 잘못되었을 때 + """, + responseCode = "400") + }) + ResponseEntity readCategory( + @Parameter(hidden = true) Member member, + @Parameter(description = "카테고리 ID", example = "1") @Min(value = 1L, message = "카테고리 식별자는 양수로 이루어져야 합니다.") long categoryId); + + @Operation(summary = "카테고리 수정", description = "카테고리 정보(썸네일, 제목, 내용, 기간)를 수정합니다.") + @ApiResponses(value = { + @ApiResponse(description = "카테고리 수정 성공", responseCode = "200"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 필수 값(카테고리 제목)이 누락되었을 때 + + (2) 날짜 형식(yyyy-MM-dd)이 잘못되었을 때 + + (3) 제목이 공백 포함 30자를 초과했을 때 + + (4) 내용이 공백 포함 500자를 초과했을 때 + + (5) 기간 설정이 잘못되었을 때 + + (6) 변경하려는 카테고리 기간이 이미 존재하는 스타카토를 포함하지 않을 때 + + (7) 수정하려는 카테고리이 존재하지 않을 때 + + (8) Path Variable 형식이 잘못되었을 때 + """, + responseCode = "400") + }) + ResponseEntity updateCategory( + @Parameter(description = "카테고리 ID", example = "1") @Min(value = 1L, message = "카테고리 식별자는 양수로 이루어져야 합니다.") long categoryId, + @Parameter(required = true) @Valid CategoryRequest categoryRequest, + @Parameter(hidden = true) Member member); + + @Operation(summary = "카테고리 삭제", description = "사용자의 카테고리을 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(description = "카테고리 삭제 성공", responseCode = "200"), + @ApiResponse(description = "Path Variable 형식이 잘못되었을 때 발생", responseCode = "400") + }) + ResponseEntity deleteCategory( + @Parameter(description = "카테고리 ID", example = "1") @Min(value = 1L, message = "카테고리 식별자는 양수로 이루어져야 합니다.") long categoryId, + @Parameter(hidden = true) Member member); +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/request/CategoryReadRequest.java b/backend/src/main/java/com/staccato/memory/service/dto/request/CategoryReadRequest.java new file mode 100644 index 000000000..3c1d9e307 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/request/CategoryReadRequest.java @@ -0,0 +1,38 @@ +package com.staccato.memory.service.dto.request; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +import com.staccato.memory.service.MemoryFilter; +import com.staccato.memory.service.MemorySort; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "카테고리 목록 조회시 정렬과 필터링 조건을 위한 요청 형식입니다.") +public record CategoryReadRequest( + @Schema(description = "사용할 필터링을 구분자(,)로 구분하여 나열 (TERM / 대소문자 구분 X)", example = "TERM") + String filters, + @Schema(description = "정렬 기준 (UPDATED, NEWEST, OLDEST / 대소문자 구분 X)", example = "NEWEST") + String sort +) { + private static final String DELIMITER = ","; + + public List getFilters() { + List filters = parseFilters().stream() + .map(String::trim) + .toList(); + return MemoryFilter.findAllByName(filters); + } + + private List parseFilters() { + if (Objects.isNull(filters)) { + return List.of(); + } + return Arrays.stream(filters.split(DELIMITER)).toList(); + } + + public MemorySort getSort() { + return MemorySort.findByName(sort); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/request/CategoryRequest.java b/backend/src/main/java/com/staccato/memory/service/dto/request/CategoryRequest.java new file mode 100644 index 000000000..b58c94d32 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/request/CategoryRequest.java @@ -0,0 +1,47 @@ +package com.staccato.memory.service.dto.request; + +import java.time.LocalDate; +import java.util.Objects; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import org.springframework.format.annotation.DateTimeFormat; + +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "카테고리를 생성/수정하기 위한 요청 형식입니다.") +public record CategoryRequest( + @Schema(example = "http://example.com/london.png") + String categoryThumbnailUrl, + @Schema(example = "런던 여행") + @NotBlank(message = "카테고리 제목을 입력해주세요.") + @Size(max = 30, message = "제목은 공백 포함 30자 이하로 설정해주세요.") + String categoryTitle, + @Schema(example = "런던 시내 탐방") + @Size(max = 500, message = "내용의 최대 허용 글자수는 공백 포함 500자입니다.") + String description, + @Schema(example = "2024-07-27") + @DateTimeFormat(pattern = "yyyy-MM-dd") + LocalDate startAt, + @Schema(example = "2024-07-29") + @DateTimeFormat(pattern = "yyyy-MM-dd") + LocalDate endAt) { + public CategoryRequest { + if (Objects.nonNull(categoryTitle)) { + categoryTitle = categoryTitle.trim(); + } + } + + public Memory toMemory() { + return Memory.builder() + .thumbnailUrl(categoryThumbnailUrl) + .title(categoryTitle) + .description(description) + .startAt(startAt) + .endAt(endAt) + .build(); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryDetailResponse.java b/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryDetailResponse.java new file mode 100644 index 000000000..43dc9262d --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryDetailResponse.java @@ -0,0 +1,49 @@ +package com.staccato.memory.service.dto.response; + +import java.time.LocalDate; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.staccato.member.service.dto.response.MemberResponse; +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "카테고리에 대한 응답 형식입니다.") +public record CategoryDetailResponse( + @Schema(example = "1") + Long categoryId, + @Schema(example = "https://example.com/categorys/geumohrm.jpg") + @JsonInclude(JsonInclude.Include.NON_NULL) + String categoryThumbnailUrl, + @Schema(example = "런던 여행") + String categoryTitle, + @Schema(example = "런던 시내 탐방") + @JsonInclude(JsonInclude.Include.NON_NULL) + String description, + @Schema(example = "2024-07-27") + @JsonInclude(JsonInclude.Include.NON_NULL) + LocalDate startAt, + @Schema(example = "2024-07-29") + @JsonInclude(JsonInclude.Include.NON_NULL) + LocalDate endAt, + List mates, + List staccatos +) { + public CategoryDetailResponse(Memory memory, List staccatoResponses) { + this( + memory.getId(), + memory.getThumbnailUrl(), + memory.getTitle(), + memory.getDescription(), + memory.getTerm().getStartAt(), + memory.getTerm().getEndAt(), + toMemberResponses(memory), + staccatoResponses + ); + } + + private static List toMemberResponses(Memory memory) { + return memory.getMates().stream().map(MemberResponse::new).toList(); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryIdResponse.java b/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryIdResponse.java new file mode 100644 index 000000000..46cccab73 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryIdResponse.java @@ -0,0 +1,10 @@ +package com.staccato.memory.service.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "카테고리를 생성했을 때에 대한 응답 형식입니다.") +public record CategoryIdResponse( + @Schema(example = "1") + long categoryId +) { +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryNameResponse.java b/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryNameResponse.java new file mode 100644 index 000000000..dcf6ee407 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryNameResponse.java @@ -0,0 +1,17 @@ +package com.staccato.memory.service.dto.response; + +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "특정 날짜를 포함하는 카테고리 목록 조회 시 각각의 카테고리에 대한 응답 형식입니다.") +public record CategoryNameResponse( + @Schema(example = "1") + Long categoryId, + @Schema(example = "런던 여행") + String categoryTitle +) { + public CategoryNameResponse(Memory memory) { + this(memory.getId(), memory.getTitle()); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryNameResponses.java b/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryNameResponses.java new file mode 100644 index 000000000..b6e35acb4 --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryNameResponses.java @@ -0,0 +1,18 @@ +package com.staccato.memory.service.dto.response; + +import java.util.List; + +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "특정 날짜를 포함하는 카테고리 목록 조회 시 반환되는 응답 형식입니다.") +public record CategoryNameResponses( + List categories +) { + public static CategoryNameResponses from(List memories) { + return new CategoryNameResponses(memories.stream() + .map(CategoryNameResponse::new) + .toList()); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryResponse.java b/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryResponse.java new file mode 100644 index 000000000..d8927a3ec --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryResponse.java @@ -0,0 +1,35 @@ +package com.staccato.memory.service.dto.response; + +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "카테고리 목록 조회 시 각각의 카테고리에 대한 응답 형식입니다.") +public record CategoryResponse( + @Schema(example = "1") + Long categoryId, + @Schema(example = "https://example.com/memorys/geumohrm.jpg") + @JsonInclude(JsonInclude.Include.NON_NULL) + String categoryThumbnailUrl, + @Schema(example = "런던 여행") + String categoryTitle, + @Schema(example = "2024-07-27") + @JsonInclude(JsonInclude.Include.NON_NULL) + LocalDate startAt, + @Schema(example = "2024-07-29") + @JsonInclude(JsonInclude.Include.NON_NULL) + LocalDate endAt +) { + public CategoryResponse(Memory memory) { + this( + memory.getId(), + memory.getThumbnailUrl(), + memory.getTitle(), + memory.getTerm().getStartAt(), + memory.getTerm().getEndAt() + ); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryResponses.java b/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryResponses.java new file mode 100644 index 000000000..c87028f7c --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/CategoryResponses.java @@ -0,0 +1,18 @@ +package com.staccato.memory.service.dto.response; + +import java.util.List; + +import com.staccato.memory.domain.Memory; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "카테고리 목록 조회 시 반환 되는 응답 형식입니다.") +public record CategoryResponses( + List categories +) { + public static CategoryResponses from(List memories) { + return new CategoryResponses(memories.stream() + .map(CategoryResponse::new) + .toList()); + } +} diff --git a/backend/src/main/java/com/staccato/memory/service/dto/response/StaccatoResponse.java b/backend/src/main/java/com/staccato/memory/service/dto/response/StaccatoResponse.java new file mode 100644 index 000000000..d6036033b --- /dev/null +++ b/backend/src/main/java/com/staccato/memory/service/dto/response/StaccatoResponse.java @@ -0,0 +1,25 @@ +package com.staccato.memory.service.dto.response; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.staccato.moment.domain.Moment; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "카테고리 조회 시 보여주는 스타카토의 정보에 대한 응답 형식입니다.") +public record StaccatoResponse( + @Schema(example = "1") + Long staccatoId, + @Schema(example = "런던 아이") + String staccatoTitle, + @Schema(example = "https://example.com/memorys/london_eye.jpg") + @JsonInclude(JsonInclude.Include.NON_NULL) + String staccatoImageUrl, + @Schema(example = "2024-07-27T11:58:20") + LocalDateTime visitedAt +) { + public StaccatoResponse(Moment moment, String momentImageUrl) { + this(moment.getId(), moment.getTitle(), momentImageUrl, moment.getVisitedAt()); + } +} diff --git a/backend/src/main/java/com/staccato/moment/controller/StaccatoController.java b/backend/src/main/java/com/staccato/moment/controller/StaccatoController.java new file mode 100644 index 000000000..da564c5cd --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/controller/StaccatoController.java @@ -0,0 +1,99 @@ +package com.staccato.moment.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.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.staccato.config.auth.LoginMember; +import com.staccato.config.log.annotation.Trace; +import com.staccato.member.domain.Member; +import com.staccato.moment.controller.docs.MomentControllerDocs; +import com.staccato.moment.controller.docs.StaccatoControllerDocs; +import com.staccato.moment.service.MomentService; +import com.staccato.moment.service.dto.request.FeelingRequest; +import com.staccato.moment.service.dto.request.MomentRequest; +import com.staccato.moment.service.dto.request.StaccatoRequest; +import com.staccato.moment.service.dto.response.MomentDetailResponse; +import com.staccato.moment.service.dto.response.MomentIdResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponses; +import com.staccato.moment.service.dto.response.StaccatoDetailResponse; +import com.staccato.moment.service.dto.response.StaccatoIdResponse; +import com.staccato.moment.service.dto.response.StaccatoLocationResponses; + +import lombok.RequiredArgsConstructor; + +@Trace +@RestController +@RequestMapping("/staccatos") +@RequiredArgsConstructor +@Validated +public class StaccatoController implements StaccatoControllerDocs { + private final MomentService momentService; + + @PostMapping + public ResponseEntity createStaccato( + @LoginMember Member member, + @Valid @RequestBody StaccatoRequest staccatoRequest + ) { + MomentIdResponse momentIdResponse = momentService.createMoment(StaccatoDtoMapper.toMomentRequest(staccatoRequest), member); + StaccatoIdResponse staccatoIdResponse = StaccatoDtoMapper.toStaccatoIdResponse(momentIdResponse); + return ResponseEntity.created(URI.create("/staccatos/" + staccatoIdResponse.staccatoId())) + .body(staccatoIdResponse); + } + + @GetMapping + public ResponseEntity readAllStaccato(@LoginMember Member member) { + MomentLocationResponses momentLocationResponses = momentService.readAllMoment(member); + return ResponseEntity.ok().body(StaccatoDtoMapper.toStaccatoLocationResponses(momentLocationResponses)); + } + + @GetMapping("/{staccatoId}") + public ResponseEntity readStaccatoById( + @LoginMember Member member, + @PathVariable @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long staccatoId) { + MomentDetailResponse momentDetailResponse = momentService.readMomentById(staccatoId, member); + return ResponseEntity.ok().body(StaccatoDtoMapper.toStaccatoDetailResponse(momentDetailResponse)); + } + + @PutMapping(path = "/{staccatoId}") + public ResponseEntity updateStaccatoById( + @LoginMember Member member, + @PathVariable @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long staccatoId, + @Valid @RequestBody StaccatoRequest staccatoRequest + ) { + MomentRequest momentRequest = StaccatoDtoMapper.toMomentRequest(staccatoRequest); + momentService.updateMomentById(staccatoId, momentRequest, member); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{staccatoId}") + public ResponseEntity deleteStaccatoById( + @LoginMember Member member, + @PathVariable @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long staccatoId + ) { + momentService.deleteMomentById(staccatoId, member); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{staccatoId}/feeling") + public ResponseEntity updateStaccatoFeelingById( + @LoginMember Member member, + @PathVariable @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long staccatoId, + @Valid @RequestBody FeelingRequest feelingRequest + ) { + momentService.updateMomentFeelingById(staccatoId, member, feelingRequest); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/staccato/moment/controller/StaccatoDtoMapper.java b/backend/src/main/java/com/staccato/moment/controller/StaccatoDtoMapper.java new file mode 100644 index 000000000..2431d558a --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/controller/StaccatoDtoMapper.java @@ -0,0 +1,66 @@ +package com.staccato.moment.controller; + +import java.util.List; + +import com.staccato.moment.service.dto.request.MomentRequest; +import com.staccato.moment.service.dto.request.StaccatoRequest; +import com.staccato.moment.service.dto.response.MomentDetailResponse; +import com.staccato.moment.service.dto.response.MomentIdResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponses; +import com.staccato.moment.service.dto.response.StaccatoDetailResponse; +import com.staccato.moment.service.dto.response.StaccatoIdResponse; +import com.staccato.moment.service.dto.response.StaccatoLocationResponse; +import com.staccato.moment.service.dto.response.StaccatoLocationResponses; + +public class StaccatoDtoMapper { + public static MomentRequest toMomentRequest(StaccatoRequest staccatoRequest) { + return new MomentRequest( + staccatoRequest.staccatoTitle(), + staccatoRequest.placeName(), + staccatoRequest.address(), + staccatoRequest.latitude(), + staccatoRequest.longitude(), + staccatoRequest.visitedAt(), + staccatoRequest.categoryId(), + staccatoRequest.staccatoImageUrls() + ); + } + + public static StaccatoIdResponse toStaccatoIdResponse(MomentIdResponse momentIdResponse) { + return new StaccatoIdResponse(momentIdResponse.momentId()); + } + + public static StaccatoLocationResponses toStaccatoLocationResponses(MomentLocationResponses momentLocationResponses) { + List staccatoLocationResponses = momentLocationResponses.momentLocationResponses().stream() + .map(StaccatoDtoMapper::toStaccatoLocationResponse) + .toList(); + return new StaccatoLocationResponses(staccatoLocationResponses); + } + + private static StaccatoLocationResponse toStaccatoLocationResponse(MomentLocationResponse momentLocationResponse) { + return new StaccatoLocationResponse( + momentLocationResponse.momentId(), + momentLocationResponse.latitude(), + momentLocationResponse.longitude() + ); + } + + public static StaccatoDetailResponse toStaccatoDetailResponse(MomentDetailResponse momentDetailResponse) { + return new StaccatoDetailResponse( + momentDetailResponse.memoryId(), + momentDetailResponse.momentId(), + momentDetailResponse.memoryTitle(), + momentDetailResponse.startAt(), + momentDetailResponse.endAt(), + momentDetailResponse.staccatoTitle(), + momentDetailResponse.momentImageUrls(), + momentDetailResponse.visitedAt(), + momentDetailResponse.feeling(), + momentDetailResponse.placeName(), + momentDetailResponse.address(), + momentDetailResponse.latitude(), + momentDetailResponse.longitude() + ); + } +} diff --git a/backend/src/main/java/com/staccato/moment/controller/docs/StaccatoControllerDocs.java b/backend/src/main/java/com/staccato/moment/controller/docs/StaccatoControllerDocs.java new file mode 100644 index 000000000..7d7189671 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/controller/docs/StaccatoControllerDocs.java @@ -0,0 +1,128 @@ +package com.staccato.moment.controller.docs; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; + +import com.staccato.member.domain.Member; +import com.staccato.moment.service.dto.request.FeelingRequest; +import com.staccato.moment.service.dto.request.MomentRequest; +import com.staccato.moment.service.dto.request.StaccatoRequest; +import com.staccato.moment.service.dto.response.MomentDetailResponse; +import com.staccato.moment.service.dto.response.MomentIdResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponses; +import com.staccato.moment.service.dto.response.StaccatoDetailResponse; +import com.staccato.moment.service.dto.response.StaccatoIdResponse; +import com.staccato.moment.service.dto.response.StaccatoLocationResponses; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Staccato", description = "Staccato API") +public interface StaccatoControllerDocs { + @Operation(summary = "스타카토 생성", description = "스타카토를 생성합니다.") + @ApiResponses(value = { + @ApiResponse(description = "스타카토 생성 성공", responseCode = "201"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 필수 값(사진을 제외한 모든 값)이 누락되었을 때 + + (2) 존재하지 않는 staccatoId일 때 + + (3) 올바르지 않은 날짜 형식일 때 + + (4) 사진이 5장을 초과했을 때 + + (5) 스타카토 날짜가 카테고리 기간에 포함되지 않을 때 + """, + responseCode = "400") + }) + ResponseEntity createStaccato( + @Parameter(hidden = true) Member member, + @Parameter(required = true) @Valid StaccatoRequest staccatoRequest + ); + + @Operation(summary = "스타카토 목록 조회", description = "스타카토 목록을 조회합니다.") + @ApiResponse(description = "스타카토 목록 조회 성공", responseCode = "200") + ResponseEntity readAllStaccato(@Parameter(hidden = true) Member member); + + @Operation(summary = "스타카토 조회", description = "스타카토를 조회합니다.") + @ApiResponses(value = { + @ApiResponse(description = "스타카토 조회 성공", responseCode = "200"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 조회하려는 스타카토가 존재하지 않을 때 + + (2) Path Variable 형식이 잘못되었을 때 + """, + responseCode = "400") + }) + ResponseEntity readStaccatoById( + @Parameter(hidden = true) Member member, + @Parameter(description = "스타카토 ID", example = "1") @PathVariable @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long staccatoId); + + @Operation(summary = "스타카토 수정", description = "스타카토를 수정합니다.") + @ApiResponses(value = { + @ApiResponse(description = "스타카토 수정 성공", responseCode = "200"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 수정하려는 스타카토가 존재하지 않을 때 + + (2) Path Variable 형식이 잘못되었을 때 + + (3) 필수 값(사진을 제외한 모든 값)이 누락되었을 때 + + (4) 존재하지 않는 staccatoId일 때 + + (5) 올바르지 않은 날짜 형식일 때 + + (6) 사진이 5장을 초과했을 때 + + (7) 스타카토 날짜가 카테고리 기간에 포함되지 않을 때 + """, + responseCode = "400") + }) + ResponseEntity updateStaccatoById( + @Parameter(hidden = true) Member member, + @Parameter(description = "스타카토 ID", example = "1") @PathVariable @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long staccatoId, + @Parameter(required = true) @Valid StaccatoRequest staccatoRequest); + + @Operation(summary = "스타카토 삭제", description = "스타카토를 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(description = "스타카토 삭제에 성공했거나 해당 스타카토가 존재하지 않는 경우", responseCode = "200"), + @ApiResponse(description = "스타카토 식별자에 양수가 아닌 값을 기입했을 경우", responseCode = "400") + }) + ResponseEntity deleteStaccatoById( + @Parameter(hidden = true) Member member, + @Parameter(description = "스타카토 ID", example = "1") @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long staccatoId + ); + + @Operation(summary = "스타카토 기분 선택", description = "스타카토의 기분을 선택합니다.") + @ApiResponses(value = { + @ApiResponse(description = "스타카토 기분 선택 성공", responseCode = "200"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 조회하려는 스타카토가 존재하지 않을 때 + + (2) Path Variable 형식이 잘못되었을 때 + + (3) RequestBody 형식이 잘못되었을 때 + + (4) 요청한 기분 표현을 찾을 수 없을 때 + """, + responseCode = "400") + }) + ResponseEntity updateStaccatoFeelingById( + @Parameter(hidden = true) Member member, + @Parameter(description = "스타카토 ID", example = "1") @Min(value = 1L, message = "스타카토 식별자는 양수로 이루어져야 합니다.") long staccatoId, + @Parameter(required = true) @Valid FeelingRequest feelingRequest); +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/request/StaccatoRequest.java b/backend/src/main/java/com/staccato/moment/service/dto/request/StaccatoRequest.java new file mode 100644 index 000000000..6710a4f58 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/request/StaccatoRequest.java @@ -0,0 +1,71 @@ +package com.staccato.moment.service.dto.request; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import org.springframework.format.annotation.DateTimeFormat; + +import com.staccato.memory.domain.Memory; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.domain.MomentImages; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "스타카토 생성 시 요청 형식입니다. 단, 멀티파트로 보내는 사진 파일은 여기에 포함되지 않습니다.") +public record StaccatoRequest( + @Schema(example = "재밌었던 런던 박물관에서의 기억") + @NotBlank(message = "스타카토 제목을 입력해주세요.") + @Size(max = 30, message = "스타카토 제목은 공백 포함 30자 이하로 설정해주세요.") + String staccatoTitle, + @Schema(example = "Great Russell St, London WC1B 3DG") + @NotNull(message = "장소 이름을 입력해주세요.") + String placeName, + @Schema(example = "British Museum") + @NotNull(message = "스타카토의 주소를 입력해주세요.") + String address, + @Schema(example = "51.51978412729915") + @NotNull(message = "스타카토의 위도를 입력해주세요.") + BigDecimal latitude, + @Schema(example = "-0.12712788587027796") + @NotNull(message = "스타카토의 경도를 입력해주세요.") + BigDecimal longitude, + @Schema(example = "2024-07-27") + @NotNull(message = "스타카토 날짜를 입력해주세요.") + @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime visitedAt, + @Schema(example = "1") + @NotNull(message = "카테고리를 선택해주세요.") + @Min(value = 1L, message = "카테고리 식별자는 양수로 이루어져야 합니다.") + long categoryId, + @ArraySchema( + arraySchema = @Schema(example = "[\"https://example.com/images/namsan_tower.jpg\", \"https://example.com/images/namsan_tower2.jpg\"]")) + @Size(max = 5, message = "사진은 5장까지만 추가할 수 있어요.") + List staccatoImageUrls +) { + public StaccatoRequest { + if (Objects.nonNull(staccatoTitle)) { + staccatoTitle = staccatoTitle.trim(); + } + } + + public Moment toMoment(Memory memory) { + return Moment.builder() + .visitedAt(visitedAt) + .title(staccatoTitle) + .placeName(placeName) + .latitude(latitude) + .longitude(longitude) + .address(address) + .memory(memory) + .momentImages(new MomentImages(staccatoImageUrls)) + .build(); + } +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/response/StaccatoDetailResponse.java b/backend/src/main/java/com/staccato/moment/service/dto/response/StaccatoDetailResponse.java new file mode 100644 index 000000000..3de5d48fe --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/response/StaccatoDetailResponse.java @@ -0,0 +1,63 @@ +package com.staccato.moment.service.dto.response; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.staccato.moment.domain.Moment; +import com.staccato.moment.domain.MomentImage; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "스타카토를 조회했을 때 응답 형식입니다.") +public record StaccatoDetailResponse( + @Schema(example = "1") + long staccatoId, + @Schema(example = "1") + long categoryId, + @Schema(example = "2024 서울 투어") + String categoryTitle, + @Schema(example = "2024-06-30") + @JsonInclude(JsonInclude.Include.NON_NULL) + LocalDate startAt, + @Schema(example = "2024-07-04") + @JsonInclude(JsonInclude.Include.NON_NULL) + LocalDate endAt, + @Schema(example = "즐거웠던 남산에서의 기억") + String staccatoTitle, + @ArraySchema(arraySchema = @Schema(example = "[\"https://example.com/images/namsan_tower.jpg\", \"https://example.com/images/namsan_tower2.jpg\"]")) + List staccatoImageUrls, + @Schema(example = "2021-11-08T11:58:20") + LocalDateTime visitedAt, + @Schema(example = "happy") + String feeling, + @Schema(example = "남산서울타워") + String placeName, + @Schema(example = "서울 용산구 남산공원길 105") + String address, + @Schema(example = "51.51978412729915") + BigDecimal latitude, + @Schema(example = "-0.12712788587027796") + BigDecimal longitude +) { + public StaccatoDetailResponse(Moment moment) { + this( + moment.getId(), + moment.getMemory().getId(), + moment.getMemory().getTitle(), + moment.getMemory().getTerm().getStartAt(), + moment.getMemory().getTerm().getEndAt(), + moment.getTitle(), + moment.getMomentImages().getImages().stream().map(MomentImage::getImageUrl).toList(), + moment.getVisitedAt(), + moment.getFeeling().getValue(), + moment.getSpot().getPlaceName(), + moment.getSpot().getAddress(), + moment.getSpot().getLatitude(), + moment.getSpot().getLongitude() + ); + } +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/response/StaccatoIdResponse.java b/backend/src/main/java/com/staccato/moment/service/dto/response/StaccatoIdResponse.java new file mode 100644 index 000000000..56bae698a --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/response/StaccatoIdResponse.java @@ -0,0 +1,10 @@ +package com.staccato.moment.service.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "스타카토 생성 시 응답 형식입니다.") +public record StaccatoIdResponse( + @Schema(example = "1") + long staccatoId +) { +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/response/StaccatoLocationResponse.java b/backend/src/main/java/com/staccato/moment/service/dto/response/StaccatoLocationResponse.java new file mode 100644 index 000000000..cf4cbdc51 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/response/StaccatoLocationResponse.java @@ -0,0 +1,21 @@ +package com.staccato.moment.service.dto.response; + +import java.math.BigDecimal; + +import com.staccato.moment.domain.Moment; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "스타카토 목록 중 하나의 스타카토에 해당하는 응답입니다.") +public record StaccatoLocationResponse( + @Schema(example = "1") + long staccatoId, + @Schema(example = "51.51978412729915") + BigDecimal latitude, + @Schema(example = "-0.12712788587027796") + BigDecimal longitude) { + + public StaccatoLocationResponse(Moment moment) { + this(moment.getId(), moment.getSpot().getLatitude(), moment.getSpot().getLongitude()); + } +} diff --git a/backend/src/main/java/com/staccato/moment/service/dto/response/StaccatoLocationResponses.java b/backend/src/main/java/com/staccato/moment/service/dto/response/StaccatoLocationResponses.java new file mode 100644 index 000000000..10d793ec3 --- /dev/null +++ b/backend/src/main/java/com/staccato/moment/service/dto/response/StaccatoLocationResponses.java @@ -0,0 +1,9 @@ +package com.staccato.moment.service.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "스타카토 목록에 해당하는 응답입니다.") +public record StaccatoLocationResponses(List staccatoLocationResponses) { +} diff --git a/backend/src/test/java/com/staccato/memory/controller/CategoryControllerTest.java b/backend/src/test/java/com/staccato/memory/controller/CategoryControllerTest.java new file mode 100644 index 000000000..103dd5de0 --- /dev/null +++ b/backend/src/test/java/com/staccato/memory/controller/CategoryControllerTest.java @@ -0,0 +1,442 @@ +package com.staccato.memory.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +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.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +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.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.staccato.ControllerTest; +import com.staccato.auth.service.AuthService; +import com.staccato.exception.ExceptionResponse; +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.fixture.memory.MemoryFixture; +import com.staccato.fixture.memory.MemoryNameResponsesFixture; +import com.staccato.fixture.memory.MemoryRequestFixture; +import com.staccato.fixture.memory.MemoryResponsesFixture; +import com.staccato.fixture.moment.MomentFixture; +import com.staccato.member.domain.Member; +import com.staccato.memory.domain.Memory; +import com.staccato.memory.service.MemoryService; +import com.staccato.memory.service.dto.request.CategoryRequest; +import com.staccato.memory.service.dto.request.MemoryReadRequest; +import com.staccato.memory.service.dto.request.MemoryRequest; +import com.staccato.memory.service.dto.response.MemoryDetailResponse; +import com.staccato.memory.service.dto.response.MemoryIdResponse; +import com.staccato.memory.service.dto.response.MemoryNameResponses; +import com.staccato.memory.service.dto.response.MemoryResponses; +import com.staccato.memory.service.dto.response.MomentResponse; + +class CategoryControllerTest extends ControllerTest { + + static Stream categoryRequestProvider() { + return Stream.of( + new CategoryRequest(null, "2023 여름 휴가", "친구들과 함께한 여름 휴가 카테고리", LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + new CategoryRequest("https://example.com/memorys/geumohrm.jpg", "2023 여름 휴가", null, LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + new CategoryRequest("https://example.com/memorys/geumohrm.jpg", "2023 여름 휴가", null, null, null) + ); + } + + static Stream invalidCategoryRequestProvider() { + return Stream.of( + Arguments.of( + new CategoryRequest("https://example.com/memorys/geumohrm.jpg", null, "친구들과 함께한 여름 휴가 카테고리", LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + "카테고리 제목을 입력해주세요." + ), + Arguments.of( + new CategoryRequest("https://example.com/memorys/geumohrm.jpg", " ", "친구들과 함께한 여름 휴가 카테고리", LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + "카테고리 제목을 입력해주세요." + ), + Arguments.of( + new CategoryRequest("https://example.com/memorys/geumohrm.jpg", "가".repeat(31), "친구들과 함께한 여름 휴가 카테고리", LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + "제목은 공백 포함 30자 이하로 설정해주세요." + ), + Arguments.of( + new CategoryRequest("https://example.com/memorys/geumohrm.jpg", "2023 여름 휴가", "가".repeat(501), LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10)), + "내용의 최대 허용 글자수는 공백 포함 500자입니다." + ) + ); + } + + @DisplayName("카테고리를 생성하는 요청/응답의 역직렬화/직렬화에 성공한다.") + @Test + void createCategory() throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + String categoryRequest = """ + { + "categoryThumbnailUrl": "https://example.com/memorys/geumohrm.jpg", + "categoryTitle": "2023 여름 휴가", + "description": "친구들과 함께한 여름 휴가 여행", + "startAt": "2023-07-01", + "endAt": "2023-07-10" + } + """; + when(memoryService.createMemory(any(), any())).thenReturn(new MemoryIdResponse(1)); + String expectedResponse = """ + { + "categoryId" : 1 + } + """; + + // when & then + mockMvc.perform(post("/categories") + .contentType(MediaType.APPLICATION_JSON) + .content(categoryRequest) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isCreated()) + .andExpect(header().string(HttpHeaders.LOCATION, "/categories/1")) + .andExpect(content().json(expectedResponse)); + } + + @DisplayName("기간이 없는 카테고리를 생성하는 요청/응답의 역직렬화/직렬화에 성공한다.") + @Test + void createCategoryWithoutTerm() throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + String categoryRequest = """ + { + "categoryThumbnailUrl": "https://example.com/memorys/geumohrm.jpg", + "categoryTitle": "2023 여름 휴가", + "description": "친구들과 함께한 여름 휴가 여행" + } + """; + when(memoryService.createMemory(any(), any())).thenReturn(new MemoryIdResponse(1)); + String expectedResponse = """ + { + "categoryId" : 1 + } + """; + + // when & then + mockMvc.perform(post("/categories") + .contentType(MediaType.APPLICATION_JSON) + .content(categoryRequest) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isCreated()) + .andExpect(header().string(HttpHeaders.LOCATION, "/categories/1")) + .andExpect(content().json(expectedResponse)); + } + + @DisplayName("사용자가 선택적으로 카테고리 정보를 입력하면, 새로운 카테고리를 생성한다.") + @ParameterizedTest + @MethodSource("categoryRequestProvider") + void createCategoryWithoutOption(CategoryRequest categoryRequest) throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + when(memoryService.createMemory(any(), any())).thenReturn(new MemoryIdResponse(1)); + + // when & then + mockMvc.perform(post("/categories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(categoryRequest)) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isCreated()) + .andExpect(header().string(HttpHeaders.LOCATION, "/categories/1")) + .andExpect(jsonPath("$.categoryId").value(1)); + } + + @DisplayName("사용자가 잘못된 형식으로 정보를 입력하면, 카테고리를 생성할 수 없다.") + @ParameterizedTest + @MethodSource("invalidCategoryRequestProvider") + void failCreateCategory(CategoryRequest categoryRequest, String expectedMessage) throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), expectedMessage); + + // when & then + mockMvc.perform(post("/categories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(categoryRequest)) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("사용자가 모든 카테고리 목록을 조회하는 응답 직렬화에 성공한다.") + @Test + void readAllCategory() throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + Memory memory = MemoryFixture.createWithMember(MemberFixture.create()); + MemoryResponses memoryResponses = MemoryResponsesFixture.create(memory); + when(memoryService.readAllMemories(any(Member.class), any(MemoryReadRequest.class))).thenReturn(memoryResponses); + String expectedResponse = """ + { + "categories": [ + { + "categoryId": null, + "categoryTitle": "2024 여름 휴가", + "categoryThumbnailUrl": "https://example.com/memorys/geumohrm.jpg", + "startAt": "2024-07-01", + "endAt": "2024-07-10" + } + ] + } + """; + + // when & then + mockMvc.perform(get("/categories") + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()) + .andExpect(content().json(expectedResponse)); + } + + @DisplayName("사용자가 기간이 없는 카테고리를 포함한 목록을 조회하는 응답 직렬화에 성공한다.") + @Test + void readAllCategoryWithoutTerm() throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + Memory memory = MemoryFixture.createWithMember(MemberFixture.create()); + MemoryResponses memoryResponses = MemoryResponsesFixture.create(memory); + when(memoryService.readAllMemories(any(Member.class), any(MemoryReadRequest.class))).thenReturn(memoryResponses); + String expectedResponse = """ + { + "categories": [ + { + "categoryId": null, + "categoryTitle": "2024 여름 휴가", + "categoryThumbnailUrl": "https://example.com/memorys/geumohrm.jpg" + } + ] + } + """; + + // when & then + mockMvc.perform(get("/categories") + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()) + .andExpect(content().json(expectedResponse)); + } + + @DisplayName("특정 날짜를 포함하고 있는 모든 카테고리 목록을 조회하는 응답 직렬화에 성공한다.") + @Test + void readAllCategoryIncludingDate() throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + Memory memory = MemoryFixture.createWithMember(MemberFixture.create()); + MemoryNameResponses memoryNameResponses = MemoryNameResponsesFixture.create(memory); + when(memoryService.readAllMemoriesByDate(any(Member.class), any())).thenReturn(memoryNameResponses); + String expectedResponse = """ + { + "categories": [ + { + "categoryId": null, + "categoryTitle": "2024 여름 휴가" + } + ] + } + """; + + // when & then + mockMvc.perform(get("/categories/candidates") + .header(HttpHeaders.AUTHORIZATION, "token") + .param("currentDate", LocalDate.now().toString())) + .andExpect(status().isOk()) + .andExpect(content().json(expectedResponse)); + } + + @DisplayName("잘못된 날짜 형식으로 카테고리 목록 조회를 시도하면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"2024.07.01", "2024-07", "2024", "a"}) + void cannotReadAllCategoryByInvalidDateFormat(String currentDate) throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "올바르지 않은 쿼리 스트링 형식입니다."); + + // when & then + mockMvc.perform(get("/categories/candidates") + .header(HttpHeaders.AUTHORIZATION, "token") + .param("currentDate", currentDate)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("사용자가 특정 카테고리를 조회하는 응답 직렬화에 한다.") + @Test + void readCategory() throws Exception { + // given + long categoryId = 1; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + Memory memory = MemoryFixture.createWithMember(MemberFixture.create()); + MomentResponse momentResponse = new MomentResponse(MomentFixture.create(memory), "image.jpg"); + MemoryDetailResponse memoryDetailResponse = new MemoryDetailResponse(memory, List.of(momentResponse)); + when(memoryService.readMemoryById(anyLong(), any(Member.class))).thenReturn(memoryDetailResponse); + String expectedResponse = """ + { + "categoryId": null, + "categoryThumbnailUrl": "https://example.com/memorys/geumohrm.jpg", + "categoryTitle": "2024 여름 휴가", + "startAt": "2024-07-01", + "endAt": "2024-07-10", + "description": "친구들과 함께한 여름 휴가 추억", + "mates": [ + { + "memberId": null, + "nickname": "staccato" + } + ], + "staccatos": [ + { + "staccatoId": null, + "staccatoTitle": "staccatoTitle", + "staccatoImageUrl": "image.jpg", + "visitedAt": "2024-07-01T10:00:00" + } + ] + } + """; + + // when & then + mockMvc.perform(get("/categories/{categoryId}", categoryId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()) + .andExpect(content().json(expectedResponse)); + } + + + @DisplayName("사용자가 기간이 없는 특정 카테고리를 조회하는 응답 직렬화에 한다.") + @Test + void readCategoryWithoutTerm() throws Exception { + // given + long categoryId = 1; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + Memory memory = MemoryFixture.createWithMember(null, null, MemberFixture.create()); + MomentResponse momentResponse = new MomentResponse(MomentFixture.create(memory), "image.jpg"); + MemoryDetailResponse memoryDetailResponse = new MemoryDetailResponse(memory, List.of(momentResponse)); + when(memoryService.readMemoryById(anyLong(), any(Member.class))).thenReturn(memoryDetailResponse); + String expectedResponse = """ + { + "categoryId": null, + "categoryThumbnailUrl": "https://example.com/memorys/geumohrm.jpg", + "categoryTitle": "2024 여름 휴가", + "description": "친구들과 함께한 여름 휴가 추억", + "mates": [ + { + "memberId": null, + "nickname": "staccato" + } + ], + "staccatos": [ + { + "staccatoId": null, + "staccatoTitle": "staccatoTitle", + "staccatoImageUrl": "image.jpg", + "visitedAt": "2024-07-01T10:00:00" + } + ] + } + """; + + // when & then + mockMvc.perform(get("/categories/{categoryId}", categoryId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()) + .andExpect(content().json(expectedResponse)); + } + + @DisplayName("적합한 경로변수와 데이터를 통해 스타카토 수정에 성공한다.") + @ParameterizedTest + @MethodSource("categoryRequestProvider") + void updateCategory(CategoryRequest categoryRequest) throws Exception { + // given + long categoryId = 1L; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(put("/categories/{categoryId}", categoryId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(categoryRequest))) + .andExpect(status().isOk()); + } + + @DisplayName("사용자가 잘못된 형식으로 정보를 입력하면, 카테고리를 수정할 수 없다.") + @ParameterizedTest + @MethodSource("invalidCategoryRequestProvider") + void failUpdateCategory(CategoryRequest categoryRequest, String expectedMessage) throws Exception { + // given + long categoryId = 1L; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), expectedMessage); + + // when & then + mockMvc.perform(put("/categories/{categoryId}", categoryId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(categoryRequest))) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("적합하지 않은 경로변수의 경우 카테고리 수정에 실패한다.") + @ParameterizedTest + @MethodSource("categoryRequestProvider") + void failUpdateCategoryByInvalidPath(CategoryRequest categoryRequest) throws Exception { + // given + long categoryId = 0L; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "카테고리 식별자는 양수로 이루어져야 합니다."); + + // when & then + mockMvc.perform(put("/categories/{categoryId}", categoryId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(categoryRequest))) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("사용자가 카테고리 식별자로 카테고리를 삭제한다.") + @Test + void deleteCategory() throws Exception { + // given + long categoryId = 1; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(delete("/categories/{categoryId}", categoryId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()); + } + + @DisplayName("사용자가 잘못된 카테고리 식별자로 삭제하려고 하면 예외가 발생한다.") + @Test + void cannotDeleteCategoryByInvalidId() throws Exception { + // given + long invalidId = 0; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "카테고리 식별자는 양수로 이루어져야 합니다."); + + // when & then + mockMvc.perform(delete("/categories/{categoryId}", invalidId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } +} diff --git a/backend/src/test/java/com/staccato/memory/service/dto/request/CategoryReadRequestTest.java b/backend/src/test/java/com/staccato/memory/service/dto/request/CategoryReadRequestTest.java new file mode 100644 index 000000000..4937f69c8 --- /dev/null +++ b/backend/src/test/java/com/staccato/memory/service/dto/request/CategoryReadRequestTest.java @@ -0,0 +1,56 @@ +package com.staccato.memory.service.dto.request; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +import com.staccato.memory.service.MemoryFilter; +import com.staccato.memory.service.MemorySort; + +class CategoryReadRequestTest { + @DisplayName("필터가 주어졌을 때 올바른 필터 목록을 반환한다") + @Test + void getFiltersWithValidFilters() { + // given + CategoryReadRequest request = new CategoryReadRequest("TERM, term", "NEWEST"); + + // when + List filters = request.getFilters(); + + // then + assertThat(filters).hasSize(1).containsOnly(MemoryFilter.TERM); + } + + @DisplayName("필터가 주어지지 않았을 때 빈 필터 목록을 반환한다") + @ParameterizedTest + @NullAndEmptySource + void getFiltersWithNullOrEmptyFilters(String filters) { + // given + CategoryReadRequest request = new CategoryReadRequest(filters, "NEWEST"); + + // when + List result = request.getFilters(); + + // then + assertThat(result).isEmpty(); + } + + @DisplayName("정렬이 주어지지 않았을 때 기본 정렬 기준을 반환한다") + @ParameterizedTest + @NullAndEmptySource + void getSortWithNullOrEmptyFilters(String sort) { + // given + CategoryReadRequest request = new CategoryReadRequest(null, sort); + + // when + MemorySort result = request.getSort(); + + // then + assertThat(result).isEqualTo(MemorySort.UPDATED); + } +} diff --git a/backend/src/test/java/com/staccato/memory/service/dto/request/CategoryRequestTest.java b/backend/src/test/java/com/staccato/memory/service/dto/request/CategoryRequestTest.java new file mode 100644 index 000000000..52a8745d3 --- /dev/null +++ b/backend/src/test/java/com/staccato/memory/service/dto/request/CategoryRequestTest.java @@ -0,0 +1,30 @@ +package com.staccato.memory.service.dto.request; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class CategoryRequestTest { + @DisplayName("CategoryRequest를 생성할 때 title에는 trim이 적용된다.") + @Test + void trimTitle() { + // given + String categoryTitle = " title "; + String expectedTitle = "title"; + + // when + CategoryRequest categoryRequest = new CategoryRequest( + "thumbnail", + categoryTitle, + "description", + LocalDate.of(2024, 8, 22), + LocalDate.of(2024, 8, 22) + ); + + // then + assertThat(categoryRequest.categoryTitle()).isEqualTo(expectedTitle); + } +} diff --git a/backend/src/test/java/com/staccato/moment/controller/StaccatoControllerTest.java b/backend/src/test/java/com/staccato/moment/controller/StaccatoControllerTest.java new file mode 100644 index 000000000..530893728 --- /dev/null +++ b/backend/src/test/java/com/staccato/moment/controller/StaccatoControllerTest.java @@ -0,0 +1,365 @@ +package com.staccato.moment.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +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.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +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.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.staccato.ControllerTest; +import com.staccato.auth.service.AuthService; +import com.staccato.exception.ExceptionResponse; +import com.staccato.fixture.Member.MemberFixture; +import com.staccato.fixture.moment.MomentDetailResponseFixture; +import com.staccato.fixture.moment.MomentLocationResponsesFixture; +import com.staccato.member.domain.Member; +import com.staccato.moment.service.MomentService; +import com.staccato.moment.service.dto.request.FeelingRequest; +import com.staccato.moment.service.dto.request.MomentRequest; +import com.staccato.moment.service.dto.request.StaccatoRequest; +import com.staccato.moment.service.dto.response.MomentDetailResponse; +import com.staccato.moment.service.dto.response.MomentIdResponse; +import com.staccato.moment.service.dto.response.MomentLocationResponses; + +class StaccatoControllerTest extends ControllerTest { + static Stream invalidStaccatoRequestProvider() { + return Stream.of( + Arguments.of( + new StaccatoRequest("staccatoTitle", "placeName", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 0L, List.of("https://example.com/images/namsan_tower.jpg")), + "카테고리 식별자는 양수로 이루어져야 합니다." + ), + Arguments.of( + new StaccatoRequest(null, "placeName", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "스타카토 제목을 입력해주세요." + ), + Arguments.of( + new StaccatoRequest(" ", "placeName", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "스타카토 제목을 입력해주세요." + ), + Arguments.of( + new StaccatoRequest("staccatoTitle", null, "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "장소 이름을 입력해주세요." + ), + Arguments.of( + new StaccatoRequest("가".repeat(31), "placeName", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "스타카토 제목은 공백 포함 30자 이하로 설정해주세요." + ), + Arguments.of( + new StaccatoRequest("staccatoTitle", "placeName", "address", null, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "스타카토의 위도를 입력해주세요." + ), + Arguments.of( + new StaccatoRequest("staccatoTitle", "placeName", "address", BigDecimal.ONE, null, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "스타카토의 경도를 입력해주세요." + ), + Arguments.of( + new StaccatoRequest("staccatoTitle", "placeName", null, BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "스타카토의 주소를 입력해주세요." + ), + Arguments.of( + new StaccatoRequest("staccatoTitle", "placeName", "address", BigDecimal.ONE, BigDecimal.ONE, null, 1L, List.of("https://example.com/images/namsan_tower.jpg")), + "스타카토 날짜를 입력해주세요." + ) + ); + } + + @DisplayName("스타카토 생성 요청/응답을 역직렬화/직렬화하는 것을 성공한다.") + @Test + void createStaccatoWithValidRequest() throws Exception { + // given + String staccatoRequest = """ + { + "staccatoTitle": "staccatoTitle", + "placeName": "placeName", + "address": "address", + "latitude": 1.0, + "longitude": 1.0, + "visitedAt": "2023-07-01T10:00:00", + "categoryId": 1, + "staccatoImageUrls": ["https://example.com/images/namsan_tower.jpg"] + } + """; + String staccatoIdResponse = """ + { + "staccatoId": 1 + } + """; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + when(momentService.createMoment(any(MomentRequest.class), any(Member.class))).thenReturn(new MomentIdResponse(1L)); + + // when & then + mockMvc.perform(post("/staccatos") + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(staccatoRequest)) + .andExpect(status().isCreated()) + .andExpect(header().string(HttpHeaders.LOCATION, "/staccatos/1")) + .andExpect(content().json(staccatoIdResponse)); + } + + @DisplayName("올바르지 않은 날짜 형식으로 스타카토 생성을 요청하면 예외가 발생한다.") + @Test + void failCreateStaccatoWithInvalidVisitedAt() throws Exception { + // given + String staccatoRequest = """ + { + "staccatoTitle": "재밌었던 런던 박물관에서의 기억", + "placeName": "British Museum", + "address": "Great Russell St, London WC1B 3DG", + "latitude": 51.51978412729915, + "longitude": -0.12712788587027796, + "visitedAt": "2024/07/27T10:00:00", + "categoryId": 1 + } + """; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + when(momentService.createMoment(any(MomentRequest.class), any(Member.class))).thenReturn(new MomentIdResponse(1L)); + + // when & then + mockMvc.perform(post("/staccatos") + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(staccatoRequest)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("요청 본문을 읽을 수 없습니다. 올바른 형식으로 데이터를 제공해주세요.")); + } + + @DisplayName("사용자가 잘못된 요청 형식으로 정보를 입력하면, 스타카토를 생성할 수 없다.") + @ParameterizedTest + @MethodSource("invalidStaccatoRequestProvider") + void failCreateStaccato(StaccatoRequest staccatoRequest, String expectedMessage) throws Exception { + // given + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), expectedMessage); + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + when(momentService.createMoment(any(MomentRequest.class), any(Member.class))).thenReturn(new MomentIdResponse(1L)); + + // when & then + mockMvc.perform(post("/staccatos") + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(staccatoRequest))) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("스타카토 목록 조회에 성공한다.") + @Test + void readAllStaccato() throws Exception { + // given + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + MomentLocationResponses responses = MomentLocationResponsesFixture.create(); + when(momentService.readAllMoment(any(Member.class))).thenReturn(responses); + String expectedResponse = """ + { + "staccatoLocationResponses": [ + { + "staccatoId": 1, + "latitude": 1, + "longitude": 0 + }, + { + "staccatoId": 2, + "latitude": 1, + "longitude": 0 + }, + { + "staccatoId": 3, + "latitude": 1, + "longitude": 0 + } + ] + } + """; + + // when & then + mockMvc.perform(get("/staccatos") + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()) + .andExpect(content().json(expectedResponse)); + } + + @DisplayName("적합한 경로변수를 통해 스타카토 조회에 성공한다.") + @Test + void readStaccatoById() throws Exception { + // given + long staccatoId = 1L; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + MomentDetailResponse response = MomentDetailResponseFixture.create(staccatoId, LocalDateTime.parse("2021-11-08T11:58:20")); + when(momentService.readMomentById(anyLong(), any(Member.class))).thenReturn(response); + String expectedResponse = """ + { + "staccatoId": 1, + "categoryId": 1, + "categoryTitle": "memoryTitle", + "startAt": "2024-06-30", + "endAt": "2024-07-04", + "staccatoTitle": "staccatoTitle", + "staccatoImageUrls": ["https://example1.com.jpg"], + "visitedAt": "2021-11-08T11:58:20", + "feeling": "happy", + "placeName": "placeName", + "address": "address", + "latitude": 37.7749, + "longitude": -122.4194 + } + """; + + // when & then + mockMvc.perform(get("/staccatos/{staccatoId}", staccatoId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()) + .andExpect(content().json(expectedResponse)); + } + + @DisplayName("적합하지 않은 경로변수의 경우 스타카토 조회에 실패한다.") + @Test + void failReadStaccatoById() throws Exception { + // given + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "스타카토 식별자는 양수로 이루어져야 합니다."); + + // when & then + mockMvc.perform(get("/staccatos/{staccatoId}", 0)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("적합한 경로변수를 통해 스타카토 수정에 성공한다.") + @Test + void updateStaccatoById() throws Exception { + // given + long staccatoId = 1L; + String staccatoRequest = """ + { + "staccatoTitle": "staccatoTitle", + "placeName": "placeName", + "address": "address", + "latitude": 1.0, + "longitude": 1.0, + "visitedAt": "2023-07-01T10:00:00", + "categoryId": 1, + "staccatoImageUrls": [ + "https://example.com/images/namsan_tower.jpg" + ] + } + """; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(put("/staccatos/{staccatoId}", staccatoId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(staccatoRequest)) + .andExpect(status().isOk()); + } + + @DisplayName("추가하려는 사진이 5장이 넘는다면 스타카토 수정에 실패한다.") + @Test + void failUpdateStaccatoByImagesSize() throws Exception { + // given + long staccatoId = 1L; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "사진은 5장까지만 추가할 수 있어요."); + StaccatoRequest staccatoRequest = new StaccatoRequest("staccatoTitle", "placeName", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.now(), 1L, + List.of("https://example.com/images/namsan_tower1.jpg", + "https://example.com/images/namsan_tower2.jpg", + "https://example.com/images/namsan_tower3.jpg", + "https://example.com/images/namsan_tower4.jpg", + "https://example.com/images/namsan_tower5.jpg", + "https://example.com/images/namsan_tower6.jpg")); + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(put("/staccatos/{StaccatoId}", staccatoId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(staccatoRequest))) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("적합하지 않은 경로변수의 경우 스타카토 수정에 실패한다.") + @Test + void failUpdateStaccatoById() throws Exception { + // given + long staccatoId = 0L; + StaccatoRequest staccatoRequest = new StaccatoRequest("staccatoTitle", "placeName", "address", BigDecimal.ONE, BigDecimal.ONE, LocalDateTime.of(2023, 7, 1, 10, 0), 1L, List.of("https://example.com/images/namsan_tower.jpg")); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "스타카토 식별자는 양수로 이루어져야 합니다."); + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(put("/staccatos/{staccatoId}", staccatoId) + .header(HttpHeaders.AUTHORIZATION, "token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(staccatoRequest))) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("스타카토를 삭제한다.") + @Test + void deleteStaccatoById() throws Exception { + // given + long staccatoId = 1L; + when(authService.extractFromToken(anyString())).thenReturn(MemberFixture.create()); + + // when & then + mockMvc.perform(delete("/staccatos/{staccatoId}", staccatoId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()); + } + + @DisplayName("양수가 아닌 id로 스타카토를 삭제할 수 없다.") + @Test + void failDeleteStaccatoById() throws Exception { + // given + long staccatoId = 0L; + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "스타카토 식별자는 양수로 이루어져야 합니다."); + + // when & then + mockMvc.perform(delete("/staccatos/{staccatoId}", staccatoId) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } + + @DisplayName("기분 선택을 하지 않은 경우 기분 수정에 실패한다.") + @Test + void failUpdateStaccatoFeelingById() throws Exception { + // given + long staccatoId = 1L; + FeelingRequest feelingRequest = new FeelingRequest(null); + ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), "기분 값을 입력해주세요."); + + // when & then + mockMvc.perform(post("/staccatos/{staccatoId}/feeling", staccatoId) + .header(HttpHeaders.AUTHORIZATION, "token") + .content(objectMapper.writeValueAsString(feelingRequest))) + .andExpect(status().isBadRequest()) + .andExpect(content().json(objectMapper.writeValueAsString(exceptionResponse))); + } +}