From eedc45467cd60c2e0c2b2b67b4b3138e7acb0e6d Mon Sep 17 00:00:00 2001 From: Guga Date: Tue, 20 Feb 2024 20:14:19 +0900 Subject: [PATCH 01/19] =?UTF-8?q?[BE]=20feat:=20=EC=B6=95=EC=A0=9C,=20?= =?UTF-8?q?=EC=95=84=ED=8B=B0=EC=8A=A4=ED=8A=B8,=20=ED=95=99=EA=B5=90=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20API=20=EA=B5=AC=ED=98=84(#707)=20(#721)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: SocialMedia 생성 * feat: SocialMedia Column 명시 * feat: SocialMedia 생성자 변경 * feat: SocialMedia repository 생성 * feat: School 과 Artist 에 배경이미지 추가 및 School logoUrl 컬럼 추가 * [BE] 잘못된 flyway 스크립트 수정 (#707-hotfix1) (#712) * fix: 컬럼 오타 수정, 카멜 케이스 -> 스네이크 케이스 변경 * refactor: 유니크 인덱스 이름 명확하게 변경 * [BE] feat: 학교 상세 조회 및 학교 별 축제 조회 API 추가 (#707) (#717) * fix: 컬럼 오타 수정, 카멜 케이스 -> 스네이크 케이스 변경 * refactor: 유니크 인덱스 이름 명확하게 변경 * refactor: 기존에 존재하던 School 조회를 Admin으로 변경 * feat: 학교 상세 조회 기능 추가 * feat: 현재 진행 중 및 진행예정인 학교의 축제 조회 기능 추가 * feat: 학교별 과거 축제를 조회할 수 있도록 기능 추가 * feat: 학교별 축제 조회 페이징 기능 추가 * test: 테스트 코드 중복 제거 * refactor: 명확하지 않은 메소드명 변경 * feat: 학교별 축제 조회 API 구현 * refactor: API 명세서에 맞게 response 변경 * refactor: SchoolV1QueryService 통합 테스트 패키지 및 클래스명 변경 * refactor: 패키지 변경 * refactor: 클래스명에 path 들어가지 않도록 변경 * refactor: School API 리소스명 복수형으로 변경 * refactor: SchoolV1QueryDslRepository에서 throw 대신 Optional 넘기도록 변경 * refactor: API가 size가 아닌 Pageable 넘기도록 변경 * refactor: API max size 20 설정 * refactor: 사용하지 않는 DTO 제거 * refactor: admin DTO 클래스명 명확하게 변경 * refactor: 페이징 관련 로직 변경 --------- Co-authored-by: seokjin8678 * [BE] feat: 아티스트 상세 조회 API 추가 (#707) (#716) * feat: 아티스트 정보 반환 기능 추가 * feat: 아티스트 축제 반환 기능 추가 * refactor: 의도치 않은 코드 변경 롤백 * chore: Swagger 설명 추가 * chore: 자잘한 코드 컨벤션 변경 * chore: 자잘한 코드 컨벤션 변경 * refactor: ModelAttribute 대신 RequestParam 사용 * chore: repository 위치 변경 * refactor: 과거 축제 조건 변경 * refactor: condition 의 isPast 를 primitive 로 변경 * feat: FestivalId 와 lastStartDate required false 설정 * test: test 의 객체 생성 메서드를 repository 를 거치도록 변경 * refactor: isPast 를 primitive 값으로 변경 * [BE] feat: 축제 상세 조회 API 추가 (#707) (#714) * feat: 축제 상세 조회 컨트롤러 추가 * feat: StageQueryInfo 추가 - FestivalQueryInfo와 같은 목적의 엔티티 * feat: JPQLTemplate.DEFAULT 추가 - https://github.com/querydsl/querydsl/issues/3428#issuecomment-1337472853 * feat: FestivalDetailV1QueryService 추가 * fix: flyway 스크립트 이름 수정 * refactor: QueryProjection으로 조회하도록 변경 * test: 공연이 없는 축제에 대한 테스트 코드 추가 * chore: FestivalDetailV1QueryServiceIntegrationTest 패키지 위치 이동 * chore: 사용하지 않는 클래스 삭제 * feat: 축제 상세 조회 시 여러 축제 조회에 대한 검증 로직 추가 * chore: 축제 상세 조회 기능 주석 추가 - 의도 설명 * feat: 축제 상세 조회 Swagger 어노테이션 추가 * test: 소셜미디어가 없는 학교 축제에 대한 테스트 추가 * test: FestivalV1Controller 테스트 깨짐 수정 - Collection -> Set 변경 - `@JsonRawValue` 역직렬화 과정 문제 수정 * feat: Stage startDate -> startDateTime으로 변경 * style: 코드 스타일 수정 --------- Co-authored-by: Seokjin Jeon Co-authored-by: Hyun-Seo Oh / 오현서 <100915276+carsago@users.noreply.github.com> --- .../v1/AdminArtistV1Controller.java | 8 +- .../v1/AdminSchoolV1Controller.java | 28 ++ .../ArtistDetailV1QueryService.java | 43 +++ .../application/ArtistV1QueryService.java | 4 +- .../com/festago/artist/domain/Artist.java | 25 +- .../artist/dto/ArtistDetailV1Response.java | 18 ++ .../dto/ArtistFestivalDetailV1Response.java | 20 ++ .../artist/dto/ArtistMediaV1Response.java | 16 ++ .../v1/ArtistDetailV1Controller.java | 64 +++++ .../ArtistDetailV1QueryDslRepository.java | 149 ++++++++++ .../ArtistFestivalSearchCondition.java | 15 + .../artist/repository/ArtistRepository.java | 2 +- .../querydsl/QueryDslRepositorySupport.java | 3 +- .../com/festago/config/QuerydslConfig.java | 3 +- .../FestivalDetailV1QueryService.java | 22 ++ .../dto/FestivalDetailV1Response.java | 21 ++ .../festival/dto/SocialMediaV1Response.java | 16 ++ .../festago/festival/dto/StageV1Response.java | 17 ++ .../presentation/v1/FestivalV1Controller.java | 13 + .../FestivalDetailV1QueryDslRepository.java | 80 ++++++ .../AdminSchoolV1QueryService.java} | 14 +- .../application/v1/SchoolV1QueryService.java | 34 +++ .../com/festago/school/domain/School.java | 29 +- .../festago/school/domain/SchoolRegion.java | 3 +- .../v1/AdminSchoolV1Response.java} | 6 +- .../school/dto/v1/SchoolDetailV1Response.java | 17 ++ .../dto/v1/SchoolFestivalV1Response.java | 19 ++ .../dto/v1/SchoolSocialMediaV1Response.java | 16 ++ .../presentation/v1/SchoolV1Controller.java | 50 ++-- .../AdminSchoolV1QueryDslRepository.java} | 18 +- .../v1/SchoolFestivalV1SearchCondition.java | 13 + .../v1/SchoolV1QueryDslRepository.java | 135 +++++++++ .../festago/socialmedia/domain/OwnerType.java | 8 + .../socialmedia/domain/SocialMedia.java | 94 ++++++ .../socialmedia/domain/SocialMediaType.java | 9 + .../repository/SocialMediaRepository.java | 9 + .../festago/stage/domain/StageQueryInfo.java | 46 +++ .../repository/StageQueryInfoRepository.java | 9 + .../repository/StageRepositoryCustomImpl.java | 16 +- .../db/migration/V13__add_socialmedia.sql | 16 ++ .../V14__add_imageurl_school_artist.sql | 9 + .../migration/V15__add_stage_query_info.sql | 14 + .../steps/SchoolStepDefinitions.java | 9 +- .../v1/AdminArtistV1ControllerTest.java | 1 + .../v1/AdminSchoolV1ControllerTest.java | 75 +++++ .../ArtistDetailV1QueryServiceTest.java | 241 ++++++++++++++++ .../ArtistCommandServiceIntegrationTest.java | 9 +- ...alDetailV1QueryServiceIntegrationTest.java | 174 ++++++++++++ .../v1/FestivalV1ControllerTest.java | 80 ++++++ ...inSchoolV1QueryServiceIntegrationTest.java | 191 +++++++++++++ .../SchoolV1QueryServiceIntegrationTest.java | 267 ++++++++++++------ .../v1/SchoolV1ControllerTest.java | 92 +++--- 52 files changed, 2097 insertions(+), 193 deletions(-) create mode 100644 backend/src/main/java/com/festago/artist/application/ArtistDetailV1QueryService.java create mode 100644 backend/src/main/java/com/festago/artist/dto/ArtistDetailV1Response.java create mode 100644 backend/src/main/java/com/festago/artist/dto/ArtistFestivalDetailV1Response.java create mode 100644 backend/src/main/java/com/festago/artist/dto/ArtistMediaV1Response.java create mode 100644 backend/src/main/java/com/festago/artist/presentation/v1/ArtistDetailV1Controller.java create mode 100644 backend/src/main/java/com/festago/artist/repository/ArtistDetailV1QueryDslRepository.java create mode 100644 backend/src/main/java/com/festago/artist/repository/ArtistFestivalSearchCondition.java create mode 100644 backend/src/main/java/com/festago/festival/application/FestivalDetailV1QueryService.java create mode 100644 backend/src/main/java/com/festago/festival/dto/FestivalDetailV1Response.java create mode 100644 backend/src/main/java/com/festago/festival/dto/SocialMediaV1Response.java create mode 100644 backend/src/main/java/com/festago/festival/dto/StageV1Response.java create mode 100644 backend/src/main/java/com/festago/festival/repository/FestivalDetailV1QueryDslRepository.java rename backend/src/main/java/com/festago/school/application/{SchoolV1QueryService.java => v1/AdminSchoolV1QueryService.java} (59%) create mode 100644 backend/src/main/java/com/festago/school/application/v1/SchoolV1QueryService.java rename backend/src/main/java/com/festago/school/{presentation/v1/dto/SchoolV1Response.java => dto/v1/AdminSchoolV1Response.java} (68%) create mode 100644 backend/src/main/java/com/festago/school/dto/v1/SchoolDetailV1Response.java create mode 100644 backend/src/main/java/com/festago/school/dto/v1/SchoolFestivalV1Response.java create mode 100644 backend/src/main/java/com/festago/school/dto/v1/SchoolSocialMediaV1Response.java rename backend/src/main/java/com/festago/school/repository/{SchoolV1QueryDslRepository.java => v1/AdminSchoolV1QueryDslRepository.java} (84%) create mode 100644 backend/src/main/java/com/festago/school/repository/v1/SchoolFestivalV1SearchCondition.java create mode 100644 backend/src/main/java/com/festago/school/repository/v1/SchoolV1QueryDslRepository.java create mode 100644 backend/src/main/java/com/festago/socialmedia/domain/OwnerType.java create mode 100644 backend/src/main/java/com/festago/socialmedia/domain/SocialMedia.java create mode 100644 backend/src/main/java/com/festago/socialmedia/domain/SocialMediaType.java create mode 100644 backend/src/main/java/com/festago/socialmedia/repository/SocialMediaRepository.java create mode 100644 backend/src/main/java/com/festago/stage/domain/StageQueryInfo.java create mode 100644 backend/src/main/java/com/festago/stage/repository/StageQueryInfoRepository.java create mode 100644 backend/src/main/resources/db/migration/V13__add_socialmedia.sql create mode 100644 backend/src/main/resources/db/migration/V14__add_imageurl_school_artist.sql create mode 100644 backend/src/main/resources/db/migration/V15__add_stage_query_info.sql create mode 100644 backend/src/test/java/com/festago/artist/application/ArtistDetailV1QueryServiceTest.java create mode 100644 backend/src/test/java/com/festago/festival/application/integration/FestivalDetailV1QueryServiceIntegrationTest.java create mode 100644 backend/src/test/java/com/festago/school/application/integration/AdminSchoolV1QueryServiceIntegrationTest.java diff --git a/backend/src/main/java/com/festago/admin/presentation/v1/AdminArtistV1Controller.java b/backend/src/main/java/com/festago/admin/presentation/v1/AdminArtistV1Controller.java index 0dfc58468..0007b6a7d 100644 --- a/backend/src/main/java/com/festago/admin/presentation/v1/AdminArtistV1Controller.java +++ b/backend/src/main/java/com/festago/admin/presentation/v1/AdminArtistV1Controller.java @@ -34,12 +34,14 @@ public class AdminArtistV1Controller { public ResponseEntity create(@RequestBody @Valid ArtistCreateRequest request) { Long artistId = artistCommandService.save(request); return ResponseEntity.created(URI.create("/admin/api/v1/artists/" + artistId)) - .build(); + .build(); } @PutMapping("/{artistId}") - public ResponseEntity update(@RequestBody @Valid ArtistUpdateRequest request, - @PathVariable Long artistId) { + public ResponseEntity update( + @RequestBody @Valid ArtistUpdateRequest request, + @PathVariable Long artistId + ) { artistCommandService.update(request, artistId); return ResponseEntity.ok().build(); } diff --git a/backend/src/main/java/com/festago/admin/presentation/v1/AdminSchoolV1Controller.java b/backend/src/main/java/com/festago/admin/presentation/v1/AdminSchoolV1Controller.java index 78eab5d1b..da3fde281 100644 --- a/backend/src/main/java/com/festago/admin/presentation/v1/AdminSchoolV1Controller.java +++ b/backend/src/main/java/com/festago/admin/presentation/v1/AdminSchoolV1Controller.java @@ -2,19 +2,27 @@ import com.festago.admin.presentation.v1.dto.SchoolV1CreateRequest; import com.festago.admin.presentation.v1.dto.SchoolV1UpdateRequest; +import com.festago.common.aop.ValidPageable; +import com.festago.common.querydsl.SearchCondition; import com.festago.school.application.SchoolCommandService; import com.festago.school.application.SchoolDeleteService; +import com.festago.school.application.v1.AdminSchoolV1QueryService; +import com.festago.school.dto.v1.AdminSchoolV1Response; import io.swagger.v3.oas.annotations.Hidden; import jakarta.validation.Valid; import java.net.URI; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -25,6 +33,7 @@ public class AdminSchoolV1Controller { private final SchoolCommandService schoolCommandService; private final SchoolDeleteService schoolDeleteService; + private final AdminSchoolV1QueryService schoolQueryService; @PostMapping public ResponseEntity createSchool( @@ -53,4 +62,23 @@ public ResponseEntity deleteSchool( return ResponseEntity.noContent() .build(); } + + @GetMapping + @ValidPageable(maxSize = 20) + public ResponseEntity> findAllSchools( + @RequestParam(defaultValue = "") String searchFilter, + @RequestParam(defaultValue = "") String searchKeyword, + Pageable pageable + ) { + return ResponseEntity.ok() + .body(schoolQueryService.findAll(new SearchCondition(searchFilter, searchKeyword, pageable))); + } + + @GetMapping("/{schoolId}") + public ResponseEntity findSchoolById( + @PathVariable Long schoolId + ) { + return ResponseEntity.ok() + .body(schoolQueryService.findById(schoolId)); + } } diff --git a/backend/src/main/java/com/festago/artist/application/ArtistDetailV1QueryService.java b/backend/src/main/java/com/festago/artist/application/ArtistDetailV1QueryService.java new file mode 100644 index 000000000..31c3364d1 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/application/ArtistDetailV1QueryService.java @@ -0,0 +1,43 @@ +package com.festago.artist.application; + +import com.festago.artist.dto.ArtistDetailV1Response; +import com.festago.artist.dto.ArtistFestivalDetailV1Response; +import com.festago.artist.repository.ArtistDetailV1QueryDslRepository; +import com.festago.artist.repository.ArtistFestivalSearchCondition; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import java.time.Clock; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ArtistDetailV1QueryService { + + private final ArtistDetailV1QueryDslRepository artistDetailV1QueryDslRepository; + private final Clock clock; + + public ArtistDetailV1Response findArtistDetail(Long artistId) { + return artistDetailV1QueryDslRepository.findArtistDetail(artistId) + .orElseThrow(() -> new NotFoundException(ErrorCode.ARTIST_NOT_FOUND)); + } + + public Slice findArtistFestivals(Long artistId, Long lastFestivalId, + LocalDate lastStartDate, boolean isPast, + Pageable pageable) { + return artistDetailV1QueryDslRepository.findArtistFestivals(new ArtistFestivalSearchCondition( + artistId, + isPast, + lastFestivalId, + lastStartDate, + pageable, + LocalDate.now(clock) + ) + ); + } +} diff --git a/backend/src/main/java/com/festago/artist/application/ArtistV1QueryService.java b/backend/src/main/java/com/festago/artist/application/ArtistV1QueryService.java index f43ef3b7a..9e2fa7326 100644 --- a/backend/src/main/java/com/festago/artist/application/ArtistV1QueryService.java +++ b/backend/src/main/java/com/festago/artist/application/ArtistV1QueryService.java @@ -22,7 +22,7 @@ public ArtistV1Response findById(Long artistId) { public List findAll() { return artistRepository.findAll().stream() - .map(ArtistV1Response::from) - .toList(); + .map(ArtistV1Response::from) + .toList(); } } diff --git a/backend/src/main/java/com/festago/artist/domain/Artist.java b/backend/src/main/java/com/festago/artist/domain/Artist.java index 9134dae95..13c24915a 100644 --- a/backend/src/main/java/com/festago/artist/domain/Artist.java +++ b/backend/src/main/java/com/festago/artist/domain/Artist.java @@ -11,18 +11,34 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Artist { + private static final String DEFAULT_URL = "https://picsum.photos/536/354"; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + private String name; + private String profileImage; + private String backgroundImageUrl; + + public Artist(Long id, String name, String profileImage, String backgroundImageUrl) { + this.id = id; + this.name = name; + this.profileImage = profileImage; + this.backgroundImageUrl = backgroundImageUrl; + } + public Artist(String name, String profileImage) { - this(null, name, profileImage); + this(null, name, profileImage, DEFAULT_URL); } public Artist(Long id, String name, String profileImage) { - this.id = id; + this(id, name, profileImage, DEFAULT_URL); + } + + public void update(String name, String profileImage) { this.name = name; this.profileImage = profileImage; } @@ -39,8 +55,7 @@ public String getProfileImage() { return profileImage; } - public void update(String name, String profileImage) { - this.name = name; - this.profileImage = profileImage; + public String getBackgroundImageUrl() { + return backgroundImageUrl; } } diff --git a/backend/src/main/java/com/festago/artist/dto/ArtistDetailV1Response.java b/backend/src/main/java/com/festago/artist/dto/ArtistDetailV1Response.java new file mode 100644 index 000000000..5293bd0de --- /dev/null +++ b/backend/src/main/java/com/festago/artist/dto/ArtistDetailV1Response.java @@ -0,0 +1,18 @@ +package com.festago.artist.dto; + +import com.querydsl.core.annotations.QueryProjection; +import java.util.List; + +public record ArtistDetailV1Response( + Long id, + String artistName, + String logoUrl, + String backgroundUrl, + List socialMedias +) { + + @QueryProjection + public ArtistDetailV1Response { + + } +} diff --git a/backend/src/main/java/com/festago/artist/dto/ArtistFestivalDetailV1Response.java b/backend/src/main/java/com/festago/artist/dto/ArtistFestivalDetailV1Response.java new file mode 100644 index 000000000..ef29d57ff --- /dev/null +++ b/backend/src/main/java/com/festago/artist/dto/ArtistFestivalDetailV1Response.java @@ -0,0 +1,20 @@ +package com.festago.artist.dto; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDate; + +public record ArtistFestivalDetailV1Response( + Long id, + String name, + LocalDate startDate, + LocalDate endDate, + String imageUrl, + @JsonRawValue String artists +) { + + @QueryProjection + public ArtistFestivalDetailV1Response { + + } +} diff --git a/backend/src/main/java/com/festago/artist/dto/ArtistMediaV1Response.java b/backend/src/main/java/com/festago/artist/dto/ArtistMediaV1Response.java new file mode 100644 index 000000000..6e79591ae --- /dev/null +++ b/backend/src/main/java/com/festago/artist/dto/ArtistMediaV1Response.java @@ -0,0 +1,16 @@ +package com.festago.artist.dto; + +import com.querydsl.core.annotations.QueryProjection; + +public record ArtistMediaV1Response( + String type, + String name, + String logoUrl, + String url +) { + + @QueryProjection + public ArtistMediaV1Response { + + } +} diff --git a/backend/src/main/java/com/festago/artist/presentation/v1/ArtistDetailV1Controller.java b/backend/src/main/java/com/festago/artist/presentation/v1/ArtistDetailV1Controller.java new file mode 100644 index 000000000..df0ebba0c --- /dev/null +++ b/backend/src/main/java/com/festago/artist/presentation/v1/ArtistDetailV1Controller.java @@ -0,0 +1,64 @@ +package com.festago.artist.presentation.v1; + +import com.festago.artist.application.ArtistDetailV1QueryService; +import com.festago.artist.dto.ArtistDetailV1Response; +import com.festago.artist.dto.ArtistFestivalDetailV1Response; +import com.festago.common.aop.ValidPageable; +import com.festago.common.exception.ValidException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/artists") +@Tag(name = "아티스트 정보 요청 V1") +@RequiredArgsConstructor +public class ArtistDetailV1Controller { + + private final ArtistDetailV1QueryService artistDetailV1QueryService; + + @GetMapping("/{artistId}") + @Operation(description = "아티스트의 정보를 조회한다.") + public ResponseEntity getArtistInfo(@PathVariable Long artistId) { + return ResponseEntity.ok(artistDetailV1QueryService.findArtistDetail(artistId)); + } + + @GetMapping("/{artistId}/festivals") + @Operation(description = "아티스트가 참석한 축제를 조회한다. isPast 값으로 종료 축제 와 진행, 예정 축제를 구분 가능하다", summary = "아티스트 축제 조회") + @ValidPageable(maxSize = 20) + public ResponseEntity> getArtistInfo( + @PathVariable Long artistId, + @RequestParam(required = false) Long lastFestivalId, + @RequestParam(required = false) LocalDate lastStartDate, + @RequestParam(required = false, defaultValue = "false") boolean isPast, + @PageableDefault(size = 10) Pageable pageable + ) { + validate(lastFestivalId, lastStartDate); + return ResponseEntity.ok( + artistDetailV1QueryService.findArtistFestivals(artistId, lastFestivalId, lastStartDate, isPast, pageable)); + } + + private void validate(Long lastFestivalId, LocalDate lastStartDate) { + validateCursor(lastFestivalId, lastStartDate); + } + + private void validateCursor(Long lastFestivalId, LocalDate lastStartDate) { + if (lastFestivalId == null && lastStartDate == null) { + return; + } + if (lastFestivalId != null && lastStartDate != null) { + return; + } + throw new ValidException("festivalId, lastStartDate 두 값 모두 요청하거나 요청하지 않아야합니다."); + } +} diff --git a/backend/src/main/java/com/festago/artist/repository/ArtistDetailV1QueryDslRepository.java b/backend/src/main/java/com/festago/artist/repository/ArtistDetailV1QueryDslRepository.java new file mode 100644 index 000000000..1cabadb4d --- /dev/null +++ b/backend/src/main/java/com/festago/artist/repository/ArtistDetailV1QueryDslRepository.java @@ -0,0 +1,149 @@ +package com.festago.artist.repository; + +import static com.festago.artist.domain.QArtist.artist; +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.festival.domain.QFestivalQueryInfo.festivalQueryInfo; +import static com.festago.socialmedia.domain.QSocialMedia.socialMedia; +import static com.festago.stage.domain.QStage.stage; +import static com.festago.stage.domain.QStageArtist.stageArtist; +import static com.querydsl.core.group.GroupBy.groupBy; +import static com.querydsl.core.group.GroupBy.list; + +import com.festago.artist.domain.Artist; +import com.festago.artist.dto.ArtistDetailV1Response; +import com.festago.artist.dto.ArtistFestivalDetailV1Response; +import com.festago.artist.dto.QArtistDetailV1Response; +import com.festago.artist.dto.QArtistFestivalDetailV1Response; +import com.festago.artist.dto.QArtistMediaV1Response; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.socialmedia.domain.OwnerType; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +@Repository +public class ArtistDetailV1QueryDslRepository extends QueryDslRepositorySupport { + + private static final int NEXT_PAGE_DATA_TEMP_COUNT = 1; + + public ArtistDetailV1QueryDslRepository() { + super(Artist.class); + } + + public Optional findArtistDetail(Long artistId) { + List response = selectFrom(artist) + .leftJoin(socialMedia).on(socialMedia.ownerId.eq(artist.id).and(socialMedia.ownerType.eq(OwnerType.ARTIST))) + .where(artist.id.eq(artistId)) + .transform( + groupBy(artist.id).list( + new QArtistDetailV1Response( + artist.id, + artist.name, + artist.profileImage, + artist.backgroundImageUrl, + list(new QArtistMediaV1Response( + socialMedia.mediaType.stringValue(), + socialMedia.name, + socialMedia.logoUrl, + socialMedia.url + ).skipNulls()) + ) + ) + ); + + if (response.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(response.get(0)); + } + + public Slice findArtistFestivals(ArtistFestivalSearchCondition condition) { + List response = + selectArtistDetailResponse(condition.artistId()) + .where(getDynamicWhere(condition.isPast(), condition.lastStartDate(), condition.lastFestivalId(), + condition.currentTime())) + .limit(condition.pageable().getPageSize() + NEXT_PAGE_DATA_TEMP_COUNT) + .orderBy(getDynamicOrderBy(condition.isPast())) + .fetch(); + + return makeResponse(condition, response); + } + + private SliceImpl makeResponse( + ArtistFestivalSearchCondition condition, List content) { + Pageable pageable = condition.pageable(); + if (content.size() > pageable.getPageSize()) { + removeTemporaryContent(content); + return new SliceImpl<>(content, pageable, true); + } + return new SliceImpl<>(content, condition.pageable(), false); + } + + private void removeTemporaryContent(List content) { + content.remove(content.size() - NEXT_PAGE_DATA_TEMP_COUNT); + } + + private JPAQuery selectArtistDetailResponse(Long artistId) { + return select( + new QArtistFestivalDetailV1Response( + festival.id, + festival.name, + festival.startDate, + festival.endDate, + festival.thumbnail, + festivalQueryInfo.artistInfo)) + .from(stageArtist) + .innerJoin(stage).on(stageArtist.artistId.eq(artistId).and(stage.id.eq(stageArtist.stageId))) + .innerJoin(festival).on(festival.id.eq(stage.festival.id)) + .leftJoin(festivalQueryInfo).on(festival.id.eq(festivalQueryInfo.festivalId)); + } + + private BooleanExpression getDynamicWhere( + Boolean isPast, + LocalDate lastStartDate, + Long lastFestivalId, + LocalDate currentTime + ) { + if (hasCursor(lastStartDate, lastFestivalId)) { + return getCursorBasedWhere(isPast, lastStartDate, lastFestivalId); + } + return getDefaultWhere(isPast, currentTime); + } + + private boolean hasCursor(LocalDate lastStartDate, Long lastFestivalId) { + return lastStartDate != null && lastFestivalId != null; + } + + private BooleanExpression getCursorBasedWhere(boolean isPast, LocalDate lastStartDate, Long lastFestivalId) { + if (isPast) { + return festival.startDate.lt(lastStartDate) + .or(festival.startDate.eq(lastStartDate) + .and(festival.id.gt(lastFestivalId))); + } + return festival.startDate.gt(lastStartDate) + .or(festival.startDate.eq(lastStartDate) + .and(festival.id.gt(lastFestivalId))); + } + + private BooleanExpression getDefaultWhere(boolean isPast, LocalDate currentTime) { + if (isPast) { + return festival.endDate.lt(currentTime); + } + return festival.endDate.goe(currentTime); + } + + private OrderSpecifier[] getDynamicOrderBy(Boolean isPast) { + if (isPast) { + return new OrderSpecifier[]{festival.endDate.desc()}; + } + return new OrderSpecifier[]{festival.startDate.asc(), festival.id.asc()}; + } +} diff --git a/backend/src/main/java/com/festago/artist/repository/ArtistFestivalSearchCondition.java b/backend/src/main/java/com/festago/artist/repository/ArtistFestivalSearchCondition.java new file mode 100644 index 000000000..7a27c464c --- /dev/null +++ b/backend/src/main/java/com/festago/artist/repository/ArtistFestivalSearchCondition.java @@ -0,0 +1,15 @@ +package com.festago.artist.repository; + +import java.time.LocalDate; +import org.springframework.data.domain.Pageable; + +public record ArtistFestivalSearchCondition( + Long artistId, + boolean isPast, + Long lastFestivalId, + LocalDate lastStartDate, + Pageable pageable, + LocalDate currentTime +) { + +} diff --git a/backend/src/main/java/com/festago/artist/repository/ArtistRepository.java b/backend/src/main/java/com/festago/artist/repository/ArtistRepository.java index aec2d73f7..8c6f9ccee 100644 --- a/backend/src/main/java/com/festago/artist/repository/ArtistRepository.java +++ b/backend/src/main/java/com/festago/artist/repository/ArtistRepository.java @@ -11,7 +11,7 @@ public interface ArtistRepository extends Repository { default Artist getOrThrow(Long artistId) { return findById(artistId) - .orElseThrow(() -> new NotFoundException(ErrorCode.ARTIST_NOT_FOUND)); + .orElseThrow(() -> new NotFoundException(ErrorCode.ARTIST_NOT_FOUND)); } Artist save(Artist artist); diff --git a/backend/src/main/java/com/festago/common/querydsl/QueryDslRepositorySupport.java b/backend/src/main/java/com/festago/common/querydsl/QueryDslRepositorySupport.java index 034d20424..67e47ae7f 100644 --- a/backend/src/main/java/com/festago/common/querydsl/QueryDslRepositorySupport.java +++ b/backend/src/main/java/com/festago/common/querydsl/QueryDslRepositorySupport.java @@ -3,6 +3,7 @@ import com.querydsl.core.types.EntityPath; import com.querydsl.core.types.Expression; import com.querydsl.core.types.dsl.PathBuilder; +import com.querydsl.jpa.JPQLTemplates; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; @@ -37,7 +38,7 @@ protected void setQueryFactory(EntityManager entityManager) { SimpleEntityPathResolver resolver = SimpleEntityPathResolver.INSTANCE; EntityPath path = resolver.createPath(entityInformation.getJavaType()); this.querydsl = new Querydsl(entityManager, new PathBuilder<>(path.getType(), path.getMetadata())); - this.queryFactory = new JPAQueryFactory(entityManager); + this.queryFactory = new JPAQueryFactory(JPQLTemplates.DEFAULT, entityManager); } protected JPAQuery select(Expression expr) { diff --git a/backend/src/main/java/com/festago/config/QuerydslConfig.java b/backend/src/main/java/com/festago/config/QuerydslConfig.java index 5f2fd66c5..8dabf4a66 100644 --- a/backend/src/main/java/com/festago/config/QuerydslConfig.java +++ b/backend/src/main/java/com/festago/config/QuerydslConfig.java @@ -1,5 +1,6 @@ package com.festago.config; +import com.querydsl.jpa.JPQLTemplates; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; @@ -14,6 +15,6 @@ public class QuerydslConfig { @Bean public JPAQueryFactory jpaQueryFactory() { - return new JPAQueryFactory(entityManager); + return new JPAQueryFactory(JPQLTemplates.DEFAULT, entityManager); } } diff --git a/backend/src/main/java/com/festago/festival/application/FestivalDetailV1QueryService.java b/backend/src/main/java/com/festago/festival/application/FestivalDetailV1QueryService.java new file mode 100644 index 000000000..73c82ac0e --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/FestivalDetailV1QueryService.java @@ -0,0 +1,22 @@ +package com.festago.festival.application; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.festival.dto.FestivalDetailV1Response; +import com.festago.festival.repository.FestivalDetailV1QueryDslRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class FestivalDetailV1QueryService { + + private final FestivalDetailV1QueryDslRepository festivalDetailV1QueryDslRepository; + + public FestivalDetailV1Response findFestivalDetail(Long festivalId) { + return festivalDetailV1QueryDslRepository.findFestivalDetail(festivalId) + .orElseThrow(() -> new NotFoundException(ErrorCode.FESTIVAL_NOT_FOUND)); + } +} diff --git a/backend/src/main/java/com/festago/festival/dto/FestivalDetailV1Response.java b/backend/src/main/java/com/festago/festival/dto/FestivalDetailV1Response.java new file mode 100644 index 000000000..de16ba1f3 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/FestivalDetailV1Response.java @@ -0,0 +1,21 @@ +package com.festago.festival.dto; + +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDate; +import java.util.Set; + +public record FestivalDetailV1Response( + Long id, + String name, + SchoolV1Response school, + LocalDate startDate, + LocalDate endDate, + String imageUrl, + Set socialMedias, + Set stages +) { + + @QueryProjection + public FestivalDetailV1Response { + } +} diff --git a/backend/src/main/java/com/festago/festival/dto/SocialMediaV1Response.java b/backend/src/main/java/com/festago/festival/dto/SocialMediaV1Response.java new file mode 100644 index 000000000..f0c3b57fd --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/SocialMediaV1Response.java @@ -0,0 +1,16 @@ +package com.festago.festival.dto; + +import com.festago.socialmedia.domain.SocialMediaType; +import com.querydsl.core.annotations.QueryProjection; + +public record SocialMediaV1Response( + SocialMediaType type, + String name, + String logoUrl, + String url +) { + + @QueryProjection + public SocialMediaV1Response { + } +} diff --git a/backend/src/main/java/com/festago/festival/dto/StageV1Response.java b/backend/src/main/java/com/festago/festival/dto/StageV1Response.java new file mode 100644 index 000000000..a0c73c4e7 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/StageV1Response.java @@ -0,0 +1,17 @@ +package com.festago.festival.dto; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDateTime; + +public record StageV1Response( + Long id, + LocalDateTime startDateTime, + @JsonRawValue + String artists +) { + + @QueryProjection + public StageV1Response { + } +} diff --git a/backend/src/main/java/com/festago/festival/presentation/v1/FestivalV1Controller.java b/backend/src/main/java/com/festago/festival/presentation/v1/FestivalV1Controller.java index 7b18fb31d..6f6a8bde2 100644 --- a/backend/src/main/java/com/festago/festival/presentation/v1/FestivalV1Controller.java +++ b/backend/src/main/java/com/festago/festival/presentation/v1/FestivalV1Controller.java @@ -2,7 +2,9 @@ import com.festago.common.aop.ValidPageable; import com.festago.common.exception.ValidException; +import com.festago.festival.application.FestivalDetailV1QueryService; import com.festago.festival.application.FestivalV1QueryService; +import com.festago.festival.dto.FestivalDetailV1Response; import com.festago.festival.dto.FestivalV1QueryRequest; import com.festago.festival.dto.FestivalV1Response; import com.festago.festival.repository.FestivalFilter; @@ -16,6 +18,7 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -27,6 +30,7 @@ public class FestivalV1Controller { private final FestivalV1QueryService festivalV1QueryService; + private final FestivalDetailV1QueryService festivalDetailV1QueryService; @GetMapping @ValidPageable(maxSize = 20) @@ -53,4 +57,13 @@ private void validateCursor(Long lastFestivalId, LocalDate lastStartDate) { } throw new ValidException("festivalId, lastStartDate 두 값 모두 요청하거나 요청하지 않아야합니다."); } + + @GetMapping("/{festivalId}") + @Operation(description = "특정 축제의 상세 정보를 조회한다.", summary = "특정 축제 상세 조회") + public ResponseEntity findFestivalDetail( + @PathVariable Long festivalId + ) { + var response = festivalDetailV1QueryService.findFestivalDetail(festivalId); + return ResponseEntity.ok(response); + } } diff --git a/backend/src/main/java/com/festago/festival/repository/FestivalDetailV1QueryDslRepository.java b/backend/src/main/java/com/festago/festival/repository/FestivalDetailV1QueryDslRepository.java new file mode 100644 index 000000000..d94d37110 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/repository/FestivalDetailV1QueryDslRepository.java @@ -0,0 +1,80 @@ +package com.festago.festival.repository; + +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.school.domain.QSchool.school; +import static com.festago.socialmedia.domain.QSocialMedia.socialMedia; +import static com.festago.stage.domain.QStage.stage; +import static com.festago.stage.domain.QStageQueryInfo.stageQueryInfo; +import static com.querydsl.core.group.GroupBy.groupBy; +import static com.querydsl.core.group.GroupBy.sortedSet; + +import com.festago.common.exception.UnexpectedException; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.festival.domain.Festival; +import com.festago.festival.dto.FestivalDetailV1Response; +import com.festago.festival.dto.QFestivalDetailV1Response; +import com.festago.festival.dto.QSchoolV1Response; +import com.festago.festival.dto.QSocialMediaV1Response; +import com.festago.festival.dto.QStageV1Response; +import com.festago.festival.dto.SocialMediaV1Response; +import com.festago.festival.dto.StageV1Response; +import com.festago.socialmedia.domain.OwnerType; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +@Repository +public class FestivalDetailV1QueryDslRepository extends QueryDslRepositorySupport { + + public FestivalDetailV1QueryDslRepository() { + super(Festival.class); + } + + /** + * 축제에 3개의 공연과 2개의 소셜미디어가 있을 때 조회되는 row 수는 다음과 같다.
1(축제) * 3(공연) * 2(소셜미디어) = 6 row
따라서 중복된 row가 생기게 + * 되는데, 이를 해결하기 위해 set을 사용했고, 항상 일관되게 정렬된 데이터를 조회하기 위해 sortedSet을 사용했음
+ */ + public Optional findFestivalDetail(Long festivalId) { + List response = selectFrom(festival) + .innerJoin(school).on(school.id.eq(festival.school.id)) + .leftJoin(socialMedia).on(socialMedia.ownerId.eq(school.id).and(socialMedia.ownerType.eq(OwnerType.SCHOOL))) + .leftJoin(stage).on(stage.festival.id.eq(festival.id)) + .leftJoin(stageQueryInfo).on(stageQueryInfo.stageId.eq(stage.id)) + .where(festival.id.eq(festivalId)) + .transform( + groupBy(festival.id).list( + new QFestivalDetailV1Response( + festival.id, + festival.name, + new QSchoolV1Response( + school.id, + school.name + ), + festival.startDate, + festival.endDate, + festival.thumbnail, + sortedSet(new QSocialMediaV1Response( + socialMedia.mediaType, + socialMedia.name, + socialMedia.logoUrl, + socialMedia.url + ).skipNulls(), Comparator.comparing(SocialMediaV1Response::name)), + sortedSet(new QStageV1Response( + stage.id, + stage.startTime, + stageQueryInfo.artistInfo + ).skipNulls(), Comparator.comparingLong(StageV1Response::id)) + ) + ) + ); + if (response.isEmpty()) { + return Optional.empty(); + } + // PK로 조회하기에 발생할 일이 없는 예외지만, 혹시 모를 상황을 방지하기 위함 + if (response.size() >= 2) { + throw new UnexpectedException("축제 상세 조회에서 2개 이상의 축제가 조회되었습니다."); + } + return Optional.of(response.get(0)); + } +} diff --git a/backend/src/main/java/com/festago/school/application/SchoolV1QueryService.java b/backend/src/main/java/com/festago/school/application/v1/AdminSchoolV1QueryService.java similarity index 59% rename from backend/src/main/java/com/festago/school/application/SchoolV1QueryService.java rename to backend/src/main/java/com/festago/school/application/v1/AdminSchoolV1QueryService.java index 9fbbcbfb1..f712fe3b0 100644 --- a/backend/src/main/java/com/festago/school/application/SchoolV1QueryService.java +++ b/backend/src/main/java/com/festago/school/application/v1/AdminSchoolV1QueryService.java @@ -1,10 +1,10 @@ -package com.festago.school.application; +package com.festago.school.application.v1; import com.festago.common.exception.ErrorCode; import com.festago.common.exception.NotFoundException; import com.festago.common.querydsl.SearchCondition; -import com.festago.school.presentation.v1.dto.SchoolV1Response; -import com.festago.school.repository.SchoolV1QueryDslRepository; +import com.festago.school.dto.v1.AdminSchoolV1Response; +import com.festago.school.repository.v1.AdminSchoolV1QueryDslRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; @@ -13,15 +13,15 @@ @Service @Transactional(readOnly = true) @RequiredArgsConstructor -public class SchoolV1QueryService { +public class AdminSchoolV1QueryService { - private final SchoolV1QueryDslRepository schoolQueryDslRepository; + private final AdminSchoolV1QueryDslRepository schoolQueryDslRepository; - public Page findAll(SearchCondition searchCondition) { + public Page findAll(SearchCondition searchCondition) { return schoolQueryDslRepository.findAll(searchCondition); } - public SchoolV1Response findById(Long schoolId) { + public AdminSchoolV1Response findById(Long schoolId) { return schoolQueryDslRepository.findById(schoolId) .orElseThrow(() -> new NotFoundException(ErrorCode.SCHOOL_NOT_FOUND)); } diff --git a/backend/src/main/java/com/festago/school/application/v1/SchoolV1QueryService.java b/backend/src/main/java/com/festago/school/application/v1/SchoolV1QueryService.java new file mode 100644 index 000000000..3b6b93c3b --- /dev/null +++ b/backend/src/main/java/com/festago/school/application/v1/SchoolV1QueryService.java @@ -0,0 +1,34 @@ +package com.festago.school.application.v1; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.school.dto.v1.SchoolDetailV1Response; +import com.festago.school.dto.v1.SchoolFestivalV1Response; +import com.festago.school.repository.v1.SchoolFestivalV1SearchCondition; +import com.festago.school.repository.v1.SchoolV1QueryDslRepository; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class SchoolV1QueryService { + + private final SchoolV1QueryDslRepository schoolV1QueryDslRepository; + + public SchoolDetailV1Response findDetailById(Long schoolId) { + return schoolV1QueryDslRepository.findDetailById(schoolId) + .orElseThrow(() -> new NotFoundException(ErrorCode.SCHOOL_NOT_FOUND)); + } + + public Slice findFestivalsBySchoolId( + Long schoolId, + LocalDate today, + SchoolFestivalV1SearchCondition searchCondition + ) { + return schoolV1QueryDslRepository.findFestivalsBySchoolId(schoolId, today, searchCondition); + } +} diff --git a/backend/src/main/java/com/festago/school/domain/School.java b/backend/src/main/java/com/festago/school/domain/School.java index d5801316b..01b552038 100644 --- a/backend/src/main/java/com/festago/school/domain/School.java +++ b/backend/src/main/java/com/festago/school/domain/School.java @@ -18,6 +18,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class School extends BaseTimeEntity { + private static final String DEFAULT_URL = "https://picsum.photos/536/354"; private static final int MAX_DOMAIN_LENGTH = 50; private static final int MAX_NAME_LENGTH = 255; @@ -35,21 +36,31 @@ public class School extends BaseTimeEntity { @Column(unique = true) private String name; + private String logoUrl; + + private String backgroundUrl; + @Enumerated(EnumType.STRING) private SchoolRegion region; - public School(String domain, String name, SchoolRegion region) { - this(null, domain, name, region); - } - - public School(Long id, String domain, String name, SchoolRegion region) { + public School(Long id, String domain, String name, String logoUrl, String backgroundUrl, SchoolRegion region) { validate(domain, name, region); this.id = id; this.domain = domain; this.name = name; + this.logoUrl = logoUrl; + this.backgroundUrl = backgroundUrl; this.region = region; } + public School(String domain, String name, SchoolRegion region) { + this(null, domain, name, DEFAULT_URL, DEFAULT_URL, region); + } + + public School(Long id, String domain, String name, SchoolRegion region) { + this(id, domain, name, DEFAULT_URL, DEFAULT_URL, region); + } + private void validate(String domain, String name, SchoolRegion region) { validateDomain(domain); validateName(name); @@ -99,6 +110,14 @@ public String getName() { return name; } + public String getLogoUrl() { + return logoUrl; + } + + public String getBackgroundUrl() { + return backgroundUrl; + } + public SchoolRegion getRegion() { return region; } diff --git a/backend/src/main/java/com/festago/school/domain/SchoolRegion.java b/backend/src/main/java/com/festago/school/domain/SchoolRegion.java index a22898007..86b5ab1c8 100644 --- a/backend/src/main/java/com/festago/school/domain/SchoolRegion.java +++ b/backend/src/main/java/com/festago/school/domain/SchoolRegion.java @@ -4,5 +4,6 @@ public enum SchoolRegion { 서울, 부산, 대구, - ANY; + ANY, + ; } diff --git a/backend/src/main/java/com/festago/school/presentation/v1/dto/SchoolV1Response.java b/backend/src/main/java/com/festago/school/dto/v1/AdminSchoolV1Response.java similarity index 68% rename from backend/src/main/java/com/festago/school/presentation/v1/dto/SchoolV1Response.java rename to backend/src/main/java/com/festago/school/dto/v1/AdminSchoolV1Response.java index c16504134..0ec743009 100644 --- a/backend/src/main/java/com/festago/school/presentation/v1/dto/SchoolV1Response.java +++ b/backend/src/main/java/com/festago/school/dto/v1/AdminSchoolV1Response.java @@ -1,9 +1,9 @@ -package com.festago.school.presentation.v1.dto; +package com.festago.school.dto.v1; import com.festago.school.domain.SchoolRegion; import com.querydsl.core.annotations.QueryProjection; -public record SchoolV1Response( +public record AdminSchoolV1Response( Long id, String domain, String name, @@ -11,7 +11,7 @@ public record SchoolV1Response( ) { @QueryProjection - public SchoolV1Response { + public AdminSchoolV1Response { // for QueryProjection } } diff --git a/backend/src/main/java/com/festago/school/dto/v1/SchoolDetailV1Response.java b/backend/src/main/java/com/festago/school/dto/v1/SchoolDetailV1Response.java new file mode 100644 index 000000000..900825d49 --- /dev/null +++ b/backend/src/main/java/com/festago/school/dto/v1/SchoolDetailV1Response.java @@ -0,0 +1,17 @@ +package com.festago.school.dto.v1; + +import com.querydsl.core.annotations.QueryProjection; +import java.util.List; + +public record SchoolDetailV1Response( + Long id, + String schoolName, + String logoUrl, + String backgroundUrl, + List socialMedias +) { + + @QueryProjection + public SchoolDetailV1Response { + } +} diff --git a/backend/src/main/java/com/festago/school/dto/v1/SchoolFestivalV1Response.java b/backend/src/main/java/com/festago/school/dto/v1/SchoolFestivalV1Response.java new file mode 100644 index 000000000..71a3876bb --- /dev/null +++ b/backend/src/main/java/com/festago/school/dto/v1/SchoolFestivalV1Response.java @@ -0,0 +1,19 @@ +package com.festago.school.dto.v1; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDate; + +public record SchoolFestivalV1Response( + Long id, + String name, + LocalDate startDate, + LocalDate endDate, + String imageUrl, + @JsonRawValue String artists +) { + + @QueryProjection + public SchoolFestivalV1Response { + } +} diff --git a/backend/src/main/java/com/festago/school/dto/v1/SchoolSocialMediaV1Response.java b/backend/src/main/java/com/festago/school/dto/v1/SchoolSocialMediaV1Response.java new file mode 100644 index 000000000..087e3ca3a --- /dev/null +++ b/backend/src/main/java/com/festago/school/dto/v1/SchoolSocialMediaV1Response.java @@ -0,0 +1,16 @@ +package com.festago.school.dto.v1; + +import com.festago.socialmedia.domain.SocialMediaType; +import com.querydsl.core.annotations.QueryProjection; + +public record SchoolSocialMediaV1Response( + SocialMediaType type, + String name, + String logoUrl, + String url +) { + + @QueryProjection + public SchoolSocialMediaV1Response { + } +} diff --git a/backend/src/main/java/com/festago/school/presentation/v1/SchoolV1Controller.java b/backend/src/main/java/com/festago/school/presentation/v1/SchoolV1Controller.java index d8a89a5ef..ed7a791d1 100644 --- a/backend/src/main/java/com/festago/school/presentation/v1/SchoolV1Controller.java +++ b/backend/src/main/java/com/festago/school/presentation/v1/SchoolV1Controller.java @@ -1,13 +1,17 @@ package com.festago.school.presentation.v1; import com.festago.common.aop.ValidPageable; -import com.festago.common.querydsl.SearchCondition; -import com.festago.school.application.SchoolV1QueryService; -import com.festago.school.presentation.v1.dto.SchoolV1Response; +import com.festago.school.application.v1.SchoolV1QueryService; +import com.festago.school.dto.v1.SchoolDetailV1Response; +import com.festago.school.dto.v1.SchoolFestivalV1Response; +import com.festago.school.repository.v1.SchoolFestivalV1SearchCondition; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -21,24 +25,32 @@ @RequiredArgsConstructor public class SchoolV1Controller { - private final SchoolV1QueryService schoolQueryService; + private final SchoolV1QueryService schoolV1QueryService; - @GetMapping - @ValidPageable(maxSize = 50) - public ResponseEntity> findAllSchools( - @RequestParam(defaultValue = "") String searchFilter, - @RequestParam(defaultValue = "") String searchKeyword, - Pageable pageable - ) { - return ResponseEntity.ok() - .body(schoolQueryService.findAll(new SearchCondition(searchFilter, searchKeyword, pageable))); + @GetMapping("/{schoolId}") + @Operation(description = "학교와 해당하는 소셜미디어 정보를 함께 조회한다.", summary = "학교 상세 조회") + public ResponseEntity findDetailId(@PathVariable Long schoolId) { + SchoolDetailV1Response response = schoolV1QueryService.findDetailById(schoolId); + return ResponseEntity.ok(response); } - @GetMapping("/{schoolId}") - public ResponseEntity findSchoolById( - @PathVariable Long schoolId + @GetMapping("/{schoolId}/festivals") + @ValidPageable(maxSize = 20) + @Operation(description = "해당 학교의 축제들을 페이징하여 조회한다.", summary = "학교 상세 조회") + public ResponseEntity> findFestivalsBySchoolId( + @PathVariable Long schoolId, + @RequestParam(required = false) Long lastFestivalId, + @RequestParam(required = false) LocalDate lastStartDate, + @RequestParam(defaultValue = "false") Boolean isPast, + @PageableDefault(size = 10) Pageable pageable ) { - return ResponseEntity.ok() - .body(schoolQueryService.findById(schoolId)); + LocalDate today = LocalDate.now(); + var searchCondition = new SchoolFestivalV1SearchCondition(lastFestivalId, lastStartDate, isPast, pageable); + Slice response = schoolV1QueryService.findFestivalsBySchoolId( + schoolId, + today, + searchCondition + ); + return ResponseEntity.ok(response); } } diff --git a/backend/src/main/java/com/festago/school/repository/SchoolV1QueryDslRepository.java b/backend/src/main/java/com/festago/school/repository/v1/AdminSchoolV1QueryDslRepository.java similarity index 84% rename from backend/src/main/java/com/festago/school/repository/SchoolV1QueryDslRepository.java rename to backend/src/main/java/com/festago/school/repository/v1/AdminSchoolV1QueryDslRepository.java index acb992f0d..041d92aa3 100644 --- a/backend/src/main/java/com/festago/school/repository/SchoolV1QueryDslRepository.java +++ b/backend/src/main/java/com/festago/school/repository/v1/AdminSchoolV1QueryDslRepository.java @@ -1,12 +1,12 @@ -package com.festago.school.repository; +package com.festago.school.repository.v1; import static com.festago.school.domain.QSchool.school; import com.festago.common.querydsl.QueryDslRepositorySupport; import com.festago.common.querydsl.SearchCondition; import com.festago.school.domain.School; -import com.festago.school.presentation.v1.dto.QSchoolV1Response; -import com.festago.school.presentation.v1.dto.SchoolV1Response; +import com.festago.school.dto.v1.AdminSchoolV1Response; +import com.festago.school.dto.v1.QAdminSchoolV1Response; import com.querydsl.core.types.dsl.BooleanExpression; import java.util.Optional; import org.springframework.data.domain.Page; @@ -15,19 +15,19 @@ import org.springframework.util.StringUtils; @Repository -public class SchoolV1QueryDslRepository extends QueryDslRepositorySupport { +public class AdminSchoolV1QueryDslRepository extends QueryDslRepositorySupport { - protected SchoolV1QueryDslRepository() { + protected AdminSchoolV1QueryDslRepository() { super(School.class); } - public Page findAll(SearchCondition searchCondition) { + public Page findAll(SearchCondition searchCondition) { Pageable pageable = searchCondition.pageable(); String searchFilter = searchCondition.searchFilter(); String searchKeyword = searchCondition.searchKeyword(); return applyPagination(pageable, queryFactory -> queryFactory.select( - new QSchoolV1Response( + new QAdminSchoolV1Response( school.id, school.domain, school.name, @@ -80,9 +80,9 @@ private BooleanExpression eqRegion(String region) { return null; } - public Optional findById(Long schoolId) { + public Optional findById(Long schoolId) { return fetchOne(queryFactory -> queryFactory.select( - new QSchoolV1Response( + new QAdminSchoolV1Response( school.id, school.domain, school.name, diff --git a/backend/src/main/java/com/festago/school/repository/v1/SchoolFestivalV1SearchCondition.java b/backend/src/main/java/com/festago/school/repository/v1/SchoolFestivalV1SearchCondition.java new file mode 100644 index 000000000..59b00caf4 --- /dev/null +++ b/backend/src/main/java/com/festago/school/repository/v1/SchoolFestivalV1SearchCondition.java @@ -0,0 +1,13 @@ +package com.festago.school.repository.v1; + +import java.time.LocalDate; +import org.springframework.data.domain.Pageable; + +public record SchoolFestivalV1SearchCondition( + Long lastFestivalId, + LocalDate lastStartDate, + Boolean isPast, + Pageable pageable +) { + +} diff --git a/backend/src/main/java/com/festago/school/repository/v1/SchoolV1QueryDslRepository.java b/backend/src/main/java/com/festago/school/repository/v1/SchoolV1QueryDslRepository.java new file mode 100644 index 000000000..25bc7ff78 --- /dev/null +++ b/backend/src/main/java/com/festago/school/repository/v1/SchoolV1QueryDslRepository.java @@ -0,0 +1,135 @@ +package com.festago.school.repository.v1; + +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.festival.domain.QFestivalQueryInfo.festivalQueryInfo; +import static com.festago.school.domain.QSchool.school; +import static com.festago.socialmedia.domain.QSocialMedia.socialMedia; +import static com.querydsl.core.group.GroupBy.groupBy; +import static com.querydsl.core.group.GroupBy.list; + +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.school.domain.School; +import com.festago.school.dto.v1.QSchoolDetailV1Response; +import com.festago.school.dto.v1.QSchoolFestivalV1Response; +import com.festago.school.dto.v1.QSchoolSocialMediaV1Response; +import com.festago.school.dto.v1.SchoolDetailV1Response; +import com.festago.school.dto.v1.SchoolFestivalV1Response; +import com.festago.socialmedia.domain.OwnerType; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +@Repository +public class SchoolV1QueryDslRepository extends QueryDslRepositorySupport { + + private static final int NEXT_PAGE_CHECK_NUMBER = 1; + + public SchoolV1QueryDslRepository() { + super(School.class); + } + + public Optional findDetailById(Long schoolId) { + List response = selectFrom(school) + .where(school.id.eq(schoolId)) + .leftJoin(socialMedia).on(socialMedia.ownerId.eq(schoolId) + .and(socialMedia.ownerType.eq(OwnerType.SCHOOL))) + .transform( + groupBy(school.id).list( + new QSchoolDetailV1Response(school.id, school.name, school.logoUrl, school.backgroundUrl, + list( + new QSchoolSocialMediaV1Response( + socialMedia.mediaType, + socialMedia.name, + socialMedia.logoUrl, + socialMedia.url + ).skipNulls() + ) + ) + ) + ); + + if (response.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(response.get(0)); + } + + public Slice findFestivalsBySchoolId(Long schoolId, LocalDate today, + SchoolFestivalV1SearchCondition searchCondition) { + List result = + select(new QSchoolFestivalV1Response(festival.id, + festival.name, + festival.startDate, + festival.endDate, + festival.thumbnail, + festivalQueryInfo.artistInfo + ) + ) + .from(festival) + .leftJoin(festivalQueryInfo).on(festivalQueryInfo.festivalId.eq(festival.id)) + .where(festival.school.id.eq(schoolId), + addPhaseOption(searchCondition.isPast(), today), + addPagingOption(searchCondition.lastFestivalId(), searchCondition.lastStartDate(), + searchCondition.isPast())) + .orderBy(addOrderOption(searchCondition.isPast())) + .limit(searchCondition.pageable().getPageSize() + NEXT_PAGE_CHECK_NUMBER) + .fetch(); + + return createResponse(searchCondition.pageable(), result); + } + + private BooleanExpression addPhaseOption(boolean isPast, LocalDate today) { + if (isPast) { + return festival.endDate.lt(today); + } + + return festival.endDate.goe(today); + } + + private BooleanExpression addPagingOption(Long lastFestivalId, LocalDate lastStartDate, boolean isPast) { + if (isNotFirstPage(lastFestivalId, lastStartDate)) { + if (isPast) { + return festival.startDate.lt(lastStartDate) + .or(festival.startDate.eq(lastStartDate) + .and(festival.id.gt(lastFestivalId))); + } + return festival.startDate.gt(lastStartDate) + .or(festival.startDate.eq(lastStartDate) + .and(festival.id.gt(lastFestivalId))); + } + + return null; + } + + private boolean isNotFirstPage(Long lastFestivalId, LocalDate lastStartDate) { + return lastFestivalId != null && lastStartDate != null; + } + + private OrderSpecifier addOrderOption(boolean isPast) { + if (isPast) { + return festival.endDate.desc(); + } + + return festival.startDate.asc(); + } + + private Slice createResponse( + Pageable pageable, + List content + ) { + boolean hasNext = true; + if (content.size() > pageable.getPageSize()) { + content.remove(content.size() - 1); + hasNext = false; + } + + return new SliceImpl(content, pageable, hasNext); + } +} diff --git a/backend/src/main/java/com/festago/socialmedia/domain/OwnerType.java b/backend/src/main/java/com/festago/socialmedia/domain/OwnerType.java new file mode 100644 index 000000000..00e70ea55 --- /dev/null +++ b/backend/src/main/java/com/festago/socialmedia/domain/OwnerType.java @@ -0,0 +1,8 @@ +package com.festago.socialmedia.domain; + +public enum OwnerType { + + ARTIST, + SCHOOL, + ; +} diff --git a/backend/src/main/java/com/festago/socialmedia/domain/SocialMedia.java b/backend/src/main/java/com/festago/socialmedia/domain/SocialMedia.java new file mode 100644 index 000000000..f7a47e4a2 --- /dev/null +++ b/backend/src/main/java/com/festago/socialmedia/domain/SocialMedia.java @@ -0,0 +1,94 @@ +package com.festago.socialmedia.domain; + +import com.festago.common.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "social_media", + uniqueConstraints = { + @UniqueConstraint( + name = "UNIQUE_OWNERTYPE_OWNERID", + columnNames = {"owner_id", "owner_type", "media_type"} + ) + } +) +public class SocialMedia extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "owner_id") + private Long ownerId; + + @Enumerated(EnumType.STRING) + @Column(name = "owner_type") + private OwnerType ownerType; + + @Enumerated(EnumType.STRING) + @Column(name = "media_type") + private SocialMediaType mediaType; + + private String name; + + private String logoUrl; + + private String url; + + public SocialMedia(Long id, Long ownerId, OwnerType ownerType, SocialMediaType mediaType, String name, + String logoUrl, + String url) { + this.id = id; + this.ownerId = ownerId; + this.ownerType = ownerType; + this.mediaType = mediaType; + this.name = name; + this.logoUrl = logoUrl; + this.url = url; + } + + public SocialMedia(Long ownerId, OwnerType ownerType, SocialMediaType mediaType, String name, String logoUrl, + String url) { + this(null, ownerId, ownerType, mediaType, name, logoUrl, url); + } + + public Long getId() { + return id; + } + + public Long getOwnerId() { + return ownerId; + } + + public OwnerType getOwnerType() { + return ownerType; + } + + public SocialMediaType getMediaType() { + return mediaType; + } + + public String getName() { + return name; + } + + public String getLogoUrl() { + return logoUrl; + } + + public String getUrl() { + return url; + } +} diff --git a/backend/src/main/java/com/festago/socialmedia/domain/SocialMediaType.java b/backend/src/main/java/com/festago/socialmedia/domain/SocialMediaType.java new file mode 100644 index 000000000..dc15eb907 --- /dev/null +++ b/backend/src/main/java/com/festago/socialmedia/domain/SocialMediaType.java @@ -0,0 +1,9 @@ +package com.festago.socialmedia.domain; + +public enum SocialMediaType { + YOUTUBE, + X, + INSTAGRAM, + FACEBOOK, + ; +} diff --git a/backend/src/main/java/com/festago/socialmedia/repository/SocialMediaRepository.java b/backend/src/main/java/com/festago/socialmedia/repository/SocialMediaRepository.java new file mode 100644 index 000000000..e766e27ad --- /dev/null +++ b/backend/src/main/java/com/festago/socialmedia/repository/SocialMediaRepository.java @@ -0,0 +1,9 @@ +package com.festago.socialmedia.repository; + +import com.festago.socialmedia.domain.SocialMedia; +import org.springframework.data.repository.Repository; + +public interface SocialMediaRepository extends Repository { + + SocialMedia save(SocialMedia socialMedia); +} diff --git a/backend/src/main/java/com/festago/stage/domain/StageQueryInfo.java b/backend/src/main/java/com/festago/stage/domain/StageQueryInfo.java new file mode 100644 index 000000000..15a7b931b --- /dev/null +++ b/backend/src/main/java/com/festago/stage/domain/StageQueryInfo.java @@ -0,0 +1,46 @@ +package com.festago.stage.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class StageQueryInfo { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private Long stageId; + + /** + * 역정규화를 위한 아티스트의 배열 JSON 컬럼
[{ "id": 1, "name": "뉴진스", "imageUrl": "https://image.com/image.png" }] + */ + @NotNull + @Column(columnDefinition = "TEXT") + private String artistInfo; + + private StageQueryInfo(Long stageId) { + this.stageId = stageId; + this.artistInfo = "[]"; + } + + public static StageQueryInfo create(Long stageId) { + return new StageQueryInfo(stageId); + } + + public void updateArtist(String artistInfo) { + this.artistInfo = artistInfo; + } + + public String getArtistInfo() { + return artistInfo; + } +} diff --git a/backend/src/main/java/com/festago/stage/repository/StageQueryInfoRepository.java b/backend/src/main/java/com/festago/stage/repository/StageQueryInfoRepository.java new file mode 100644 index 000000000..76e8b6028 --- /dev/null +++ b/backend/src/main/java/com/festago/stage/repository/StageQueryInfoRepository.java @@ -0,0 +1,9 @@ +package com.festago.stage.repository; + +import com.festago.stage.domain.StageQueryInfo; +import org.springframework.data.repository.Repository; + +public interface StageQueryInfoRepository extends Repository { + + StageQueryInfo save(StageQueryInfo stageQueryInfo); +} diff --git a/backend/src/main/java/com/festago/stage/repository/StageRepositoryCustomImpl.java b/backend/src/main/java/com/festago/stage/repository/StageRepositoryCustomImpl.java index 733d96bf4..749a34269 100644 --- a/backend/src/main/java/com/festago/stage/repository/StageRepositoryCustomImpl.java +++ b/backend/src/main/java/com/festago/stage/repository/StageRepositoryCustomImpl.java @@ -20,18 +20,18 @@ public class StageRepositoryCustomImpl implements StageRepositoryCustom { @Override public List findAllDetailByFestivalId(Long festivalId) { return queryFactory.selectFrom(stage) - .leftJoin(stage.tickets, ticket).fetchJoin() - .leftJoin(ticket.ticketAmount, ticketAmount).fetchJoin() - .where(stage.festival.id.eq(festivalId)) - .fetch(); + .leftJoin(stage.tickets, ticket).fetchJoin() + .leftJoin(ticket.ticketAmount, ticketAmount).fetchJoin() + .where(stage.festival.id.eq(festivalId)) + .fetch(); } @Override public Optional findByIdWithFetch(Long id) { return Optional.ofNullable(queryFactory.selectFrom(stage) - .leftJoin(stage.festival, festival).fetchJoin() - .leftJoin(festival.school, school).fetchJoin() - .where(stage.id.eq(id)) - .fetchOne()); + .leftJoin(stage.festival, festival).fetchJoin() + .leftJoin(festival.school, school).fetchJoin() + .where(stage.id.eq(id)) + .fetchOne()); } } diff --git a/backend/src/main/resources/db/migration/V13__add_socialmedia.sql b/backend/src/main/resources/db/migration/V13__add_socialmedia.sql new file mode 100644 index 000000000..221b635b3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V13__add_socialmedia.sql @@ -0,0 +1,16 @@ +create table if not exists social_media +( + id bigint not null auto_increment, + owner_id bigint, + owner_type varchar(255), + media_type varchar(255), + name varchar(255), + logo_url varchar(255), + url varchar(255), + created_at datetime(6), + updated_at datetime(6), + primary key (id) +); + +alter table social_media + add constraint UNIQUE_OWNER_ID_OWNER_TYPE_MEDIA_TYPE unique (owner_id, owner_type, media_type); diff --git a/backend/src/main/resources/db/migration/V14__add_imageurl_school_artist.sql b/backend/src/main/resources/db/migration/V14__add_imageurl_school_artist.sql new file mode 100644 index 000000000..a4f129cc7 --- /dev/null +++ b/backend/src/main/resources/db/migration/V14__add_imageurl_school_artist.sql @@ -0,0 +1,9 @@ +alter table school + add column logo_url varchar(255); + +alter table school + add column background_url varchar(255); + +alter table artist + add column background_image_url varchar(255); + diff --git a/backend/src/main/resources/db/migration/V15__add_stage_query_info.sql b/backend/src/main/resources/db/migration/V15__add_stage_query_info.sql new file mode 100644 index 000000000..14481dfe1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V15__add_stage_query_info.sql @@ -0,0 +1,14 @@ +create table if not exists stage_query_info +( + id bigint not null auto_increment, + stage_id bigint not null, + created_at datetime(6), + updated_at datetime(6), + artist_info text not null, + primary key (id) +) engine innodb + default charset = utf8mb4 + collate = utf8mb4_0900_ai_ci; + +alter table stage_query_info + add constraint UNIQUE_STAGE_ID unique (stage_id); diff --git a/backend/src/test/java/com/festago/acceptance/steps/SchoolStepDefinitions.java b/backend/src/test/java/com/festago/acceptance/steps/SchoolStepDefinitions.java index 52ebda851..8bec71d4a 100644 --- a/backend/src/test/java/com/festago/acceptance/steps/SchoolStepDefinitions.java +++ b/backend/src/test/java/com/festago/acceptance/steps/SchoolStepDefinitions.java @@ -6,7 +6,7 @@ import com.festago.admin.presentation.v1.dto.SchoolV1CreateRequest; import com.festago.admin.presentation.v1.dto.SchoolV1UpdateRequest; import com.festago.school.domain.SchoolRegion; -import com.festago.school.presentation.v1.dto.SchoolV1Response; +import com.festago.school.dto.v1.AdminSchoolV1Response; import io.cucumber.java.en.Given; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; @@ -49,18 +49,19 @@ public class SchoolStepDefinitions { .statusCode(200); } - public List getSchoolResponsesByName(String src) { + public List getSchoolResponsesByName(String src) { return RestAssured.given() .contentType(ContentType.JSON) .queryParams(Map.of("searchFilter", "name", "searchKeyword", src)) - .get("/api/v1/schools") + .cookie("token", cucumberClient.getToken()) + .get("/admin/api/v1/schools") .then() .log().ifError() .statusCode(200) .extract() .body() .jsonPath() - .getList("content", SchoolV1Response.class); + .getList("content", AdminSchoolV1Response.class); } @When("이름이 {string}인 학교를 삭제한다.") diff --git a/backend/src/test/java/com/festago/admin/presentation/v1/AdminArtistV1ControllerTest.java b/backend/src/test/java/com/festago/admin/presentation/v1/AdminArtistV1ControllerTest.java index d364f4951..5d4f6f939 100644 --- a/backend/src/test/java/com/festago/admin/presentation/v1/AdminArtistV1ControllerTest.java +++ b/backend/src/test/java/com/festago/admin/presentation/v1/AdminArtistV1ControllerTest.java @@ -51,6 +51,7 @@ class AdminArtistV1ControllerTest { @Autowired ArtistCommandService artistCommandService; + private static final Cookie COOKIE = new Cookie("token", "token"); @Nested class 아티스트_생성 { diff --git a/backend/src/test/java/com/festago/admin/presentation/v1/AdminSchoolV1ControllerTest.java b/backend/src/test/java/com/festago/admin/presentation/v1/AdminSchoolV1ControllerTest.java index 02ed74c08..3b1b96ed5 100644 --- a/backend/src/test/java/com/festago/admin/presentation/v1/AdminSchoolV1ControllerTest.java +++ b/backend/src/test/java/com/festago/admin/presentation/v1/AdminSchoolV1ControllerTest.java @@ -1,30 +1,40 @@ package com.festago.admin.presentation.v1; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.BDDMockito.any; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; import com.festago.admin.presentation.v1.dto.SchoolV1CreateRequest; import com.festago.admin.presentation.v1.dto.SchoolV1UpdateRequest; import com.festago.auth.domain.Role; +import com.festago.common.querydsl.SearchCondition; import com.festago.school.application.SchoolCommandService; import com.festago.school.application.SchoolDeleteService; +import com.festago.school.application.v1.AdminSchoolV1QueryService; import com.festago.school.domain.SchoolRegion; import com.festago.school.dto.SchoolCreateCommand; +import com.festago.school.dto.v1.AdminSchoolV1Response; import com.festago.support.CustomWebMvcTest; import com.festago.support.WithMockAuth; import jakarta.servlet.http.Cookie; +import java.nio.charset.StandardCharsets; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageImpl; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; @@ -48,6 +58,9 @@ class AdminSchoolV1ControllerTest { @Autowired SchoolDeleteService schoolDeleteService; + @Autowired + AdminSchoolV1QueryService adminSchoolV1QueryService; + @Nested class 학교_생성 { @@ -169,4 +182,66 @@ class 올바른_주소로 { } } } + + @Nested + class 모든_학교_정보_조회 { + + final String uri = "/admin/api/v1/schools"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_하면_200_응답과_학교_정보_목록이_반환된다() throws Exception { + // given + var expected = List.of( + new AdminSchoolV1Response(1L, "teco.ac.kr", "테코대학교", SchoolRegion.서울), + new AdminSchoolV1Response(2L, "wote.ac.kr", "우테대학교", SchoolRegion.부산) + ); + given(adminSchoolV1QueryService.findAll(any(SearchCondition.class))) + .willReturn(new PageImpl<>(expected)); + + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .cookie(new Cookie("token", "Bearer token"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.size()").value(2)); + } + } + } + + @Nested + class 단일_학교_정보_조회 { + + final String uri = "/admin/api/v1/schools/{schoolId}"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_하면_200_응답과_학교_정보가_반환된다() throws Exception { + // given + var expected = new AdminSchoolV1Response(1L, "teco.ac.kr", "테코대학교", SchoolRegion.서울); + given(adminSchoolV1QueryService.findById(anyLong())) + .willReturn(expected); + + // when & then + String content = mockMvc.perform(get(uri, 1L) + .contentType(MediaType.APPLICATION_JSON) + .cookie(new Cookie("token", "Bearer token"))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + var actual = objectMapper.readValue(content, AdminSchoolV1Response.class); + + assertThat(actual).isEqualTo(expected); + } + } + } } diff --git a/backend/src/test/java/com/festago/artist/application/ArtistDetailV1QueryServiceTest.java b/backend/src/test/java/com/festago/artist/application/ArtistDetailV1QueryServiceTest.java new file mode 100644 index 000000000..4004e8e89 --- /dev/null +++ b/backend/src/test/java/com/festago/artist/application/ArtistDetailV1QueryServiceTest.java @@ -0,0 +1,241 @@ +package com.festago.artist.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.artist.domain.Artist; +import com.festago.artist.dto.ArtistDetailV1Response; +import com.festago.artist.dto.ArtistFestivalDetailV1Response; +import com.festago.artist.repository.ArtistRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.festival.domain.Festival; +import com.festago.festival.domain.FestivalInfoSerializer; +import com.festago.festival.domain.FestivalQueryInfo; +import com.festago.festival.repository.FestivalInfoRepository; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.repository.SchoolRepository; +import com.festago.socialmedia.domain.OwnerType; +import com.festago.socialmedia.domain.SocialMedia; +import com.festago.socialmedia.domain.SocialMediaType; +import com.festago.socialmedia.repository.SocialMediaRepository; +import com.festago.stage.domain.Stage; +import com.festago.stage.domain.StageArtist; +import com.festago.stage.repository.StageArtistRepository; +import com.festago.stage.repository.StageRepository; +import com.festago.support.ApplicationIntegrationTest; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ArtistDetailV1QueryServiceTest extends ApplicationIntegrationTest { + + @Autowired + StageArtistRepository stageArtistRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalInfoRepository festivalInfoRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + StageRepository stageRepository; + + @Autowired + ArtistRepository artistRepository; + + @Autowired + FestivalInfoSerializer festivalInfoSerializer; + + @Autowired + SocialMediaRepository socialMediaRepository; + + @Autowired + ArtistDetailV1QueryService artistDetailV1QueryService; + + @Nested + class 아티스트_정보_는 { + + @Test + void 조회할_수_있다() { + // given + Artist pooh = artistRepository.save(new Artist("pooh", "image.jpg")); + Long id = pooh.getId(); + makeArtistSocialMedia(id, OwnerType.ARTIST, SocialMediaType.INSTAGRAM); + makeArtistSocialMedia(id, OwnerType.ARTIST, SocialMediaType.YOUTUBE); + + // when + ArtistDetailV1Response acutal = artistDetailV1QueryService.findArtistDetail(id); + + // then + assertThat(acutal.socialMedias()).hasSize(2); + } + + @Test + void 소셜_매체가_없더라도_반환한다() { + // given + Artist pooh = artistRepository.save(new Artist("pooh", "image.jpg")); + Long id = pooh.getId(); + + // when + ArtistDetailV1Response acutal = artistDetailV1QueryService.findArtistDetail(id); + + // then + assertThat(acutal.socialMedias()).isEmpty(); + } + + @Test + void 소셜_매체의_주인_아이디가_같더라도_주인의_타입에_따라_구분하여_반환한다() { + // given + Artist pooh = artistRepository.save(new Artist("pooh", "image.jpg")); + Long id = pooh.getId(); + makeArtistSocialMedia(id, OwnerType.ARTIST, SocialMediaType.INSTAGRAM); + makeArtistSocialMedia(id, OwnerType.SCHOOL, SocialMediaType.INSTAGRAM); + + // when + ArtistDetailV1Response acutal = artistDetailV1QueryService.findArtistDetail(id); + + // then + assertThat(acutal.socialMedias()).hasSize(1); + } + + @Test + void 존재하지_않는_아티스트를_검색하면_에외() { + // given & when & then + assertThatThrownBy(() -> artistDetailV1QueryService.findArtistDetail(1L)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.ARTIST_NOT_FOUND.getMessage()); + + } + + SocialMedia makeArtistSocialMedia(Long id, OwnerType ownerType, SocialMediaType socialMediaType) { + return socialMediaRepository.save( + new SocialMedia(id, ownerType, socialMediaType, "총학생회", "logoUrl", "url")); + } + } + + @Nested + class 아티스트_축제_는 { + + LocalDate nowDate; + LocalDateTime nowDateTime; + Artist 푸우; + Artist 오리; + Artist 글렌; + + @BeforeEach + void setting() { + nowDate = LocalDate.now(); + nowDateTime = LocalDateTime.now(); + + School 부산_학교 = schoolRepository.save(new School("domain1", "부산 학교", SchoolRegion.부산)); + School 서울_학교 = schoolRepository.save(new School("domain2", "서울 학교", SchoolRegion.서울)); + School 대구_학교 = schoolRepository.save(new School("domain3", "대구 학교", SchoolRegion.대구)); + + Festival 부산_축제 = festivalRepository.save( + new Festival("부산 축제", nowDate.minusDays(5), nowDate.minusDays(1), 부산_학교)); + Festival 서울_축제 = festivalRepository.save( + new Festival("서울 축제", nowDate.minusDays(1), nowDate.plusDays(3), 서울_학교)); + Festival 대구_축제 = festivalRepository.save( + new Festival("대구 축제", nowDate.plusDays(1), nowDate.plusDays(5), 대구_학교)); + + Stage 부산_공연 = stageRepository.save(new Stage(nowDateTime.minusDays(5L), nowDateTime.minusDays(6L), 부산_축제)); + Stage 서울_공연 = stageRepository.save(new Stage(nowDateTime.minusDays(1L), nowDateTime.minusDays(2L), 서울_축제)); + Stage 대구_공연 = stageRepository.save(new Stage(nowDateTime.plusDays(1L), nowDateTime, 대구_축제)); + + 푸우 = artistRepository.save(new Artist("푸우", "푸우.jpg")); + 오리 = artistRepository.save(new Artist("오리", "오리.jpg")); + 글렌 = artistRepository.save(new Artist("글렌", "글렌.jpg")); + + stageArtistRepository.save(new StageArtist(부산_공연.getId(), 푸우.getId())); + stageArtistRepository.save(new StageArtist(부산_공연.getId(), 오리.getId())); + festivalInfoRepository.save(FestivalQueryInfo.of(부산_축제, List.of(푸우, 오리), festivalInfoSerializer)); + + stageArtistRepository.save(new StageArtist(서울_공연.getId(), 푸우.getId())); + stageArtistRepository.save(new StageArtist(서울_공연.getId(), 글렌.getId())); + festivalInfoRepository.save(FestivalQueryInfo.of(서울_축제, List.of(푸우, 글렌), festivalInfoSerializer)); + + stageArtistRepository.save(new StageArtist(대구_공연.getId(), 오리.getId())); + stageArtistRepository.save(new StageArtist(대구_공연.getId(), 글렌.getId())); + festivalInfoRepository.save(FestivalQueryInfo.of(대구_축제, List.of(오리, 글렌), festivalInfoSerializer)); + } + + @Test + void 진행중인_축제_조회가_가능하다() { + // given & when + Slice actual = artistDetailV1QueryService.findArtistFestivals( + 오리.getId(), + null, + null, + false, + PageRequest.ofSize(10) + ); + + // then + assertThat(actual.getContent()).hasSize(1); + } + + @Test + void 종료된_축제_조회가_가능하다() { + // given & when + Slice actual = artistDetailV1QueryService.findArtistFestivals( + 오리.getId(), + null, + null, + true, + PageRequest.ofSize(10) + ); + + // then + assertThat(actual.getContent()).hasSize(1); + } + + @Test + void 커서_기반_검색이_가능하다() { + // given + PageRequest pageable = PageRequest.ofSize(1); + + Slice firstResponse = artistDetailV1QueryService.findArtistFestivals( + 글렌.getId(), + null, + null, + false, + pageable + ); + + ArtistFestivalDetailV1Response firstFestivalResponse = firstResponse.getContent().get(0); + + // when + Slice secondResponse = artistDetailV1QueryService.findArtistFestivals( + 글렌.getId(), + firstFestivalResponse.id(), + firstFestivalResponse.startDate(), + false, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(firstResponse.isLast()).isFalse(); + softly.assertThat(secondResponse.isLast()).isTrue(); + }); + } + } +} diff --git a/backend/src/test/java/com/festago/artist/application/integration/ArtistCommandServiceIntegrationTest.java b/backend/src/test/java/com/festago/artist/application/integration/ArtistCommandServiceIntegrationTest.java index 3a396c24f..fa5cd0c67 100644 --- a/backend/src/test/java/com/festago/artist/application/integration/ArtistCommandServiceIntegrationTest.java +++ b/backend/src/test/java/com/festago/artist/application/integration/ArtistCommandServiceIntegrationTest.java @@ -1,6 +1,7 @@ package com.festago.artist.application.integration; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; import com.festago.admin.dto.ArtistCreateRequest; import com.festago.admin.dto.ArtistUpdateRequest; @@ -47,9 +48,11 @@ class ArtistCommandServiceIntegrationTest extends ApplicationIntegrationTest { // then Artist actual = artistRepository.getOrThrow(artistId); - assertThat(actual).usingRecursiveComparison() - .ignoringFields("id") - .isEqualTo(request); + + assertSoftly(softly -> { + softly.assertThat(actual.getName()).isEqualTo(request.name()); + softly.assertThat(actual.getProfileImage()).isEqualTo(request.profileImage()); + }); } @Test diff --git a/backend/src/test/java/com/festago/festival/application/integration/FestivalDetailV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/festival/application/integration/FestivalDetailV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..9a98263cc --- /dev/null +++ b/backend/src/test/java/com/festago/festival/application/integration/FestivalDetailV1QueryServiceIntegrationTest.java @@ -0,0 +1,174 @@ +package com.festago.festival.application.integration; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.festival.application.FestivalDetailV1QueryService; +import com.festago.festival.domain.Festival; +import com.festago.festival.dto.SocialMediaV1Response; +import com.festago.festival.dto.StageV1Response; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.repository.SchoolRepository; +import com.festago.socialmedia.domain.OwnerType; +import com.festago.socialmedia.domain.SocialMedia; +import com.festago.socialmedia.domain.SocialMediaType; +import com.festago.socialmedia.repository.SocialMediaRepository; +import com.festago.stage.domain.Stage; +import com.festago.stage.domain.StageQueryInfo; +import com.festago.stage.repository.StageQueryInfoRepository; +import com.festago.stage.repository.StageRepository; +import com.festago.support.ApplicationIntegrationTest; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@Transactional +class FestivalDetailV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + FestivalDetailV1QueryService festivalDetailV1QueryService; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + StageRepository stageRepository; + + @Autowired + StageQueryInfoRepository stageQueryInfoRepository; + + @Autowired + SocialMediaRepository socialMediaRepository; + + LocalDate now = LocalDate.parse("2077-06-30"); + + /** + * 축제에는 1,2,3 일차로 이뤄진 공연이 있고, 각 공연에는 아티스트 정보가 포함되어 있다. 또한, 테코대학교의 소셜미디어는 인스타그램, 페이스북이 등록되어 있다. + */ + Festival 축제; + Festival 공연_없는_축제; + Festival 소셜미디어_없는_축제; + + @BeforeEach + void setUp() { + School 테코대학교 = schoolRepository.save(new School("teco.ac.kr", "테코대학교", SchoolRegion.서울)); + School 소셜미디어_없는_학교 = schoolRepository.save(new School("wote.ac.kr", "우테대학교", SchoolRegion.서울)); + + 축제 = festivalRepository.save( + new Festival("테코대학교 축제", now, now.plusDays(2), "https://school.com/image.com", 테코대학교)); + 공연_없는_축제 = festivalRepository.save( + new Festival("테코대학교 공연 없는 축제", now, now, "https://school.com/image.com", 테코대학교) + ); + 소셜미디어_없는_축제 = festivalRepository.save( + new Festival("우테대학교 소셜미디어 없는 축제", now, now, "https://school.com/image.com", 소셜미디어_없는_학교) + ); + + Stage 첫째날_공연 = stageRepository.save( + new Stage(now.atTime(18, 0), now.minusWeeks(1).atStartOfDay(), 축제)); + Stage 둘째날_공연 = stageRepository.save( + new Stage(now.plusDays(1).atTime(18, 0), now.minusWeeks(1).atStartOfDay(), 축제)); + Stage 셋째날_공연 = stageRepository.save( + new Stage(now.plusDays(2).atTime(18, 0), now.minusWeeks(1).atStartOfDay(), 축제)); + Stage 소셜미디어_없는_축제_공연 = stageRepository.save( + new Stage(now.atTime(18, 0), now.minusWeeks(1).atStartOfDay(), 소셜미디어_없는_축제)); + + StageQueryInfo 첫째날_공연_QueryInfo = StageQueryInfo.create(첫째날_공연.getId()); + 첫째날_공연_QueryInfo.updateArtist("뉴진스"); + stageQueryInfoRepository.save(첫째날_공연_QueryInfo); + + StageQueryInfo 둘째날_공연_QueryInfo = StageQueryInfo.create(둘째날_공연.getId()); + 둘째날_공연_QueryInfo.updateArtist("에픽하이"); + stageQueryInfoRepository.save(둘째날_공연_QueryInfo); + + StageQueryInfo 셋째날_공연_QueryInfo = StageQueryInfo.create(셋째날_공연.getId()); + 셋째날_공연_QueryInfo.updateArtist("소녀시대"); + stageQueryInfoRepository.save(셋째날_공연_QueryInfo); + + StageQueryInfo 소셜미디어_없는_축제_공연_QueryInfo = StageQueryInfo.create(소셜미디어_없는_축제_공연.getId()); + 소셜미디어_없는_축제_공연_QueryInfo.updateArtist("SG워너비"); + stageQueryInfoRepository.save(소셜미디어_없는_축제_공연_QueryInfo); + + socialMediaRepository.save( + new SocialMedia(테코대학교.getId(), OwnerType.SCHOOL, SocialMediaType.INSTAGRAM, "총학생회 인스타그램", + "https://logo.com/instagram.png", "https://instagram.com/테코대학교_총학생회")); + socialMediaRepository.save( + new SocialMedia(테코대학교.getId(), OwnerType.SCHOOL, SocialMediaType.FACEBOOK, "총학생회 페이스북", + "https://logo.com/facebook.png", "https://facebook.com/테코대학교_총학생회")); + } + + /** + * 결국 응답의 형식을 테스트하고 있다. 너무 많은 Repository 의존이 발생하고 있기도 하고, 이럴거면 서비스 레이어 통합 테스트말고, 컨트롤러 레이어 통합 테스트 혹은 인수 테스트로 수행하는 것이 + * 어떨까? + */ + @Test + void 축제의_식별자로_축제의_상세_조회를_할_수_있다() { + // when + var response = festivalDetailV1QueryService.findFestivalDetail(축제.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(response.name()).isEqualTo("테코대학교 축제"); + softly.assertThat(response.startDate()).isEqualTo("2077-06-30"); + softly.assertThat(response.endDate()).isEqualTo("2077-07-02"); + softly.assertThat(response.imageUrl()).isEqualTo("https://school.com/image.com"); + softly.assertThat(response.school().name()).isEqualTo("테코대학교"); + softly.assertThat(response.socialMedias()) + .map(SocialMediaV1Response::name) + .containsExactlyInAnyOrder("총학생회 인스타그램", "총학생회 페이스북"); + softly.assertThat(response.stages()) + .map(StageV1Response::artists) + .containsExactlyInAnyOrder("뉴진스", "에픽하이", "소녀시대"); + }); + } + + @Test + void 축제에_공연이_없으면_응답의_공연에는_비어있는_컬렉션이_반환된다() { + // when + var response = festivalDetailV1QueryService.findFestivalDetail(공연_없는_축제.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(response.name()).isEqualTo("테코대학교 공연 없는 축제"); + softly.assertThat(response.stages()).isEmpty(); + softly.assertThat(response.socialMedias()) + .map(SocialMediaV1Response::name) + .containsExactlyInAnyOrder("총학생회 인스타그램", "총학생회 페이스북"); + }); + } + + @Test + void 축제에_속한_학교에_소셜미디어가_없으면_소셜미디어에는_비어있는_컬렉션이_반환된다() { + // when + var response = festivalDetailV1QueryService.findFestivalDetail(소셜미디어_없는_축제.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(response.name()).isEqualTo("우테대학교 소셜미디어 없는 축제"); + softly.assertThat(response.socialMedias()).isEmpty(); + softly.assertThat(response.stages()) + .map(StageV1Response::artists) + .containsExactlyInAnyOrder("SG워너비"); + }); + } + + @Test + void 축제의_식별자에_대한_축제가_없으면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> festivalDetailV1QueryService.findFestivalDetail(4885L)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.FESTIVAL_NOT_FOUND.getMessage()); + } +} diff --git a/backend/src/test/java/com/festago/festival/presentation/v1/FestivalV1ControllerTest.java b/backend/src/test/java/com/festago/festival/presentation/v1/FestivalV1ControllerTest.java index 0ed81d73c..7b50c2799 100644 --- a/backend/src/test/java/com/festago/festival/presentation/v1/FestivalV1ControllerTest.java +++ b/backend/src/test/java/com/festago/festival/presentation/v1/FestivalV1ControllerTest.java @@ -1,10 +1,24 @@ package com.festago.festival.presentation.v1; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.anyLong; +import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.festival.application.FestivalDetailV1QueryService; +import com.festago.festival.dto.FestivalDetailV1Response; +import com.festago.festival.dto.SchoolV1Response; +import com.festago.festival.dto.SocialMediaV1Response; +import com.festago.festival.dto.StageV1Response; +import com.festago.socialmedia.domain.SocialMediaType; import com.festago.support.CustomWebMvcTest; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Set; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; @@ -14,6 +28,7 @@ import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; @CustomWebMvcTest @@ -28,6 +43,12 @@ class FestivalV1ControllerTest { @Autowired MockMvc mockMvc; + @Autowired + FestivalDetailV1QueryService festivalDetailV1QueryService; + + @Autowired + ObjectMapper objectMapper; + @Nested class 축제_목록_커서_기반_페이징_조회 { @@ -82,4 +103,63 @@ class 올바른_주소로 { } } } + + @Nested + class 축제_상세_조회 { + + final String uri = "/api/v1/festivals/{festivalId}"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답과_축제_상세_정보가_반환된다() throws Exception { + // given + var expect = new FestivalDetailV1Response( + 1L, + "테코대학교 축제", + new SchoolV1Response( + 1L, + "테코대학교" + ), + LocalDate.parse("2077-06-30"), + LocalDate.parse("2077-06-30"), + "https://image.com/schoolImage.png", + Set.of( + new SocialMediaV1Response( + SocialMediaType.INSTAGRAM, + "총학 인스타", + "https://example.com/instagram.png", + "https://www.instagram.com/example_university" + ) + ), + Set.of( + new StageV1Response( + 1L, + LocalDateTime.parse("2077-06-30T00:00:00"), + null // @JsonRawValue 때문에 직렬화된 JSON을 다시 역직렬화 할 때 문제가 발생함 + ) + ) + ); + given(festivalDetailV1QueryService.findFestivalDetail(anyLong())) + .willReturn(expect); + // when & then + String content = mockMvc.perform(get(uri, 1L) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + + // 이렇게 검증해도 괜찮은지 의문. 마치 거울보고 가위바위보를 하는 느낌이 강하게 듦 + // jsonPath를 사용하여 json 명세가 정확한지(오타, 누락) 명시적으로 검사가 필요할까?? + // 많은 andExpect(jsonPath("$.id").value(1L)) 절이 호출되어 보기에 불편하지만 + // 코드 리뷰시 DTO 내부를 헤집을 필요 없이, 테스트 코드만 보고 JSON 명세를 확인 가능한게 장점인듯 + // 또한, @JsonRawValue를 사용하여 역직렬화된 JSON을 다시 직렬화하는 것이 불가능함. + var actual = objectMapper.readValue(content, FestivalDetailV1Response.class); + assertThat(actual).isEqualTo(expect); + } + } + } } diff --git a/backend/src/test/java/com/festago/school/application/integration/AdminSchoolV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/school/application/integration/AdminSchoolV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..3178677cb --- /dev/null +++ b/backend/src/test/java/com/festago/school/application/integration/AdminSchoolV1QueryServiceIntegrationTest.java @@ -0,0 +1,191 @@ +package com.festago.school.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.common.querydsl.SearchCondition; +import com.festago.school.application.v1.AdminSchoolV1QueryService; +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.dto.v1.AdminSchoolV1Response; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminSchoolV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + AdminSchoolV1QueryService adminSchoolV1QueryService; + + @Autowired + SchoolRepository schoolRepository; + + School 테코대학교; + School 우테대학교; + School 글렌대학교; + + @BeforeEach + void setUp() { + 테코대학교 = schoolRepository.save(new School("teco.ac.kr", "테코대학교", SchoolRegion.서울)); + 우테대학교 = schoolRepository.save(new School("wote.ac.kr", "우테대학교", SchoolRegion.서울)); + 글렌대학교 = schoolRepository.save(new School("glen.ac.kr", "글렌대학교", SchoolRegion.대구)); + } + + @Nested + class findAll { + + @Test + void 정렬이_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "name")); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + Page response = adminSchoolV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminSchoolV1Response::name) + .containsExactly(글렌대학교.getName(), 우테대학교.getName(), 테코대학교.getName()); + } + + @Test + void 식별자로_검색이_되어야_한다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("id", 글렌대학교.getId().toString(), pageable); + + // when + Page response = adminSchoolV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminSchoolV1Response::name) + .containsExactlyInAnyOrder(글렌대학교.getName()); + } + + @Test + void 지역으로_검색이_되어야_한다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("region", "서울", pageable); + + // when + Page response = adminSchoolV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminSchoolV1Response::name) + .containsExactlyInAnyOrder(우테대학교.getName(), 테코대학교.getName()); + } + + @Test + void 도메인이_포함된_검색이_되어야_한다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("domain", "wote", pageable); + + // when + Page response = adminSchoolV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminSchoolV1Response::name) + .containsExactly(우테대학교.getName()); + } + + @Test + void 이름이_포함된_검색이_되어야_한다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("name", "글렌", pageable); + + // when + Page response = adminSchoolV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminSchoolV1Response::name) + .containsExactly(글렌대학교.getName()); + } + + @Test + void 검색_필터가_비어있으면_필터링이_적용되지_않는다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("", "글렌", pageable); + + // when + Page response = adminSchoolV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .hasSize(3); + } + + @Test + void 검색어가_비어있으면_필터링이_적용되지_않는다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("id", "", pageable); + + // when + Page response = adminSchoolV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .hasSize(3); + } + + @Test + void 페이지네이션이_적용_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 2); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + Page response = adminSchoolV1QueryService.findAll(searchCondition); + + // then + assertSoftly(softly -> { + softly.assertThat(response.getSize()).isEqualTo(2); + softly.assertThat(response.getTotalPages()).isEqualTo(2); + softly.assertThat(response.getTotalElements()).isEqualTo(3); + }); + } + } + + @Nested + class findById { + + @Test + void 식별자로_조회가_되어야_한다() { + // given + Long 테코대학교_식별자 = 테코대학교.getId(); + + // when + AdminSchoolV1Response response = adminSchoolV1QueryService.findById(테코대학교_식별자); + + // then + assertThat(response.name()).isEqualTo("테코대학교"); + } + + @Test + void 식별자로_찾을_수_없으면_예외가_발생한다() { + // given + Long invalidId = 4885L; + + // when + assertThatThrownBy(() -> adminSchoolV1QueryService.findById(invalidId)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.SCHOOL_NOT_FOUND.getMessage()); + } + } +} diff --git a/backend/src/test/java/com/festago/school/application/integration/SchoolV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/school/application/integration/SchoolV1QueryServiceIntegrationTest.java index b86a05b8c..ebfd86445 100644 --- a/backend/src/test/java/com/festago/school/application/integration/SchoolV1QueryServiceIntegrationTest.java +++ b/backend/src/test/java/com/festago/school/application/integration/SchoolV1QueryServiceIntegrationTest.java @@ -4,27 +4,34 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.SoftAssertions.assertSoftly; -import com.festago.common.exception.ErrorCode; import com.festago.common.exception.NotFoundException; -import com.festago.common.querydsl.SearchCondition; -import com.festago.school.application.SchoolV1QueryService; +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.application.v1.SchoolV1QueryService; import com.festago.school.domain.School; -import com.festago.school.domain.SchoolRegion; -import com.festago.school.presentation.v1.dto.SchoolV1Response; +import com.festago.school.dto.v1.SchoolDetailV1Response; +import com.festago.school.dto.v1.SchoolFestivalV1Response; import com.festago.school.repository.SchoolRepository; +import com.festago.school.repository.v1.SchoolFestivalV1SearchCondition; +import com.festago.socialmedia.domain.OwnerType; +import com.festago.socialmedia.domain.SocialMedia; +import com.festago.socialmedia.domain.SocialMediaType; +import com.festago.socialmedia.repository.SocialMediaRepository; import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.FestivalFixture; +import com.festago.support.SchoolFixture; +import java.time.LocalDate; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Slice; -@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") class SchoolV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { @@ -34,158 +41,242 @@ class SchoolV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { @Autowired SchoolRepository schoolRepository; - School 테코대학교; - School 우테대학교; - School 글렌대학교; + @Autowired + SocialMediaRepository socialMediaRepository; - @BeforeEach - void setUp() { - 테코대학교 = schoolRepository.save(new School("teco.ac.kr", "테코대학교", SchoolRegion.서울)); - 우테대학교 = schoolRepository.save(new School("wote.ac.kr", "우테대학교", SchoolRegion.서울)); - 글렌대학교 = schoolRepository.save(new School("glen.ac.kr", "글렌대학교", SchoolRegion.대구)); - } + @Autowired + FestivalRepository festivalRepository; @Nested - class findAll { + class 학교_상세_정보_조회 { + + @Test + void 해당하는_학교가_존재하지_않으면_예외() { + // when && then + assertThatThrownBy(() -> schoolV1QueryService.findDetailById(-1L)) + .isInstanceOf(NotFoundException.class) + .hasMessage("존재하지 않는 학교입니다."); + } @Test - void 정렬이_되어야_한다() { + void 학교에_소셜미디어가_존재하지_않아도_조회된다() { // given - Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "name")); - SearchCondition searchCondition = new SearchCondition("", "", pageable); + School school = schoolRepository.save(SchoolFixture.school().build()); // when - Page response = schoolV1QueryService.findAll(searchCondition); + SchoolDetailV1Response actual = schoolV1QueryService.findDetailById(school.getId()); - assertThat(response.getContent()) - .map(SchoolV1Response::name) - .containsExactly(글렌대학교.getName(), 우테대학교.getName(), 테코대학교.getName()); + // then + assertSoftly(softly -> { + softly.assertThat(actual).isNotNull(); + softly.assertThat(actual.socialMedias()).isEmpty(); + }); } @Test - void 식별자로_검색이_되어야_한다() { + void 아티스트의_소셜미디어는_아이디가_같아도_조회되지_않는다() { // given - Pageable pageable = Pageable.ofSize(10); - SearchCondition searchCondition = new SearchCondition("id", 글렌대학교.getId().toString(), pageable); + School school = schoolRepository.save(SchoolFixture.school().build()); + saveSocialMedia(school.getId(), OwnerType.SCHOOL, SocialMediaType.X); + saveSocialMedia(school.getId(), OwnerType.ARTIST, SocialMediaType.YOUTUBE); // when - Page response = schoolV1QueryService.findAll(searchCondition); + SchoolDetailV1Response actual = schoolV1QueryService.findDetailById(school.getId()); - assertThat(response.getContent()) - .map(SchoolV1Response::name) - .containsExactlyInAnyOrder(글렌대학교.getName()); + // then + assertThat(actual.socialMedias()).hasSize(1); } @Test - void 지역으로_검색이_되어야_한다() { + void 학교와_포함된_소셜미디어를_모두_조회한다() { // given - Pageable pageable = Pageable.ofSize(10); - SearchCondition searchCondition = new SearchCondition("region", "서울", pageable); + School school = schoolRepository.save(SchoolFixture.school().build()); + saveSocialMedia(school.getId(), OwnerType.SCHOOL, SocialMediaType.X); + saveSocialMedia(school.getId(), OwnerType.SCHOOL, SocialMediaType.YOUTUBE); // when - Page response = schoolV1QueryService.findAll(searchCondition); + SchoolDetailV1Response actual = schoolV1QueryService.findDetailById(school.getId()); + + // then + assertThat(actual.socialMedias()).hasSize(2); + } + + private void saveSocialMedia(Long ownerId, OwnerType ownerType, SocialMediaType mediaType) { + socialMediaRepository.save( + new SocialMedia(ownerId, ownerType, mediaType, + "defaultName", "www.logoUrl.com", "www.url.com") + ); + } + } + + @Nested + class 학교별_축제_페이징_조회 { + + School school; + LocalDate today = LocalDate.now(); - assertThat(response.getContent()) - .map(SchoolV1Response::name) - .containsExactlyInAnyOrder(우테대학교.getName(), 테코대학교.getName()); + @BeforeEach + void setUp() { + school = schoolRepository.save(SchoolFixture.school().build()); } @Test - void 도메인이_포함된_검색이_되어야_한다() { + void 과거_축제만_가져온다() { // given - Pageable pageable = Pageable.ofSize(10); - SearchCondition searchCondition = new SearchCondition("domain", "wote", pageable); + // 진행중 + saveFestival(today, today.plusDays(1)); + saveFestival(today, today.plusDays(1)); + + // 진행예정 + saveFestival(today.plusDays(1), today.plusDays(2)); + saveFestival(today.plusDays(1), today.plusDays(2)); + + // 종료 + Festival lastFestival = saveFestival(today.minusDays(3), today.minusDays(1)); + + var searchCondition = new SchoolFestivalV1SearchCondition(null, null, true, Pageable.ofSize(10)); // when - Page response = schoolV1QueryService.findAll(searchCondition); + List actual = schoolV1QueryService.findFestivalsBySchoolId( + school.getId(), today, searchCondition).getContent(); - assertThat(response.getContent()) - .map(SchoolV1Response::name) - .containsExactly(우테대학교.getName()); + // then + assertThat(actual).hasSize(1); + assertThat(actual.get(0).id()).isEqualTo(lastFestival.getId()); } @Test - void 이름이_포함된_검색이_되어야_한다() { + void 현재_혹은_예정_축제만_가져온다() { // given - Pageable pageable = Pageable.ofSize(10); - SearchCondition searchCondition = new SearchCondition("name", "글렌", pageable); + + // 진행 혹은 예정 축제 + saveFestival(today, today.plusDays(1)); + saveFestival(today.plusDays(1), today.plusDays(2)); + + var searchCondition = new SchoolFestivalV1SearchCondition(null, null, false, Pageable.ofSize(10)); + + // 종료 축제 + saveFestival(today.minusDays(3), today.minusDays(1)); // when - Page response = schoolV1QueryService.findAll(searchCondition); + List actual = schoolV1QueryService.findFestivalsBySchoolId( + school.getId(), today, searchCondition).getContent(); - assertThat(response.getContent()) - .map(SchoolV1Response::name) - .containsExactly(글렌대학교.getName()); + // then + assertThat(actual).hasSize(2); } @Test - void 검색_필터가_비어있으면_필터링이_적용되지_않는다() { + void 현재_축제를_시작일자가_빠른순으로_조회한다() { // given - Pageable pageable = Pageable.ofSize(10); - SearchCondition searchCondition = new SearchCondition("", "글렌", pageable); + saveFestival(today.plusDays(2), today.plusDays(3)); + saveFestival(today.plusDays(2), today.plusDays(3)); + Festival recentFestival = saveFestival(today, today.plusDays(1)); + var searchCondition = new SchoolFestivalV1SearchCondition(null, null, false, Pageable.ofSize(10)); // when - Page response = schoolV1QueryService.findAll(searchCondition); + List actual = schoolV1QueryService.findFestivalsBySchoolId( + school.getId(), today, searchCondition).getContent(); - assertThat(response.getContent()) - .hasSize(3); + // then + assertThat(actual.get(0).id()).isEqualTo(recentFestival.getId()); } @Test - void 검색어가_비어있으면_필터링이_적용되지_않는다() { + void 과거_축제를_종료일자가_느린순으로_조회한다() { // given - Pageable pageable = Pageable.ofSize(10); - SearchCondition searchCondition = new SearchCondition("id", "", pageable); + saveFestival(today.minusDays(4), today.minusDays(3)); + saveFestival(today.minusDays(3), today.minusDays(2)); + Festival recentFestival = saveFestival(today.minusDays(3), today.minusDays(1)); + var searchCondition = new SchoolFestivalV1SearchCondition(null, null, true, Pageable.ofSize(10)); // when - Page response = schoolV1QueryService.findAll(searchCondition); + List actual = schoolV1QueryService.findFestivalsBySchoolId( + school.getId(), today, searchCondition).getContent(); - assertThat(response.getContent()) - .hasSize(3); + // then + assertThat(actual.get(0).id()).isEqualTo(recentFestival.getId()); } @Test - void 페이지네이션이_적용_되어야_한다() { + void 페이징하여_현재_축제를_조회한다() { // given - Pageable pageable = PageRequest.of(0, 2); - SearchCondition searchCondition = new SearchCondition("", "", pageable); + saveFestival(today, today.plusDays(3)); + Festival nextPageFirstReadFestival = saveFestival(today.plusDays(1), today.plusDays(1)); + Festival lastReadFestival = saveFestival(today, today.plusDays(1)); + saveFestival(today.plusDays(1), today.plusDays(1)); + saveFestival(today.plusDays(2), today.plusDays(2)); + var searchCondition = new SchoolFestivalV1SearchCondition( + lastReadFestival.getId(), lastReadFestival.getStartDate(), false, Pageable.ofSize(2)); // when - Page response = schoolV1QueryService.findAll(searchCondition); + List actual = schoolV1QueryService.findFestivalsBySchoolId( + school.getId(), today, searchCondition).getContent(); // then - assertSoftly(softly -> { - softly.assertThat(response.getSize()).isEqualTo(2); - softly.assertThat(response.getTotalPages()).isEqualTo(2); - softly.assertThat(response.getTotalElements()).isEqualTo(3); - }); + assertThat(actual).hasSize(2); + assertThat(actual.get(0).id()).isEqualTo(nextPageFirstReadFestival.getId()); } - } - @Nested - class findById { + @Test + void 페이징하여_과거_축제를_조회한다() { + // given + LocalDate yesterday = today.minusDays(1); + + saveFestival(yesterday.minusDays(2), yesterday); + Festival nextPageFirstReadFestival = saveFestival(yesterday.minusDays(3), yesterday); + Festival lastReadFestival = saveFestival(yesterday.minusDays(2), yesterday); + saveFestival(yesterday.minusDays(4), yesterday); + saveFestival(yesterday.minusDays(4), yesterday); + var searchCondition = new SchoolFestivalV1SearchCondition( + lastReadFestival.getId(), lastReadFestival.getStartDate(), true, Pageable.ofSize(2)); + + // when + List actual = schoolV1QueryService.findFestivalsBySchoolId( + school.getId(), today, searchCondition).getContent(); + + // then + assertThat(actual).hasSize(2); + assertThat(actual.get(0).id()).isEqualTo(nextPageFirstReadFestival.getId()); + } @Test - void 식별자로_조회가_되어야_한다() { + void 다음_페이지가_존재한다() { // given - Long 테코대학교_식별자 = 테코대학교.getId(); + saveFestival(today, today.plusDays(1)); + saveFestival(today, today.plusDays(1)); + var searchCondition = new SchoolFestivalV1SearchCondition(null, null, false, Pageable.ofSize(1)); // when - SchoolV1Response response = schoolV1QueryService.findById(테코대학교_식별자); + Slice actual = schoolV1QueryService.findFestivalsBySchoolId( + school.getId(), today, + searchCondition); // then - assertThat(response.name()).isEqualTo("테코대학교"); + assertThat(actual.hasNext()).isFalse(); } @Test - void 식별자로_찾을_수_없으면_예외가_발생한다() { + void 다음_페이지가_존재하지않는다() { // given - Long invalidId = 4885L; + saveFestival(today, today.plusDays(1)); + var searchCondition = new SchoolFestivalV1SearchCondition(null, null, false, Pageable.ofSize(1)); // when - assertThatThrownBy(() -> schoolV1QueryService.findById(invalidId)) - .isInstanceOf(NotFoundException.class) - .hasMessage(ErrorCode.SCHOOL_NOT_FOUND.getMessage()); + Slice actual = schoolV1QueryService.findFestivalsBySchoolId( + school.getId(), today, searchCondition); + + // then + assertThat(actual.hasNext()).isTrue(); + } + + private Festival saveFestival(LocalDate startDate, LocalDate endDate) { + return festivalRepository.save( + FestivalFixture.festival() + .startDate(startDate) + .endDate(endDate) + .school(school) + .build()); } } } diff --git a/backend/src/test/java/com/festago/school/presentation/v1/SchoolV1ControllerTest.java b/backend/src/test/java/com/festago/school/presentation/v1/SchoolV1ControllerTest.java index 5c60a4c23..44fc3162c 100644 --- a/backend/src/test/java/com/festago/school/presentation/v1/SchoolV1ControllerTest.java +++ b/backend/src/test/java/com/festago/school/presentation/v1/SchoolV1ControllerTest.java @@ -1,20 +1,20 @@ package com.festago.school.presentation.v1; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.any; -import static org.mockito.BDDMockito.anyLong; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; -import com.festago.common.querydsl.SearchCondition; -import com.festago.school.application.SchoolV1QueryService; -import com.festago.school.domain.SchoolRegion; -import com.festago.school.presentation.v1.dto.SchoolV1Response; +import com.festago.school.application.v1.SchoolV1QueryService; +import com.festago.school.dto.v1.SchoolDetailV1Response; +import com.festago.school.dto.v1.SchoolFestivalV1Response; +import com.festago.school.dto.v1.SchoolSocialMediaV1Response; +import com.festago.school.repository.v1.SchoolFestivalV1SearchCondition; +import com.festago.socialmedia.domain.SocialMediaType; import com.festago.support.CustomWebMvcTest; -import java.nio.charset.StandardCharsets; +import java.time.LocalDate; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayNameGeneration; @@ -22,7 +22,8 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.SliceImpl; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; @@ -35,65 +36,90 @@ class SchoolV1ControllerTest { MockMvc mockMvc; @Autowired - ObjectMapper objectMapper; + SchoolV1QueryService schoolV1QueryService; @Autowired - SchoolV1QueryService schoolV1QueryService; + ObjectMapper objectMapper; @Nested - class 모든_학교_정보_조회 { + class 학교_상세_조회 { - final String uri = "/api/v1/schools"; + final String uri = "/api/v1/schools/{schoolId}"; @Nested @DisplayName("GET " + uri) class 올바른_주소로 { @Test - void 요청을_하면_200_응답과_학교_정보_목록이_반환된다() throws Exception { + void 요청을_보내면_200_응답과_body가_반환된다() throws Exception { // given - var expected = List.of( - new SchoolV1Response(1L, "teco.ac.kr", "테코대학교", SchoolRegion.서울), - new SchoolV1Response(2L, "wote.ac.kr", "우테대학교", SchoolRegion.부산) + var expected = new SchoolDetailV1Response( + 1L, "경북대학교", + "https://image.com/logo.png", + "https://image.com/backgroundLogo.png", + List.of( + new SchoolSocialMediaV1Response(SocialMediaType.YOUTUBE, "유튜브", + "https://image.com/youtube.png", "www.knu-youtube.com"), + new SchoolSocialMediaV1Response(SocialMediaType.INSTAGRAM, "인스타그램", + "https://image.com/youtube.png", "www.knu-instagram.com") + ) ); - given(schoolV1QueryService.findAll(any(SearchCondition.class))) - .willReturn(new PageImpl<>(expected)); + given(schoolV1QueryService.findDetailById(expected.id())) + .willReturn(expected); // when & then - mockMvc.perform(get(uri) + mockMvc.perform(get(uri, 1L) .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.size()").value(2)); + .andExpect(content().json(objectMapper.writeValueAsString(expected))); } } } @Nested - class 단일_학교_정보_조회 { + class 학교_축제_조회 { - final String uri = "/api/v1/schools/{schoolId}"; + final String uri = "/api/v1/schools/{schoolId}/festivals"; @Nested @DisplayName("GET " + uri) class 올바른_주소로 { @Test - void 요청을_하면_200_응답과_학교_정보가_반환된다() throws Exception { + void 요청을_보내면_200_응답과_body가_반환된다() throws Exception { // given - var expected = new SchoolV1Response(1L, "teco.ac.kr", "테코대학교", SchoolRegion.서울); - given(schoolV1QueryService.findById(anyLong())) + var today = LocalDate.now(); + var searchCondition = new SchoolFestivalV1SearchCondition(null, null, false, Pageable.ofSize(10)); + var content = List.of(new SchoolFestivalV1Response( + 1L, "경북대학교", today, today.plusDays(1), "www.image.com/image.png", + "아티스트" + )); + var expected = new SliceImpl(content, Pageable.ofSize(10), true); + + given(schoolV1QueryService.findFestivalsBySchoolId(1L, today, searchCondition)) .willReturn(expected); // when & then - String content = mockMvc.perform(get(uri, 1L) + mockMvc.perform(get(uri, 1L) .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) .andExpect(status().isOk()) - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8); - var actual = objectMapper.readValue(content, SchoolV1Response.class); + .andExpect(content().json(objectMapper.writeValueAsString(expected))); + } + + @Test + void 요청시_페이지가_20을_넘어가면_예외() throws Exception { + // given + int maxPageSize = 20; + + // when && then + mockMvc.perform(get(uri, 1L) + .contentType(MediaType.APPLICATION_JSON) + .param("size", String.valueOf(maxPageSize + 1))) + .andDo(print()) + .andExpect(status().isBadRequest()); - assertThat(actual).isEqualTo(expected); } } } From 6a04708cd680f6c98996d5273b102df5b76b5616 Mon Sep 17 00:00:00 2001 From: Seokjin Jeon Date: Wed, 21 Feb 2024 00:38:38 +0900 Subject: [PATCH 02/19] =?UTF-8?q?[BE]=20refactor:=20application-local.yml?= =?UTF-8?q?=20.gitignore=20=EB=93=B1=EB=A1=9D=20(#726)=20(#727)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: application-local.yml .gitignore 등록 (cherry picked from commit 51d81ec0be123702735e8d71bacc40d0e8118703) --- backend/.gitignore | 3 ++ .../src/main/resources/application-local.yml | 37 ------------------- 2 files changed, 3 insertions(+), 37 deletions(-) delete mode 100644 backend/src/main/resources/application-local.yml diff --git a/backend/.gitignore b/backend/.gitignore index 728a2c212..2ac591b77 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -40,3 +40,6 @@ out/ ### QUERYDSL ### /src/main/generated/ + +### APPLICATION PROPERTIES ### +src/main/resources/application-local.yml diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml deleted file mode 100644 index 4a61d05eb..000000000 --- a/backend/src/main/resources/application-local.yml +++ /dev/null @@ -1,37 +0,0 @@ -spring: - datasource: - url: jdbc:mysql://localhost:13306/festago - username: root - password: root - driver-class-name: com.mysql.cj.jdbc.Driver - jpa: - properties: - hibernate: - format_sql: true - show-sql: true - hibernate: - ddl-auto: validate - open-in-view: false - flyway: - enabled: true - baseline-on-migrate: true - baseline-version: 1 - data: - web: - pageable: - one-indexed-parameters: true - -logging: - file: - path: ./ - level: - org: - hibernate: - orm: - jdbc: - bind: trace - -festago: - qr-secret-key: festagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestago - auth-secret-key: festagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestago - cors-allow-origins: http://localhost:3000 From 8d6787e0cef89343e15c90c42a9242a3faf30864 Mon Sep 17 00:00:00 2001 From: Seokjin Jeon Date: Thu, 22 Feb 2024 15:43:59 +0900 Subject: [PATCH 03/19] =?UTF-8?q?[BE]=20fix:=20submodule=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=ED=95=98=EC=97=AC=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=ED=8E=98=EC=9D=B4=EC=A7=80=20CORS=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95=20(#728)=20(#729)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: 서브모듈 업데이트하여 관리자 페이지 CORS 문제 수정 --- backend/src/main/resources/festago-config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/resources/festago-config b/backend/src/main/resources/festago-config index 104ed427e..691d66a3c 160000 --- a/backend/src/main/resources/festago-config +++ b/backend/src/main/resources/festago-config @@ -1 +1 @@ -Subproject commit 104ed427e2ddcb2fb94ac151ad21c2e98d1d98f1 +Subproject commit 691d66a3ca69cd811862c06138c949043b546f59 From 160c8e18a2fb04e437889561335968214669ba94 Mon Sep 17 00:00:00 2001 From: Seokjin Jeon Date: Thu, 22 Feb 2024 15:44:10 +0900 Subject: [PATCH 04/19] =?UTF-8?q?[BE]=20refactor:=20AdminAuthService=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=83=81=EC=88=98=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20(#6?= =?UTF-8?q?12)=20(#720)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: AdminAuthService 도메인 상수 도메인으로 이동 - ROOT_ADMIN 상수 Admin 클래스로 이동 --- backend/src/main/java/com/festago/admin/domain/Admin.java | 1 + .../com/festago/auth/application/AdminAuthService.java | 8 +++----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/com/festago/admin/domain/Admin.java b/backend/src/main/java/com/festago/admin/domain/Admin.java index 009fc5319..48fea47aa 100644 --- a/backend/src/main/java/com/festago/admin/domain/Admin.java +++ b/backend/src/main/java/com/festago/admin/domain/Admin.java @@ -25,6 +25,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Admin extends BaseTimeEntity { + public static final String ROOT_ADMIN_NAME = "admin"; private static final int MIN_USERNAME_LENGTH = 4; private static final int MAX_USERNAME_LENGTH = 20; private static final int MIN_PASSWORD_LENGTH = 4; diff --git a/backend/src/main/java/com/festago/auth/application/AdminAuthService.java b/backend/src/main/java/com/festago/auth/application/AdminAuthService.java index e3de380a2..67e113105 100644 --- a/backend/src/main/java/com/festago/auth/application/AdminAuthService.java +++ b/backend/src/main/java/com/festago/auth/application/AdminAuthService.java @@ -22,8 +22,6 @@ @RequiredArgsConstructor public class AdminAuthService { - private static final String ROOT_ADMIN = "admin"; - private final AuthProvider authProvider; private final AdminRepository adminRepository; private final PasswordEncoder passwordEncoder; @@ -42,9 +40,9 @@ private Admin findAdminWithAuthenticate(AdminLoginRequest request) { } public void initializeRootAdmin(String password) { - adminRepository.findByUsername(ROOT_ADMIN).ifPresentOrElse(admin -> { + adminRepository.findByUsername(Admin.ROOT_ADMIN_NAME).ifPresentOrElse(admin -> { throw new BadRequestException(ErrorCode.DUPLICATE_ACCOUNT_USERNAME); - }, () -> adminRepository.save(new Admin(ROOT_ADMIN, passwordEncoder.encode(password)))); + }, () -> adminRepository.save(new Admin(Admin.ROOT_ADMIN_NAME, passwordEncoder.encode(password)))); } public AdminSignupResponse signup(Long adminId, AdminSignupRequest request) { @@ -65,7 +63,7 @@ private void validateExistsUsername(String username) { private void validateRootAdmin(Long adminId) { adminRepository.findById(adminId) .map(Admin::getUsername) - .filter(username -> Objects.equals(username, ROOT_ADMIN)) + .filter(username -> Objects.equals(username, Admin.ROOT_ADMIN_NAME)) .ifPresentOrElse(username -> { }, () -> { throw new ForbiddenException(ErrorCode.NOT_ENOUGH_PERMISSION); From d087b173bcdb62504009df251d952db418c05510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hyun-Seo=20Oh=20/=20=EC=98=A4=ED=98=84=EC=84=9C?= <100915276+carsago@users.noreply.github.com> Date: Thu, 22 Feb 2024 17:10:09 +0900 Subject: [PATCH 05/19] =?UTF-8?q?[BE]=20Admin=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EB=90=98=EB=8A=94=20School=20CRUD=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EC=9D=84=20admin=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#738)=20(#739)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 해당 패키지 admin으로 변경 * fix: QClass 패키지가 바뀌어서 깨지던 문제 수정 --- .../v1 => admin/application}/AdminSchoolV1QueryService.java | 6 +++--- .../{school/dto/v1 => admin/dto}/AdminSchoolV1Response.java | 2 +- .../admin/presentation/v1/AdminSchoolV1Controller.java | 4 ++-- .../repository}/AdminSchoolV1QueryDslRepository.java | 6 +++--- ...StepDefinitions.java => AdminSchoolStepDefinitions.java} | 4 ++-- .../AdminSchoolV1QueryServiceIntegrationTest.java | 6 +++--- .../admin/presentation/v1/AdminSchoolV1ControllerTest.java | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) rename backend/src/main/java/com/festago/{school/application/v1 => admin/application}/AdminSchoolV1QueryService.java (83%) rename backend/src/main/java/com/festago/{school/dto/v1 => admin/dto}/AdminSchoolV1Response.java (89%) rename backend/src/main/java/com/festago/{school/repository/v1 => admin/repository}/AdminSchoolV1QueryDslRepository.java (95%) rename backend/src/test/java/com/festago/acceptance/steps/{SchoolStepDefinitions.java => AdminSchoolStepDefinitions.java} (97%) rename backend/src/test/java/com/festago/{school => admin}/application/integration/AdminSchoolV1QueryServiceIntegrationTest.java (97%) diff --git a/backend/src/main/java/com/festago/school/application/v1/AdminSchoolV1QueryService.java b/backend/src/main/java/com/festago/admin/application/AdminSchoolV1QueryService.java similarity index 83% rename from backend/src/main/java/com/festago/school/application/v1/AdminSchoolV1QueryService.java rename to backend/src/main/java/com/festago/admin/application/AdminSchoolV1QueryService.java index f712fe3b0..01672f0ea 100644 --- a/backend/src/main/java/com/festago/school/application/v1/AdminSchoolV1QueryService.java +++ b/backend/src/main/java/com/festago/admin/application/AdminSchoolV1QueryService.java @@ -1,10 +1,10 @@ -package com.festago.school.application.v1; +package com.festago.admin.application; +import com.festago.admin.dto.AdminSchoolV1Response; +import com.festago.admin.repository.AdminSchoolV1QueryDslRepository; import com.festago.common.exception.ErrorCode; import com.festago.common.exception.NotFoundException; import com.festago.common.querydsl.SearchCondition; -import com.festago.school.dto.v1.AdminSchoolV1Response; -import com.festago.school.repository.v1.AdminSchoolV1QueryDslRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; diff --git a/backend/src/main/java/com/festago/school/dto/v1/AdminSchoolV1Response.java b/backend/src/main/java/com/festago/admin/dto/AdminSchoolV1Response.java similarity index 89% rename from backend/src/main/java/com/festago/school/dto/v1/AdminSchoolV1Response.java rename to backend/src/main/java/com/festago/admin/dto/AdminSchoolV1Response.java index 0ec743009..a5427281f 100644 --- a/backend/src/main/java/com/festago/school/dto/v1/AdminSchoolV1Response.java +++ b/backend/src/main/java/com/festago/admin/dto/AdminSchoolV1Response.java @@ -1,4 +1,4 @@ -package com.festago.school.dto.v1; +package com.festago.admin.dto; import com.festago.school.domain.SchoolRegion; import com.querydsl.core.annotations.QueryProjection; diff --git a/backend/src/main/java/com/festago/admin/presentation/v1/AdminSchoolV1Controller.java b/backend/src/main/java/com/festago/admin/presentation/v1/AdminSchoolV1Controller.java index da3fde281..c4ed314f2 100644 --- a/backend/src/main/java/com/festago/admin/presentation/v1/AdminSchoolV1Controller.java +++ b/backend/src/main/java/com/festago/admin/presentation/v1/AdminSchoolV1Controller.java @@ -1,13 +1,13 @@ package com.festago.admin.presentation.v1; +import com.festago.admin.application.AdminSchoolV1QueryService; +import com.festago.admin.dto.AdminSchoolV1Response; import com.festago.admin.presentation.v1.dto.SchoolV1CreateRequest; import com.festago.admin.presentation.v1.dto.SchoolV1UpdateRequest; import com.festago.common.aop.ValidPageable; import com.festago.common.querydsl.SearchCondition; import com.festago.school.application.SchoolCommandService; import com.festago.school.application.SchoolDeleteService; -import com.festago.school.application.v1.AdminSchoolV1QueryService; -import com.festago.school.dto.v1.AdminSchoolV1Response; import io.swagger.v3.oas.annotations.Hidden; import jakarta.validation.Valid; import java.net.URI; diff --git a/backend/src/main/java/com/festago/school/repository/v1/AdminSchoolV1QueryDslRepository.java b/backend/src/main/java/com/festago/admin/repository/AdminSchoolV1QueryDslRepository.java similarity index 95% rename from backend/src/main/java/com/festago/school/repository/v1/AdminSchoolV1QueryDslRepository.java rename to backend/src/main/java/com/festago/admin/repository/AdminSchoolV1QueryDslRepository.java index 041d92aa3..b4e0dedfc 100644 --- a/backend/src/main/java/com/festago/school/repository/v1/AdminSchoolV1QueryDslRepository.java +++ b/backend/src/main/java/com/festago/admin/repository/AdminSchoolV1QueryDslRepository.java @@ -1,12 +1,12 @@ -package com.festago.school.repository.v1; +package com.festago.admin.repository; import static com.festago.school.domain.QSchool.school; +import com.festago.admin.dto.AdminSchoolV1Response; +import com.festago.admin.dto.QAdminSchoolV1Response; import com.festago.common.querydsl.QueryDslRepositorySupport; import com.festago.common.querydsl.SearchCondition; import com.festago.school.domain.School; -import com.festago.school.dto.v1.AdminSchoolV1Response; -import com.festago.school.dto.v1.QAdminSchoolV1Response; import com.querydsl.core.types.dsl.BooleanExpression; import java.util.Optional; import org.springframework.data.domain.Page; diff --git a/backend/src/test/java/com/festago/acceptance/steps/SchoolStepDefinitions.java b/backend/src/test/java/com/festago/acceptance/steps/AdminSchoolStepDefinitions.java similarity index 97% rename from backend/src/test/java/com/festago/acceptance/steps/SchoolStepDefinitions.java rename to backend/src/test/java/com/festago/acceptance/steps/AdminSchoolStepDefinitions.java index 8bec71d4a..aabdb5850 100644 --- a/backend/src/test/java/com/festago/acceptance/steps/SchoolStepDefinitions.java +++ b/backend/src/test/java/com/festago/acceptance/steps/AdminSchoolStepDefinitions.java @@ -3,10 +3,10 @@ import static org.assertj.core.api.Assertions.assertThat; import com.festago.acceptance.CucumberClient; +import com.festago.admin.dto.AdminSchoolV1Response; import com.festago.admin.presentation.v1.dto.SchoolV1CreateRequest; import com.festago.admin.presentation.v1.dto.SchoolV1UpdateRequest; import com.festago.school.domain.SchoolRegion; -import com.festago.school.dto.v1.AdminSchoolV1Response; import io.cucumber.java.en.Given; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; @@ -16,7 +16,7 @@ import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; -public class SchoolStepDefinitions { +public class AdminSchoolStepDefinitions { @Autowired CucumberClient cucumberClient; diff --git a/backend/src/test/java/com/festago/school/application/integration/AdminSchoolV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/admin/application/integration/AdminSchoolV1QueryServiceIntegrationTest.java similarity index 97% rename from backend/src/test/java/com/festago/school/application/integration/AdminSchoolV1QueryServiceIntegrationTest.java rename to backend/src/test/java/com/festago/admin/application/integration/AdminSchoolV1QueryServiceIntegrationTest.java index 3178677cb..1750ceea0 100644 --- a/backend/src/test/java/com/festago/school/application/integration/AdminSchoolV1QueryServiceIntegrationTest.java +++ b/backend/src/test/java/com/festago/admin/application/integration/AdminSchoolV1QueryServiceIntegrationTest.java @@ -1,16 +1,16 @@ -package com.festago.school.application.integration; +package com.festago.admin.application.integration; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.SoftAssertions.assertSoftly; +import com.festago.admin.application.AdminSchoolV1QueryService; +import com.festago.admin.dto.AdminSchoolV1Response; import com.festago.common.exception.ErrorCode; import com.festago.common.exception.NotFoundException; import com.festago.common.querydsl.SearchCondition; -import com.festago.school.application.v1.AdminSchoolV1QueryService; import com.festago.school.domain.School; import com.festago.school.domain.SchoolRegion; -import com.festago.school.dto.v1.AdminSchoolV1Response; import com.festago.school.repository.SchoolRepository; import com.festago.support.ApplicationIntegrationTest; import org.junit.jupiter.api.BeforeEach; diff --git a/backend/src/test/java/com/festago/admin/presentation/v1/AdminSchoolV1ControllerTest.java b/backend/src/test/java/com/festago/admin/presentation/v1/AdminSchoolV1ControllerTest.java index 3b1b96ed5..fe7cec8c4 100644 --- a/backend/src/test/java/com/festago/admin/presentation/v1/AdminSchoolV1ControllerTest.java +++ b/backend/src/test/java/com/festago/admin/presentation/v1/AdminSchoolV1ControllerTest.java @@ -13,16 +13,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.admin.application.AdminSchoolV1QueryService; +import com.festago.admin.dto.AdminSchoolV1Response; import com.festago.admin.presentation.v1.dto.SchoolV1CreateRequest; import com.festago.admin.presentation.v1.dto.SchoolV1UpdateRequest; import com.festago.auth.domain.Role; import com.festago.common.querydsl.SearchCondition; import com.festago.school.application.SchoolCommandService; import com.festago.school.application.SchoolDeleteService; -import com.festago.school.application.v1.AdminSchoolV1QueryService; import com.festago.school.domain.SchoolRegion; import com.festago.school.dto.SchoolCreateCommand; -import com.festago.school.dto.v1.AdminSchoolV1Response; import com.festago.support.CustomWebMvcTest; import com.festago.support.WithMockAuth; import jakarta.servlet.http.Cookie; From 0bf5236493c4205bbc9eb6789f057a9d8b29ef9c Mon Sep 17 00:00:00 2001 From: Seokjin Jeon Date: Thu, 22 Feb 2024 17:55:08 +0900 Subject: [PATCH 06/19] =?UTF-8?q?[BE]=20refactor:=20FestivalV1QueryDslRepo?= =?UTF-8?q?sitory=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81,=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=B6=95=EC=A0=9C=20=EC=A1=B0=ED=9A=8C=20Service,?= =?UTF-8?q?=20Repository=20=EB=B6=84=EB=A6=AC=20(#709)=20(#715)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PopularFestivalV1QueryService.java | 7 +- .../FestivalV1QueryDslRepository.java | 135 +++++++++++------- .../PopularFestivalV1QueryDslRepository.java | 47 ++++++ ...estivalV1QueryServiceIntegrationTest.java} | 29 ++-- ...FestivalV1QueryServiceIntegrationTest.java | 103 +++++++++++++ .../PopularFestivalV1QueryServiceTest.java | 101 ------------- 6 files changed, 249 insertions(+), 173 deletions(-) create mode 100644 backend/src/main/java/com/festago/festival/repository/PopularFestivalV1QueryDslRepository.java rename backend/src/test/java/com/festago/festival/application/integration/{FestivalV1QueryServiceTest.java => FestivalV1QueryServiceIntegrationTest.java} (95%) create mode 100644 backend/src/test/java/com/festago/festival/application/integration/PopularFestivalV1QueryServiceIntegrationTest.java delete mode 100644 backend/src/test/java/com/festago/festival/application/integration/PopularFestivalV1QueryServiceTest.java diff --git a/backend/src/main/java/com/festago/festival/application/PopularFestivalV1QueryService.java b/backend/src/main/java/com/festago/festival/application/PopularFestivalV1QueryService.java index 59efc1348..ff850a433 100644 --- a/backend/src/main/java/com/festago/festival/application/PopularFestivalV1QueryService.java +++ b/backend/src/main/java/com/festago/festival/application/PopularFestivalV1QueryService.java @@ -2,7 +2,7 @@ import com.festago.festival.dto.FestivalV1Response; import com.festago.festival.dto.PopularFestivalsV1Response; -import com.festago.festival.repository.FestivalV1QueryDslRepository; +import com.festago.festival.repository.PopularFestivalV1QueryDslRepository; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -13,14 +13,13 @@ @RequiredArgsConstructor public class PopularFestivalV1QueryService { - private final FestivalV1QueryDslRepository festivalRepository; + private final PopularFestivalV1QueryDslRepository popularFestivalRepository; public PopularFestivalsV1Response findPopularFestivals() { - List popularFestivals = festivalRepository.findPopularFestival(); + List popularFestivals = popularFestivalRepository.findPopularFestivals(); return new PopularFestivalsV1Response( "요즘 뜨는 축제", popularFestivals ); } - } diff --git a/backend/src/main/java/com/festago/festival/repository/FestivalV1QueryDslRepository.java b/backend/src/main/java/com/festago/festival/repository/FestivalV1QueryDslRepository.java index 27ef3b36c..74262d4c1 100644 --- a/backend/src/main/java/com/festago/festival/repository/FestivalV1QueryDslRepository.java +++ b/backend/src/main/java/com/festago/festival/repository/FestivalV1QueryDslRepository.java @@ -1,10 +1,11 @@ package com.festago.festival.repository; - import static com.festago.festival.domain.QFestival.festival; import static com.festago.festival.domain.QFestivalQueryInfo.festivalQueryInfo; import static com.festago.school.domain.QSchool.school; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.festival.domain.Festival; import com.festago.festival.dto.FestivalV1Response; import com.festago.festival.dto.QFestivalV1Response; import com.festago.festival.dto.QSchoolV1Response; @@ -12,94 +13,119 @@ import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQuery; -import com.querydsl.jpa.impl.JPAQueryFactory; import java.time.LocalDate; import java.util.List; -import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.domain.SliceImpl; import org.springframework.stereotype.Repository; -@RequiredArgsConstructor @Repository -public class FestivalV1QueryDslRepository { +public class FestivalV1QueryDslRepository extends QueryDslRepositorySupport { - private static final long NEXT_PAGE_DATA = 1; + private static final long NEXT_PAGE_TEMPORARY_COUNT = 1; - private final JPAQueryFactory queryFactory; + public FestivalV1QueryDslRepository() { + super(Festival.class); + } public Slice findBy(FestivalSearchCondition searchCondition) { FestivalFilter filter = searchCondition.filter(); Pageable page = searchCondition.page(); - List content = selectResponse() - .where( - dynamicWhere(filter, searchCondition.currentTime(), searchCondition.lastFestivalId(), - searchCondition.lastStartDate(), searchCondition.region())) + List content = getSelectQuery() + .where(dynamicWhere(filter, searchCondition.currentTime(), searchCondition.lastFestivalId(), + searchCondition.lastStartDate(), searchCondition.region())) .orderBy(dynamicOrderBy(filter)) - .limit(page.getPageSize() + NEXT_PAGE_DATA) + .limit(page.getPageSize() + NEXT_PAGE_TEMPORARY_COUNT) .fetch(); - - return new SliceImpl<>(removeNextPageData(content, page), page, haveNextPageContent(content, page)); + return getResponse(content, page); } - private JPAQuery selectResponse() { - return queryFactory.select(new QFestivalV1Response( - festival.id, - festival.name, - festival.startDate, - festival.endDate, - festival.thumbnail, - new QSchoolV1Response( - school.id, - school.name - ), - festivalQueryInfo.artistInfo) - ) + private JPAQuery getSelectQuery() { + return select(new QFestivalV1Response( + festival.id, + festival.name, + festival.startDate, + festival.endDate, + festival.thumbnail, + new QSchoolV1Response( + school.id, + school.name + ), + festivalQueryInfo.artistInfo) + ) .from(festival) .innerJoin(school).on(school.id.eq(festival.school.id)) .innerJoin(festivalQueryInfo).on(festivalQueryInfo.festivalId.eq(festival.id)); } - private BooleanExpression dynamicWhere(FestivalFilter filter, LocalDate currentTime, Long lastFestivalId, - LocalDate lastStartDate, - SchoolRegion region) { + private BooleanExpression dynamicWhere( + FestivalFilter filter, + LocalDate currentTime, + Long lastFestivalId, + LocalDate lastStartDate, + SchoolRegion region + ) { + BooleanExpression booleanExpression = getBooleanExpression(filter, currentTime, lastFestivalId, lastStartDate); + booleanExpression = applyRegion(booleanExpression, region); + return booleanExpression; + } + + private BooleanExpression getBooleanExpression( + FestivalFilter filter, + LocalDate currentTime, + Long lastFestivalId, + LocalDate lastStartDate + ) { if (hasCursor(lastStartDate, lastFestivalId)) { - return cursorBasedWhere(filter, currentTime, lastFestivalId, lastStartDate, region); + return getCursorBasedBooleanExpression(filter, currentTime, lastFestivalId, lastStartDate); } - - BooleanExpression filterResult = switch (filter) { - case PLANNED -> festival.startDate.gt(currentTime); - case PROGRESS -> festival.startDate.loe(currentTime).and(festival.endDate.goe(currentTime)); - case END -> festival.endDate.lt(currentTime); - }; - return addRegion(filterResult, region); + return getDefaultBooleanExpression(filter, currentTime); } private boolean hasCursor(LocalDate lastStartDate, Long lastFestivalId) { return lastStartDate != null && lastFestivalId != null; } - private BooleanExpression cursorBasedWhere(FestivalFilter filter, LocalDate currentTime, Long lastFestivalId, - LocalDate lastStartDate, SchoolRegion region) { - BooleanExpression filterResult = switch (filter) { + private BooleanExpression getCursorBasedBooleanExpression( + FestivalFilter filter, + LocalDate currentTime, + Long lastFestivalId, + LocalDate lastStartDate + ) { + return switch (filter) { case PLANNED -> festival.startDate.gt(lastStartDate) .or(festival.startDate.eq(lastStartDate) .and(festival.id.gt(lastFestivalId))); + case PROGRESS -> festival.startDate.lt(lastStartDate) .or(festival.startDate.eq(lastStartDate) .and(festival.id.gt(lastFestivalId))) .and(festival.endDate.goe(currentTime)); + case END -> festival.endDate.lt(currentTime); }; - return addRegion(filterResult, region); } - private BooleanExpression addRegion(BooleanExpression filterResult, SchoolRegion region) { - if (region == null || region == SchoolRegion.ANY) { - return filterResult; + private BooleanExpression getDefaultBooleanExpression( + FestivalFilter filter, + LocalDate currentTime + ) { + return switch (filter) { + case PLANNED -> festival.startDate.gt(currentTime); + + case PROGRESS -> festival.startDate.loe(currentTime) + .and(festival.endDate.goe(currentTime)); + + case END -> festival.endDate.lt(currentTime); + }; + } + + private BooleanExpression applyRegion(BooleanExpression booleanExpression, SchoolRegion region) { + if (region == SchoolRegion.ANY) { + return booleanExpression; } - return filterResult.and(school.region.eq(region)); + return booleanExpression.and(school.region.eq(region)); } private OrderSpecifier[] dynamicOrderBy(FestivalFilter filter) { @@ -110,20 +136,19 @@ private OrderSpecifier[] dynamicOrderBy(FestivalFilter filter) { }; } - private List removeNextPageData(List content, Pageable page) { - if (haveNextPageContent(content, page)) { - return content.subList(0, page.getPageSize()); + private Slice getResponse(List content, Pageable page) { + if (hasContentNextPage(content, page)) { + removeTemporaryContent(content); + return new SliceImpl<>(content, page, true); } - return content; + return new SliceImpl<>(content, page, false); } - private boolean haveNextPageContent(List content, Pageable page) { + private boolean hasContentNextPage(List content, Pageable page) { return content.size() > page.getPageSize(); } - public List findPopularFestival() { - return selectResponse() - .limit(7) - .fetch(); + private void removeTemporaryContent(List content) { + content.remove(content.size() - 1); } } diff --git a/backend/src/main/java/com/festago/festival/repository/PopularFestivalV1QueryDslRepository.java b/backend/src/main/java/com/festago/festival/repository/PopularFestivalV1QueryDslRepository.java new file mode 100644 index 000000000..21e9f2fd5 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/repository/PopularFestivalV1QueryDslRepository.java @@ -0,0 +1,47 @@ +package com.festago.festival.repository; + +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.festival.domain.QFestivalQueryInfo.festivalQueryInfo; +import static com.festago.school.domain.QSchool.school; + +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.festival.domain.Festival; +import com.festago.festival.dto.FestivalV1Response; +import com.festago.festival.dto.QFestivalV1Response; +import com.festago.festival.dto.QSchoolV1Response; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class PopularFestivalV1QueryDslRepository extends QueryDslRepositorySupport { + + private static final int POPULAR_FESTIVAL_LIMIT_COUNT = 7; + + public PopularFestivalV1QueryDslRepository() { + super(Festival.class); + } + + /** + * 아직 명확한 추천 축제 기준이 없으므로 생성 시간(식별자) 내림차순으로 반환하도록 함 + */ + public List findPopularFestivals() { + return select(new QFestivalV1Response( + festival.id, + festival.name, + festival.startDate, + festival.endDate, + festival.thumbnail, + new QSchoolV1Response( + school.id, + school.name + ), + festivalQueryInfo.artistInfo) + ) + .from(festival) + .innerJoin(school).on(school.id.eq(festival.school.id)) + .innerJoin(festivalQueryInfo).on(festivalQueryInfo.festivalId.eq(festival.id)) + .orderBy(festival.id.desc()) + .limit(POPULAR_FESTIVAL_LIMIT_COUNT) + .fetch(); + } +} diff --git a/backend/src/test/java/com/festago/festival/application/integration/FestivalV1QueryServiceTest.java b/backend/src/test/java/com/festago/festival/application/integration/FestivalV1QueryServiceIntegrationTest.java similarity index 95% rename from backend/src/test/java/com/festago/festival/application/integration/FestivalV1QueryServiceTest.java rename to backend/src/test/java/com/festago/festival/application/integration/FestivalV1QueryServiceIntegrationTest.java index d639c6088..46e43b2f7 100644 --- a/backend/src/test/java/com/festago/festival/application/integration/FestivalV1QueryServiceTest.java +++ b/backend/src/test/java/com/festago/festival/application/integration/FestivalV1QueryServiceIntegrationTest.java @@ -4,7 +4,6 @@ import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.mockito.BDDMockito.given; -import com.fasterxml.jackson.databind.ObjectMapper; import com.festago.artist.domain.Artist; import com.festago.artist.repository.ArtistRepository; import com.festago.festival.application.FestivalV1QueryService; @@ -40,22 +39,26 @@ @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") @Transactional -class FestivalV1QueryServiceTest extends ApplicationIntegrationTest { +class FestivalV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + FestivalV1QueryService festivalV1QueryService; @Autowired FestivalInfoRepository festivalInfoRepository; + @Autowired FestivalRepository festivalRepository; + @Autowired SchoolRepository schoolRepository; + @Autowired ArtistRepository artistRepository; + @Autowired FestivalInfoSerializer festivalInfoSerializer; - @Autowired - FestivalV1QueryService festivalV1QueryService; - @Autowired - ObjectMapper objectMapper; + @Autowired Clock clock; @@ -131,7 +134,7 @@ class 지역_필터_미적용 { @Test void 진행_중_축제는_5개_이다() { // given - var request = new FestivalV1QueryRequest(null, FestivalFilter.PROGRESS, null, null); + var request = new FestivalV1QueryRequest(SchoolRegion.ANY, FestivalFilter.PROGRESS, null, null); // when var actual = festivalV1QueryService.findFestivals(Pageable.ofSize(10), request); @@ -146,7 +149,7 @@ class 지역_필터_미적용 { @Test void 진행_예정_축제는_3개_이다() { // given - var request = new FestivalV1QueryRequest(null, FestivalFilter.PLANNED, null, null); + var request = new FestivalV1QueryRequest(SchoolRegion.ANY, FestivalFilter.PLANNED, null, null); // when var actual = festivalV1QueryService.findFestivals(Pageable.ofSize(10), request); @@ -161,7 +164,7 @@ class 지역_필터_미적용 { @Test void 원하는_갯수의_축제를_조회하면_마지막_페이지_여부를_알_수_있다() { // given - var request = new FestivalV1QueryRequest(null, FestivalFilter.PROGRESS, null, null); + var request = new FestivalV1QueryRequest(SchoolRegion.ANY, FestivalFilter.PROGRESS, null, null); // when var response = festivalV1QueryService.findFestivals(Pageable.ofSize(4), request); @@ -176,7 +179,7 @@ class 지역_필터_미적용 { @Test void 진행_예정_축제는_시작_날짜가_빠른_순으로_정렬되고_시작_날짜가_같으면_식별자의_오름차순으로_정렬되어_반환된다() { // given - var request = new FestivalV1QueryRequest(null, FestivalFilter.PLANNED, null, null); + var request = new FestivalV1QueryRequest(SchoolRegion.ANY, FestivalFilter.PLANNED, null, null); // when var response = festivalV1QueryService.findFestivals(Pageable.ofSize(10), request); @@ -194,7 +197,7 @@ class 지역_필터_미적용 { @Test void 진행_중_축제는_시작_날짜가_느린_순으로_정렬되고_시작_날짜가_같으면_식별자의_오름차순으로_정렬되어_반환된다() { // given - var request = new FestivalV1QueryRequest(null, FestivalFilter.PROGRESS, null, null); + var request = new FestivalV1QueryRequest(SchoolRegion.ANY, FestivalFilter.PROGRESS, null, null); // when var response = festivalV1QueryService.findFestivals(Pageable.ofSize(10), request); @@ -214,10 +217,10 @@ class 지역_필터_미적용 { @Test void 커서_기반_페이징이_적용되어야_한다() { // given - var firstRequest = new FestivalV1QueryRequest(null, FestivalFilter.PROGRESS, null, null); + var firstRequest = new FestivalV1QueryRequest(SchoolRegion.ANY, FestivalFilter.PROGRESS, null, null); var firstResponse = festivalV1QueryService.findFestivals(Pageable.ofSize(2), firstRequest); var lastElement = firstResponse.getContent().get(1); - var secondRequest = new FestivalV1QueryRequest(null, FestivalFilter.PROGRESS, lastElement.id(), + var secondRequest = new FestivalV1QueryRequest(SchoolRegion.ANY, FestivalFilter.PROGRESS, lastElement.id(), lastElement.startDate()); // when diff --git a/backend/src/test/java/com/festago/festival/application/integration/PopularFestivalV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/festival/application/integration/PopularFestivalV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..f3d36376d --- /dev/null +++ b/backend/src/test/java/com/festago/festival/application/integration/PopularFestivalV1QueryServiceIntegrationTest.java @@ -0,0 +1,103 @@ +package com.festago.festival.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.artist.domain.Artist; +import com.festago.artist.repository.ArtistRepository; +import com.festago.festival.application.PopularFestivalV1QueryService; +import com.festago.festival.domain.Festival; +import com.festago.festival.domain.FestivalInfoSerializer; +import com.festago.festival.domain.FestivalQueryInfo; +import com.festago.festival.dto.FestivalV1Response; +import com.festago.festival.repository.FestivalInfoRepository; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class PopularFestivalV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + PopularFestivalV1QueryService popularQueryService; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + ArtistRepository artistRepository; + + @Autowired + FestivalInfoRepository festivalInfoRepository; + + @Autowired + FestivalInfoSerializer festivalInfoSerializer; + + LocalDate now = LocalDate.parse("2077-06-30"); + + Festival 첫번째로_저장된_축제; + Festival 두번째로_저장된_축제; + Festival 세번째로_저장된_축제; + Festival 네번째로_저장된_축제; + Festival 다섯번째로_저장된_축제; + Festival 여섯번째로_저장된_축제; + Festival 일곱번째로_저장된_축제; + Festival 여덟번째로_저장된_축제; + + @BeforeEach + void setting() { + School 테코대학교 = schoolRepository.save(new School("teco.ac.kr", "테코대학교", SchoolRegion.서울)); + + 첫번째로_저장된_축제 = festivalRepository.save(new Festival("첫번째로 저장된 축제", now, now, 테코대학교)); + 두번째로_저장된_축제 = festivalRepository.save(new Festival("두번째로 저장된 축제", now, now, 테코대학교)); + 세번째로_저장된_축제 = festivalRepository.save(new Festival("세번째로 저장된 축제", now, now, 테코대학교)); + 네번째로_저장된_축제 = festivalRepository.save(new Festival("네번째로 저장된 축제", now, now, 테코대학교)); + 다섯번째로_저장된_축제 = festivalRepository.save(new Festival("다섯번째로 저장된 축제", now, now, 테코대학교)); + 여섯번째로_저장된_축제 = festivalRepository.save(new Festival("여섯번째로 저장된 축제", now, now, 테코대학교)); + 일곱번째로_저장된_축제 = festivalRepository.save(new Festival("일곱번째로 저장된 축제", now, now, 테코대학교)); + 여덟번째로_저장된_축제 = festivalRepository.save(new Festival("여덟번째로 저장된 축제", now, now, 테코대학교)); + + Artist artist = artistRepository.save(new Artist("name1", "image1")); + List artists = List.of(artist); + festivalInfoRepository.save(FestivalQueryInfo.of(첫번째로_저장된_축제, artists, festivalInfoSerializer)); + festivalInfoRepository.save(FestivalQueryInfo.of(두번째로_저장된_축제, artists, festivalInfoSerializer)); + festivalInfoRepository.save(FestivalQueryInfo.of(세번째로_저장된_축제, artists, festivalInfoSerializer)); + festivalInfoRepository.save(FestivalQueryInfo.of(네번째로_저장된_축제, artists, festivalInfoSerializer)); + festivalInfoRepository.save(FestivalQueryInfo.of(다섯번째로_저장된_축제, artists, festivalInfoSerializer)); + festivalInfoRepository.save(FestivalQueryInfo.of(여섯번째로_저장된_축제, artists, festivalInfoSerializer)); + festivalInfoRepository.save(FestivalQueryInfo.of(일곱번째로_저장된_축제, artists, festivalInfoSerializer)); + festivalInfoRepository.save(FestivalQueryInfo.of(여덟번째로_저장된_축제, artists, festivalInfoSerializer)); + } + + @Test + void 인기_축제는_7개까지_반환되고_식별자의_내림차순으로_정렬되어_조회된다() { + // given && when + var expect = popularQueryService.findPopularFestivals().content(); + + // then + assertThat(expect) + .map(FestivalV1Response::id) + .hasSize(7) + .containsExactly( + 여덟번째로_저장된_축제.getId(), + 일곱번째로_저장된_축제.getId(), + 여섯번째로_저장된_축제.getId(), + 다섯번째로_저장된_축제.getId(), + 네번째로_저장된_축제.getId(), + 세번째로_저장된_축제.getId(), + 두번째로_저장된_축제.getId() + ); + } +} diff --git a/backend/src/test/java/com/festago/festival/application/integration/PopularFestivalV1QueryServiceTest.java b/backend/src/test/java/com/festago/festival/application/integration/PopularFestivalV1QueryServiceTest.java deleted file mode 100644 index aaae263d1..000000000 --- a/backend/src/test/java/com/festago/festival/application/integration/PopularFestivalV1QueryServiceTest.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.festago.festival.application.integration; - -import static org.assertj.core.api.SoftAssertions.assertSoftly; - -import com.festago.artist.domain.Artist; -import com.festago.artist.repository.ArtistRepository; -import com.festago.festival.application.PopularFestivalV1QueryService; -import com.festago.festival.domain.Festival; -import com.festago.festival.domain.FestivalInfoSerializer; -import com.festago.festival.domain.FestivalQueryInfo; -import com.festago.festival.dto.PopularFestivalsV1Response; -import com.festago.festival.repository.FestivalInfoRepository; -import com.festago.festival.repository.FestivalRepository; -import com.festago.school.domain.School; -import com.festago.school.domain.SchoolRegion; -import com.festago.school.repository.SchoolRepository; -import com.festago.support.ApplicationIntegrationTest; -import java.time.LocalDate; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class PopularFestivalV1QueryServiceTest extends ApplicationIntegrationTest { - - @Autowired - PopularFestivalV1QueryService popularQueryService; - - @Autowired - SchoolRepository schoolRepository; - - @Autowired - FestivalRepository festivalRepository; - - @Autowired - ArtistRepository artistRepository; - - @Autowired - FestivalInfoRepository festivalInfoRepository; - - @Autowired - FestivalInfoSerializer festivalInfoSerializer; - - @BeforeEach - void setting() { - LocalDate now = LocalDate.now(); - - School school1 = schoolRepository.save(new School("domain1", "school1", SchoolRegion.서울)); - School school2 = schoolRepository.save(new School("domain2", "school2", SchoolRegion.서울)); - School school3 = schoolRepository.save(new School("domain3", "school3", SchoolRegion.대구)); - - Festival festival1 = festivalRepository.save( - new Festival("festival1", now.minusDays(4), now.plusDays(2), school1)); - Festival festival2 = festivalRepository.save( - new Festival("festival2", now.minusDays(4), now.plusDays(3), school2)); - Festival festival3 = festivalRepository.save( - new Festival("festival3", now.plusDays(3), now.plusDays(4), school3)); - Festival festival4 = festivalRepository.save( - new Festival("festival1", now.minusDays(3), now.plusDays(2), school1)); - Festival festival5 = festivalRepository.save( - new Festival("festival1", now.minusDays(2), now.plusDays(2), school1)); - Festival festival6 = festivalRepository.save( - new Festival("festival1", now.minusDays(1), now.plusDays(2), school1)); - Festival festival7 = festivalRepository.save( - new Festival("festival3", now.plusDays(3), now.plusDays(4), school3)); - Festival festival8 = festivalRepository.save( - new Festival("festival3", now.plusDays(2), now.plusDays(4), school3)); - - Artist artist1 = artistRepository.save(new Artist("name1", "image1")); - Artist artist2 = artistRepository.save(new Artist("name2", "image2")); - Artist artist3 = artistRepository.save(new Artist("name3", "image3")); - - List artists = List.of(artist1, artist2, artist3); - - festivalInfoRepository.save(FestivalQueryInfo.of(festival1, artists, festivalInfoSerializer)); - festivalInfoRepository.save(FestivalQueryInfo.of(festival2, artists, festivalInfoSerializer)); - festivalInfoRepository.save(FestivalQueryInfo.of(festival3, artists, festivalInfoSerializer)); - festivalInfoRepository.save(FestivalQueryInfo.of(festival4, artists, festivalInfoSerializer)); - festivalInfoRepository.save(FestivalQueryInfo.of(festival5, artists, festivalInfoSerializer)); - festivalInfoRepository.save(FestivalQueryInfo.of(festival6, artists, festivalInfoSerializer)); - festivalInfoRepository.save(FestivalQueryInfo.of(festival7, artists, festivalInfoSerializer)); - festivalInfoRepository.save(FestivalQueryInfo.of(festival8, artists, festivalInfoSerializer)); - } - - @Test - void 인기_축제_목록을_받는다() { - // given && when - PopularFestivalsV1Response actual = popularQueryService.findPopularFestivals(); - - // then - assertSoftly(softly -> { - softly.assertThat(actual.title()).isEqualTo("요즘 뜨는 축제"); - softly.assertThat(actual.content()).hasSize(7); - }); - } - -} From dac012562fb98fb31e40364dd59f45fab31ecddb Mon Sep 17 00:00:00 2001 From: Seokjin Jeon Date: Thu, 22 Feb 2024 19:04:09 +0900 Subject: [PATCH 07/19] =?UTF-8?q?[BE]=20refactor:=20Bean=20Validation=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EA=B0=9C=EC=84=A0=20(#733)=20(#7?= =?UTF-8?q?34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: Bean Validation 메시지 개선 --- .../common/exception/dto/ErrorResponse.java | 29 ---------------- .../exception/dto/ValidErrorResponse.java | 33 +++++++++++++++++++ .../handler/GlobalExceptionHandler.java | 12 ++++--- .../java/com/festago/config/WebConfig.java | 9 +++++ 4 files changed, 50 insertions(+), 33 deletions(-) create mode 100644 backend/src/main/java/com/festago/common/exception/dto/ValidErrorResponse.java diff --git a/backend/src/main/java/com/festago/common/exception/dto/ErrorResponse.java b/backend/src/main/java/com/festago/common/exception/dto/ErrorResponse.java index 610b55dac..110bf7866 100644 --- a/backend/src/main/java/com/festago/common/exception/dto/ErrorResponse.java +++ b/backend/src/main/java/com/festago/common/exception/dto/ErrorResponse.java @@ -3,18 +3,12 @@ import com.festago.common.exception.ErrorCode; import com.festago.common.exception.FestaGoException; import com.festago.common.exception.ValidException; -import java.util.List; -import org.springframework.validation.BindException; -import org.springframework.validation.FieldError; -import org.springframework.web.bind.MethodArgumentNotValidException; public record ErrorResponse( ErrorCode errorCode, String message ) { - private static final String NOT_CUSTOM_EXCEPTION = "Validation failed"; - public static ErrorResponse from(FestaGoException festaGoException) { return ErrorResponse.from(festaGoException.getErrorCode()); } @@ -26,27 +20,4 @@ public static ErrorResponse from(ErrorCode errorCode) { public static ErrorResponse from(ValidException e) { return new ErrorResponse(e.getErrorCode(), e.getMessage()); } - - public static ErrorResponse from(ErrorCode errorCode, MethodArgumentNotValidException e) { - List fieldErrors = e.getBindingResult().getFieldErrors(); - if (fieldErrors.isEmpty()) { - return new ErrorResponse(errorCode, errorCode.getMessage()); - } - if (e.getMessage().startsWith(NOT_CUSTOM_EXCEPTION)) { - return new ErrorResponse(errorCode, fieldErrors.get(0).getDefaultMessage()); - } - return new ErrorResponse(errorCode, e.getMessage()); - } - - public static ErrorResponse from(ErrorCode errorCode, BindException e) { - List fieldErrors = e.getBindingResult().getFieldErrors(); - String message1 = e.getMessage(); - if (fieldErrors.isEmpty()) { - return new ErrorResponse(errorCode, errorCode.getMessage()); - } - if (e.getMessage().startsWith(NOT_CUSTOM_EXCEPTION)) { - return new ErrorResponse(errorCode, fieldErrors.get(0).getDefaultMessage()); - } - return new ErrorResponse(errorCode, e.getMessage()); - } } diff --git a/backend/src/main/java/com/festago/common/exception/dto/ValidErrorResponse.java b/backend/src/main/java/com/festago/common/exception/dto/ValidErrorResponse.java new file mode 100644 index 000000000..6874008f2 --- /dev/null +++ b/backend/src/main/java/com/festago/common/exception/dto/ValidErrorResponse.java @@ -0,0 +1,33 @@ +package com.festago.common.exception.dto; + +import static java.util.stream.Collectors.toMap; + +import com.festago.common.exception.ErrorCode; +import java.util.Map; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; + +public record ValidErrorResponse( + ErrorCode errorCode, + String message, + Map result +) { + + public static ValidErrorResponse from(MethodArgumentNotValidException e) { + Map result = e.getBindingResult().getFieldErrors().stream() + .collect(toMap(FieldError::getField, ValidErrorResponse::getFieldErrorMessage)); + return new ValidErrorResponse( + ErrorCode.INVALID_REQUEST_ARGUMENT, + ErrorCode.INVALID_REQUEST_ARGUMENT.getMessage(), + result + ); + } + + private static String getFieldErrorMessage(FieldError error) { + String message = error.getDefaultMessage(); + if (message == null) { + return "잘못된 요청입니다."; + } + return message; + } +} diff --git a/backend/src/main/java/com/festago/common/handler/GlobalExceptionHandler.java b/backend/src/main/java/com/festago/common/handler/GlobalExceptionHandler.java index 15224f338..5ef69d696 100644 --- a/backend/src/main/java/com/festago/common/handler/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/festago/common/handler/GlobalExceptionHandler.java @@ -12,6 +12,7 @@ import com.festago.common.exception.UnexpectedException; import com.festago.common.exception.ValidException; import com.festago.common.exception.dto.ErrorResponse; +import com.festago.common.exception.dto.ValidErrorResponse; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; @@ -121,11 +122,14 @@ public ResponseEntity handle(Exception e, HttpServletRequest requ } @Override - protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException e, - HttpHeaders headers, - HttpStatusCode status, WebRequest request) { + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException e, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request + ) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(ErrorResponse.from(ErrorCode.INVALID_REQUEST_ARGUMENT, e)); + .body(ValidErrorResponse.from(e)); } private void logInfo(FestaGoException e, HttpServletRequest request) { diff --git a/backend/src/main/java/com/festago/config/WebConfig.java b/backend/src/main/java/com/festago/config/WebConfig.java index 2a9a03d2f..5c85d6d1c 100644 --- a/backend/src/main/java/com/festago/config/WebConfig.java +++ b/backend/src/main/java/com/festago/config/WebConfig.java @@ -1,9 +1,13 @@ package com.festago.config; +import java.util.Locale; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.LocaleResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.i18n.FixedLocaleResolver; @Configuration public class WebConfig implements WebMvcConfigurer { @@ -23,4 +27,9 @@ public void addCorsMappings(CorsRegistry registry) { .allowCredentials(true) .maxAge(3600); } + + @Bean + public LocaleResolver localeResolver() { + return new FixedLocaleResolver(Locale.KOREA); + } } From e56db9fb983866b94bfc02a5743617532dbd5b6f Mon Sep 17 00:00:00 2001 From: Seokjin Jeon Date: Thu, 22 Feb 2024 20:18:53 +0900 Subject: [PATCH 08/19] =?UTF-8?q?[BE]=20feat:=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#740)=20(#741)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 관리자 로그아웃 API 추가 * feat: 로그아웃 쿠키 maxAge 0으로 설정 * refactor: 불필요한 `@Hidden` 어노테이션 삭제 --- .../presentation/AdminAuthController.java | 28 +++++++++-- .../presentation/AdminAuthControllerTest.java | 46 ++++++++++++++++++- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/backend/src/main/java/com/festago/auth/presentation/AdminAuthController.java b/backend/src/main/java/com/festago/auth/presentation/AdminAuthController.java index 737731caf..73cfe36b2 100644 --- a/backend/src/main/java/com/festago/auth/presentation/AdminAuthController.java +++ b/backend/src/main/java/com/festago/auth/presentation/AdminAuthController.java @@ -8,10 +8,12 @@ import com.festago.auth.dto.RootAdminInitializeRequest; import io.swagger.v3.oas.annotations.Hidden; import jakarta.validation.Valid; +import java.time.Duration; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -26,14 +28,13 @@ public class AdminAuthController { private final AdminAuthService adminAuthService; @PostMapping("/login") - @Hidden public ResponseEntity login(@RequestBody @Valid AdminLoginRequest request) { String token = adminAuthService.login(request); - return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, getCookie(token)) + return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, createLoginCookie(token)) .build(); } - private String getCookie(String token) { + private String createLoginCookie(String token) { return ResponseCookie.from("token", token) .httpOnly(true) .secure(true) @@ -42,8 +43,26 @@ private String getCookie(String token) { .build().toString(); } + /** + * 클라이언트 측에서 httpOnly 쿠키를 조작할 수 없기 때문에, 서버 측에서 쿠키를 관리해주어야 함 + */ + @GetMapping("/logout") + public ResponseEntity logout() { + return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, createLogoutCookie()) + .build(); + } + + private String createLogoutCookie() { + return ResponseCookie.from("token", "") + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .maxAge(Duration.ZERO) + .build().toString(); + } + @PostMapping("/signup") - @Hidden public ResponseEntity signupAdminAccount(@RequestBody @Valid AdminSignupRequest request, @Admin Long adminId) { AdminSignupResponse response = adminAuthService.signup(adminId, request); @@ -52,7 +71,6 @@ public ResponseEntity signupAdminAccount(@RequestBody @Vali } @PostMapping("/initialize") - @Hidden public ResponseEntity initializeRootAdmin(@RequestBody @Valid RootAdminInitializeRequest request) { adminAuthService.initializeRootAdmin(request.password()); return ResponseEntity.ok() diff --git a/backend/src/test/java/com/festago/auth/presentation/AdminAuthControllerTest.java b/backend/src/test/java/com/festago/auth/presentation/AdminAuthControllerTest.java index 0ff4de8fe..9159c0f15 100644 --- a/backend/src/test/java/com/festago/auth/presentation/AdminAuthControllerTest.java +++ b/backend/src/test/java/com/festago/auth/presentation/AdminAuthControllerTest.java @@ -3,6 +3,7 @@ import static org.mockito.BDDMockito.any; import static org.mockito.BDDMockito.anyLong; import static org.mockito.BDDMockito.given; +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.result.MockMvcResultMatchers.cookie; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -73,6 +74,48 @@ class 올바른_주소로 { } } + @Nested + class 어드민_로그아웃 { + + final String uri = "/admin/api/logout"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답과_비어있는_값의_로그인_토큰이_담긴_쿠기가_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .cookie(AUTH_TOKEN)) + .andExpect(status().isOk()) + .andExpect(cookie().exists(AUTH_TOKEN.getName())) + .andExpect(cookie().value(AUTH_TOKEN.getName(), "")) + .andExpect(cookie().path(AUTH_TOKEN.getName(), "/")) + .andExpect(cookie().secure(AUTH_TOKEN.getName(), true)) + .andExpect(cookie().httpOnly(AUTH_TOKEN.getName(), true)) + .andExpect(cookie().sameSite(AUTH_TOKEN.getName(), "None")); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .cookie(AUTH_TOKEN)) + .andExpect(status().isNotFound()); + } + } + } + @Nested class 어드민_회원가입 { @@ -91,9 +134,8 @@ class 올바른_주소로 { .willReturn(response); // when & then - Cookie token = new Cookie("token", "token"); mockMvc.perform(post(uri) - .cookie(token) + .cookie(AUTH_TOKEN) .content(objectMapper.writeValueAsString(request)) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) From e1a97b587ef49410c702f45ed3d8ea81cbc17b91 Mon Sep 17 00:00:00 2001 From: Seokjin Jeon Date: Fri, 23 Feb 2024 01:16:19 +0900 Subject: [PATCH 09/19] =?UTF-8?q?[BE]=20feat:=20SchoolRegion=20=EC=A7=80?= =?UTF-8?q?=EC=97=AD=20=EC=B6=94=EA=B0=80=20(#718)=20(#742)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: SchoolRegion 지역 추가 --- .../com/festago/school/domain/SchoolRegion.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/backend/src/main/java/com/festago/school/domain/SchoolRegion.java b/backend/src/main/java/com/festago/school/domain/SchoolRegion.java index 86b5ab1c8..e0f7602e3 100644 --- a/backend/src/main/java/com/festago/school/domain/SchoolRegion.java +++ b/backend/src/main/java/com/festago/school/domain/SchoolRegion.java @@ -4,6 +4,20 @@ public enum SchoolRegion { 서울, 부산, 대구, + 인천, + 광주, + 대전, + 울산, + 세종, + 경기, + 강원, + 충북, + 충남, + 전북, + 전남, + 경북, + 경남, + 제주, ANY, ; } From aedbe47cd3b7e8f6e97520a4b1a345aaa9f6d310 Mon Sep 17 00:00:00 2001 From: Seokjin Jeon Date: Fri, 23 Feb 2024 16:18:58 +0900 Subject: [PATCH 10/19] =?UTF-8?q?[BE]=20School=20=EC=83=9D=EC=84=B1,=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=EC=97=90=20=EB=88=84=EB=9D=BD=EB=90=9C=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#731)=20(#744)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: School 생성, 수정 시 logoUrl, backgroundImageUrl 추가 - Request, Command 빌더 패턴 적용 * feat: School 도메인 logoUrl, backgroundImageUrl 로직 추가 * test: School 도메인 테스트 개선 * feat: SchoolCommandService 수정 메서드 반영 --- .../v1/dto/SchoolV1CreateRequest.java | 23 +- .../v1/dto/SchoolV1UpdateRequest.java | 23 +- .../application/SchoolCommandService.java | 20 +- .../com/festago/school/domain/School.java | 39 +- .../school/dto/SchoolCreateCommand.java | 22 +- .../school/dto/SchoolUpdateCommand.java | 6 +- .../steps/AdminSchoolStepDefinitions.java | 14 +- .../v1/AdminSchoolV1ControllerTest.java | 16 +- .../SchoolCommandServiceIntegrationTest.java | 38 +- .../com/festago/school/domain/SchoolTest.java | 417 ++++++++++++++---- .../com/festago/support/SchoolFixture.java | 21 +- 11 files changed, 503 insertions(+), 136 deletions(-) diff --git a/backend/src/main/java/com/festago/admin/presentation/v1/dto/SchoolV1CreateRequest.java b/backend/src/main/java/com/festago/admin/presentation/v1/dto/SchoolV1CreateRequest.java index deff6a96e..37486b0d8 100644 --- a/backend/src/main/java/com/festago/admin/presentation/v1/dto/SchoolV1CreateRequest.java +++ b/backend/src/main/java/com/festago/admin/presentation/v1/dto/SchoolV1CreateRequest.java @@ -2,19 +2,32 @@ import com.festago.school.domain.SchoolRegion; import com.festago.school.dto.SchoolCreateCommand; +import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import lombok.Builder; +@Builder public record SchoolV1CreateRequest( - @NotBlank(message = "name은 공백일 수 없습니다.") + @NotBlank String name, - @NotBlank(message = "domain은 공백일 수 없습니다.") + @NotBlank String domain, - @NotNull(message = "region은 null일 수 없습니다.") - SchoolRegion region + @NotNull + SchoolRegion region, + @Nullable + String logoUrl, + @Nullable + String backgroundImageUrl ) { public SchoolCreateCommand toCommand() { - return new SchoolCreateCommand(name, domain, region); + return SchoolCreateCommand.builder() + .name(name) + .domain(domain) + .region(region) + .logoUrl(logoUrl) + .backgroundImageUrl(backgroundImageUrl) + .build(); } } diff --git a/backend/src/main/java/com/festago/admin/presentation/v1/dto/SchoolV1UpdateRequest.java b/backend/src/main/java/com/festago/admin/presentation/v1/dto/SchoolV1UpdateRequest.java index 678cfe5f7..6ae47caeb 100644 --- a/backend/src/main/java/com/festago/admin/presentation/v1/dto/SchoolV1UpdateRequest.java +++ b/backend/src/main/java/com/festago/admin/presentation/v1/dto/SchoolV1UpdateRequest.java @@ -2,19 +2,32 @@ import com.festago.school.domain.SchoolRegion; import com.festago.school.dto.SchoolUpdateCommand; +import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import lombok.Builder; +@Builder public record SchoolV1UpdateRequest( - @NotBlank(message = "name은 공백일 수 없습니다.") + @NotBlank String name, - @NotBlank(message = "domain은 공백일 수 없습니다.") + @NotBlank String domain, - @NotNull(message = "region은 null일 수 없습니다.") - SchoolRegion region + @NotNull + SchoolRegion region, + @Nullable + String logoUrl, + @Nullable + String backgroundImageUrl ) { public SchoolUpdateCommand toCommand() { - return new SchoolUpdateCommand(name, domain, region); + return SchoolUpdateCommand.builder() + .name(name) + .domain(domain) + .region(region) + .logoUrl(logoUrl) + .backgroundImageUrl(backgroundImageUrl) + .build(); } } diff --git a/backend/src/main/java/com/festago/school/application/SchoolCommandService.java b/backend/src/main/java/com/festago/school/application/SchoolCommandService.java index e57e52f80..714c81337 100644 --- a/backend/src/main/java/com/festago/school/application/SchoolCommandService.java +++ b/backend/src/main/java/com/festago/school/application/SchoolCommandService.java @@ -3,7 +3,6 @@ import com.festago.common.exception.BadRequestException; import com.festago.common.exception.ErrorCode; import com.festago.school.domain.School; -import com.festago.school.domain.SchoolRegion; import com.festago.school.dto.SchoolCreateCommand; import com.festago.school.dto.SchoolUpdateCommand; import com.festago.school.repository.SchoolRepository; @@ -20,15 +19,14 @@ public class SchoolCommandService { private final SchoolRepository schoolRepository; public Long createSchool(SchoolCreateCommand command) { - String domain = command.domain(); - String name = command.name(); - SchoolRegion region = command.region(); - validateCreate(domain, name); - School school = schoolRepository.save(new School(domain, name, region)); + validateCreate(command); + School school = schoolRepository.save(command.toDomain()); return school.getId(); } - private void validateCreate(String domain, String name) { + private void validateCreate(SchoolCreateCommand command) { + String domain = command.domain(); + String name = command.name(); if (schoolRepository.existsByDomain(domain)) { throw new BadRequestException(ErrorCode.DUPLICATE_SCHOOL_DOMAIN); } @@ -42,13 +40,17 @@ private void validateCreate(String domain, String name) { */ public void updateSchool(Long schoolId, SchoolUpdateCommand command) { School school = schoolRepository.getOrThrow(schoolId); - validateUpdate(school, command.domain(), command.name()); + validateUpdate(school, command); school.changeName(command.name()); school.changeDomain(command.domain()); school.changeRegion(command.region()); + school.changeLogoUrl(command.logoUrl()); + school.changeBackgroundImageUrl(command.backgroundImageUrl()); } - private void validateUpdate(School school, String domain, String name) { + private void validateUpdate(School school, SchoolUpdateCommand command) { + String domain = command.domain(); + String name = command.name(); if (!Objects.equals(school.getDomain(), domain) && schoolRepository.existsByDomain(domain)) { throw new BadRequestException(ErrorCode.DUPLICATE_SCHOOL_DOMAIN); } diff --git a/backend/src/main/java/com/festago/school/domain/School.java b/backend/src/main/java/com/festago/school/domain/School.java index 01b552038..cf6d09cfa 100644 --- a/backend/src/main/java/com/festago/school/domain/School.java +++ b/backend/src/main/java/com/festago/school/domain/School.java @@ -13,6 +13,7 @@ import jakarta.validation.constraints.Size; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.springframework.util.StringUtils; @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -21,6 +22,7 @@ public class School extends BaseTimeEntity { private static final String DEFAULT_URL = "https://picsum.photos/536/354"; private static final int MAX_DOMAIN_LENGTH = 50; private static final int MAX_NAME_LENGTH = 255; + private static final int MAX_IMAGE_URL_LENGTH = 255; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -43,28 +45,33 @@ public class School extends BaseTimeEntity { @Enumerated(EnumType.STRING) private SchoolRegion region; - public School(Long id, String domain, String name, String logoUrl, String backgroundUrl, SchoolRegion region) { - validate(domain, name, region); + public School(Long id, String domain, String name, String logoUrl, String backgroundImageUrl, SchoolRegion region) { + validate(domain, name, region, logoUrl, backgroundImageUrl); this.id = id; this.domain = domain; this.name = name; - this.logoUrl = logoUrl; - this.backgroundUrl = backgroundUrl; + this.logoUrl = getDefaultUrlIfBlank(logoUrl); + this.backgroundUrl = getDefaultUrlIfBlank(backgroundImageUrl); this.region = region; } - public School(String domain, String name, SchoolRegion region) { - this(null, domain, name, DEFAULT_URL, DEFAULT_URL, region); + private String getDefaultUrlIfBlank(String imageUrl) { + if (StringUtils.hasText(imageUrl)) { + return imageUrl; + } + return DEFAULT_URL; } - public School(Long id, String domain, String name, SchoolRegion region) { - this(id, domain, name, DEFAULT_URL, DEFAULT_URL, region); + public School(String domain, String name, SchoolRegion region) { + this(null, domain, name, DEFAULT_URL, DEFAULT_URL, region); } - private void validate(String domain, String name, SchoolRegion region) { + private void validate(String domain, String name, SchoolRegion region, String logoUrl, String backgroundImageUrl) { validateDomain(domain); validateName(name); validateRegion(region); + validateImageUrl(logoUrl, "logoUrl"); + validateImageUrl(backgroundImageUrl, "backgroundImageUrl"); } private void validateDomain(String domain) { @@ -83,6 +90,10 @@ private void validateRegion(SchoolRegion region) { Validator.notNull(region, "region"); } + private void validateImageUrl(String logoUrl, String fieldName) { + Validator.maxLength(logoUrl, MAX_IMAGE_URL_LENGTH, fieldName); + } + public void changeDomain(String domain) { validateDomain(domain); this.domain = domain; @@ -98,6 +109,16 @@ public void changeRegion(SchoolRegion region) { this.region = region; } + public void changeLogoUrl(String logoUrl) { + validateImageUrl(logoUrl, "logoUrl"); + this.logoUrl = getDefaultUrlIfBlank(logoUrl); + } + + public void changeBackgroundImageUrl(String backgroundImageUrl) { + validateImageUrl(backgroundImageUrl, "backgroundImageUrl"); + this.backgroundUrl = getDefaultUrlIfBlank(backgroundImageUrl); + } + public Long getId() { return id; } diff --git a/backend/src/main/java/com/festago/school/dto/SchoolCreateCommand.java b/backend/src/main/java/com/festago/school/dto/SchoolCreateCommand.java index de453d0f6..6b266319a 100644 --- a/backend/src/main/java/com/festago/school/dto/SchoolCreateCommand.java +++ b/backend/src/main/java/com/festago/school/dto/SchoolCreateCommand.java @@ -1,12 +1,17 @@ package com.festago.school.dto; import com.festago.common.util.Validator; +import com.festago.school.domain.School; import com.festago.school.domain.SchoolRegion; +import lombok.Builder; +@Builder public record SchoolCreateCommand( String name, String domain, - SchoolRegion region + SchoolRegion region, + String logoUrl, + String backgroundImageUrl ) { public SchoolCreateCommand { @@ -14,4 +19,19 @@ public record SchoolCreateCommand( Validator.notNull(domain, "domain"); Validator.notNull(region, "region"); } + + /** + * TODO 도메인에도 빌더 패턴을 적용해야할까? + * 생성자에 같은 타입이 중복적으로 발생하여 버그 발생 가능성이 매우 높다. + */ + public School toDomain() { + return new School( + null, + domain, + name, + logoUrl, + backgroundImageUrl, + region + ); + } } diff --git a/backend/src/main/java/com/festago/school/dto/SchoolUpdateCommand.java b/backend/src/main/java/com/festago/school/dto/SchoolUpdateCommand.java index c8cef2796..0dcbf4a62 100644 --- a/backend/src/main/java/com/festago/school/dto/SchoolUpdateCommand.java +++ b/backend/src/main/java/com/festago/school/dto/SchoolUpdateCommand.java @@ -2,11 +2,15 @@ import com.festago.common.util.Validator; import com.festago.school.domain.SchoolRegion; +import lombok.Builder; +@Builder public record SchoolUpdateCommand( String name, String domain, - SchoolRegion region + SchoolRegion region, + String logoUrl, + String backgroundImageUrl ) { public SchoolUpdateCommand { diff --git a/backend/src/test/java/com/festago/acceptance/steps/AdminSchoolStepDefinitions.java b/backend/src/test/java/com/festago/acceptance/steps/AdminSchoolStepDefinitions.java index aabdb5850..c52728336 100644 --- a/backend/src/test/java/com/festago/acceptance/steps/AdminSchoolStepDefinitions.java +++ b/backend/src/test/java/com/festago/acceptance/steps/AdminSchoolStepDefinitions.java @@ -23,9 +23,14 @@ public class AdminSchoolStepDefinitions { @Given("지역이 {string}에 있고, 이름이 {string}이고, 도메인이 {string}인 학교를 생성한다.") public void 학교를_생성한다(String region, String name, String domain) { + var request = SchoolV1CreateRequest.builder() + .name(name) + .domain(domain) + .region(SchoolRegion.valueOf(region)) + .build(); RestAssured.given() .contentType(ContentType.JSON) - .body(new SchoolV1CreateRequest(name, domain, SchoolRegion.valueOf(region))) + .body(request) .cookie("token", cucumberClient.getToken()) .post("/admin/api/v1/schools") .then() @@ -38,9 +43,14 @@ public class AdminSchoolStepDefinitions { @When("이름이 {string}인 학교의 이름을 {string}로 변경한다.") public void 학교의_이름을_다른_이름으로_변경한다(String srcName, String dstName) { var response = getSchoolResponsesByName(srcName).get(0); + var request = SchoolV1UpdateRequest.builder() + .name(dstName) + .domain(response.domain()) + .region(response.region()) + .build(); RestAssured.given() .contentType(ContentType.JSON) - .body(new SchoolV1UpdateRequest(dstName, response.domain(), response.region())) + .body(request) .cookie("token", cucumberClient.getToken()) .pathParam("id", response.id()) .patch("/admin/api/v1/schools/{id}") diff --git a/backend/src/test/java/com/festago/admin/presentation/v1/AdminSchoolV1ControllerTest.java b/backend/src/test/java/com/festago/admin/presentation/v1/AdminSchoolV1ControllerTest.java index fe7cec8c4..67b72f001 100644 --- a/backend/src/test/java/com/festago/admin/presentation/v1/AdminSchoolV1ControllerTest.java +++ b/backend/src/test/java/com/festago/admin/presentation/v1/AdminSchoolV1ControllerTest.java @@ -74,7 +74,13 @@ class 올바른_주소로 { @WithMockAuth(role = Role.ADMIN) void 요청을_보내면_201_응답과_Location_헤더에_식별자가_반환된다() throws Exception { // given - var request = new SchoolV1CreateRequest("테코대학교", "teco.ac.kr", SchoolRegion.서울); + var request = SchoolV1CreateRequest.builder() + .name("테코대학교") + .domain("teco.ac.kr") + .region(SchoolRegion.서울) + .logoUrl("https://image.com/logo.png") + .backgroundImageUrl("https://image.com/backgroundImage.png") + .build(); given(schoolCommandService.createSchool(any(SchoolCreateCommand.class))) .willReturn(1L); @@ -118,7 +124,13 @@ class 올바른_주소로 { @WithMockAuth(role = Role.ADMIN) void 요청을_보내면_200_응답이_반환된다() throws Exception { // given - var request = new SchoolV1UpdateRequest("테코대학교", "teco.ac.kr", SchoolRegion.서울); + var request = SchoolV1UpdateRequest.builder() + .name("테코대학교") + .domain("teco.ac.kr") + .region(SchoolRegion.서울) + .logoUrl("https://image.com/logo.png") + .backgroundImageUrl("https://image.com/backgroundImage.png") + .build(); // when & then mockMvc.perform(patch(uri, 1L) diff --git a/backend/src/test/java/com/festago/school/application/integration/SchoolCommandServiceIntegrationTest.java b/backend/src/test/java/com/festago/school/application/integration/SchoolCommandServiceIntegrationTest.java index 917275589..fa6514d11 100644 --- a/backend/src/test/java/com/festago/school/application/integration/SchoolCommandServiceIntegrationTest.java +++ b/backend/src/test/java/com/festago/school/application/integration/SchoolCommandServiceIntegrationTest.java @@ -35,7 +35,11 @@ class SchoolCommandServiceIntegrationTest extends ApplicationIntegrationTest { @Nested class createSchool { - SchoolCreateCommand command = new SchoolCreateCommand("테코대학교", "teco.ac.kr", SchoolRegion.서울); + SchoolCreateCommand command = SchoolCreateCommand.builder() + .name("테코대학교") + .domain("teco.ac.kr") + .region(SchoolRegion.서울) + .build(); @Test void 같은_도메인의_학교가_저장되어_있으면_예외가_발생한다() { @@ -72,12 +76,25 @@ class createSchool { @Nested class updateSchool { - SchoolUpdateCommand command = new SchoolUpdateCommand("테코대학교", "teco.ac.kr", SchoolRegion.서울); School school; + SchoolUpdateCommand command = SchoolUpdateCommand.builder() + .name("테코대학교") + .domain("teco.ac.kr") + .region(SchoolRegion.서울) + .logoUrl("https://image.com/newLogo.png") + .backgroundImageUrl("https://image.com/newBackgroundImage.png") + .build(); @BeforeEach void setUp() { - school = schoolRepository.save(new School("wote.ac.kr", "우테대학교", SchoolRegion.대구)); + school = schoolRepository.save(SchoolFixture.school() + .name("우테대학교") + .domain("wote.ac.kr") + .region(SchoolRegion.대구) + .logoUrl("https://image.com/logo.png") + .backgroundImageUrl("https://image.com/backgroundImage.png") + .build() + ); } @Test @@ -119,7 +136,11 @@ void setUp() { void 수정할_이름이_수정할_학교의_이름과_같으면_이름은_수정되지_않는다() { // given Long schoolId = school.getId(); - SchoolUpdateCommand command = new SchoolUpdateCommand(school.getName(), "teco.ac.kr", SchoolRegion.서울); + var command = SchoolUpdateCommand.builder() + .name(school.getName()) + .domain("teco.ac.kr") + .region(SchoolRegion.서울) + .build(); // when schoolCommandService.updateSchool(schoolId, command); @@ -133,7 +154,11 @@ void setUp() { void 수정할_도메인이_수정할_학교의_도메인과_같으면_도메인은_수정되지_않는다() { // given Long schoolId = school.getId(); - SchoolUpdateCommand command = new SchoolUpdateCommand("테코대학교", school.getDomain(), SchoolRegion.서울); + var command = SchoolUpdateCommand.builder() + .name("테코대학교") + .domain(school.getDomain()) + .region(SchoolRegion.서울) + .build(); // when schoolCommandService.updateSchool(schoolId, command); @@ -157,6 +182,9 @@ void setUp() { softly.assertThat(updatedSchool.getName()).isEqualTo("테코대학교"); softly.assertThat(updatedSchool.getDomain()).isEqualTo("teco.ac.kr"); softly.assertThat(updatedSchool.getRegion()).isEqualTo(SchoolRegion.서울); + softly.assertThat(updatedSchool.getLogoUrl()).isEqualTo("https://image.com/newLogo.png"); + softly.assertThat(updatedSchool.getBackgroundUrl()) + .isEqualTo("https://image.com/newBackgroundImage.png"); }); } } diff --git a/backend/src/test/java/com/festago/school/domain/SchoolTest.java b/backend/src/test/java/com/festago/school/domain/SchoolTest.java index c103d5faf..1b3276adc 100644 --- a/backend/src/test/java/com/festago/school/domain/SchoolTest.java +++ b/backend/src/test/java/com/festago/school/domain/SchoolTest.java @@ -2,10 +2,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; import com.festago.common.exception.ValidException; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullSource; @@ -15,123 +18,345 @@ @SuppressWarnings("NonAsciiCharacters") class SchoolTest { - @Test - void 학교의_도메인_길이가_50자를_넘으면_예외() { - // given - String domain = "1".repeat(51); + @Nested + class 생성 { - // when & then - assertThatThrownBy(() -> new School(domain, "테코대학교", SchoolRegion.서울)) - .isInstanceOf(ValidException.class); - } + @Test + void 도메인이_50자를_넘으면_예외() { + // given + String domain = "1".repeat(51); - @ParameterizedTest - @NullSource - @ValueSource(strings = {"", " ", "\t", "\n"}) - void 학교의_도메인이_null_또는_공백이면_예외(String domain) { - // when & then - assertThatThrownBy(() -> new School(domain, "테코대학교", SchoolRegion.서울)) - .isInstanceOf(ValidException.class); - } + // when & then + assertThatThrownBy(() -> new School(domain, "테코대학교", SchoolRegion.서울)) + .isInstanceOf(ValidException.class); + } - @Test - void 학교의_이름이_255자를_넘으면_예외() { - // given - String name = "1".repeat(256); + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void 도메인이_null_또는_공백이면_예외(String domain) { + // when & then + assertThatThrownBy(() -> new School(domain, "테코대학교", SchoolRegion.서울)) + .isInstanceOf(ValidException.class); + } - // when & then - assertThatThrownBy(() -> new School("teco.ac.kr", name, SchoolRegion.서울)) - .isInstanceOf(ValidException.class); - } + @ParameterizedTest + @ValueSource(ints = {1, 50}) + void 도메인이_50자_이내이면_성공(int length) { + // given + String domain = "1".repeat(length); - @ParameterizedTest - @NullSource - @ValueSource(strings = {"", " ", "\t", "\n"}) - void 학교의_이름이_null_또는_공백이면_예외(String name) { - // when & then - assertThatThrownBy(() -> new School("teco.ac.kr", name, SchoolRegion.서울)) - .isInstanceOf(ValidException.class); - } + // when + School school = new School(domain, "테코대학교", SchoolRegion.서울); - @Test - void 학교의_도메인을_수정할때_255자를_넘으면_예외() { - // given - School school = new School("teco.ac.kr", "테코대학교", SchoolRegion.서울); + // then + assertThat(school.getDomain()).isEqualTo(domain); + } - // when & then - String domain = "1".repeat(256); - assertThatThrownBy(() -> school.changeDomain(domain)) - .isInstanceOf(ValidException.class); - } + @Test + void 이름이_255자를_넘으면_예외() { + // given + String name = "1".repeat(256); - @ParameterizedTest - @NullSource - @ValueSource(strings = {"", " ", "\t", "\n"}) - void 학교의_도메인을_수정할때_null_또는_공백이면_예외(String domain) { - // given - School school = new School("teco.ac.kr", "테코대학교", SchoolRegion.서울); + // when & then + assertThatThrownBy(() -> new School("teco.ac.kr", name, SchoolRegion.서울)) + .isInstanceOf(ValidException.class); + } - // when & then - assertThatThrownBy(() -> school.changeDomain(domain)) - .isInstanceOf(ValidException.class); - } + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void 이름이_null_또는_공백이면_예외(String name) { + // when & then + assertThatThrownBy(() -> new School("teco.ac.kr", name, SchoolRegion.서울)) + .isInstanceOf(ValidException.class); + } - @Test - void 학교의_이름을_수정할때_255자를_넘으면_예외() { - // given - School school = new School("teco.ac.kr", "테코대학교", SchoolRegion.서울); + @ParameterizedTest + @ValueSource(ints = {1, 255}) + void 이름이_255자_이내이면_성공(int length) { + // given + String name = "1".repeat(length); - // when & then - String name = "1".repeat(256); - assertThatThrownBy(() -> school.changeName(name)) - .isInstanceOf(ValidException.class); - } + // when + School school = new School("teco.ac.kr", name, SchoolRegion.서울); - @ParameterizedTest - @NullSource - @ValueSource(strings = {"", " ", "\t", "\n"}) - void 학교의_이름을_수정할때_null_또는_공백이면_예외(String name) { - // given - School school = new School("teco.ac.kr", "테코대학교", SchoolRegion.서울); + // then + assertThat(school.getName()).isEqualTo(name); + } - // when & then - assertThatThrownBy(() -> school.changeName(name)) - .isInstanceOf(ValidException.class); - } + @Test + void 지역이_null이면_예외() { + // given + SchoolRegion region = null; - @Test - void 학교_생성_성공() { - // given - School school = new School("teco.ac.kr", "테코대학교", SchoolRegion.서울); + // when & then + assertThatThrownBy(() -> new School("teco.ac.kr", "테코대학교", region)) + .isInstanceOf(ValidException.class); + } - // when & then - assertThat(school.getName()).isEqualTo("테코대학교"); - assertThat(school.getDomain()).isEqualTo("teco.ac.kr"); - } + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void logoUrl이_null_또는_공백이어도_성공(String logoUrl) { + // when + School school = new School(1L, "teco.ac.kr", "테코대학교", logoUrl, "https://image.com/backgroundImage.png", + SchoolRegion.서울); + + // then + assertThat(school.getLogoUrl()).isNotBlank(); + } + + @ParameterizedTest + @ValueSource(ints = {1, 255}) + void logoUrl이_255자_이내이면_성공(int length) { + // given + String logoUrl = "1".repeat(length); + + // when + School school = new School(1L, "teco.ac.kr", "테코대학교", logoUrl, "https://image.com/backgroundImage.png", + SchoolRegion.서울); + + // then + assertThat(school.getLogoUrl()).isEqualTo(logoUrl); + } - @ParameterizedTest - @ValueSource(ints = {1, 50}) - void 학교를_생성할때_도메인이_50글자_이내_성공(int length) { - // given - String domain = "1".repeat(length); + @Test + void logoUrl이_255자를_넘으면_예외() { + // given + String logoUrl = "1".repeat(256); - // when - School school = new School(domain, "테코대학교", SchoolRegion.서울); + // when & then + assertThatThrownBy(() -> { + new School(1L, "teco.ac.kr", "테코대학교", logoUrl, "https://image.com/backgroundImage.png", + SchoolRegion.서울); + }).isInstanceOf(ValidException.class); + } - // then - assertThat(school.getDomain()).isEqualTo(domain); + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void backgroundImageUrl이_null_또는_공백이어도_성공(String backgroundImageUrl) { + // when + School school = new School(1L, "teco.ac.kr", "테코대학교", "https://image.com/logo.png", backgroundImageUrl, + SchoolRegion.서울); + + // then + assertThat(school.getBackgroundUrl()).isNotBlank(); + } + + @ParameterizedTest + @ValueSource(ints = {1, 255}) + void backgroundImageUrl이_255자_이내이면_성공(int length) { + // given + String backgroundImageUrl = "1".repeat(length); + + // when + School school = new School(1L, "teco.ac.kr", "테코대학교", "https://image.com/logo.png", backgroundImageUrl, + SchoolRegion.서울); + + // then + assertThat(school.getBackgroundUrl()).isEqualTo(backgroundImageUrl); + } + + @Test + void backgroundImageUrl이_255자를_넘으면_예외() { + // given + String backgroundImageUrl = "1".repeat(256); + + // when & then + assertThatThrownBy(() -> { + new School(1L, "teco.ac.kr", "테코대학교", "https://image.com/logo.png", backgroundImageUrl, + SchoolRegion.서울); + }).isInstanceOf(ValidException.class); + } + + // TODO 해당 테스트는 생성자 파라미터 순서가 올바른지 검사하는데 의의가 있음 + // 다만 빌더 패턴을 적용하면 해당 테스트의 필요성이 있을까? + @Test + void 성공() { + // given + Long id = 1L; + String domain = "teco.ac.kr"; + String name = "테코대학교"; + String logoUrl = "https://image.com/logo.png"; + String backgroundImageUrl = "https://image.com/backgroundImage.png"; + SchoolRegion region = SchoolRegion.서울; + + School school = new School(id, domain, name, logoUrl, backgroundImageUrl, region); + + // when & then + assertSoftly(softly -> { + softly.assertThat(school.getId()).isEqualTo(1L); + softly.assertThat(school.getDomain()).isEqualTo(domain); + softly.assertThat(school.getName()).isEqualTo(name); + softly.assertThat(school.getLogoUrl()).isEqualTo(logoUrl); + softly.assertThat(school.getBackgroundUrl()).isEqualTo(backgroundImageUrl); + softly.assertThat(school.getRegion()).isEqualTo(region); + }); + } } - @ParameterizedTest - @ValueSource(ints = {1, 255}) - void 학교를_생성할때_이름이_255글자_이내_성공(int length) { - // given - String name = "1".repeat(length); + @Nested + class 수정 { + + School school; + + @BeforeEach + void setUp() { + school = new School(1L, "teco.ac.kr", "테코대학교", "https://image.com/logo.png", + "https://image.com/backgroundImage.png", SchoolRegion.서울); + } + + @Test + void 도메인이_51자를_넘으면_예외() { + // given + String domain = "1".repeat(51); + + // when & then + assertThatThrownBy(() -> school.changeDomain(domain)) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void 도메인이_null_또는_공백이면_예외(String domain) { + // when & then + assertThatThrownBy(() -> school.changeDomain(domain)) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @ValueSource(ints = {1, 50}) + void 도메인이_50자_이내이면_성공(int length) { + // given + String domain = "1".repeat(length); + + // when + school.changeDomain(domain); + + // then + assertThat(school.getDomain()).isEqualTo(domain); + } + + @Test + void 이름이_255자를_넘으면_예외() { + // when & then + String name = "1".repeat(256); + assertThatThrownBy(() -> school.changeName(name)) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void 이름이_null_또는_공백이면_예외(String name) { + // when & then + assertThatThrownBy(() -> school.changeName(name)) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @ValueSource(ints = {1, 255}) + void 이름이_255자_이내이면_성공(int length) { + // given + String name = "1".repeat(length); + + // when + school.changeName(name); + + // then + assertThat(school.getName()).isEqualTo(name); + } + + @Test + void 지역이_null이면_예외() { + // given + SchoolRegion region = null; + + // when & then + assertThatThrownBy(() -> school.changeRegion(region)) + .isInstanceOf(ValidException.class); + } + + @Test + void 지역이_null이_아니면_성공() { + // given + SchoolRegion region = SchoolRegion.대구; + + // when + school.changeRegion(region); + + // then + assertThat(school.getRegion()).isEqualTo(region); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void logoUrl이_null_또는_공백이어도_성공(String logoUrl) { + // when + school.changeLogoUrl(logoUrl); + + // then + assertThat(school.getLogoUrl()).isNotBlank(); + } + + @ParameterizedTest + @ValueSource(ints = {1, 255}) + void logoUrl이_255글자_이내이면_성공(int length) { + String logoUrl = "1".repeat(length); + + // when + school.changeLogoUrl(logoUrl); + + // then + assertThat(school.getLogoUrl()).isEqualTo(logoUrl); + } + + @Test + void logoUrl이_255자를_넘으면_예외() { + // given + String logoUrl = "1".repeat(256); + + // when & then + assertThatThrownBy(() -> school.changeLogoUrl(logoUrl)) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void backgroundImageUrl이_null_또는_공백이어도_성공(String backgroundImageUrl) { + // when + school.changeBackgroundImageUrl(backgroundImageUrl); + + // then + assertThat(school.getBackgroundUrl()).isNotBlank(); + } + + @ParameterizedTest + @ValueSource(ints = {1, 255}) + void backgroundImageUrl이_255글자_이내이면_성공(int length) { + // given + String backgroundImageUrl = "1".repeat(length); + + // when + school.changeBackgroundImageUrl(backgroundImageUrl); + + // then + assertThat(school.getBackgroundUrl()).isEqualTo(backgroundImageUrl); + } - // when - School school = new School("teco.ac.kr", name, SchoolRegion.서울); + @Test + void backgroundImageUrl이_255자를_넘으면_예외() { + // given + String backgroundImageUrl = "1".repeat(256); - // then - assertThat(school.getName()).isEqualTo(name); + // when & then + assertThatThrownBy(() -> school.changeBackgroundImageUrl(backgroundImageUrl)) + .isInstanceOf(ValidException.class); + } } } diff --git a/backend/src/test/java/com/festago/support/SchoolFixture.java b/backend/src/test/java/com/festago/support/SchoolFixture.java index 9f97cb769..fdbf69fea 100644 --- a/backend/src/test/java/com/festago/support/SchoolFixture.java +++ b/backend/src/test/java/com/festago/support/SchoolFixture.java @@ -13,6 +13,10 @@ public class SchoolFixture { private SchoolRegion region = SchoolRegion.서울; + private String logoUrl = "https://image.com/logo.png"; + + private String backgroundImageUrl = "https://image.com/backgroundImage.png"; + private SchoolFixture() { } @@ -35,7 +39,22 @@ public SchoolFixture name(String name) { return this; } + public SchoolFixture region(SchoolRegion region) { + this.region = region; + return this; + } + + public SchoolFixture logoUrl(String logoUrl) { + this.logoUrl = logoUrl; + return this; + } + + public SchoolFixture backgroundImageUrl(String backgroundImageUrl) { + this.backgroundImageUrl = backgroundImageUrl; + return this; + } + public School build() { - return new School(id, domain, name, region); + return new School(id, domain, name, logoUrl, backgroundImageUrl, region); } } From ef89f7e3921455aaca96fa78a38900026751d439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hyun-Seo=20Oh=20/=20=EC=98=A4=ED=98=84=EC=84=9C?= <100915276+carsago@users.noreply.github.com> Date: Fri, 23 Feb 2024 18:57:05 +0900 Subject: [PATCH 11/19] =?UTF-8?q?[BE]=20=EC=95=84=ED=8B=B0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=84=20=EC=B6=95=EC=A0=9C=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?,=20=ED=95=99=EA=B5=90=20=EB=B3=84=20=EC=B6=95=EC=A0=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=8B=9C=20API=20=EB=AA=85=EC=84=B8=EC=84=9C?= =?UTF-8?q?=EC=99=80=20=EA=B0=99=EC=9D=80=20Response=EB=A5=BC=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=20(#743)=20(#746)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: SliceResponse 객체 추가 * feat: 아티스트 별 축제 조회가 last만 가지도록 변경 * feat: 학교 별 축제 조회가 last만 가지도록 변경 --- .../v1/ArtistDetailV1Controller.java | 8 +- .../com/festago/common/dto/SliceResponse.java | 14 ++ .../presentation/v1/SchoolV1Controller.java | 5 +- .../v1/ArtistDetailV1ControllerTest.java | 131 ++++++++++++++++++ .../v1/SchoolV1ControllerTest.java | 6 +- 5 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 backend/src/main/java/com/festago/common/dto/SliceResponse.java create mode 100644 backend/src/test/java/com/festago/artist/presentation/v1/ArtistDetailV1ControllerTest.java diff --git a/backend/src/main/java/com/festago/artist/presentation/v1/ArtistDetailV1Controller.java b/backend/src/main/java/com/festago/artist/presentation/v1/ArtistDetailV1Controller.java index df0ebba0c..76bcb801d 100644 --- a/backend/src/main/java/com/festago/artist/presentation/v1/ArtistDetailV1Controller.java +++ b/backend/src/main/java/com/festago/artist/presentation/v1/ArtistDetailV1Controller.java @@ -4,6 +4,7 @@ import com.festago.artist.dto.ArtistDetailV1Response; import com.festago.artist.dto.ArtistFestivalDetailV1Response; import com.festago.common.aop.ValidPageable; +import com.festago.common.dto.SliceResponse; import com.festago.common.exception.ValidException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -36,7 +37,7 @@ public ResponseEntity getArtistInfo(@PathVariable Long a @GetMapping("/{artistId}/festivals") @Operation(description = "아티스트가 참석한 축제를 조회한다. isPast 값으로 종료 축제 와 진행, 예정 축제를 구분 가능하다", summary = "아티스트 축제 조회") @ValidPageable(maxSize = 20) - public ResponseEntity> getArtistInfo( + public ResponseEntity> getArtistInfo( @PathVariable Long artistId, @RequestParam(required = false) Long lastFestivalId, @RequestParam(required = false) LocalDate lastStartDate, @@ -44,8 +45,9 @@ public ResponseEntity> getArtistInfo( @PageableDefault(size = 10) Pageable pageable ) { validate(lastFestivalId, lastStartDate); - return ResponseEntity.ok( - artistDetailV1QueryService.findArtistFestivals(artistId, lastFestivalId, lastStartDate, isPast, pageable)); + Slice response = artistDetailV1QueryService.findArtistFestivals(artistId, + lastFestivalId, lastStartDate, isPast, pageable); + return ResponseEntity.ok(SliceResponse.from(response)); } private void validate(Long lastFestivalId, LocalDate lastStartDate) { diff --git a/backend/src/main/java/com/festago/common/dto/SliceResponse.java b/backend/src/main/java/com/festago/common/dto/SliceResponse.java new file mode 100644 index 000000000..7368ce541 --- /dev/null +++ b/backend/src/main/java/com/festago/common/dto/SliceResponse.java @@ -0,0 +1,14 @@ +package com.festago.common.dto; + +import java.util.List; +import org.springframework.data.domain.Slice; + +public record SliceResponse( + boolean last, + List content +) { + + public static SliceResponse from(Slice slice) { + return new SliceResponse(slice.isLast(), slice.getContent()); + } +} diff --git a/backend/src/main/java/com/festago/school/presentation/v1/SchoolV1Controller.java b/backend/src/main/java/com/festago/school/presentation/v1/SchoolV1Controller.java index ed7a791d1..33992d0e6 100644 --- a/backend/src/main/java/com/festago/school/presentation/v1/SchoolV1Controller.java +++ b/backend/src/main/java/com/festago/school/presentation/v1/SchoolV1Controller.java @@ -1,6 +1,7 @@ package com.festago.school.presentation.v1; import com.festago.common.aop.ValidPageable; +import com.festago.common.dto.SliceResponse; import com.festago.school.application.v1.SchoolV1QueryService; import com.festago.school.dto.v1.SchoolDetailV1Response; import com.festago.school.dto.v1.SchoolFestivalV1Response; @@ -37,7 +38,7 @@ public ResponseEntity findDetailId(@PathVariable Long sc @GetMapping("/{schoolId}/festivals") @ValidPageable(maxSize = 20) @Operation(description = "해당 학교의 축제들을 페이징하여 조회한다.", summary = "학교 상세 조회") - public ResponseEntity> findFestivalsBySchoolId( + public ResponseEntity> findFestivalsBySchoolId( @PathVariable Long schoolId, @RequestParam(required = false) Long lastFestivalId, @RequestParam(required = false) LocalDate lastStartDate, @@ -51,6 +52,6 @@ public ResponseEntity> findFestivalsBySchoolId( today, searchCondition ); - return ResponseEntity.ok(response); + return ResponseEntity.ok(SliceResponse.from(response)); } } diff --git a/backend/src/test/java/com/festago/artist/presentation/v1/ArtistDetailV1ControllerTest.java b/backend/src/test/java/com/festago/artist/presentation/v1/ArtistDetailV1ControllerTest.java new file mode 100644 index 000000000..694fef42c --- /dev/null +++ b/backend/src/test/java/com/festago/artist/presentation/v1/ArtistDetailV1ControllerTest.java @@ -0,0 +1,131 @@ +package com.festago.artist.presentation.v1; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.artist.application.ArtistDetailV1QueryService; +import com.festago.artist.dto.ArtistDetailV1Response; +import com.festago.artist.dto.ArtistFestivalDetailV1Response; +import com.festago.artist.dto.ArtistMediaV1Response; +import com.festago.common.dto.SliceResponse; +import com.festago.socialmedia.domain.SocialMediaType; +import com.festago.support.CustomWebMvcTest; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.SliceImpl; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ArtistDetailV1ControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ArtistDetailV1QueryService artistDetailV1QueryService; + + @Autowired + ObjectMapper objectMapper; + + @Nested + class 아티스트_상세_조회 { + + final String uri = "/api/v1/artists/{artistId}"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답과_body가_반환된다() throws Exception { + // given + var expected = new ArtistDetailV1Response( + 1L, "경북대학교", + "https://image.com/logo.png", + "https://image.com/backgroundLogo.png", + List.of( + new ArtistMediaV1Response(SocialMediaType.YOUTUBE.name(), "유튜브", + "https://image.com/youtube.png", "www.knu-youtube.com"), + new ArtistMediaV1Response(SocialMediaType.INSTAGRAM.name(), "인스타그램", + "https://image.com/youtube.png", "www.knu-instagram.com") + ) + ); + given(artistDetailV1QueryService.findArtistDetail(expected.id())) + .willReturn(expected); + + // when & then + mockMvc.perform(get(uri, 1L) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(expected))); + } + } + } + + + + + + @Nested + class 아티스트별_축제_조회 { + + final String uri = "/api/v1/artists/{artistId}/festivals"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답과_body가_반환된다() throws Exception { + // given + var today = LocalDate.now(); + var content = List.of(new ArtistFestivalDetailV1Response( + 1L, "경북대학교", today, today.plusDays(1), "www.image.com/image.png", + "아티스트" + )); + Pageable pageable = Pageable.ofSize(10); + var slice = new SliceImpl(content, pageable, true); + var expected = SliceResponse.from(slice); + + given(artistDetailV1QueryService.findArtistFestivals(1L, null, null, false, Pageable.ofSize(10))) + .willReturn(slice); + + // when & then + mockMvc.perform(get(uri, 1L) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(expected))); + } + + @Test + void 요청시_페이지가_20을_넘어가면_예외() throws Exception { + // given + int maxPageSize = 20; + + // when && then + mockMvc.perform(get(uri, 1L) + .contentType(MediaType.APPLICATION_JSON) + .param("size", String.valueOf(maxPageSize + 1))) + .andDo(print()) + .andExpect(status().isBadRequest()); + + } + } + } +} diff --git a/backend/src/test/java/com/festago/school/presentation/v1/SchoolV1ControllerTest.java b/backend/src/test/java/com/festago/school/presentation/v1/SchoolV1ControllerTest.java index 44fc3162c..7cda2659c 100644 --- a/backend/src/test/java/com/festago/school/presentation/v1/SchoolV1ControllerTest.java +++ b/backend/src/test/java/com/festago/school/presentation/v1/SchoolV1ControllerTest.java @@ -7,6 +7,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.common.dto.SliceResponse; import com.festago.school.application.v1.SchoolV1QueryService; import com.festago.school.dto.v1.SchoolDetailV1Response; import com.festago.school.dto.v1.SchoolFestivalV1Response; @@ -95,10 +96,11 @@ class 올바른_주소로 { 1L, "경북대학교", today, today.plusDays(1), "www.image.com/image.png", "아티스트" )); - var expected = new SliceImpl(content, Pageable.ofSize(10), true); + var slice = new SliceImpl(content, Pageable.ofSize(10), true); + var expected = SliceResponse.from(slice); given(schoolV1QueryService.findFestivalsBySchoolId(1L, today, searchCondition)) - .willReturn(expected); + .willReturn(slice); // when & then mockMvc.perform(get(uri, 1L) From fb08f5022fabf2b91214aa5eea5a00efa375dccb Mon Sep 17 00:00:00 2001 From: Seokjin Jeon Date: Sat, 24 Feb 2024 00:22:27 +0900 Subject: [PATCH 12/19] =?UTF-8?q?[BE]=20feat:=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EC=9A=B4=20FestivalCommandService=20=EC=B6=94=EA=B0=80=20(#684?= =?UTF-8?q?)=20(#695)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: FestivalInfoRepository -> FestivalQueryInfoRepository 이름 변경, 기능 추가/삭제 - 사용하지 않는 findAllByFestivalIdIn 메서드 삭제 - findByFestivalId 메서드 추가 * refactor: FestivalRepository Repository 상속하도록 변경, getOrThrow 추가 * refactor: Festival canCreate 메서드 이름 isBeforeStartDate으로 변경 - 보다 명확한 메서드 이름으로 변경 * feat: AdminFestivalV1Controller 추가 * feat: FestivalCommandService createFestival 메서드 구현 * feat: Festival 수정 기능 추가 * feat: 레거시 FestivalService `@deprecated` 추가 * feat: Festival 삭제 기능 추가 * test: findById -> getOrThrow Stub 변경 * refactor: Festival CUD 기능 각 Service로 분리, Facade 사용 * feat: FestivalUpdateValidator 추가 * fix: FestivalQueryInfoEventListener 트랜잭션 전파 수준 수정 - SUPPORTS -> MANDATORY * feat: festival 변수 save() 반환값 사용하도록 변경 * refactor: Validator 클래스 전용 패키지로 이동 - 클래스의 혼잡으로 인한 혼란 방지 * feat: 축제의 기간을 변경할 때, 등록된 공연의 기간을 검증하는 클래스 추가 * refactor: MemoryStageRepository save() 인자를 반환하도록 변경 * refactor: 축제 수정 시 축제 도메인 검증 우선하도록 변경 및 주석 제거 * fix: thumbnail -> posterImageUrl 변경 * fix: FestivalQueryInfoRepository -> FestivalInfoRepository 변 * chore: 변수명 수정 - isOutOfRange -> isOutOfDate * refactor: Validator 패키지 위치 변경 - application -> domain * refactor: Festival 검증 로직 재활용하도록 리팩터링 * fix: 예외 메시지 올바르게 수정 --- .../admin/dto/FestivalV1CreateRequest.java | 38 ++++ .../admin/dto/FestivalV1UpdateRequest.java | 34 ++++ .../admin/presentation/AdminController.java | 12 ++ .../v1/AdminFestivalV1Controller.java | 54 ++++++ .../festago/common/exception/ErrorCode.java | 2 + .../FestivalQueryInfoEventListener.java | 37 ++++ .../festival/application/FestivalService.java | 13 +- .../command/FestivalCommandFacadeService.java | 27 +++ .../command/FestivalCreateService.java | 41 ++++ .../command/FestivalDeleteService.java | 26 +++ .../command/FestivalUpdateService.java | 30 +++ .../com/festago/festival/domain/Festival.java | 4 +- .../festival/domain/FestivalQueryInfo.java | 5 + .../validator/FestivalDeleteValidator.java | 6 + .../validator/FestivalUpdateValidator.java | 8 + .../ExistsFestivalSchoolDeleteValidator.java} | 6 +- .../dto/command/FestivalCreateCommand.java | 18 ++ .../dto/command/FestivalUpdateCommand.java | 12 ++ .../dto/event/FestivalCreatedEvent.java | 7 + .../dto/event/FestivalDeletedEvent.java | 7 + .../presentation/FestivalController.java | 4 + .../repository/FestivalInfoRepository.java | 6 +- .../repository/FestivalRepository.java | 20 +- .../application/SchoolDeleteService.java | 1 + .../validator}/SchoolDeleteValidator.java | 2 +- .../application/FestivalStageServiceImpl.java | 9 +- .../stage/application/StageService.java | 7 +- .../ExistsStageFestivalDeleteValidator.java | 24 +++ ...OutOfDateStageFestivalUpdateValidator.java | 33 ++++ .../stage/repository/StageRepository.java | 19 +- .../school}/StudentSchoolDeleteValidator.java | 4 +- .../v1/AdminFestivalV1ControllerTest.java | 175 ++++++++++++++++++ .../command/FestivalCreateServiceTest.java | 105 +++++++++++ .../command/FestivalDeleteServiceTest.java | 106 +++++++++++ .../command/FestivalUpdateServiceTest.java | 109 +++++++++++ .../FestivalStageServiceImplTest.java | 12 +- .../stage/application/StageServiceTest.java | 5 +- ...fDateStageFestivalUpdateValidatorTest.java | 131 +++++++++++++ .../repository/MemoryStageRepository.java | 69 +++++++ 39 files changed, 1186 insertions(+), 42 deletions(-) create mode 100644 backend/src/main/java/com/festago/admin/dto/FestivalV1CreateRequest.java create mode 100644 backend/src/main/java/com/festago/admin/dto/FestivalV1UpdateRequest.java create mode 100644 backend/src/main/java/com/festago/admin/presentation/v1/AdminFestivalV1Controller.java create mode 100644 backend/src/main/java/com/festago/festival/application/FestivalQueryInfoEventListener.java create mode 100644 backend/src/main/java/com/festago/festival/application/command/FestivalCommandFacadeService.java create mode 100644 backend/src/main/java/com/festago/festival/application/command/FestivalCreateService.java create mode 100644 backend/src/main/java/com/festago/festival/application/command/FestivalDeleteService.java create mode 100644 backend/src/main/java/com/festago/festival/application/command/FestivalUpdateService.java create mode 100644 backend/src/main/java/com/festago/festival/domain/validator/FestivalDeleteValidator.java create mode 100644 backend/src/main/java/com/festago/festival/domain/validator/FestivalUpdateValidator.java rename backend/src/main/java/com/festago/festival/{application/FestivalSchoolDeleteValidator.java => domain/validator/school/ExistsFestivalSchoolDeleteValidator.java} (76%) create mode 100644 backend/src/main/java/com/festago/festival/dto/command/FestivalCreateCommand.java create mode 100644 backend/src/main/java/com/festago/festival/dto/command/FestivalUpdateCommand.java create mode 100644 backend/src/main/java/com/festago/festival/dto/event/FestivalCreatedEvent.java create mode 100644 backend/src/main/java/com/festago/festival/dto/event/FestivalDeletedEvent.java rename backend/src/main/java/com/festago/school/{application => domain/validator}/SchoolDeleteValidator.java (63%) create mode 100644 backend/src/main/java/com/festago/stage/domain/validator/festival/ExistsStageFestivalDeleteValidator.java create mode 100644 backend/src/main/java/com/festago/stage/domain/validator/festival/OutOfDateStageFestivalUpdateValidator.java rename backend/src/main/java/com/festago/student/{application => domain/validator/school}/StudentSchoolDeleteValidator.java (86%) create mode 100644 backend/src/test/java/com/festago/admin/presentation/v1/AdminFestivalV1ControllerTest.java create mode 100644 backend/src/test/java/com/festago/festival/application/command/FestivalCreateServiceTest.java create mode 100644 backend/src/test/java/com/festago/festival/application/command/FestivalDeleteServiceTest.java create mode 100644 backend/src/test/java/com/festago/festival/application/command/FestivalUpdateServiceTest.java create mode 100644 backend/src/test/java/com/festago/stage/domain/validator/festival/OutOfDateStageFestivalUpdateValidatorTest.java create mode 100644 backend/src/test/java/com/festago/stage/repository/MemoryStageRepository.java diff --git a/backend/src/main/java/com/festago/admin/dto/FestivalV1CreateRequest.java b/backend/src/main/java/com/festago/admin/dto/FestivalV1CreateRequest.java new file mode 100644 index 000000000..a86daad98 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/FestivalV1CreateRequest.java @@ -0,0 +1,38 @@ +package com.festago.admin.dto; + +import com.festago.festival.dto.command.FestivalCreateCommand; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; + +public record FestivalV1CreateRequest( + @NotBlank(message = "name은 공백일 수 없습니다.") + String name, + + @NotNull(message = "startDate는 null 일 수 없습니다.") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate startDate, + + @NotNull(message = "endDate는 null 일 수 없습니다.") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate endDate, + + @Nullable + String posterImageUrl, + + @NotNull(message = "schoolId는 null 일 수 없습니다.") + Long schoolId +) { + + public FestivalCreateCommand toCommand() { + return new FestivalCreateCommand( + name, + startDate, + endDate, + posterImageUrl, + schoolId + ); + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/FestivalV1UpdateRequest.java b/backend/src/main/java/com/festago/admin/dto/FestivalV1UpdateRequest.java new file mode 100644 index 000000000..dd0a13d0d --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/FestivalV1UpdateRequest.java @@ -0,0 +1,34 @@ +package com.festago.admin.dto; + +import com.festago.festival.dto.command.FestivalUpdateCommand; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; + +public record FestivalV1UpdateRequest( + @NotBlank(message = "name은 공백일 수 없습니다.") + String name, + + @NotNull(message = "startDate는 null 일 수 없습니다.") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate startDate, + + @NotNull(message = "endDate는 null 일 수 없습니다.") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate endDate, + + @Nullable + String posterImageUrl +) { + + public FestivalUpdateCommand toCommand() { + return new FestivalUpdateCommand( + name, + startDate, + endDate, + posterImageUrl + ); + } +} diff --git a/backend/src/main/java/com/festago/admin/presentation/AdminController.java b/backend/src/main/java/com/festago/admin/presentation/AdminController.java index 89e12b712..f4d1ab11a 100644 --- a/backend/src/main/java/com/festago/admin/presentation/AdminController.java +++ b/backend/src/main/java/com/festago/admin/presentation/AdminController.java @@ -47,6 +47,10 @@ public class AdminController { private final SchoolService schoolService; private final Optional properties; + /** + * @deprecated 새로운 Festival CRUD 기능이 안정되면 삭제 + */ + @Deprecated(forRemoval = true) @PostMapping("/festivals") public ResponseEntity createFestival(@RequestBody @Valid FestivalCreateRequest request) { FestivalResponse response = festivalService.create(request); @@ -54,6 +58,10 @@ public ResponseEntity createFestival(@RequestBody @Valid Festi .body(response); } + /** + * @deprecated 새로운 Festival CRUD 기능이 안정되면 삭제 + */ + @Deprecated(forRemoval = true) @PatchMapping("/festivals/{festivalId}") public ResponseEntity updateFestival(@RequestBody @Valid FestivalUpdateRequest request, @PathVariable Long festivalId) { @@ -62,6 +70,10 @@ public ResponseEntity updateFestival(@RequestBody @Valid FestivalUpdateReq .build(); } + /** + * @deprecated 새로운 Festival CRUD 기능이 안정되면 삭제 + */ + @Deprecated(forRemoval = true) @DeleteMapping("/festivals/{festivalId}") public ResponseEntity deleteFestival(@PathVariable Long festivalId) { festivalService.delete(festivalId); diff --git a/backend/src/main/java/com/festago/admin/presentation/v1/AdminFestivalV1Controller.java b/backend/src/main/java/com/festago/admin/presentation/v1/AdminFestivalV1Controller.java new file mode 100644 index 000000000..c77cfdd50 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/presentation/v1/AdminFestivalV1Controller.java @@ -0,0 +1,54 @@ +package com.festago.admin.presentation.v1; + +import com.festago.admin.dto.FestivalV1CreateRequest; +import com.festago.admin.dto.FestivalV1UpdateRequest; +import com.festago.festival.application.command.FestivalCommandFacadeService; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin/api/v1/festivals") +@RequiredArgsConstructor +@Hidden +public class AdminFestivalV1Controller { + + private final FestivalCommandFacadeService festivalCommandFacadeService; + + @PostMapping + public ResponseEntity createFestival( + @RequestBody @Valid FestivalV1CreateRequest request + ) { + Long festivalId = festivalCommandFacadeService.createFestival(request.toCommand()); + return ResponseEntity.created(URI.create("/admin/api/v1/festivals/" + festivalId)) + .build(); + } + + @PatchMapping("/{festivalId}") + public ResponseEntity updateFestival( + @PathVariable Long festivalId, + @RequestBody @Valid FestivalV1UpdateRequest request + ) { + festivalCommandFacadeService.updateFestival(festivalId, request.toCommand()); + return ResponseEntity.ok() + .build(); + } + + @DeleteMapping("/{festivalId}") + public ResponseEntity deleteFestival( + @PathVariable Long festivalId + ) { + festivalCommandFacadeService.deleteFestival(festivalId); + return ResponseEntity.noContent() + .build(); + } +} diff --git a/backend/src/main/java/com/festago/common/exception/ErrorCode.java b/backend/src/main/java/com/festago/common/exception/ErrorCode.java index 44f0e7736..8e4aa6f3b 100644 --- a/backend/src/main/java/com/festago/common/exception/ErrorCode.java +++ b/backend/src/main/java/com/festago/common/exception/ErrorCode.java @@ -36,6 +36,8 @@ public enum ErrorCode { DUPLICATE_SCHOOL_DOMAIN("이미 존재하는 학교의 도메인입니다."), INVALID_PAGING_MAX_SIZE("최대 size 값을 초과했습니다."), INVALID_NUMBER_FORMAT_PAGING_SIZE("size는 1 이상의 정수 형식이어야 합니다."), + FESTIVAL_DELETE_CONSTRAINT_EXISTS_STAGE("공연이 등록된 축제는 삭제할 수 없습니다."), + FESTIVAL_UPDATE_OUT_OF_DATE_STAGE_START_TIME("축제에 등록된 공연 중 변경하려는 날짜에 포함되지 않는 공연이 있습니다."), // 401 EXPIRED_AUTH_TOKEN("만료된 로그인 토큰입니다."), diff --git a/backend/src/main/java/com/festago/festival/application/FestivalQueryInfoEventListener.java b/backend/src/main/java/com/festago/festival/application/FestivalQueryInfoEventListener.java new file mode 100644 index 000000000..e866d9bd9 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/FestivalQueryInfoEventListener.java @@ -0,0 +1,37 @@ +package com.festago.festival.application; + +import com.festago.festival.domain.FestivalQueryInfo; +import com.festago.festival.dto.event.FestivalCreatedEvent; +import com.festago.festival.dto.event.FestivalDeletedEvent; +import com.festago.festival.repository.FestivalInfoRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class FestivalQueryInfoEventListener { + + private final FestivalInfoRepository festivalInfoRepository; + + /** + * 해당 이벤트는 비동기로 실행하면 문제가 발생할 수 있으니, 동기적으로 처리해야함
축제가 생성되면 FestivalQueryInfo는 반드시! 생성되어야 함 + */ + @EventListener + @Transactional(propagation = Propagation.MANDATORY) + public void festivalCreatedEventHandler(FestivalCreatedEvent event) { + FestivalQueryInfo festivalQueryInfo = FestivalQueryInfo.create(event.festivalId()); + festivalInfoRepository.save(festivalQueryInfo); + } + + /** + * 삭제의 경우 동기적으로 처리될 필요가 없음
하지만 일관성을 위해 동기적으로 처리함 + */ + @EventListener + @Transactional(propagation = Propagation.MANDATORY) + public void festivalDeletedEventHandler(FestivalDeletedEvent event) { + festivalInfoRepository.deleteByFestivalId(event.festivalId()); + } +} diff --git a/backend/src/main/java/com/festago/festival/application/FestivalService.java b/backend/src/main/java/com/festago/festival/application/FestivalService.java index 9c323514b..0c13c4500 100644 --- a/backend/src/main/java/com/festago/festival/application/FestivalService.java +++ b/backend/src/main/java/com/festago/festival/application/FestivalService.java @@ -21,6 +21,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * @deprecated 새로운 Festival CRUD 기능이 안정되면 삭제 + */ +@Deprecated(forRemoval = true) @Service @Transactional @RequiredArgsConstructor @@ -40,7 +44,7 @@ public FestivalResponse create(FestivalCreateRequest request) { } private void validate(Festival festival) { - if (!festival.canCreate(LocalDate.now(clock))) { + if (festival.isBeforeStartDate(LocalDate.now(clock))) { throw new BadRequestException(ErrorCode.INVALID_FESTIVAL_START_DATE); } } @@ -56,13 +60,8 @@ public DetailFestivalResponse findDetail(Long festivalId) { return festivalStageService.findDetail(festivalId); } - private Festival findFestival(Long festivalId) { - return festivalRepository.findById(festivalId) - .orElseThrow(() -> new NotFoundException(ErrorCode.FESTIVAL_NOT_FOUND)); - } - public void update(Long festivalId, FestivalUpdateRequest request) { - Festival festival = findFestival(festivalId); + Festival festival = festivalRepository.getOrThrow(festivalId); festival.changeName(request.name()); festival.changeThumbnail(request.thumbnail()); festival.changeDate(request.startDate(), request.endDate()); diff --git a/backend/src/main/java/com/festago/festival/application/command/FestivalCommandFacadeService.java b/backend/src/main/java/com/festago/festival/application/command/FestivalCommandFacadeService.java new file mode 100644 index 000000000..c4ae3c303 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/command/FestivalCommandFacadeService.java @@ -0,0 +1,27 @@ +package com.festago.festival.application.command; + +import com.festago.festival.dto.command.FestivalCreateCommand; +import com.festago.festival.dto.command.FestivalUpdateCommand; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class FestivalCommandFacadeService { + + private final FestivalCreateService festivalCreateService; + private final FestivalUpdateService festivalUpdateService; + private final FestivalDeleteService festivalDeleteService; + + public Long createFestival(FestivalCreateCommand command) { + return festivalCreateService.createFestival(command); + } + + public void updateFestival(Long festivalId, FestivalUpdateCommand command) { + festivalUpdateService.updateFestival(festivalId, command); + } + + public void deleteFestival(Long festivalId) { + festivalDeleteService.deleteFestival(festivalId); + } +} diff --git a/backend/src/main/java/com/festago/festival/application/command/FestivalCreateService.java b/backend/src/main/java/com/festago/festival/application/command/FestivalCreateService.java new file mode 100644 index 000000000..776747c00 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/command/FestivalCreateService.java @@ -0,0 +1,41 @@ +package com.festago.festival.application.command; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.domain.Festival; +import com.festago.festival.dto.command.FestivalCreateCommand; +import com.festago.festival.dto.event.FestivalCreatedEvent; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import java.time.Clock; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class FestivalCreateService { + + private final FestivalRepository festivalRepository; + private final SchoolRepository schoolRepository; + private final ApplicationEventPublisher eventPublisher; + private final Clock clock; + + public Long createFestival(FestivalCreateCommand command) { + School school = schoolRepository.getOrThrow(command.schoolId()); + Festival festival = festivalRepository.save(command.toEntity(school)); + validate(festival); + eventPublisher.publishEvent(new FestivalCreatedEvent(festival.getId())); + return festival.getId(); + } + + private void validate(Festival festival) { + if (festival.isBeforeStartDate(LocalDate.now(clock))) { + throw new BadRequestException(ErrorCode.INVALID_FESTIVAL_START_DATE); + } + } +} diff --git a/backend/src/main/java/com/festago/festival/application/command/FestivalDeleteService.java b/backend/src/main/java/com/festago/festival/application/command/FestivalDeleteService.java new file mode 100644 index 000000000..f96ba4337 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/command/FestivalDeleteService.java @@ -0,0 +1,26 @@ +package com.festago.festival.application.command; + +import com.festago.festival.domain.validator.FestivalDeleteValidator; +import com.festago.festival.dto.event.FestivalDeletedEvent; +import com.festago.festival.repository.FestivalRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class FestivalDeleteService { + + private final FestivalRepository festivalRepository; + private final List validators; + private final ApplicationEventPublisher eventPublisher; + + public void deleteFestival(Long festivalId) { + validators.forEach(validator -> validator.validate(festivalId)); + festivalRepository.deleteById(festivalId); + eventPublisher.publishEvent(new FestivalDeletedEvent(festivalId)); + } +} diff --git a/backend/src/main/java/com/festago/festival/application/command/FestivalUpdateService.java b/backend/src/main/java/com/festago/festival/application/command/FestivalUpdateService.java new file mode 100644 index 000000000..89c47b130 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/command/FestivalUpdateService.java @@ -0,0 +1,30 @@ +package com.festago.festival.application.command; + +import com.festago.festival.domain.Festival; +import com.festago.festival.domain.validator.FestivalUpdateValidator; +import com.festago.festival.dto.command.FestivalUpdateCommand; +import com.festago.festival.repository.FestivalRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class FestivalUpdateService { + + private final FestivalRepository festivalRepository; + private final List validators; + + /** + * 강제로 수정할 일이 필요할 수 있으므로, 시작일이 과거여도 예외를 발생하지 않음 + */ + public void updateFestival(Long festivalId, FestivalUpdateCommand command) { + Festival festival = festivalRepository.getOrThrow(festivalId); + festival.changeName(command.name()); + festival.changeThumbnail(command.posterImageUrl()); + festival.changeDate(command.startDate(), command.endDate()); + validators.forEach(validator -> validator.validate(festival)); + } +} diff --git a/backend/src/main/java/com/festago/festival/domain/Festival.java b/backend/src/main/java/com/festago/festival/domain/Festival.java index 1775f2b7a..a47de38c0 100644 --- a/backend/src/main/java/com/festago/festival/domain/Festival.java +++ b/backend/src/main/java/com/festago/festival/domain/Festival.java @@ -92,8 +92,8 @@ private void validateDate(LocalDate startDate, LocalDate endDate) { } } - public boolean canCreate(LocalDate currentDate) { - return startDate.isEqual(currentDate) || startDate.isAfter(currentDate); + public boolean isBeforeStartDate(LocalDate currentDate) { + return startDate.isBefore(currentDate); } public boolean isNotInDuration(LocalDateTime time) { diff --git a/backend/src/main/java/com/festago/festival/domain/FestivalQueryInfo.java b/backend/src/main/java/com/festago/festival/domain/FestivalQueryInfo.java index 62ba7d98e..c2acfb218 100644 --- a/backend/src/main/java/com/festago/festival/domain/FestivalQueryInfo.java +++ b/backend/src/main/java/com/festago/festival/domain/FestivalQueryInfo.java @@ -21,6 +21,7 @@ public class FestivalQueryInfo extends BaseTimeEntity { private Long id; @NotNull + @Column(unique = true) private Long festivalId; @NotNull @@ -36,6 +37,10 @@ public static FestivalQueryInfo of(Festival festival, List artists, Fest return new FestivalQueryInfo(festival.getId(), serializer.serialize(artists)); } + public static FestivalQueryInfo create(Long festivalId) { + return new FestivalQueryInfo(festivalId, "[]"); + } + public Long getId() { return id; } diff --git a/backend/src/main/java/com/festago/festival/domain/validator/FestivalDeleteValidator.java b/backend/src/main/java/com/festago/festival/domain/validator/FestivalDeleteValidator.java new file mode 100644 index 000000000..15f49105e --- /dev/null +++ b/backend/src/main/java/com/festago/festival/domain/validator/FestivalDeleteValidator.java @@ -0,0 +1,6 @@ +package com.festago.festival.domain.validator; + +public interface FestivalDeleteValidator { + + void validate(Long festivalId); +} diff --git a/backend/src/main/java/com/festago/festival/domain/validator/FestivalUpdateValidator.java b/backend/src/main/java/com/festago/festival/domain/validator/FestivalUpdateValidator.java new file mode 100644 index 000000000..c4f27a83d --- /dev/null +++ b/backend/src/main/java/com/festago/festival/domain/validator/FestivalUpdateValidator.java @@ -0,0 +1,8 @@ +package com.festago.festival.domain.validator; + +import com.festago.festival.domain.Festival; + +public interface FestivalUpdateValidator { + + void validate(Festival festival); +} diff --git a/backend/src/main/java/com/festago/festival/application/FestivalSchoolDeleteValidator.java b/backend/src/main/java/com/festago/festival/domain/validator/school/ExistsFestivalSchoolDeleteValidator.java similarity index 76% rename from backend/src/main/java/com/festago/festival/application/FestivalSchoolDeleteValidator.java rename to backend/src/main/java/com/festago/festival/domain/validator/school/ExistsFestivalSchoolDeleteValidator.java index be7d2b745..79859e6bc 100644 --- a/backend/src/main/java/com/festago/festival/application/FestivalSchoolDeleteValidator.java +++ b/backend/src/main/java/com/festago/festival/domain/validator/school/ExistsFestivalSchoolDeleteValidator.java @@ -1,9 +1,9 @@ -package com.festago.festival.application; +package com.festago.festival.domain.validator.school; import com.festago.common.exception.BadRequestException; import com.festago.common.exception.ErrorCode; import com.festago.festival.repository.FestivalRepository; -import com.festago.school.application.SchoolDeleteValidator; +import com.festago.school.domain.validator.SchoolDeleteValidator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -11,7 +11,7 @@ @Component @Transactional(readOnly = true) @RequiredArgsConstructor -public class FestivalSchoolDeleteValidator implements SchoolDeleteValidator { +public class ExistsFestivalSchoolDeleteValidator implements SchoolDeleteValidator { private final FestivalRepository festivalRepository; diff --git a/backend/src/main/java/com/festago/festival/dto/command/FestivalCreateCommand.java b/backend/src/main/java/com/festago/festival/dto/command/FestivalCreateCommand.java new file mode 100644 index 000000000..eb335cc8e --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/command/FestivalCreateCommand.java @@ -0,0 +1,18 @@ +package com.festago.festival.dto.command; + +import com.festago.festival.domain.Festival; +import com.festago.school.domain.School; +import java.time.LocalDate; + +public record FestivalCreateCommand( + String name, + LocalDate startDate, + LocalDate endDate, + String posterImageUrl, + Long schoolId +) { + + public Festival toEntity(School school) { + return new Festival(name, startDate, endDate, posterImageUrl, school); + } +} diff --git a/backend/src/main/java/com/festago/festival/dto/command/FestivalUpdateCommand.java b/backend/src/main/java/com/festago/festival/dto/command/FestivalUpdateCommand.java new file mode 100644 index 000000000..57aa9f1de --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/command/FestivalUpdateCommand.java @@ -0,0 +1,12 @@ +package com.festago.festival.dto.command; + +import java.time.LocalDate; + +public record FestivalUpdateCommand( + String name, + LocalDate startDate, + LocalDate endDate, + String posterImageUrl +) { + +} diff --git a/backend/src/main/java/com/festago/festival/dto/event/FestivalCreatedEvent.java b/backend/src/main/java/com/festago/festival/dto/event/FestivalCreatedEvent.java new file mode 100644 index 000000000..6d2814767 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/event/FestivalCreatedEvent.java @@ -0,0 +1,7 @@ +package com.festago.festival.dto.event; + +public record FestivalCreatedEvent( + Long festivalId +) { + +} diff --git a/backend/src/main/java/com/festago/festival/dto/event/FestivalDeletedEvent.java b/backend/src/main/java/com/festago/festival/dto/event/FestivalDeletedEvent.java new file mode 100644 index 000000000..8b3f0a931 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/event/FestivalDeletedEvent.java @@ -0,0 +1,7 @@ +package com.festago.festival.dto.event; + +public record FestivalDeletedEvent( + Long festivalId +) { + +} diff --git a/backend/src/main/java/com/festago/festival/presentation/FestivalController.java b/backend/src/main/java/com/festago/festival/presentation/FestivalController.java index 5ffddeefe..5abcb985d 100644 --- a/backend/src/main/java/com/festago/festival/presentation/FestivalController.java +++ b/backend/src/main/java/com/festago/festival/presentation/FestivalController.java @@ -14,6 +14,10 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +/** + * @deprecated 새로운 Festival CRUD 기능이 안정되면 삭제 + */ +@Deprecated(forRemoval = true) @RestController @RequestMapping("/festivals") @Tag(name = "축제 정보 요청") diff --git a/backend/src/main/java/com/festago/festival/repository/FestivalInfoRepository.java b/backend/src/main/java/com/festago/festival/repository/FestivalInfoRepository.java index 497d57e48..56c44fd56 100644 --- a/backend/src/main/java/com/festago/festival/repository/FestivalInfoRepository.java +++ b/backend/src/main/java/com/festago/festival/repository/FestivalInfoRepository.java @@ -1,13 +1,15 @@ package com.festago.festival.repository; import com.festago.festival.domain.FestivalQueryInfo; -import java.util.List; +import java.util.Optional; import org.springframework.data.repository.Repository; public interface FestivalInfoRepository extends Repository { FestivalQueryInfo save(FestivalQueryInfo festivalQueryInfo); - List findAllByFestivalIdIn(List festivalIds); + Optional findByFestivalId(Long festivalId); + + void deleteByFestivalId(Long festivalId); } diff --git a/backend/src/main/java/com/festago/festival/repository/FestivalRepository.java b/backend/src/main/java/com/festago/festival/repository/FestivalRepository.java index 8c0dfea78..b2087974c 100644 --- a/backend/src/main/java/com/festago/festival/repository/FestivalRepository.java +++ b/backend/src/main/java/com/festago/festival/repository/FestivalRepository.java @@ -1,9 +1,25 @@ package com.festago.festival.repository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; import com.festago.festival.domain.Festival; -import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +import org.springframework.data.repository.Repository; -public interface FestivalRepository extends JpaRepository, FestivalRepositoryCustom { +public interface FestivalRepository extends Repository, FestivalRepositoryCustom { + + default Festival getOrThrow(Long festivalId) { + return findById(festivalId) + .orElseThrow(() -> new NotFoundException(ErrorCode.FESTIVAL_NOT_FOUND)); + } boolean existsBySchoolId(Long schoolId); + + Festival save(Festival festival); + + Optional findById(Long festivalId); + + void deleteById(Long festivalId); + + void flush(); } diff --git a/backend/src/main/java/com/festago/school/application/SchoolDeleteService.java b/backend/src/main/java/com/festago/school/application/SchoolDeleteService.java index b52b306c5..aac675960 100644 --- a/backend/src/main/java/com/festago/school/application/SchoolDeleteService.java +++ b/backend/src/main/java/com/festago/school/application/SchoolDeleteService.java @@ -1,5 +1,6 @@ package com.festago.school.application; +import com.festago.school.domain.validator.SchoolDeleteValidator; import com.festago.school.repository.SchoolRepository; import java.util.List; import lombok.RequiredArgsConstructor; diff --git a/backend/src/main/java/com/festago/school/application/SchoolDeleteValidator.java b/backend/src/main/java/com/festago/school/domain/validator/SchoolDeleteValidator.java similarity index 63% rename from backend/src/main/java/com/festago/school/application/SchoolDeleteValidator.java rename to backend/src/main/java/com/festago/school/domain/validator/SchoolDeleteValidator.java index c95700111..4c681c4be 100644 --- a/backend/src/main/java/com/festago/school/application/SchoolDeleteValidator.java +++ b/backend/src/main/java/com/festago/school/domain/validator/SchoolDeleteValidator.java @@ -1,4 +1,4 @@ -package com.festago.school.application; +package com.festago.school.domain.validator; public interface SchoolDeleteValidator { diff --git a/backend/src/main/java/com/festago/stage/application/FestivalStageServiceImpl.java b/backend/src/main/java/com/festago/stage/application/FestivalStageServiceImpl.java index 02b557ce3..b5018190b 100644 --- a/backend/src/main/java/com/festago/stage/application/FestivalStageServiceImpl.java +++ b/backend/src/main/java/com/festago/stage/application/FestivalStageServiceImpl.java @@ -4,8 +4,6 @@ import static java.util.stream.Collectors.collectingAndThen; import static java.util.stream.Collectors.toList; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.NotFoundException; import com.festago.festival.application.FestivalStageService; import com.festago.festival.domain.Festival; import com.festago.festival.dto.DetailFestivalResponse; @@ -31,7 +29,7 @@ public class FestivalStageServiceImpl implements FestivalStageService { @Transactional(readOnly = true) @Override public DetailFestivalResponse findDetail(Long festivalId) { - Festival festival = findFestival(festivalId); + Festival festival = festivalRepository.getOrThrow(festivalId); return stageRepository.findAllDetailByFestivalId(festivalId).stream() .sorted(comparing(Stage::getStartTime)) .map(this::createResponse) @@ -49,11 +47,6 @@ public DetailFestivalResponse findDetail(Long festivalId) { )); } - private Festival findFestival(Long festivalId) { - return festivalRepository.findById(festivalId) - .orElseThrow(() -> new NotFoundException(ErrorCode.FESTIVAL_NOT_FOUND)); - } - private DetailStageResponse createResponse(Stage stage) { return stage.getTickets().stream() .map(this::createResponse) diff --git a/backend/src/main/java/com/festago/stage/application/StageService.java b/backend/src/main/java/com/festago/stage/application/StageService.java index 75dfc63ab..e59a7e35e 100644 --- a/backend/src/main/java/com/festago/stage/application/StageService.java +++ b/backend/src/main/java/com/festago/stage/application/StageService.java @@ -24,7 +24,7 @@ public class StageService { private final FestivalRepository festivalRepository; public StageResponse create(StageCreateRequest request) { - Festival festival = findFestival(request.festivalId()); + Festival festival = festivalRepository.getOrThrow(request.festivalId()); Stage newStage = stageRepository.save(new Stage( request.startTime(), request.lineUp(), @@ -34,11 +34,6 @@ public StageResponse create(StageCreateRequest request) { return StageResponse.from(newStage); } - private Festival findFestival(Long festivalId) { - return festivalRepository.findById(festivalId) - .orElseThrow(() -> new NotFoundException(ErrorCode.FESTIVAL_NOT_FOUND)); - } - public StageResponse findDetail(Long stageId) { Stage stage = findStage(stageId); return StageResponse.from(stage); diff --git a/backend/src/main/java/com/festago/stage/domain/validator/festival/ExistsStageFestivalDeleteValidator.java b/backend/src/main/java/com/festago/stage/domain/validator/festival/ExistsStageFestivalDeleteValidator.java new file mode 100644 index 000000000..72fd18ec9 --- /dev/null +++ b/backend/src/main/java/com/festago/stage/domain/validator/festival/ExistsStageFestivalDeleteValidator.java @@ -0,0 +1,24 @@ +package com.festago.stage.domain.validator.festival; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.domain.validator.FestivalDeleteValidator; +import com.festago.stage.repository.StageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ExistsStageFestivalDeleteValidator implements FestivalDeleteValidator { + + private final StageRepository stageRepository; + + @Override + public void validate(Long festivalId) { + if (stageRepository.existsByFestivalId(festivalId)) { + throw new BadRequestException(ErrorCode.FESTIVAL_DELETE_CONSTRAINT_EXISTS_STAGE); + } + } +} diff --git a/backend/src/main/java/com/festago/stage/domain/validator/festival/OutOfDateStageFestivalUpdateValidator.java b/backend/src/main/java/com/festago/stage/domain/validator/festival/OutOfDateStageFestivalUpdateValidator.java new file mode 100644 index 000000000..e799a0f84 --- /dev/null +++ b/backend/src/main/java/com/festago/stage/domain/validator/festival/OutOfDateStageFestivalUpdateValidator.java @@ -0,0 +1,33 @@ +package com.festago.stage.domain.validator.festival; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.domain.Festival; +import com.festago.festival.domain.validator.FestivalUpdateValidator; +import com.festago.stage.domain.Stage; +import com.festago.stage.repository.StageRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 축제를 수정할 때, 축제에 포함된 공연이 수정할 축제 기간의 범위를 벗어난 공연이 있는지 검증하는 클래스 + */ +@Component +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class OutOfDateStageFestivalUpdateValidator implements FestivalUpdateValidator { + + private final StageRepository stageRepository; + + @Override + public void validate(Festival festival) { + List stages = stageRepository.findAllByFestivalId(festival.getId()); + boolean isOutOfDate = stages.stream() + .anyMatch(stage -> festival.isNotInDuration(stage.getStartTime())); + if (isOutOfDate) { + throw new BadRequestException(ErrorCode.FESTIVAL_UPDATE_OUT_OF_DATE_STAGE_START_TIME); + } + } +} diff --git a/backend/src/main/java/com/festago/stage/repository/StageRepository.java b/backend/src/main/java/com/festago/stage/repository/StageRepository.java index 1b6fc5383..584107e74 100644 --- a/backend/src/main/java/com/festago/stage/repository/StageRepository.java +++ b/backend/src/main/java/com/festago/stage/repository/StageRepository.java @@ -1,7 +1,22 @@ package com.festago.stage.repository; import com.festago.stage.domain.Stage; -import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; +import org.springframework.data.repository.Repository; + +public interface StageRepository extends Repository, StageRepositoryCustom { + + Stage save(Stage stage); + + Optional findById(Long stageId); + + void deleteById(Long stageId); + + void flush(); + + boolean existsByFestivalId(Long festivalId); + + List findAllByFestivalId(Long festivalId); -public interface StageRepository extends JpaRepository, StageRepositoryCustom { } diff --git a/backend/src/main/java/com/festago/student/application/StudentSchoolDeleteValidator.java b/backend/src/main/java/com/festago/student/domain/validator/school/StudentSchoolDeleteValidator.java similarity index 86% rename from backend/src/main/java/com/festago/student/application/StudentSchoolDeleteValidator.java rename to backend/src/main/java/com/festago/student/domain/validator/school/StudentSchoolDeleteValidator.java index 9c8d91cf1..9dd3736d6 100644 --- a/backend/src/main/java/com/festago/student/application/StudentSchoolDeleteValidator.java +++ b/backend/src/main/java/com/festago/student/domain/validator/school/StudentSchoolDeleteValidator.java @@ -1,8 +1,8 @@ -package com.festago.student.application; +package com.festago.student.domain.validator.school; import com.festago.common.exception.BadRequestException; import com.festago.common.exception.ErrorCode; -import com.festago.school.application.SchoolDeleteValidator; +import com.festago.school.domain.validator.SchoolDeleteValidator; import com.festago.student.repository.StudentRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; diff --git a/backend/src/test/java/com/festago/admin/presentation/v1/AdminFestivalV1ControllerTest.java b/backend/src/test/java/com/festago/admin/presentation/v1/AdminFestivalV1ControllerTest.java new file mode 100644 index 000000000..e43585c07 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/presentation/v1/AdminFestivalV1ControllerTest.java @@ -0,0 +1,175 @@ +package com.festago.admin.presentation.v1; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.admin.dto.FestivalV1UpdateRequest; +import com.festago.auth.domain.Role; +import com.festago.festival.application.command.FestivalCommandFacadeService; +import com.festago.festival.dto.FestivalCreateRequest; +import com.festago.festival.dto.command.FestivalCreateCommand; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import jakarta.servlet.http.Cookie; +import java.time.LocalDate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminFestivalV1ControllerTest { + + private static final Cookie TOKEN_COOKIE = new Cookie("token", "token"); + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + FestivalCommandFacadeService festivalCommandFacadeService; + + @Nested + class 축제_생성 { + + final String uri = "/admin/api/v1/festivals"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + String name = "테코대학교 축제"; + LocalDate startDate = LocalDate.parse("2024-01-31"); + LocalDate endDate = LocalDate.parse("2024-02-01"); + String thumbnail = "https://image.com/image.png"; + FestivalCreateRequest request = new FestivalCreateRequest(name, startDate, endDate, thumbnail, 1L); + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_201_응답과_Location_헤더에_식별자가_반환된다() throws Exception { + // given + given(festivalCommandFacadeService.createFestival(any(FestivalCreateCommand.class))) + .willReturn(1L); + + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(header().string(HttpHeaders.LOCATION, "/admin/api/v1/festivals/1")); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 축제_수정 { + + final String uri = "/admin/api/v1/festivals/{festivalId}"; + + @Nested + @DisplayName("PATCH " + uri) + class 올바른_주소로 { + + String name = "테코대학교 축제"; + LocalDate startDate = LocalDate.parse("2024-01-31"); + LocalDate endDate = LocalDate.parse("2024-02-01"); + String thumbnail = "https://image.com/image.png"; + FestivalV1UpdateRequest request = new FestivalV1UpdateRequest(name, startDate, endDate, thumbnail); + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(patch(uri, 1L) + .cookie(TOKEN_COOKIE) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(patch(uri, 1L)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(patch(uri, 1L) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 축제_삭제 { + + final String uri = "/admin/api/v1/festivals/{festivalId}"; + + @Nested + @DisplayName("DELETE " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_204_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, 1L) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNoContent()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, 1L)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, 1L) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/festival/application/command/FestivalCreateServiceTest.java b/backend/src/test/java/com/festago/festival/application/command/FestivalCreateServiceTest.java new file mode 100644 index 000000000..d30e50ce0 --- /dev/null +++ b/backend/src/test/java/com/festago/festival/application/command/FestivalCreateServiceTest.java @@ -0,0 +1,105 @@ +package com.festago.festival.application.command; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.BDDMockito.given; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.dto.command.FestivalCreateCommand; +import com.festago.festival.repository.FestivalInfoRepository; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.SchoolFixture; +import com.festago.support.TimeInstantProvider; +import java.time.Clock; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalCreateServiceTest extends ApplicationIntegrationTest { + + @Autowired + FestivalCreateService festivalCreateService; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + FestivalInfoRepository festivalInfoRepository; + + @Autowired + Clock clock; + + @Nested + class createFestival { + + Long schoolId; + LocalDate now = LocalDate.parse("2023-01-31"); + + @BeforeEach + void setUp() { + given(clock.instant()) + .willReturn(TimeInstantProvider.from(now)); + School school = schoolRepository.save(SchoolFixture.school().build()); + schoolId = school.getId(); + } + + @Test + void 축제의_시작일이_현재_시간보다_과거이면_예외가_발생한다() { + // given + String festivalName = "테코대학교 축제"; + LocalDate startDate = now.minusDays(1); + LocalDate endDate = now.plusDays(3); + String thumbnail = "https://image.com/image.png"; + var command = new FestivalCreateCommand( + festivalName, + startDate, + endDate, + thumbnail, + schoolId + ); + + // when & then + assertThatThrownBy(() -> festivalCreateService.createFestival(command)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_FESTIVAL_START_DATE.getMessage()); + } + + @Test + void 축제를_생성하면_축제가_저장되고_FestivalQueryInfo도_저장된다() { + // given + String festivalName = "테코대학교 축제"; + LocalDate startDate = now.plusDays(1); + LocalDate endDate = now.plusDays(3); + String thumbnail = "https://image.com/image.png"; + var command = new FestivalCreateCommand( + festivalName, + startDate, + endDate, + thumbnail, + schoolId + ); + + // when + Long festivalId = festivalCreateService.createFestival(command); + + // then + assertSoftly(softly -> { + softly.assertThat(festivalRepository.findById(festivalId)).isPresent(); + softly.assertThat(festivalInfoRepository.findByFestivalId(festivalId)).isPresent(); + }); + } + } +} diff --git a/backend/src/test/java/com/festago/festival/application/command/FestivalDeleteServiceTest.java b/backend/src/test/java/com/festago/festival/application/command/FestivalDeleteServiceTest.java new file mode 100644 index 000000000..43d9ea903 --- /dev/null +++ b/backend/src/test/java/com/festago/festival/application/command/FestivalDeleteServiceTest.java @@ -0,0 +1,106 @@ +package com.festago.festival.application.command; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.BDDMockito.given; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.domain.Festival; +import com.festago.festival.domain.FestivalQueryInfo; +import com.festago.festival.repository.FestivalInfoRepository; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.stage.repository.StageRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.FestivalFixture; +import com.festago.support.SchoolFixture; +import com.festago.support.StageFixture; +import com.festago.support.TimeInstantProvider; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalDeleteServiceTest extends ApplicationIntegrationTest { + + @Autowired + FestivalDeleteService festivalDeleteService; + + @Autowired + FestivalInfoRepository festivalInfoRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + StageRepository stageRepository; + + @Autowired + Clock clock; + + @Nested + class deleteFestival { + + Long festivalId; + LocalDate now = LocalDate.parse("2023-01-31"); + + @BeforeEach + void setUp() { + given(clock.instant()) + .willReturn(TimeInstantProvider.from(now)); + School school = schoolRepository.save(SchoolFixture.school().build()); + Festival festival = festivalRepository.save(FestivalFixture.festival() + .startDate(now) + .endDate(now) + .school(school) + .build() + ); + festivalId = festival.getId(); + } + + @Test + void 공연이_등록된_축제는_삭제할_수_없다() { + // given + Festival festival = festivalRepository.getOrThrow(festivalId); + LocalDateTime startTime = LocalDateTime.now(clock); + LocalDateTime ticketOpenTime = startTime.minusDays(1); + stageRepository.save(StageFixture.stage() + .festival(festival) + .startTime(startTime) + .ticketOpenTime(ticketOpenTime) + .build() + ); + + assertThatThrownBy(() -> festivalDeleteService.deleteFestival(festivalId)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.FESTIVAL_DELETE_CONSTRAINT_EXISTS_STAGE.getMessage()); + } + + @Test + void 축제를_삭제하면_FestivalQueryInfo도_삭제된다() { + // given + festivalInfoRepository.save(FestivalQueryInfo.create(festivalId)); + + // when + festivalDeleteService.deleteFestival(festivalId); + + // then + assertSoftly(softly -> { + softly.assertThat(festivalRepository.findById(festivalId)).isEmpty(); + softly.assertThat(festivalInfoRepository.findByFestivalId(festivalId)).isEmpty(); + }); + } + } +} diff --git a/backend/src/test/java/com/festago/festival/application/command/FestivalUpdateServiceTest.java b/backend/src/test/java/com/festago/festival/application/command/FestivalUpdateServiceTest.java new file mode 100644 index 000000000..fab41c9a9 --- /dev/null +++ b/backend/src/test/java/com/festago/festival/application/command/FestivalUpdateServiceTest.java @@ -0,0 +1,109 @@ +package com.festago.festival.application.command; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.BDDMockito.given; + +import com.festago.festival.domain.Festival; +import com.festago.festival.dto.command.FestivalUpdateCommand; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.FestivalFixture; +import com.festago.support.SchoolFixture; +import com.festago.support.TimeInstantProvider; +import java.time.Clock; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalUpdateServiceTest extends ApplicationIntegrationTest { + + @Autowired + FestivalUpdateService festivalUpdateService; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + Clock clock; + + @Nested + class updateFestival { + + Long festivalId; + LocalDate now = LocalDate.parse("2023-01-31"); + + @BeforeEach + void setUp() { + given(clock.instant()) + .willReturn(TimeInstantProvider.from(now)); + School school = schoolRepository.save(SchoolFixture.school().build()); + Festival festival = festivalRepository.save(FestivalFixture.festival().school(school).build()); + festivalId = festival.getId(); + } + + @Test + void 시작일이_현재_시간보다_과거여도_수정할_수_있다() { + // given + String newFestivalName = "변경된 축제"; + LocalDate newStartDate = now.minusDays(1); + LocalDate newEndDate = now.plusDays(1); + String newThumbnail = "https://image.com/new-image.png"; + var command = new FestivalUpdateCommand( + newFestivalName, + newStartDate, + newEndDate, + newThumbnail + ); + + // when + festivalUpdateService.updateFestival(festivalId, command); + + // then + Festival updatedFestival = festivalRepository.getOrThrow(festivalId); + assertSoftly(softly -> { + softly.assertThat(updatedFestival.getName()).isEqualTo(newFestivalName); + softly.assertThat(updatedFestival.getStartDate()).isEqualTo(newStartDate); + softly.assertThat(updatedFestival.getEndDate()).isEqualTo(newEndDate); + softly.assertThat(updatedFestival.getThumbnail()).isEqualTo(newThumbnail); + }); + } + + @Test + void 축제를_수정할_수_있다() { + // given + String newFestivalName = "변경된 축제"; + LocalDate newStartDate = now.plusDays(1); + LocalDate newEndDate = now.plusDays(1); + String newThumbnail = "https://image.com/new-image.png"; + var command = new FestivalUpdateCommand( + newFestivalName, + newStartDate, + newEndDate, + newThumbnail + ); + + // when + festivalUpdateService.updateFestival(festivalId, command); + + // then + Festival updatedFestival = festivalRepository.getOrThrow(festivalId); + assertSoftly(softly -> { + softly.assertThat(updatedFestival.getName()).isEqualTo(newFestivalName); + softly.assertThat(updatedFestival.getStartDate()).isEqualTo(newStartDate); + softly.assertThat(updatedFestival.getEndDate()).isEqualTo(newEndDate); + softly.assertThat(updatedFestival.getThumbnail()).isEqualTo(newThumbnail); + }); + } + } +} diff --git a/backend/src/test/java/com/festago/stage/application/FestivalStageServiceImplTest.java b/backend/src/test/java/com/festago/stage/application/FestivalStageServiceImplTest.java index fc6e0dcf4..28c2c6504 100644 --- a/backend/src/test/java/com/festago/stage/application/FestivalStageServiceImplTest.java +++ b/backend/src/test/java/com/festago/stage/application/FestivalStageServiceImplTest.java @@ -16,7 +16,7 @@ import com.festago.support.StageFixture; import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.Nested; @@ -43,14 +43,18 @@ class FestivalStageServiceImplTest { @Nested class 축제_무대_상세_조회 { + /** + * getOrThrow() 메서드가 stub 되어서 예외를 발생시키지 못함. 따라서 Disable 처리함. 해결하려면 Memory로 구현된 Fake Repository를 사용해야 할듯함 + */ @Test + @Disabled void 존재하지_않는_축제에_대한_상세_무대_조희를_하면_예외() { // given Long festivalId = 1L; - given(festivalRepository.findById(festivalId)).willReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> festivalStageService.findDetail(festivalId)).isInstanceOf(NotFoundException.class) + assertThatThrownBy(() -> festivalStageService.findDetail(festivalId)) + .isInstanceOf(NotFoundException.class) .hasMessage(FESTIVAL_NOT_FOUND.getMessage()); } @@ -63,7 +67,7 @@ class 축제_무대_상세_조회 { Stage stage1 = StageFixture.stage().id(1L).startTime(now).festival(festival).build(); Stage stage2 = StageFixture.stage().id(2L).startTime(now.plusDays(1)).festival(festival).build(); - given(festivalRepository.findById(festivalId)).willReturn(Optional.of(festival)); + given(festivalRepository.getOrThrow(festivalId)).willReturn(festival); given(stageRepository.findAllDetailByFestivalId(festival.getId())).willReturn(List.of(stage2, stage1)); // when diff --git a/backend/src/test/java/com/festago/stage/application/StageServiceTest.java b/backend/src/test/java/com/festago/stage/application/StageServiceTest.java index 3fb4d7da3..1caff79e4 100644 --- a/backend/src/test/java/com/festago/stage/application/StageServiceTest.java +++ b/backend/src/test/java/com/festago/stage/application/StageServiceTest.java @@ -13,7 +13,6 @@ import com.festago.stage.repository.StageRepository; import com.festago.support.FestivalFixture; import java.time.LocalDateTime; -import java.util.Optional; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.Test; @@ -47,8 +46,8 @@ class StageServiceTest { LocalDateTime.now().minusDays(1), 1L ); - given(festivalRepository.findById(anyLong())) - .willReturn(Optional.of(festival)); + given(festivalRepository.getOrThrow(anyLong())) + .willReturn(festival); given(stageRepository.save(any(Stage.class))) .willAnswer(invocation -> invocation.getArgument(0)); diff --git a/backend/src/test/java/com/festago/stage/domain/validator/festival/OutOfDateStageFestivalUpdateValidatorTest.java b/backend/src/test/java/com/festago/stage/domain/validator/festival/OutOfDateStageFestivalUpdateValidatorTest.java new file mode 100644 index 000000000..2d445c437 --- /dev/null +++ b/backend/src/test/java/com/festago/stage/domain/validator/festival/OutOfDateStageFestivalUpdateValidatorTest.java @@ -0,0 +1,131 @@ +package com.festago.stage.domain.validator.festival; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.domain.Festival; +import com.festago.stage.repository.MemoryStageRepository; +import com.festago.support.FestivalFixture; +import com.festago.support.StageFixture; +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class OutOfDateStageFestivalUpdateValidatorTest { + + LocalDate festivalStartDate = LocalDate.parse("2077-02-19"); + LocalDate festivalEndDate = LocalDate.parse("2077-02-21"); + MemoryStageRepository stageRepository = new MemoryStageRepository(); + OutOfDateStageFestivalUpdateValidator validator = new OutOfDateStageFestivalUpdateValidator(stageRepository); + Festival 축제; + + @BeforeEach + void setUp() { + stageRepository.clear(); + 축제 = FestivalFixture.festival() + .startDate(festivalStartDate) + .endDate(festivalEndDate) + .build(); + } + + @Nested + class 축제에_등록된_공연이_있을때 { + + @BeforeEach + void setUp() { + LocalDateTime ticketOpenTime = festivalStartDate.atStartOfDay().minusWeeks(1); + // 19, 20, 21 일자의 공연 생성 + for (int i = 0; i <= 2; i++) { + stageRepository.save( + StageFixture.stage() + .festival(축제) + .ticketOpenTime(ticketOpenTime) + .startTime(festivalStartDate.plusDays(i).atTime(18, 0)) + .build() + ); + } + } + + @Test + void 축제의_일자를_확장하면_예외가_발생하지_않는다() { + // given + LocalDate startDate = festivalStartDate.minusDays(1); + LocalDate endDate = festivalEndDate.plusDays(1); + 축제.changeDate(startDate, endDate); + + // when & then + assertDoesNotThrow(() -> validator.validate(축제)); + } + + @Test + void 축제의_시작일자를_축소하면_예외가_발생한다() { + // given + LocalDate startDate = festivalStartDate.plusDays(1); + LocalDate endDate = festivalEndDate; + 축제.changeDate(startDate, endDate); + + // when & then + assertThatThrownBy(() -> validator.validate(축제)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.FESTIVAL_UPDATE_OUT_OF_DATE_STAGE_START_TIME.getMessage()); + } + + @Test + void 축제의_종료일자를_축소하면_예외가_발생한다() { + // given + LocalDate startDate = festivalStartDate; + LocalDate endDate = festivalEndDate.minusDays(1); + 축제.changeDate(startDate, endDate); + + // when & then + assertThatThrownBy(() -> validator.validate(축제)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.FESTIVAL_UPDATE_OUT_OF_DATE_STAGE_START_TIME.getMessage()); + } + } + + @Nested + class 축제에_등록된_공연이_없을때 { + + @Test + void 축제의_일자를_확장하면_예외가_발생하지_않는다() { + // given + LocalDate startDate = festivalStartDate.minusDays(1); + LocalDate endDate = festivalEndDate.plusDays(1); + 축제.changeDate(startDate, endDate); + + // when & then + assertDoesNotThrow(() -> validator.validate(축제)); + } + + @Test + void 축제의_시작일자를_축소하면_예외가_발생하지_않는다() { + // given + LocalDate startDate = festivalStartDate.plusDays(1); + LocalDate endDate = festivalEndDate; + 축제.changeDate(startDate, endDate); + + // when & then + assertDoesNotThrow(() -> validator.validate(축제)); + } + + @Test + void 축제의_종료일자를_축소하면_예외가_발생하지_않는다() { + // given + LocalDate startDate = festivalStartDate; + LocalDate endDate = festivalEndDate.minusDays(1); + 축제.changeDate(startDate, endDate); + + // when & then + assertDoesNotThrow(() -> validator.validate(축제)); + } + } +} diff --git a/backend/src/test/java/com/festago/stage/repository/MemoryStageRepository.java b/backend/src/test/java/com/festago/stage/repository/MemoryStageRepository.java new file mode 100644 index 000000000..0724e300a --- /dev/null +++ b/backend/src/test/java/com/festago/stage/repository/MemoryStageRepository.java @@ -0,0 +1,69 @@ +package com.festago.stage.repository; + +import com.festago.stage.domain.Stage; +import java.lang.reflect.Field; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import lombok.SneakyThrows; + +public class MemoryStageRepository implements StageRepository { + + private final ConcurrentHashMap memory = new ConcurrentHashMap<>(); + private final AtomicLong autoIncrement = new AtomicLong(); + + public void clear() { + memory.clear(); + } + + @Override + @SneakyThrows + public Stage save(Stage stage) { + Field idField = stage.getClass() + .getDeclaredField("id"); + idField.setAccessible(true); + idField.set(stage, autoIncrement.incrementAndGet()); + memory.put(stage.getId(), stage); + return stage; + } + + @Override + public Optional findById(Long stageId) { + return Optional.ofNullable(memory.get(stageId)); + } + + @Override + public void deleteById(Long stageId) { + memory.remove(stageId); + } + + @Override + public void flush() { + //NOOP + } + + @Override + public boolean existsByFestivalId(Long festivalId) { + return memory.values().stream() + .anyMatch(stage -> Objects.equals(stage.getFestival().getId(), festivalId)); + } + + @Override + public List findAllByFestivalId(Long festivalId) { + return memory.values().stream() + .filter(stage -> Objects.equals(stage.getFestival().getId(), festivalId)) + .toList(); + } + + @Override + public List findAllDetailByFestivalId(Long festivalId) { + return findAllByFestivalId(festivalId); + } + + @Override + public Optional findByIdWithFetch(Long id) { + return findById(id); + } +} From 180ea3fc07f5aa3bab51a610eee66af2c8a698aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hyun-Seo=20Oh=20/=20=EC=98=A4=ED=98=84=EC=84=9C?= <100915276+carsago@users.noreply.github.com> Date: Sun, 25 Feb 2024 23:28:17 +0900 Subject: [PATCH 13/19] =?UTF-8?q?[ALL]=20=EC=B6=94=EC=83=81=EC=A0=81=20?= =?UTF-8?q?=EC=9D=98=EB=AF=B8=EB=A5=BC=20=EA=B0=80=EC=A7=80=EB=8A=94=20API?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EC=9D=B4=EB=A6=84=EC=9D=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#723)=20(#737)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 축제 조회 API response 이미지 필드명 변경 * refactor: 축제 상세 조회 API response 이미지 필드명 변경 * refactor: 학교 상세 조회 Response 필드명 변경 * refactor: 학교 축제 조회 Response 필드명 변경 * refactor: 아티스트 조회 Response 필드명 변경 --- .../java/com/festago/artist/dto/ArtistDetailV1Response.java | 6 +++--- .../festago/artist/dto/ArtistFestivalDetailV1Response.java | 2 +- .../com/festago/festival/dto/FestivalDetailV1Response.java | 2 +- .../java/com/festago/festival/dto/FestivalV1Response.java | 3 ++- .../main/java/com/festago/festival/dto/StageV1Response.java | 1 + .../com/festago/school/dto/v1/SchoolDetailV1Response.java | 4 ++-- .../com/festago/school/dto/v1/SchoolFestivalV1Response.java | 2 +- .../artist/application/ArtistDetailV1QueryServiceTest.java | 2 +- .../FestivalDetailV1QueryServiceIntegrationTest.java | 2 +- .../integration/SchoolV1QueryServiceIntegrationTest.java | 2 +- 10 files changed, 14 insertions(+), 12 deletions(-) diff --git a/backend/src/main/java/com/festago/artist/dto/ArtistDetailV1Response.java b/backend/src/main/java/com/festago/artist/dto/ArtistDetailV1Response.java index 5293bd0de..59c11cabf 100644 --- a/backend/src/main/java/com/festago/artist/dto/ArtistDetailV1Response.java +++ b/backend/src/main/java/com/festago/artist/dto/ArtistDetailV1Response.java @@ -5,9 +5,9 @@ public record ArtistDetailV1Response( Long id, - String artistName, - String logoUrl, - String backgroundUrl, + String name, + String profileImageUrl, + String backgroundImageUrl, List socialMedias ) { diff --git a/backend/src/main/java/com/festago/artist/dto/ArtistFestivalDetailV1Response.java b/backend/src/main/java/com/festago/artist/dto/ArtistFestivalDetailV1Response.java index ef29d57ff..aae4eb601 100644 --- a/backend/src/main/java/com/festago/artist/dto/ArtistFestivalDetailV1Response.java +++ b/backend/src/main/java/com/festago/artist/dto/ArtistFestivalDetailV1Response.java @@ -9,7 +9,7 @@ public record ArtistFestivalDetailV1Response( String name, LocalDate startDate, LocalDate endDate, - String imageUrl, + String posterImageUrl, @JsonRawValue String artists ) { diff --git a/backend/src/main/java/com/festago/festival/dto/FestivalDetailV1Response.java b/backend/src/main/java/com/festago/festival/dto/FestivalDetailV1Response.java index de16ba1f3..0f62e9d12 100644 --- a/backend/src/main/java/com/festago/festival/dto/FestivalDetailV1Response.java +++ b/backend/src/main/java/com/festago/festival/dto/FestivalDetailV1Response.java @@ -10,7 +10,7 @@ public record FestivalDetailV1Response( SchoolV1Response school, LocalDate startDate, LocalDate endDate, - String imageUrl, + String posterImageUrl, Set socialMedias, Set stages ) { diff --git a/backend/src/main/java/com/festago/festival/dto/FestivalV1Response.java b/backend/src/main/java/com/festago/festival/dto/FestivalV1Response.java index b6a1e0f22..a2ed926c6 100644 --- a/backend/src/main/java/com/festago/festival/dto/FestivalV1Response.java +++ b/backend/src/main/java/com/festago/festival/dto/FestivalV1Response.java @@ -4,12 +4,13 @@ import com.querydsl.core.annotations.QueryProjection; import java.time.LocalDate; +//TODO: 아티스트 필드명을 변경할려면 JsonRawValue 형식이라 DB에 저장할 필드이름을 다르게 해야함 public record FestivalV1Response( Long id, String name, LocalDate startDate, LocalDate endDate, - String imageUrl, + String posterImageUrl, SchoolV1Response school, @JsonRawValue String artists) { diff --git a/backend/src/main/java/com/festago/festival/dto/StageV1Response.java b/backend/src/main/java/com/festago/festival/dto/StageV1Response.java index a0c73c4e7..e1bafdedf 100644 --- a/backend/src/main/java/com/festago/festival/dto/StageV1Response.java +++ b/backend/src/main/java/com/festago/festival/dto/StageV1Response.java @@ -4,6 +4,7 @@ import com.querydsl.core.annotations.QueryProjection; import java.time.LocalDateTime; +//TODO: 필드명 변경으로 변경 불가 public record StageV1Response( Long id, LocalDateTime startDateTime, diff --git a/backend/src/main/java/com/festago/school/dto/v1/SchoolDetailV1Response.java b/backend/src/main/java/com/festago/school/dto/v1/SchoolDetailV1Response.java index 900825d49..efdf8cd37 100644 --- a/backend/src/main/java/com/festago/school/dto/v1/SchoolDetailV1Response.java +++ b/backend/src/main/java/com/festago/school/dto/v1/SchoolDetailV1Response.java @@ -5,9 +5,9 @@ public record SchoolDetailV1Response( Long id, - String schoolName, + String name, String logoUrl, - String backgroundUrl, + String backgroundImageUrl, List socialMedias ) { diff --git a/backend/src/main/java/com/festago/school/dto/v1/SchoolFestivalV1Response.java b/backend/src/main/java/com/festago/school/dto/v1/SchoolFestivalV1Response.java index 71a3876bb..86d488650 100644 --- a/backend/src/main/java/com/festago/school/dto/v1/SchoolFestivalV1Response.java +++ b/backend/src/main/java/com/festago/school/dto/v1/SchoolFestivalV1Response.java @@ -9,7 +9,7 @@ public record SchoolFestivalV1Response( String name, LocalDate startDate, LocalDate endDate, - String imageUrl, + String posterImageUrl, @JsonRawValue String artists ) { diff --git a/backend/src/test/java/com/festago/artist/application/ArtistDetailV1QueryServiceTest.java b/backend/src/test/java/com/festago/artist/application/ArtistDetailV1QueryServiceTest.java index 4004e8e89..66cc42b3f 100644 --- a/backend/src/test/java/com/festago/artist/application/ArtistDetailV1QueryServiceTest.java +++ b/backend/src/test/java/com/festago/artist/application/ArtistDetailV1QueryServiceTest.java @@ -127,7 +127,7 @@ class 아티스트_정보_는 { SocialMedia makeArtistSocialMedia(Long id, OwnerType ownerType, SocialMediaType socialMediaType) { return socialMediaRepository.save( - new SocialMedia(id, ownerType, socialMediaType, "총학생회", "logoUrl", "url")); + new SocialMedia(id, ownerType, socialMediaType, "총학생회", "profileImageUrl", "url")); } } diff --git a/backend/src/test/java/com/festago/festival/application/integration/FestivalDetailV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/festival/application/integration/FestivalDetailV1QueryServiceIntegrationTest.java index 9a98263cc..e36d4626c 100644 --- a/backend/src/test/java/com/festago/festival/application/integration/FestivalDetailV1QueryServiceIntegrationTest.java +++ b/backend/src/test/java/com/festago/festival/application/integration/FestivalDetailV1QueryServiceIntegrationTest.java @@ -123,7 +123,7 @@ void setUp() { softly.assertThat(response.name()).isEqualTo("테코대학교 축제"); softly.assertThat(response.startDate()).isEqualTo("2077-06-30"); softly.assertThat(response.endDate()).isEqualTo("2077-07-02"); - softly.assertThat(response.imageUrl()).isEqualTo("https://school.com/image.com"); + softly.assertThat(response.posterImageUrl()).isEqualTo("https://school.com/image.com"); softly.assertThat(response.school().name()).isEqualTo("테코대학교"); softly.assertThat(response.socialMedias()) .map(SocialMediaV1Response::name) diff --git a/backend/src/test/java/com/festago/school/application/integration/SchoolV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/school/application/integration/SchoolV1QueryServiceIntegrationTest.java index ebfd86445..1616fc769 100644 --- a/backend/src/test/java/com/festago/school/application/integration/SchoolV1QueryServiceIntegrationTest.java +++ b/backend/src/test/java/com/festago/school/application/integration/SchoolV1QueryServiceIntegrationTest.java @@ -104,7 +104,7 @@ class 학교_상세_정보_조회 { private void saveSocialMedia(Long ownerId, OwnerType ownerType, SocialMediaType mediaType) { socialMediaRepository.save( new SocialMedia(ownerId, ownerType, mediaType, - "defaultName", "www.logoUrl.com", "www.url.com") + "defaultName", "www.profileImageUrl.com", "www.url.com") ); } } From e4fa895a3dd43f63f86ec90422463c00a95376d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=B4=EC=8B=9C?= <67777523+EmilyCh0@users.noreply.github.com> Date: Mon, 26 Feb 2024 19:45:05 +0900 Subject: [PATCH 14/19] =?UTF-8?q?[AN/USER]=20feat:=20=ED=95=99=EA=B5=90=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#697)=20(#722)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 학교 상세 도메인 모델 추가 * feat: SchoolRepository 정의 * feat: 테스트용 FakeSchool 추가 * feat: SchoolDefaultRepository 임시 구현 (Fake 데이터 리턴) * feat: 학교 상세 dto 추가 * feat: SchoolDetailUiState 정의 * feat: SchoolDetailViewModel 구현 * feat: SchoolDetailFragment 구현 * feat: UiState 분리 * feat: FakeSchool 소셜미디어 데이터 추가 * feat: SchoolDetailFragment 뷰 수정 * move: 패키지 위치 변경 * feat: 상단 뒤로 가기 버튼 * feat: schoolId 추가 * fix: dday view 수정 * feat: 북마크 * fix: resolve conflict * refactor: ktlint check * refactor: ktlint check * refactor: Fake 리포지토리명 변경 * refactor: id 카멜케이스로 수정 * refactor: FakeSchoolRepository 주석 삭제 * refactor: 중복된 소셜미디어 뷰 제거 --- .../di/singletonscope/RepositoryModule.kt | 6 + .../data/dto/school/SchoolInfoResponse.kt | 21 +++ .../data/dto/school/SocialMediaResponse.kt | 19 +++ .../festago/data/repository/FakeSchool.kt | 27 +++ .../data/repository/FakeSchoolRepository.kt | 21 +++ .../festago/domain/model/school/SchoolInfo.kt | 11 ++ .../domain/model/social/SocialMedia.kt | 8 + .../domain/repository/SchoolRepository.kt | 9 + .../home/festivallist/FestivalListFragment.kt | 11 ++ .../ui/schooldetail/ArtistAdapter.kt | 35 ++++ .../ui/schooldetail/ArtistViewHolder.kt | 27 +++ .../SchoolDetailFestivalViewHolder.kt | 98 +++++++++++ .../ui/schooldetail/SchoolDetailFragment.kt | 109 ++++++++++++ .../ui/schooldetail/SchoolDetailViewModel.kt | 72 ++++++++ .../schooldetail/SchoolFestivalListAdapter.kt | 39 +++++ .../ui/schooldetail/uistate/ArtistUiState.kt | 7 + .../uistate/FestivalItemUiState.kt | 13 ++ .../uistate/SchoolDetailUiState.kt | 19 +++ .../ui/schooldetail/uistate/SchoolUiState.kt | 6 + .../src/main/res/drawable/ic_back.xml | 13 ++ .../res/layout/fragment_school_detail.xml | 158 ++++++++++++++++++ .../res/layout/item_school_detail_artist.xml | 55 ++++++ .../layout/item_school_detail_festival.xml | 105 ++++++++++++ .../src/main/res/layout/view_social_media.xml | 19 +++ 24 files changed, 908 insertions(+) create mode 100644 android/festago/data/src/main/java/com/festago/festago/data/dto/school/SchoolInfoResponse.kt create mode 100644 android/festago/data/src/main/java/com/festago/festago/data/dto/school/SocialMediaResponse.kt create mode 100644 android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSchool.kt create mode 100644 android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSchoolRepository.kt create mode 100644 android/festago/domain/src/main/java/com/festago/festago/domain/model/school/SchoolInfo.kt create mode 100644 android/festago/domain/src/main/java/com/festago/festago/domain/model/social/SocialMedia.kt create mode 100644 android/festago/domain/src/main/java/com/festago/festago/domain/repository/SchoolRepository.kt create mode 100644 android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/ArtistAdapter.kt create mode 100644 android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/ArtistViewHolder.kt create mode 100644 android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailFestivalViewHolder.kt create mode 100644 android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailFragment.kt create mode 100644 android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailViewModel.kt create mode 100644 android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolFestivalListAdapter.kt create mode 100644 android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/ArtistUiState.kt create mode 100644 android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/FestivalItemUiState.kt create mode 100644 android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/SchoolDetailUiState.kt create mode 100644 android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/SchoolUiState.kt create mode 100644 android/festago/presentation/src/main/res/drawable/ic_back.xml create mode 100644 android/festago/presentation/src/main/res/layout/fragment_school_detail.xml create mode 100644 android/festago/presentation/src/main/res/layout/item_school_detail_artist.xml create mode 100644 android/festago/presentation/src/main/res/layout/item_school_detail_festival.xml create mode 100644 android/festago/presentation/src/main/res/layout/view_social_media.xml diff --git a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt index f486a1ebf..2ca7f616d 100644 --- a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt +++ b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt @@ -2,8 +2,10 @@ package com.festago.festago.data.di.singletonscope import com.festago.festago.data.repository.FakeArtistRepository import com.festago.festago.data.repository.FakeFestivalRepository +import com.festago.festago.data.repository.FakeSchoolRepository import com.festago.festago.domain.repository.ArtistRepository import com.festago.festago.domain.repository.FestivalRepository +import com.festago.festago.domain.repository.SchoolRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -21,4 +23,8 @@ interface RepositoryModule { @Binds @Singleton fun bindsArtistRepository(artistRepository: FakeArtistRepository): ArtistRepository + + @Binds + @Singleton + fun bindsSchoolRepository(schoolRepository: FakeSchoolRepository): SchoolRepository } diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SchoolInfoResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SchoolInfoResponse.kt new file mode 100644 index 000000000..580239a74 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SchoolInfoResponse.kt @@ -0,0 +1,21 @@ +package com.festago.festago.data.dto.school + +import com.festago.festago.domain.model.school.SchoolInfo +import kotlinx.serialization.Serializable + +@Serializable +data class SchoolInfoResponse( + val id: Int, + val schoolName: String, + val logoUrl: String, + val backgroundUrl: String, + val socialMediaResponse: List, +) { + fun toDomain(): SchoolInfo = SchoolInfo( + id = id, + schoolName = schoolName, + logoUrl = logoUrl, + backgroundUrl = backgroundUrl, + socialMedia = socialMediaResponse.map { it.toDomain() }, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SocialMediaResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SocialMediaResponse.kt new file mode 100644 index 000000000..412704746 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SocialMediaResponse.kt @@ -0,0 +1,19 @@ +package com.festago.festago.data.dto.school + +import com.festago.festago.domain.model.social.SocialMedia +import kotlinx.serialization.Serializable + +@Serializable +data class SocialMediaResponse( + val type: String, + val name: String, + val logoUrl: String, + val url: String, +) { + fun toDomain(): SocialMedia = SocialMedia( + type = type, + name = name, + logoUrl = logoUrl, + url = url, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSchool.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSchool.kt new file mode 100644 index 000000000..cfa9f1ea8 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSchool.kt @@ -0,0 +1,27 @@ +package com.festago.festago.data.repository + +import com.festago.festago.domain.model.school.SchoolInfo +import com.festago.festago.domain.model.social.SocialMedia + +object FakeSchool { + val googleSchool = SchoolInfo( + id = 1, + schoolName = "구글대학교", + logoUrl = "https://cdn1.iconfinder.com/data/icons/logos-brands-in-colors/544/Google__G__Logo-512.png", + backgroundUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Google_2015_logo.svg/1200px-Google_2015_logo.svg.png", + socialMedia = listOf( + SocialMedia( + type = "INSTAGRAM", + name = "구글대학교 인스타", + logoUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Instagram_logo_2016.svg/2048px-Instagram_logo_2016.svg.png", + url = "https://www.instagram.com/", + ), + SocialMedia( + type = "INSTAGRAM", + name = "구글대학교 X", + logoUrl = "https://about.x.com/content/dam/about-twitter/x/brand-toolkit/logo-black.png.twimg.1920.png", + url = "https://twitter.com/?lang=en", + ) + ) + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSchoolRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSchoolRepository.kt new file mode 100644 index 000000000..27823ce70 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSchoolRepository.kt @@ -0,0 +1,21 @@ +package com.festago.festago.data.repository + +import com.festago.festago.domain.model.festival.FestivalsPage +import com.festago.festago.domain.model.school.SchoolInfo +import com.festago.festago.domain.repository.SchoolRepository +import javax.inject.Inject + +class FakeSchoolRepository @Inject constructor() : SchoolRepository { + override suspend fun loadSchoolInfo(schoolId: Long): Result { + return Result.success(FakeSchool.googleSchool) + } + + override suspend fun loadSchoolFestivals(schoolId: Long): Result { + return Result.success( + FestivalsPage( + isLastPage = true, + festivals = FakeFestivals.progressFestivals, + ) + ) + } +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/school/SchoolInfo.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/school/SchoolInfo.kt new file mode 100644 index 000000000..fdbcf27ce --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/school/SchoolInfo.kt @@ -0,0 +1,11 @@ +package com.festago.festago.domain.model.school + +import com.festago.festago.domain.model.social.SocialMedia + +data class SchoolInfo( + val id: Int, + val schoolName: String, + val logoUrl: String, + val backgroundUrl: String, + val socialMedia: List +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/social/SocialMedia.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/social/SocialMedia.kt new file mode 100644 index 000000000..64a1c76af --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/social/SocialMedia.kt @@ -0,0 +1,8 @@ +package com.festago.festago.domain.model.social + +data class SocialMedia( + val type: String, + val name: String, + val logoUrl: String, + val url: String +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/repository/SchoolRepository.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/SchoolRepository.kt new file mode 100644 index 000000000..e30ae9ada --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/SchoolRepository.kt @@ -0,0 +1,9 @@ +package com.festago.festago.domain.repository + +import com.festago.festago.domain.model.festival.FestivalsPage +import com.festago.festago.domain.model.school.SchoolInfo + +interface SchoolRepository { + suspend fun loadSchoolInfo(schoolId: Long): Result + suspend fun loadSchoolFestivals(schoolId: Long): Result +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt index f1f0cd708..3f03a190f 100644 --- a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt @@ -20,6 +20,7 @@ import com.festago.festago.presentation.ui.home.festivallist.festival.FestivalLi import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalFilterUiState import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalListUiState import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalTabUiState +import com.festago.festago.presentation.ui.schooldetail.SchoolDetailFragment import com.festago.festago.presentation.util.repeatOnStarted import com.festago.festago.presentation.util.setOnApplyWindowInsetsCompatListener import dagger.hilt.android.AndroidEntryPoint @@ -76,6 +77,9 @@ class FestivalListFragment : Fragment() { vm.loadFestivals() binding.srlFestivalList.isRefreshing = false } + binding.ivSearch.setOnClickListener { // 임시 연결 + showSchoolDetail() + } } private fun initViewPager() { @@ -141,6 +145,13 @@ class FestivalListFragment : Fragment() { ) } + private fun showSchoolDetail() { + activity?.supportFragmentManager!!.beginTransaction() + .replace(R.id.fcvHomeContainer, SchoolDetailFragment.newInstance(0)) + .addToBackStack(null) + .commit() + } + override fun onDestroyView() { _binding = null super.onDestroyView() diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/ArtistAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/ArtistAdapter.kt new file mode 100644 index 000000000..0393024f6 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/ArtistAdapter.kt @@ -0,0 +1,35 @@ +package com.festago.festago.presentation.ui.schooldetail + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.schooldetail.uistate.ArtistUiState + +class ArtistAdapter : ListAdapter(diffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArtistViewHolder { + return ArtistViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + private val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ArtistUiState, + newItem: ArtistUiState, + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: ArtistUiState, + newItem: ArtistUiState, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/ArtistViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/ArtistViewHolder.kt new file mode 100644 index 000000000..6011c50ca --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/ArtistViewHolder.kt @@ -0,0 +1,27 @@ +package com.festago.festago.presentation.ui.schooldetail + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.databinding.ItemSchoolDetailArtistBinding +import com.festago.festago.presentation.ui.schooldetail.uistate.ArtistUiState + +class ArtistViewHolder( + private val binding: ItemSchoolDetailArtistBinding, +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: ArtistUiState) { + binding.artist = item + } + + companion object { + fun of(parent: ViewGroup): ArtistViewHolder { + val binding = ItemSchoolDetailArtistBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ArtistViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailFestivalViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailFestivalViewHolder.kt new file mode 100644 index 000000000..8c145006c --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailFestivalViewHolder.kt @@ -0,0 +1,98 @@ +package com.festago.festago.presentation.ui.schooldetail + +import android.content.res.Resources +import android.graphics.Rect +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.content.res.AppCompatResources +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ItemSchoolDetailFestivalBinding +import com.festago.festago.presentation.ui.schooldetail.uistate.FestivalItemUiState +import java.time.LocalDate + +class SchoolDetailFestivalViewHolder( + private val binding: ItemSchoolDetailFestivalBinding +) : RecyclerView.ViewHolder(binding.root) { + private val artistAdapter = ArtistAdapter() + + init { + binding.rvFestivalArtists.adapter = artistAdapter + binding.rvFestivalArtists.addItemDecoration(ArtistItemDecoration()) + } + + fun bind(item: FestivalItemUiState) { + binding.item = item + artistAdapter.submitList(item.artists) + bindDDayView(item) + } + + private fun bindDDayView(item: FestivalItemUiState) { + val context = binding.root.context + + val dDayView = binding.tvFestivalDDay + when { + LocalDate.now() > item.endDate -> Unit + + LocalDate.now() >= item.startDate -> { + dDayView.text = context.getString(R.string.festival_list_tv_dday_in_progress) + dDayView.setTextColor(context.getColor(R.color.secondary_pink_01)) + dDayView.background = AppCompatResources.getDrawable( + context, + R.drawable.bg_festival_list_dday_in_progress, + ) + } + + LocalDate.now() == item.startDate.minusDays(1) -> { + dDayView.setTextColor(context.getColor(R.color.background_gray_01)) + dDayView.text = context.getString( + R.string.festival_list_tv_dday_format, + LocalDate.now().compareTo(item.startDate).toString(), + ) + dDayView.setBackgroundColor(0xffff1273.toInt()) + } + + else -> binding.tvFestivalDDay.apply { + dDayView.setTextColor(context.getColor(R.color.background_gray_01)) + dDayView.text = context.getString( + R.string.festival_list_tv_dday_format, + (LocalDate.now().toEpochDay() - item.startDate.toEpochDay()).toString(), + ) + dDayView.setBackgroundColor(context.getColor(android.R.color.black)) + } + } + } + + private class ArtistItemDecoration : ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State, + ) { + super.getItemOffsets(outRect, view, parent, state) + outRect.right = 8.dpToPx + } + + private val Int.dpToPx: Int + get() = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + this.toFloat(), + Resources.getSystem().displayMetrics, + ).toInt() + } + + companion object { + fun of(parent: ViewGroup): SchoolDetailFestivalViewHolder { + val binding = ItemSchoolDetailFestivalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return SchoolDetailFestivalViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailFragment.kt new file mode 100644 index 000000000..b02bec137 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailFragment.kt @@ -0,0 +1,109 @@ +package com.festago.festago.presentation.ui.schooldetail + +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.festago.festago.presentation.databinding.FragmentSchoolDetailBinding +import com.festago.festago.presentation.databinding.ItemMediaBinding +import com.festago.festago.presentation.ui.schooldetail.uistate.SchoolDetailUiState +import com.festago.festago.presentation.util.repeatOnStarted +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class SchoolDetailFragment : Fragment() { + + private var _binding: FragmentSchoolDetailBinding? = null + private val binding get() = _binding!! + + private val vm: SchoolDetailViewModel by viewModels() + + private lateinit var adapter: SchoolFestivalListAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSchoolDetailBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val schoolId = requireArguments().getLong(SCHOOL_ID_KEY) + initView(schoolId) + initObserve() + } + + private fun initObserve() { + repeatOnStarted(viewLifecycleOwner) { + vm.uiState.collect { + binding.uiState = it + updateUi(it) + } + } + } + + private fun initView(schoolId: Long) { + adapter = SchoolFestivalListAdapter() + binding.rvFestivalList.adapter = adapter + vm.loadSchoolDetail(schoolId) + binding.ivBack.setOnClickListener { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + binding.cvBookmark.setOnClickListener { + binding.ivBookmark.isSelected = !binding.ivBookmark.isSelected + } + } + + private fun updateUi(uiState: SchoolDetailUiState) { + when (uiState) { + is SchoolDetailUiState.Loading, + is SchoolDetailUiState.Error, + -> Unit + + is SchoolDetailUiState.Success -> handleSuccess(uiState) + } + } + + private fun handleSuccess(uiState: SchoolDetailUiState.Success) { + binding.successUiState = uiState + binding.ivSchoolBackground.setColorFilter(Color.parseColor("#66000000")) + adapter.submitList(uiState.festivals) + binding.llcSchoolSocialMedia.removeAllViews() + uiState.schoolInfo.socialMedia.forEach { media -> + with(ItemMediaBinding.inflate(layoutInflater, binding.llcSchoolSocialMedia, false)) { + imageUrl = media.logoUrl + ivImage.setOnClickListener { startBrowser(media.url) } + binding.llcSchoolSocialMedia.addView(ivImage) + } + } + } + + private fun startBrowser(url: String) { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(url) + startActivity(intent) + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } + + companion object { + private const val SCHOOL_ID_KEY = "SCHOOL_ID_KEY" + + fun newInstance(id: Long) = SchoolDetailFragment().apply { + arguments = Bundle().apply { + putLong(SCHOOL_ID_KEY, id) + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailViewModel.kt new file mode 100644 index 000000000..7f4804934 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailViewModel.kt @@ -0,0 +1,72 @@ +package com.festago.festago.presentation.ui.schooldetail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.logNetworkFailure +import com.festago.festago.domain.model.festival.Festival +import com.festago.festago.domain.repository.SchoolRepository +import com.festago.festago.presentation.ui.schooldetail.uistate.ArtistUiState +import com.festago.festago.presentation.ui.schooldetail.uistate.FestivalItemUiState +import com.festago.festago.presentation.ui.schooldetail.uistate.SchoolDetailUiState +import com.festago.festago.presentation.ui.schooldetail.uistate.SchoolUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SchoolDetailViewModel @Inject constructor( + private val schoolRepository: SchoolRepository, + private val analyticsHelper: AnalyticsHelper, +) : ViewModel() { + + private val _uiState = MutableStateFlow(SchoolDetailUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadSchoolDetail(schoolId: Long) { + viewModelScope.launch { + val deferredSchoolInfo = async { schoolRepository.loadSchoolInfo(schoolId) } + val deferredFestivalPage = async { schoolRepository.loadSchoolFestivals(schoolId) } + + runCatching { + val schoolInfo = deferredSchoolInfo.await().getOrThrow() + val festivalPage = deferredFestivalPage.await().getOrThrow() + + _uiState.value = SchoolDetailUiState.Success( + schoolInfo = schoolInfo, + festivals = festivalPage.festivals.map { it.toUiState() }, + isLast = festivalPage.isLastPage + ) + }.onFailure { + _uiState.value = SchoolDetailUiState.Error + analyticsHelper.logNetworkFailure( + key = KEY_LOAD_SCHOOL_DETAIL, + value = it.message.toString(), + ) + } + } + } + + private fun Festival.toUiState() = FestivalItemUiState( + id = id, + name = name, + startDate = startDate, + endDate = endDate, + imageUrl = imageUrl, + schoolUiState = SchoolUiState( + id = school.id, + name = school.name, + ), + artists = artists.map { artist -> + ArtistUiState(artist.id, artist.name, artist.imageUrl) + }, + ) + + companion object { + private const val KEY_LOAD_SCHOOL_DETAIL = "KEY_LOAD_SCHOOL_DETAIL" + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolFestivalListAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolFestivalListAdapter.kt new file mode 100644 index 000000000..36819d510 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolFestivalListAdapter.kt @@ -0,0 +1,39 @@ +package com.festago.festago.presentation.ui.schooldetail + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.schooldetail.uistate.FestivalItemUiState + +class SchoolFestivalListAdapter : + ListAdapter(diffUtil) { + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): SchoolDetailFestivalViewHolder { + return SchoolDetailFestivalViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: SchoolDetailFestivalViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: FestivalItemUiState, + newItem: FestivalItemUiState, + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: FestivalItemUiState, + newItem: FestivalItemUiState, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/ArtistUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/ArtistUiState.kt new file mode 100644 index 000000000..36f2bf4c6 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/ArtistUiState.kt @@ -0,0 +1,7 @@ +package com.festago.festago.presentation.ui.schooldetail.uistate + +data class ArtistUiState( + val id: Long, + val name: String, + val imageUrl: String, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/FestivalItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/FestivalItemUiState.kt new file mode 100644 index 000000000..626a3bd97 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/FestivalItemUiState.kt @@ -0,0 +1,13 @@ +package com.festago.festago.presentation.ui.schooldetail.uistate + +import java.time.LocalDate + +data class FestivalItemUiState( + val id: Long, + val name: String, + val startDate: LocalDate, + val endDate: LocalDate, + val imageUrl: String, + val schoolUiState: SchoolUiState, + val artists: List, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/SchoolDetailUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/SchoolDetailUiState.kt new file mode 100644 index 000000000..d51806c68 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/SchoolDetailUiState.kt @@ -0,0 +1,19 @@ +package com.festago.festago.presentation.ui.schooldetail.uistate + +import com.festago.festago.domain.model.school.SchoolInfo + +sealed interface SchoolDetailUiState { + object Loading : SchoolDetailUiState + + data class Success( + val schoolInfo: SchoolInfo, + val festivals: List, + val isLast: Boolean, + ) : SchoolDetailUiState + + object Error : SchoolDetailUiState + + val shouldShowSuccess get() = this is Success + val shouldShowLoading get() = this is Loading + val shouldShowError get() = this is Error +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/SchoolUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/SchoolUiState.kt new file mode 100644 index 000000000..720a57334 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/SchoolUiState.kt @@ -0,0 +1,6 @@ +package com.festago.festago.presentation.ui.schooldetail.uistate + +data class SchoolUiState( + val id: Long, + val name: String, +) diff --git a/android/festago/presentation/src/main/res/drawable/ic_back.xml b/android/festago/presentation/src/main/res/drawable/ic_back.xml new file mode 100644 index 000000000..b2d774246 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_back.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/layout/fragment_school_detail.xml b/android/festago/presentation/src/main/res/layout/fragment_school_detail.xml new file mode 100644 index 000000000..0aafdbd75 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/fragment_school_detail.xml @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_school_detail_artist.xml b/android/festago/presentation/src/main/res/layout/item_school_detail_artist.xml new file mode 100644 index 000000000..0304ef60d --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_school_detail_artist.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_school_detail_festival.xml b/android/festago/presentation/src/main/res/layout/item_school_detail_festival.xml new file mode 100644 index 000000000..8f5ece995 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_school_detail_festival.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/view_social_media.xml b/android/festago/presentation/src/main/res/layout/view_social_media.xml new file mode 100644 index 000000000..fb2db4334 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/view_social_media.xml @@ -0,0 +1,19 @@ + + + + + + + + + From 9732ee6fb22f01258fa35854b618ce0e51dce4ba Mon Sep 17 00:00:00 2001 From: Seokjin Jeon Date: Tue, 27 Feb 2024 02:25:40 +0900 Subject: [PATCH 15/19] =?UTF-8?q?[BE]=20refactor:=20Validator=20hasBlank,?= =?UTF-8?q?=20isNegative=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=B3=80=EA=B2=BD=20(#753)=20(#755)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: hasBlank -> notBlank 메서드 명 변경 * refactor: isNegative -> notNegative 메서드 명 변경 --- .../src/main/java/com/festago/admin/domain/Admin.java | 4 ++-- .../admin/presentation/v1/AdminSchoolV1Controller.java | 5 +++-- .../main/java/com/festago/common/util/Validator.java | 6 +++--- .../main/java/com/festago/fcm/domain/MemberFCM.java | 2 +- .../java/com/festago/festival/domain/Festival.java | 4 ++-- .../main/java/com/festago/member/domain/Member.java | 4 ++-- .../main/java/com/festago/school/domain/School.java | 4 ++-- .../main/java/com/festago/student/domain/Student.java | 2 +- .../java/com/festago/student/domain/StudentCode.java | 2 +- .../com/festago/student/domain/VerificationCode.java | 2 +- .../java/com/festago/common/util/ValidatorTest.java | 10 +++++----- 11 files changed, 23 insertions(+), 22 deletions(-) diff --git a/backend/src/main/java/com/festago/admin/domain/Admin.java b/backend/src/main/java/com/festago/admin/domain/Admin.java index 48fea47aa..80205ee21 100644 --- a/backend/src/main/java/com/festago/admin/domain/Admin.java +++ b/backend/src/main/java/com/festago/admin/domain/Admin.java @@ -61,14 +61,14 @@ private void validate(String username, String password) { private void validateUsername(String username) { String fieldName = "username"; - Validator.hasBlank(username, fieldName); + Validator.notBlank(username, fieldName); Validator.minLength(username, MIN_USERNAME_LENGTH, fieldName); Validator.maxLength(username, MAX_USERNAME_LENGTH, fieldName); } private void validatePassword(String password) { String fieldName = "password"; - Validator.hasBlank(password, fieldName); + Validator.notBlank(password, fieldName); Validator.minLength(password, MIN_PASSWORD_LENGTH, fieldName); Validator.maxLength(password, MAX_PASSWORD_LENGTH, fieldName); } diff --git a/backend/src/main/java/com/festago/admin/presentation/v1/AdminSchoolV1Controller.java b/backend/src/main/java/com/festago/admin/presentation/v1/AdminSchoolV1Controller.java index c4ed314f2..abe269b19 100644 --- a/backend/src/main/java/com/festago/admin/presentation/v1/AdminSchoolV1Controller.java +++ b/backend/src/main/java/com/festago/admin/presentation/v1/AdminSchoolV1Controller.java @@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -64,11 +65,11 @@ public ResponseEntity deleteSchool( } @GetMapping - @ValidPageable(maxSize = 20) + @ValidPageable(maxSize = 50) public ResponseEntity> findAllSchools( @RequestParam(defaultValue = "") String searchFilter, @RequestParam(defaultValue = "") String searchKeyword, - Pageable pageable + @PageableDefault(size = 10) Pageable pageable ) { return ResponseEntity.ok() .body(schoolQueryService.findAll(new SearchCondition(searchFilter, searchKeyword, pageable))); diff --git a/backend/src/main/java/com/festago/common/util/Validator.java b/backend/src/main/java/com/festago/common/util/Validator.java index 6ae4516aa..fddb2bfa2 100644 --- a/backend/src/main/java/com/festago/common/util/Validator.java +++ b/backend/src/main/java/com/festago/common/util/Validator.java @@ -15,7 +15,7 @@ private Validator() { * @param fieldName 예외 메시지에 출력할 필드명 * @throws ValidException input이 null 또는 공백이면 */ - public static void hasBlank(String input, String fieldName) { + public static void notBlank(String input, String fieldName) { if (input == null || input.isBlank()) { throw new ValidException("%s은/는 null 또는 공백이 될 수 없습니다.".formatted(fieldName)); } @@ -141,7 +141,7 @@ public static void minValue(long value, long minValue, String fieldName) { * @param fieldName 예외 메시지에 출력할 필드명 * @throws ValidException value가 음수이면 */ - public static void isNegative(int value, String fieldName) { + public static void notNegative(int value, String fieldName) { if (value < 0) { throw new ValidException("%s은/는 음수가 될 수 없습니다.".formatted(fieldName)); } @@ -154,7 +154,7 @@ public static void isNegative(int value, String fieldName) { * @param fieldName 예외 메시지에 출력할 필드명 * @throws ValidException value가 음수이면 */ - public static void isNegative(long value, String fieldName) { + public static void notNegative(long value, String fieldName) { if (value < 0) { throw new ValidException("%s은/는 음수가 될 수 없습니다.".formatted(fieldName)); } diff --git a/backend/src/main/java/com/festago/fcm/domain/MemberFCM.java b/backend/src/main/java/com/festago/fcm/domain/MemberFCM.java index 852f9b615..6b110d3d9 100644 --- a/backend/src/main/java/com/festago/fcm/domain/MemberFCM.java +++ b/backend/src/main/java/com/festago/fcm/domain/MemberFCM.java @@ -52,7 +52,7 @@ private void validateMemberId(Long memberId) { private void validateFcmToken(String fcmToken) { String fieldName = "fcmToken"; - Validator.hasBlank(fcmToken, fieldName); + Validator.notBlank(fcmToken, fieldName); Validator.maxLength(fcmToken, MAX_FCM_TOKEN_LENGTH, fieldName); } diff --git a/backend/src/main/java/com/festago/festival/domain/Festival.java b/backend/src/main/java/com/festago/festival/domain/Festival.java index a47de38c0..be07ae610 100644 --- a/backend/src/main/java/com/festago/festival/domain/Festival.java +++ b/backend/src/main/java/com/festago/festival/domain/Festival.java @@ -74,13 +74,13 @@ private void validate(String name, LocalDate startDate, LocalDate endDate, Strin private void validateName(String name) { String fieldName = "name"; - Validator.hasBlank(name, fieldName); + Validator.notBlank(name, fieldName); Validator.maxLength(name, MAX_NAME_LENGTH, fieldName); } private void validateThumbnail(String thumbnail) { String fieldName = "thumbnail"; - Validator.hasBlank(thumbnail, fieldName); + Validator.notBlank(thumbnail, fieldName); Validator.maxLength(thumbnail, MAX_THUMBNAIL_LENGTH, fieldName); } diff --git a/backend/src/main/java/com/festago/member/domain/Member.java b/backend/src/main/java/com/festago/member/domain/Member.java index a7f0e3378..e8d64f922 100644 --- a/backend/src/main/java/com/festago/member/domain/Member.java +++ b/backend/src/main/java/com/festago/member/domain/Member.java @@ -89,7 +89,7 @@ private void validate(String socialId, SocialType socialType, String nickname, S private void validateSocialId(String socialId) { String fieldName = "socialId"; - Validator.hasBlank(socialId, fieldName); + Validator.notBlank(socialId, fieldName); Validator.maxLength(socialId, MAX_SOCIAL_ID_LENGTH, fieldName); } @@ -99,7 +99,7 @@ private void validateSocialType(SocialType socialType) { private void validateNickname(String nickname) { String fieldName = "nickname"; - Validator.hasBlank(nickname, fieldName); + Validator.notBlank(nickname, fieldName); Validator.maxLength(nickname, MAX_NICKNAME_LENGTH, fieldName); } diff --git a/backend/src/main/java/com/festago/school/domain/School.java b/backend/src/main/java/com/festago/school/domain/School.java index cf6d09cfa..fd72a20fa 100644 --- a/backend/src/main/java/com/festago/school/domain/School.java +++ b/backend/src/main/java/com/festago/school/domain/School.java @@ -76,13 +76,13 @@ private void validate(String domain, String name, SchoolRegion region, String lo private void validateDomain(String domain) { String fieldName = "domain"; - Validator.hasBlank(domain, fieldName); + Validator.notBlank(domain, fieldName); Validator.maxLength(domain, MAX_DOMAIN_LENGTH, fieldName); } private void validateName(String name) { String fieldName = "name"; - Validator.hasBlank(name, fieldName); + Validator.notBlank(name, fieldName); Validator.maxLength(name, MAX_NAME_LENGTH, fieldName); } diff --git a/backend/src/main/java/com/festago/student/domain/Student.java b/backend/src/main/java/com/festago/student/domain/Student.java index 7857d6a8d..c93d80ea8 100644 --- a/backend/src/main/java/com/festago/student/domain/Student.java +++ b/backend/src/main/java/com/festago/student/domain/Student.java @@ -66,7 +66,7 @@ private void validateSchool(School school) { private void validateUsername(String username) { String fieldName = "username"; - Validator.hasBlank(username, fieldName); + Validator.notBlank(username, fieldName); Validator.maxLength(username, MAX_USERNAME_LENGTH, fieldName); } diff --git a/backend/src/main/java/com/festago/student/domain/StudentCode.java b/backend/src/main/java/com/festago/student/domain/StudentCode.java index aaf8cb966..6d3615f27 100644 --- a/backend/src/main/java/com/festago/student/domain/StudentCode.java +++ b/backend/src/main/java/com/festago/student/domain/StudentCode.java @@ -90,7 +90,7 @@ private void validateMember(Member member) { private void validateUsername(String username) { String fieldName = "username"; - Validator.hasBlank(username, fieldName); + Validator.notBlank(username, fieldName); Validator.maxLength(username, MAX_USERNAME_LENGTH, fieldName); } diff --git a/backend/src/main/java/com/festago/student/domain/VerificationCode.java b/backend/src/main/java/com/festago/student/domain/VerificationCode.java index 24fe117bf..fe8d1e136 100644 --- a/backend/src/main/java/com/festago/student/domain/VerificationCode.java +++ b/backend/src/main/java/com/festago/student/domain/VerificationCode.java @@ -29,7 +29,7 @@ private void validate(String value) { } private void validateBlank(String value) { - Validator.hasBlank(value, "VerificationCode"); + Validator.notBlank(value, "VerificationCode"); } private void validateLength(String value) { diff --git a/backend/src/test/java/com/festago/common/util/ValidatorTest.java b/backend/src/test/java/com/festago/common/util/ValidatorTest.java index dad4a00dd..8e60376e2 100644 --- a/backend/src/test/java/com/festago/common/util/ValidatorTest.java +++ b/backend/src/test/java/com/festago/common/util/ValidatorTest.java @@ -24,7 +24,7 @@ class hasBlank { @NullSource void 문자열이_null이면_예외(String input) { // when & then - assertThatThrownBy(() -> Validator.hasBlank(input, "")) + assertThatThrownBy(() -> Validator.notBlank(input, "")) .isInstanceOf(ValidException.class); } @@ -32,7 +32,7 @@ class hasBlank { @ValueSource(strings = {"", " ", "\t", "\n"}) void 문자열이_공백이면_예외(String input) { // when & then - assertThatThrownBy(() -> Validator.hasBlank(input, "")) + assertThatThrownBy(() -> Validator.notBlank(input, "")) .isInstanceOf(ValidException.class); } @@ -43,7 +43,7 @@ class hasBlank { // when & then assertThatNoException() - .isThrownBy(() -> Validator.hasBlank(input, "")); + .isThrownBy(() -> Validator.notBlank(input, "")); } } @@ -222,7 +222,7 @@ class isNegative { int value = -1; // when & then - assertThatThrownBy(() -> Validator.isNegative(value, "")) + assertThatThrownBy(() -> Validator.notNegative(value, "")) .isInstanceOf(ValidException.class); } @@ -231,7 +231,7 @@ class isNegative { void 값이_음수가_아니면_통과(int value) { // when & then assertThatNoException() - .isThrownBy(() -> Validator.isNegative(value, "")); + .isThrownBy(() -> Validator.notNegative(value, "")); } } } From 8308755a673c4e5a702cfc163af311df8625e7e4 Mon Sep 17 00:00:00 2001 From: Seokjin Jeon Date: Tue, 27 Feb 2024 02:26:43 +0900 Subject: [PATCH 16/19] =?UTF-8?q?[BE]=20refactor:=20Stage=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20lineUp=20=ED=95=84=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#752)=20(#754)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Stage 엔티티 lineUp 필드 제거 - getter는 하위 호환성을 위해 빈 문자열을 반환하도록 유지 * test: 데이터 셋업용 SQL에 lineUp 삭제 * fix: 빈 문자열 반환에서 "deprecated" 의미를 가지는 문자열 반환 --- .../stage/application/StageService.java | 2 -- .../java/com/festago/stage/domain/Stage.java | 35 +++++-------------- .../migration/V16__remove_stage_line_up.sql | 2 ++ .../com/festago/support/StageFixture.java | 8 +---- .../test/resources/ticketing-test-data.sql | 4 +-- 5 files changed, 14 insertions(+), 37 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V16__remove_stage_line_up.sql diff --git a/backend/src/main/java/com/festago/stage/application/StageService.java b/backend/src/main/java/com/festago/stage/application/StageService.java index e59a7e35e..cf5e01b8b 100644 --- a/backend/src/main/java/com/festago/stage/application/StageService.java +++ b/backend/src/main/java/com/festago/stage/application/StageService.java @@ -27,7 +27,6 @@ public StageResponse create(StageCreateRequest request) { Festival festival = festivalRepository.getOrThrow(request.festivalId()); Stage newStage = stageRepository.save(new Stage( request.startTime(), - request.lineUp(), request.ticketOpenTime(), festival)); @@ -47,7 +46,6 @@ private Stage findStage(Long stageId) { public void update(Long stageId, StageUpdateRequest request) { Stage stage = findStage(stageId); stage.changeTime(request.startTime(), request.ticketOpenTime()); - stage.changeLineUp(request.lineUp()); } public void delete(Long stageId) { diff --git a/backend/src/main/java/com/festago/stage/domain/Stage.java b/backend/src/main/java/com/festago/stage/domain/Stage.java index 6f0ffd4ea..0c2d5ee64 100644 --- a/backend/src/main/java/com/festago/stage/domain/Stage.java +++ b/backend/src/main/java/com/festago/stage/domain/Stage.java @@ -14,7 +14,6 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -25,8 +24,6 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Stage extends BaseTimeEntity { - private static final int MAX_LINEUP_LENGTH = 255; - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -34,9 +31,6 @@ public class Stage extends BaseTimeEntity { @NotNull private LocalDateTime startTime; - @Size(max = MAX_LINEUP_LENGTH) - private String lineUp; - @NotNull private LocalDateTime ticketOpenTime; @@ -47,34 +41,24 @@ public class Stage extends BaseTimeEntity { @OneToMany(mappedBy = "stage", fetch = FetchType.LAZY) private List tickets = new ArrayList<>(); - public Stage(LocalDateTime startTime, String lineUp, LocalDateTime ticketOpenTime, Festival festival) { - this(null, startTime, lineUp, ticketOpenTime, festival); - } - public Stage(LocalDateTime startTime, LocalDateTime ticketOpenTime, Festival festival) { - this(null, startTime, null, ticketOpenTime, festival); + this(null, startTime, ticketOpenTime, festival); } - public Stage(Long id, LocalDateTime startTime, String lineUp, LocalDateTime ticketOpenTime, + public Stage(Long id, LocalDateTime startTime, LocalDateTime ticketOpenTime, Festival festival) { - validate(startTime, lineUp, ticketOpenTime, festival); + validate(startTime, ticketOpenTime, festival); this.id = id; this.startTime = startTime; - this.lineUp = lineUp; this.ticketOpenTime = ticketOpenTime; this.festival = festival; } - private void validate(LocalDateTime startTime, String lineUp, LocalDateTime ticketOpenTime, Festival festival) { - validateLineUp(lineUp); + private void validate(LocalDateTime startTime, LocalDateTime ticketOpenTime, Festival festival) { validateFestival(festival); validateTime(startTime, ticketOpenTime, festival); } - private void validateLineUp(String lineUp) { - Validator.maxLength(lineUp, MAX_LINEUP_LENGTH, "lineUp"); - } - private void validateFestival(Festival festival) { Validator.notNull(festival, "festival"); } @@ -100,11 +84,6 @@ public void changeTime(LocalDateTime startTime, LocalDateTime ticketOpenTime) { this.ticketOpenTime = ticketOpenTime; } - public void changeLineUp(String lineUp) { - validateLineUp(lineUp); - this.lineUp = lineUp; - } - public Long getId() { return id; } @@ -113,8 +92,12 @@ public LocalDateTime getStartTime() { return startTime; } + /** + * API 일부에 사용되는 곳이 있기 때문에 deprecated 문자열을 반환하도록 처리 + */ + @Deprecated(forRemoval = true) public String getLineUp() { - return lineUp; + return "deprecated"; } public LocalDateTime getTicketOpenTime() { diff --git a/backend/src/main/resources/db/migration/V16__remove_stage_line_up.sql b/backend/src/main/resources/db/migration/V16__remove_stage_line_up.sql new file mode 100644 index 000000000..1bc48a948 --- /dev/null +++ b/backend/src/main/resources/db/migration/V16__remove_stage_line_up.sql @@ -0,0 +1,2 @@ +alter table stage + drop column line_up diff --git a/backend/src/test/java/com/festago/support/StageFixture.java b/backend/src/test/java/com/festago/support/StageFixture.java index 5c13eda00..6c4b12ebc 100644 --- a/backend/src/test/java/com/festago/support/StageFixture.java +++ b/backend/src/test/java/com/festago/support/StageFixture.java @@ -8,7 +8,6 @@ public class StageFixture { private Long id; private LocalDateTime startTime = LocalDateTime.now(); - private String lineUp = "오리, 글렌, 푸우, 애쉬"; private LocalDateTime ticketOpenTime = startTime.minusWeeks(1); private Festival festival = FestivalFixture.festival().build(); @@ -29,11 +28,6 @@ public StageFixture startTime(LocalDateTime startTime) { return this; } - public StageFixture lineUp(String lineUp) { - this.lineUp = lineUp; - return this; - } - public StageFixture ticketOpenTime(LocalDateTime ticketOpenTime) { this.ticketOpenTime = ticketOpenTime; return this; @@ -46,6 +40,6 @@ public StageFixture festival(Festival festival) { } public Stage build() { - return new Stage(id, startTime, lineUp, ticketOpenTime, festival); + return new Stage(id, startTime, ticketOpenTime, festival); } } diff --git a/backend/src/test/resources/ticketing-test-data.sql b/backend/src/test/resources/ticketing-test-data.sql index 58a8894dc..0b0c146cf 100644 --- a/backend/src/test/resources/ticketing-test-data.sql +++ b/backend/src/test/resources/ticketing-test-data.sql @@ -4,8 +4,8 @@ values ('festago.com', '페스타고 대학교'); insert into festival (school_id, end_date, name, start_date, thumbnail) values (1, '2023-07-30', '테코 대학교', '2023-08-02', ''); -insert into stage (festival_id, line_up, start_time, ticket_open_time) -values (1, '', '2023-07-30T03:21:31.964676', '2023-07-23T03:21:31.964676'); +insert into stage (festival_id, start_time, ticket_open_time) +values (1, '2023-07-30T03:21:31.964676', '2023-07-23T03:21:31.964676'); insert into ticket (school_id, stage_id, ticket_type) values (1, 1, 'VISITOR'); From 6e066bdccb27dbc4e68d1f3a161c4c5d9e1e7287 Mon Sep 17 00:00:00 2001 From: Guga Date: Tue, 27 Feb 2024 16:08:30 +0900 Subject: [PATCH 17/19] =?UTF-8?q?[BE]=20feat:=20Artist=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1,=20=EC=88=98=EC=A0=95=EC=97=90=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EB=90=9C=20=ED=95=84=EB=93=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.(#732)=20(#756)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: ArtistUpdateRequest 에 backgroundImageUrl 를 보낸다. * chore: bean validation 의 기본 메시지를 사용하도록 변경. * feat: 아티스트 생성 요청에 backgroundImageUrl 을 포함하도록 변경 * chore: bean validation 메세지를 사용하도록 변경 * test: 테스트 수정 * feat: 사용하지 않는 생성자 삭제 * test: 사용하지 않는 쿠키 값 제거 --- .../com/festago/admin/dto/ArtistCreateRequest.java | 8 +++++--- .../com/festago/admin/dto/ArtistUpdateRequest.java | 8 +++++--- .../artist/application/ArtistCommandService.java | 5 +++-- .../main/java/com/festago/artist/domain/Artist.java | 7 ++++--- .../presentation/v1/AdminArtistV1ControllerTest.java | 11 ++++------- .../ArtistCommandServiceIntegrationTest.java | 6 ++++-- 6 files changed, 25 insertions(+), 20 deletions(-) diff --git a/backend/src/main/java/com/festago/admin/dto/ArtistCreateRequest.java b/backend/src/main/java/com/festago/admin/dto/ArtistCreateRequest.java index 4058a9cfc..b8c7c121a 100644 --- a/backend/src/main/java/com/festago/admin/dto/ArtistCreateRequest.java +++ b/backend/src/main/java/com/festago/admin/dto/ArtistCreateRequest.java @@ -3,10 +3,12 @@ import jakarta.validation.constraints.NotBlank; public record ArtistCreateRequest( - @NotBlank(message = "아티스트 이름은 비어있을 수 없습니다.") + @NotBlank String name, - @NotBlank(message = "아티스트 이미지는 비어있을 수 없습니다.") - String profileImage + @NotBlank + String profileImage, + @NotBlank + String backgroundImageUrl ) { } diff --git a/backend/src/main/java/com/festago/admin/dto/ArtistUpdateRequest.java b/backend/src/main/java/com/festago/admin/dto/ArtistUpdateRequest.java index 35b4c6f82..f827955fc 100644 --- a/backend/src/main/java/com/festago/admin/dto/ArtistUpdateRequest.java +++ b/backend/src/main/java/com/festago/admin/dto/ArtistUpdateRequest.java @@ -3,10 +3,12 @@ import jakarta.validation.constraints.NotBlank; public record ArtistUpdateRequest( - @NotBlank(message = "아티스트 이름은 비어있을 수 없습니다.") + @NotBlank String name, - @NotBlank(message = "아티스트 이미지는 비어있을 수 없습니다.") - String profileImage + @NotBlank + String profileImage, + @NotBlank + String backgroundImageUrl ) { } diff --git a/backend/src/main/java/com/festago/artist/application/ArtistCommandService.java b/backend/src/main/java/com/festago/artist/application/ArtistCommandService.java index 54926b8fe..9e7748368 100644 --- a/backend/src/main/java/com/festago/artist/application/ArtistCommandService.java +++ b/backend/src/main/java/com/festago/artist/application/ArtistCommandService.java @@ -16,13 +16,14 @@ public class ArtistCommandService { private final ArtistRepository artistRepository; public Long save(ArtistCreateRequest request) { - Artist artist = artistRepository.save(new Artist(request.name(), request.profileImage())); + Artist artist = artistRepository.save( + new Artist(request.name(), request.profileImage(), request.backgroundImageUrl())); return artist.getId(); } public void update(ArtistUpdateRequest request, Long artistId) { Artist artist = artistRepository.getOrThrow(artistId); - artist.update(request.name(), request.profileImage()); + artist.update(request.name(), request.profileImage(), request.backgroundImageUrl()); } public void delete(Long artistId) { diff --git a/backend/src/main/java/com/festago/artist/domain/Artist.java b/backend/src/main/java/com/festago/artist/domain/Artist.java index 13c24915a..f29ecf703 100644 --- a/backend/src/main/java/com/festago/artist/domain/Artist.java +++ b/backend/src/main/java/com/festago/artist/domain/Artist.java @@ -34,13 +34,14 @@ public Artist(String name, String profileImage) { this(null, name, profileImage, DEFAULT_URL); } - public Artist(Long id, String name, String profileImage) { - this(id, name, profileImage, DEFAULT_URL); + public Artist(String name, String profileImage, String backgroundImageUrl) { + this(null, name, profileImage, backgroundImageUrl); } - public void update(String name, String profileImage) { + public void update(String name, String profileImage, String backgroundImageUrl) { this.name = name; this.profileImage = profileImage; + this.backgroundImageUrl = backgroundImageUrl; } public Long getId() { diff --git a/backend/src/test/java/com/festago/admin/presentation/v1/AdminArtistV1ControllerTest.java b/backend/src/test/java/com/festago/admin/presentation/v1/AdminArtistV1ControllerTest.java index 5d4f6f939..705bbd3a3 100644 --- a/backend/src/test/java/com/festago/admin/presentation/v1/AdminArtistV1ControllerTest.java +++ b/backend/src/test/java/com/festago/admin/presentation/v1/AdminArtistV1ControllerTest.java @@ -41,18 +41,13 @@ class AdminArtistV1ControllerTest { @Autowired MockMvc mockMvc; - @Autowired ObjectMapper objectMapper; - @Autowired ArtistV1QueryService artistV1QueryService; - @Autowired ArtistCommandService artistCommandService; - private static final Cookie COOKIE = new Cookie("token", "token"); - @Nested class 아티스트_생성 { @@ -66,7 +61,8 @@ class 올바른_주소로 { @WithMockAuth(role = Role.ADMIN) void 요청을_보내면_201_응답과_Location_헤더에_식별자가_반환된다() throws Exception { // given - ArtistCreateRequest request = new ArtistCreateRequest("윤서연", "https://image.com/image.png"); + ArtistCreateRequest request = new ArtistCreateRequest("윤서연", "https://image.com/image.png", + "https://image.com/image.png"); given(artistCommandService.save(any(ArtistCreateRequest.class))) .willReturn(1L); @@ -111,7 +107,8 @@ class 올바른_주소로 { @WithMockAuth(role = Role.ADMIN) void 요청을_보내면_200_응답이_반환된다() throws Exception { // given - ArtistUpdateRequest request = new ArtistUpdateRequest("윤하", "https://image.com/image.png"); + ArtistUpdateRequest request = new ArtistUpdateRequest("윤하", "https://image.com/image.png", + "https://image.com/image.png"); // when & then mockMvc.perform(put(uri, 1L) diff --git a/backend/src/test/java/com/festago/artist/application/integration/ArtistCommandServiceIntegrationTest.java b/backend/src/test/java/com/festago/artist/application/integration/ArtistCommandServiceIntegrationTest.java index fa5cd0c67..d89887765 100644 --- a/backend/src/test/java/com/festago/artist/application/integration/ArtistCommandServiceIntegrationTest.java +++ b/backend/src/test/java/com/festago/artist/application/integration/ArtistCommandServiceIntegrationTest.java @@ -27,7 +27,8 @@ class ArtistCommandServiceIntegrationTest extends ApplicationIntegrationTest { @Test void 아티스트를_저장한다() { // given - ArtistCreateRequest request = new ArtistCreateRequest("윤서연", "https://image.com/image.png"); + ArtistCreateRequest request = new ArtistCreateRequest("윤서연", "https://image.com/image.png", + "https://image.com/image.png"); // when Long artistId = artistCommandService.save(request); @@ -41,7 +42,8 @@ class ArtistCommandServiceIntegrationTest extends ApplicationIntegrationTest { void 아티스트_정보를_변경한다() { // given Long artistId = artistRepository.save(new Artist("고윤하", "https://image.com/image1.png")).getId(); - ArtistUpdateRequest request = new ArtistUpdateRequest("윤하", "https://image.com/image2.png"); + ArtistUpdateRequest request = new ArtistUpdateRequest("윤하", "https://image.com/image2.png", + "https://image.com/image2.png"); // when artistCommandService.update(request, artistId); From c41210db87888ef206e41ee18fe8e9cbe8674449 Mon Sep 17 00:00:00 2001 From: SeongHoonC <108349655+SeongHoonC@users.noreply.github.com> Date: Wed, 28 Feb 2024 17:07:41 +0900 Subject: [PATCH 18/19] =?UTF-8?q?[AN/USER]=20feat:=20=EC=B6=95=EC=A0=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=99=94=EB=A9=B4=20=EC=9D=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=B6=95=EC=A0=9C=20=EB=AA=A9=EB=A1=9D=EC=97=90=20=EB=AC=B4?= =?UTF-8?q?=ED=95=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=EC=9D=84=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=EB=8B=A4(#735)=20(#736)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(PopularFestivalForegroundAdapter): foreground 인기 축제 목록을 무한 스크롤 가능하게 변경한다. * refactor(PopularFestivalViewPagerAdapter): 변수 이름 카멜 케이스로 변경 * refactor(FestivalListPopularViewHolder): DotIndicator 는 터치해서 이동할 수 없다 * refactor(PopularFestivalViewPagerAdapter): initialPosition 계산 변수로 분리 * fix(PopularFestivalViewPagerAdapter): 축제 목록이 변경되면 포지션 초기화 * feat(PopularFestivalViewPagerAdapter): 미리 로딩하는 좌우 화면 개수를 2로 변경한다 * feat(PopularFestivalViewPagerAdapter): 인기 축제 개수가 0 개면 인기 축제 목록이 보이지 않는다 --- .../home/festivallist/FestivalListFragment.kt | 32 ++++++++++------ .../festivallist/FestivalListViewModel.kt | 2 +- .../festival/FestivalListPopularViewHolder.kt | 8 ++-- .../PopularFestivalViewPagerAdapter.kt | 23 +++++++++--- .../PopularFestivalForegroundAdapter.kt | 37 +++++++++---------- .../uistate/FestivalListUiState.kt | 2 +- .../uistate/PopularFestivalUiState.kt | 2 +- 7 files changed, 62 insertions(+), 44 deletions(-) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt index 3f03a190f..d0930335c 100644 --- a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt @@ -130,19 +130,27 @@ class FestivalListFragment : Fragment() { } private fun handleSuccess(uiState: FestivalListUiState.Success) { - festivalListAdapter.submitList( - listOf( - uiState.popularFestivals, - FestivalTabUiState { - val festivalFilter = when (it) { - 0 -> FestivalFilterUiState.PROGRESS - 1 -> FestivalFilterUiState.PLANNED - else -> FestivalFilterUiState.PROGRESS - } - vm.loadFestivals(festivalFilter) - }, - ) + uiState.festivals, + val items = uiState.getItems() + festivalListAdapter.submitList(items) + } + + private fun FestivalListUiState.Success.getItems(): List { + val items = mutableListOf() + if (popularFestivalUiState.festivals.isNotEmpty()) { + items.add(popularFestivalUiState) + } + items.add( + FestivalTabUiState { + val festivalFilter = when (it) { + 0 -> FestivalFilterUiState.PROGRESS + 1 -> FestivalFilterUiState.PLANNED + else -> FestivalFilterUiState.PROGRESS + } + vm.loadFestivals(festivalFilter) + }, ) + items.addAll(festivals) + return items.toList() } private fun showSchoolDetail() { diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModel.kt index 26173889f..fb362b89d 100644 --- a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModel.kt +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModel.kt @@ -49,7 +49,7 @@ class FestivalListViewModel @Inject constructor( _uiState.value = FestivalListUiState.Success( PopularFestivalUiState( title = popularFestivals.title, - popularFestivals = popularFestivals.festivals.map { it.toUiState() }, + festivals = popularFestivals.festivals.map { it.toUiState() }, ), festivals = festivalsPage.festivals.map { it.toUiState() }, isLastPage = festivalsPage.isLastPage, diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListPopularViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListPopularViewHolder.kt index 5cd451149..64bd3c047 100644 --- a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListPopularViewHolder.kt +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListPopularViewHolder.kt @@ -22,13 +22,15 @@ class FestivalListPopularViewHolder(val binding: ItemFestivalListPopularBinding) init { TabLayoutMediator( binding.tlDotIndicator, - binding.vpPopularFestivalForeground, - ) { tab, position -> }.attach() + binding.vpPopularFestivalBackground, + ) { tab, position -> + tab.view.isClickable = false + }.attach() } fun bind(popularFestivalUiState: PopularFestivalUiState) { binding.tvPopularFestivalTitle.text = popularFestivalUiState.title - popularFestivalViewPager.submitList(popularFestivalUiState.popularFestivals) + popularFestivalViewPager.submitList(popularFestivalUiState.festivals) } companion object { diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/PopularFestivalViewPagerAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/PopularFestivalViewPagerAdapter.kt index 792643f92..3357e515f 100644 --- a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/PopularFestivalViewPagerAdapter.kt +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/PopularFestivalViewPagerAdapter.kt @@ -9,7 +9,7 @@ import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalIte import kotlin.math.abs class PopularFestivalViewPagerAdapter( - foregroundViewPager: ViewPager2, + private val foregroundViewPager: ViewPager2, backgroundViewPager: ViewPager2, private val onPopularFestivalSelected: (FestivalItemUiState) -> Unit, ) { @@ -24,22 +24,23 @@ class PopularFestivalViewPagerAdapter( foregroundViewPager.adapter = foregroundAdapter backgroundViewPager.adapter = backgroundAdapter - setTargetItemOnPageSelected(viewpager = foregroundViewPager, target = backgroundViewPager) + setTargetItemOnPageSelected(viewPager = foregroundViewPager, target = backgroundViewPager) narrowSpaceViewPager(viewPager = foregroundViewPager) setOffscreenPagesLimit(foregroundViewPager, PAGE_LIMIT) setOffscreenPagesLimit(backgroundViewPager, PAGE_LIMIT) backgroundViewPager.isUserInputEnabled = false } - private fun setTargetItemOnPageSelected(viewpager: ViewPager2, target: ViewPager2) { + private fun setTargetItemOnPageSelected(viewPager: ViewPager2, target: ViewPager2) { val onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { super.onPageSelected(position) - target.setCurrentItem(position, false) - onPopularFestivalSelected(popularFestivals[position]) + val itemIndex = position % popularFestivals.size + target.setCurrentItem(itemIndex, false) + onPopularFestivalSelected(popularFestivals[itemIndex]) } } - viewpager.registerOnPageChangeCallback(onPageChangeCallback) + viewPager.registerOnPageChangeCallback(onPageChangeCallback) } private fun narrowSpaceViewPager(viewPager: ViewPager2) { @@ -85,10 +86,20 @@ class PopularFestivalViewPagerAdapter( } fun submitList(festivals: List) { + val lastFestivals = popularFestivals.toList() popularFestivals.clear() popularFestivals.addAll(festivals) foregroundAdapter.submitList(festivals) backgroundAdapter.submitList(festivals) + + if (lastFestivals != festivals) { + initItemPosition() + } + } + + private fun initItemPosition() { + val initialPosition = Int.MAX_VALUE / 2 - (Int.MAX_VALUE / 2 % popularFestivals.size) + foregroundViewPager.setCurrentItem(initialPosition, false) } companion object { diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/foreground/PopularFestivalForegroundAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/foreground/PopularFestivalForegroundAdapter.kt index 02f571510..03548fbaf 100644 --- a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/foreground/PopularFestivalForegroundAdapter.kt +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/foreground/PopularFestivalForegroundAdapter.kt @@ -1,36 +1,33 @@ package com.festago.festago.presentation.ui.home.festivallist.popularfestival.foreground import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalItemUiState -class PopularFestivalForegroundAdapter : - ListAdapter(diffUtil) { +class PopularFestivalForegroundAdapter(festivals: List = listOf()) : + RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PopularFestivalForegroundViewHolder { + private val _festivals = festivals.toMutableList() + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): PopularFestivalForegroundViewHolder { return PopularFestivalForegroundViewHolder.of(parent) } override fun onBindViewHolder(holder: PopularFestivalForegroundViewHolder, position: Int) { - holder.bind(getItem(position)) + holder.bind(_festivals[position % _festivals.size]) } - companion object { - val diffUtil = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: FestivalItemUiState, - newItem: FestivalItemUiState, - ): Boolean { - return oldItem.id == newItem.id - } + override fun getItemCount(): Int = Int.MAX_VALUE - override fun areContentsTheSame( - oldItem: FestivalItemUiState, - newItem: FestivalItemUiState, - ): Boolean { - return oldItem == newItem - } + fun submitList(festivals: List) { + if (_festivals.toList() == festivals) { + return } + _festivals.clear() + _festivals.addAll(festivals) + notifyDataSetChanged() } } diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalListUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalListUiState.kt index be1410762..0a4ef1618 100644 --- a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalListUiState.kt +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalListUiState.kt @@ -4,7 +4,7 @@ sealed interface FestivalListUiState { object Loading : FestivalListUiState data class Success( - val popularFestivals: PopularFestivalUiState, + val popularFestivalUiState: PopularFestivalUiState, val festivals: List, val isLastPage: Boolean, ) : FestivalListUiState diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/PopularFestivalUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/PopularFestivalUiState.kt index 524f7ed14..55a6f3d7e 100644 --- a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/PopularFestivalUiState.kt +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/PopularFestivalUiState.kt @@ -2,5 +2,5 @@ package com.festago.festago.presentation.ui.home.festivallist.uistate data class PopularFestivalUiState( val title: String, - val popularFestivals: List, + val festivals: List, ) From 19faf78967a44b8b0e69ded52f5149203d5da906 Mon Sep 17 00:00:00 2001 From: Seokjin Jeon Date: Tue, 5 Mar 2024 23:53:05 +0900 Subject: [PATCH 19/19] =?UTF-8?q?[BE]=20feat:=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=B6=95=EC=A0=9C=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#745)=20(#749)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 관리자 축제 조회 기능 추가 * feat: 관리자 축제 조회 API 추가 --- .../AdminFestivalV1QueryService.java | 21 ++ .../admin/dto/AdminFestivalV1Response.java | 18 ++ .../v1/AdminFestivalV1Controller.java | 21 ++ .../AdminFestivalV1QueryDslRepository.java | 97 +++++++ .../common/querydsl/OrderSpecifierUtils.java | 20 ++ .../common/querydsl/QueryDslHelper.java | 39 +++ ...FestivalV1QueryServiceIntegrationTest.java | 269 ++++++++++++++++++ .../v1/AdminFestivalV1ControllerTest.java | 55 ++++ 8 files changed, 540 insertions(+) create mode 100644 backend/src/main/java/com/festago/admin/application/AdminFestivalV1QueryService.java create mode 100644 backend/src/main/java/com/festago/admin/dto/AdminFestivalV1Response.java create mode 100644 backend/src/main/java/com/festago/admin/repository/AdminFestivalV1QueryDslRepository.java create mode 100644 backend/src/main/java/com/festago/common/querydsl/OrderSpecifierUtils.java create mode 100644 backend/src/main/java/com/festago/common/querydsl/QueryDslHelper.java create mode 100644 backend/src/test/java/com/festago/admin/application/integration/AdminFestivalV1QueryServiceIntegrationTest.java diff --git a/backend/src/main/java/com/festago/admin/application/AdminFestivalV1QueryService.java b/backend/src/main/java/com/festago/admin/application/AdminFestivalV1QueryService.java new file mode 100644 index 000000000..01d8430b5 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/application/AdminFestivalV1QueryService.java @@ -0,0 +1,21 @@ +package com.festago.admin.application; + +import com.festago.admin.dto.AdminFestivalV1Response; +import com.festago.admin.repository.AdminFestivalV1QueryDslRepository; +import com.festago.common.querydsl.SearchCondition; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AdminFestivalV1QueryService { + + private final AdminFestivalV1QueryDslRepository adminFestivalV1QueryDslRepository; + + public Page findAll(SearchCondition searchCondition) { + return adminFestivalV1QueryDslRepository.findAll(searchCondition); + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/AdminFestivalV1Response.java b/backend/src/main/java/com/festago/admin/dto/AdminFestivalV1Response.java new file mode 100644 index 000000000..593844918 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/AdminFestivalV1Response.java @@ -0,0 +1,18 @@ +package com.festago.admin.dto; + +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDate; + +public record AdminFestivalV1Response( + Long id, + String name, + String schoolName, + LocalDate startDate, + LocalDate endDate, + long stageCount +) { + + @QueryProjection + public AdminFestivalV1Response { + } +} diff --git a/backend/src/main/java/com/festago/admin/presentation/v1/AdminFestivalV1Controller.java b/backend/src/main/java/com/festago/admin/presentation/v1/AdminFestivalV1Controller.java index c77cfdd50..a467ade4f 100644 --- a/backend/src/main/java/com/festago/admin/presentation/v1/AdminFestivalV1Controller.java +++ b/backend/src/main/java/com/festago/admin/presentation/v1/AdminFestivalV1Controller.java @@ -1,19 +1,28 @@ package com.festago.admin.presentation.v1; +import com.festago.admin.application.AdminFestivalV1QueryService; +import com.festago.admin.dto.AdminFestivalV1Response; import com.festago.admin.dto.FestivalV1CreateRequest; import com.festago.admin.dto.FestivalV1UpdateRequest; +import com.festago.common.aop.ValidPageable; +import com.festago.common.querydsl.SearchCondition; import com.festago.festival.application.command.FestivalCommandFacadeService; import io.swagger.v3.oas.annotations.Hidden; import jakarta.validation.Valid; import java.net.URI; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -22,8 +31,20 @@ @Hidden public class AdminFestivalV1Controller { + private final AdminFestivalV1QueryService adminFestivalV1QueryService; private final FestivalCommandFacadeService festivalCommandFacadeService; + @ValidPageable(maxSize = 50) + @GetMapping + public ResponseEntity> findAll( + @RequestParam(defaultValue = "") String searchFilter, + @RequestParam(defaultValue = "") String searchKeyword, + @PageableDefault(size = 10) Pageable pageable + ) { + return ResponseEntity.ok() + .body(adminFestivalV1QueryService.findAll(new SearchCondition(searchFilter, searchKeyword, pageable))); + } + @PostMapping public ResponseEntity createFestival( @RequestBody @Valid FestivalV1CreateRequest request diff --git a/backend/src/main/java/com/festago/admin/repository/AdminFestivalV1QueryDslRepository.java b/backend/src/main/java/com/festago/admin/repository/AdminFestivalV1QueryDslRepository.java new file mode 100644 index 000000000..f4bfdb79e --- /dev/null +++ b/backend/src/main/java/com/festago/admin/repository/AdminFestivalV1QueryDslRepository.java @@ -0,0 +1,97 @@ +package com.festago.admin.repository; + +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.school.domain.QSchool.school; +import static com.festago.stage.domain.QStage.stage; + +import com.festago.admin.dto.AdminFestivalV1Response; +import com.festago.admin.dto.QAdminFestivalV1Response; +import com.festago.common.querydsl.OrderSpecifierUtils; +import com.festago.common.querydsl.QueryDslHelper; +import com.festago.common.querydsl.SearchCondition; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +@Repository +@RequiredArgsConstructor +public class AdminFestivalV1QueryDslRepository { + + private final QueryDslHelper queryDslHelper; + + public Page findAll(SearchCondition searchCondition) { + Pageable pageable = searchCondition.pageable(); + String searchFilter = searchCondition.searchFilter(); + String searchKeyword = searchCondition.searchKeyword(); + return queryDslHelper.applyPagination(pageable, + queryFactory -> queryFactory.select( + new QAdminFestivalV1Response( + festival.id, + festival.name, + school.name, + festival.startDate, + festival.endDate, + stage.count() + )) + .from(festival) + .innerJoin(school).on(school.id.eq(festival.school.id)) + .leftJoin(stage).on(stage.festival.id.eq(festival.id)) + .where(applySearchFilter(searchFilter, searchKeyword)) + .groupBy(festival.id) + .orderBy(getOrderSpecifier(pageable.getSort())) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()), + queryFactory -> queryFactory.select(festival.count()) + .from(festival) + .where(applySearchFilter(searchFilter, searchKeyword))); + } + + private BooleanExpression applySearchFilter(String searchFilter, String searchKeyword) { + return switch (searchFilter) { + case "id" -> eqId(searchKeyword); + case "name" -> containsName(searchKeyword); + case "schoolName" -> containsSchoolName(searchKeyword); + default -> null; + }; + } + + private BooleanExpression eqId(String searchKeyword) { + if (StringUtils.hasText(searchKeyword)) { + return festival.id.stringValue().eq(searchKeyword); + } + return null; + } + + private BooleanExpression containsName(String searchKeyword) { + if (StringUtils.hasText(searchKeyword)) { + return festival.name.contains(searchKeyword); + } + return null; + } + + private BooleanExpression containsSchoolName(String searchKeyword) { + if (StringUtils.hasText(searchKeyword)) { + return school.name.contains(searchKeyword); + } + return null; + } + + private OrderSpecifier getOrderSpecifier(Sort sort) { + return sort.stream() + .findFirst() + .map(it -> switch (it.getProperty()) { + case "id" -> OrderSpecifierUtils.of(it.getDirection(), festival.id); + case "name" -> OrderSpecifierUtils.of(it.getDirection(), festival.name); + case "schoolName" -> OrderSpecifierUtils.of(it.getDirection(), school.name); + case "startDate" -> OrderSpecifierUtils.of(it.getDirection(), festival.startDate); + case "endDate" -> OrderSpecifierUtils.of(it.getDirection(), festival.endDate); + default -> OrderSpecifierUtils.NULL; + }) + .orElse(OrderSpecifierUtils.NULL); + } +} diff --git a/backend/src/main/java/com/festago/common/querydsl/OrderSpecifierUtils.java b/backend/src/main/java/com/festago/common/querydsl/OrderSpecifierUtils.java new file mode 100644 index 000000000..8c9befb5a --- /dev/null +++ b/backend/src/main/java/com/festago/common/querydsl/OrderSpecifierUtils.java @@ -0,0 +1,20 @@ +package com.festago.common.querydsl; + +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.NullExpression; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import org.springframework.data.domain.Sort; + +public class OrderSpecifierUtils { + + public static final OrderSpecifier NULL = new OrderSpecifier(Order.ASC, NullExpression.DEFAULT, + OrderSpecifier.NullHandling.Default); + + private OrderSpecifierUtils() { + } + + public static OrderSpecifier of(Sort.Direction direction, Expression target) { + return new OrderSpecifier(direction.isAscending() ? Order.ASC : Order.DESC, target); + } +} diff --git a/backend/src/main/java/com/festago/common/querydsl/QueryDslHelper.java b/backend/src/main/java/com/festago/common/querydsl/QueryDslHelper.java new file mode 100644 index 000000000..bc47fdc3e --- /dev/null +++ b/backend/src/main/java/com/festago/common/querydsl/QueryDslHelper.java @@ -0,0 +1,39 @@ +package com.festago.common.querydsl; + +import com.querydsl.core.types.Expression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class QueryDslHelper { + + private final JPAQueryFactory queryFactory; + + public JPAQuery select(Expression expr) { + return queryFactory.select(expr); + } + + public Optional fetchOne(Function> queryFunction) { + JPAQuery query = queryFunction.apply(queryFactory); + return Optional.ofNullable(query.fetchOne()); + } + + public Page applyPagination( + Pageable pageable, + Function> contentQueryFunction, + Function> countQueryFunction + ) { + List content = contentQueryFunction.apply(queryFactory).fetch(); + JPAQuery countQuery = countQueryFunction.apply(queryFactory); + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } +} diff --git a/backend/src/test/java/com/festago/admin/application/integration/AdminFestivalV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/admin/application/integration/AdminFestivalV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..180cde1f3 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/application/integration/AdminFestivalV1QueryServiceIntegrationTest.java @@ -0,0 +1,269 @@ +package com.festago.admin.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.admin.application.AdminFestivalV1QueryService; +import com.festago.admin.dto.AdminFestivalV1Response; +import com.festago.common.querydsl.SearchCondition; +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.repository.SchoolRepository; +import com.festago.stage.domain.Stage; +import com.festago.stage.repository.StageRepository; +import com.festago.support.ApplicationIntegrationTest; +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminFestivalV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + AdminFestivalV1QueryService adminFestivalV1QueryService; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + StageRepository stageRepository; + + LocalDate now = LocalDate.parse("2077-06-30"); + LocalDate tomorrow = now.plusDays(1); + + Festival 테코대학교_축제; + Festival 테코대학교_공연_없는_축제; + Festival 우테대학교_축제; + Stage 테코대학교_공연; + Stage 우테대학교_첫째날_공연; + Stage 우테대학교_둘째날_공연; + + @BeforeEach + void setUp() { + LocalDateTime ticketOpenTime = now.atStartOfDay().minusWeeks(1); + School 테코대학교 = schoolRepository.save(new School("teco.ac.kr", "테코대학교", SchoolRegion.서울)); + School 우테대학교 = schoolRepository.save(new School("wote.ac.kr", "우테대학교", SchoolRegion.서울)); + 테코대학교_축제 = festivalRepository.save(new Festival("테코대학교 축제", now, now, 테코대학교)); + 테코대학교_공연_없는_축제 = festivalRepository.save(new Festival("테코대학교 공연 없는 축제", tomorrow, tomorrow, 테코대학교)); + 우테대학교_축제 = festivalRepository.save(new Festival("우테대학교 축제", now, tomorrow, 우테대학교)); + 테코대학교_공연 = stageRepository.save(new Stage(now.atTime(18, 0), ticketOpenTime, 테코대학교_축제)); + 우테대학교_첫째날_공연 = stageRepository.save(new Stage(now.atTime(18, 0), ticketOpenTime, 우테대학교_축제)); + 우테대학교_둘째날_공연 = stageRepository.save(new Stage(tomorrow.atTime(18, 0), ticketOpenTime, 우테대학교_축제)); + } + + @Nested + class findAll { + + @Test + void 페이지네이션이_적용되어야_한다() { + // given + Pageable pageable = PageRequest.ofSize(2); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + assertSoftly(softly -> { + softly.assertThat(response.getSize()).isEqualTo(2); + softly.assertThat(response.getTotalPages()).isEqualTo(2); + softly.assertThat(response.getTotalElements()).isEqualTo(3); + }); + } + + @Test + void 공연의_수가_정확하게_반환되어야_한다() { + // given + Pageable pageable = PageRequest.ofSize(10); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + // then + assertThat(response.getContent()) + .map(AdminFestivalV1Response::stageCount) + .containsExactly(1L, 0L, 2L); + } + + @Nested + class 정렬 { + + @Test + void 축제의_식별자로_정렬_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "id")); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + // then + assertThat(response.getContent()) + .map(AdminFestivalV1Response::id) + .containsExactly(우테대학교_축제.getId(), 테코대학교_공연_없는_축제.getId(), 테코대학교_축제.getId()); + } + + @Test + void 축제의_이름으로_정렬이_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "name")); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + // then + assertThat(response.getContent()) + .map(AdminFestivalV1Response::name) + .containsExactly(우테대학교_축제.getName(), 테코대학교_공연_없는_축제.getName(), 테코대학교_축제.getName()); + } + + @Test + void 학교의_이름으로_정렬이_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "schoolName")); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + // then + assertThat(response.getContent()) + .map(AdminFestivalV1Response::id) + .containsExactly(우테대학교_축제.getId(), 테코대학교_축제.getId(), 테코대학교_공연_없는_축제.getId()); + } + + @Test + void 축제의_시작일으로_정렬이_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "startDate")); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + // then + assertThat(response.getContent()) + .map(AdminFestivalV1Response::id) + .containsExactly(테코대학교_축제.getId(), 우테대학교_축제.getId(), 테코대학교_공연_없는_축제.getId()); + } + + @Test + void 축제의_종료일으로_정렬이_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "endDate")); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + // then + System.out.println(response.getContent()); + assertThat(response.getContent()) + .map(AdminFestivalV1Response::id) + .containsExactly(테코대학교_공연_없는_축제.getId(), 우테대학교_축제.getId(), 테코대학교_축제.getId()); + } + + @Test + void 정렬_조건에_없으면_식별자의_오름차순으로_정렬이_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "foo")); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + // then + assertThat(response.getContent()) + .map(AdminFestivalV1Response::id) + .containsExactly(테코대학교_축제.getId(), 테코대학교_공연_없는_축제.getId(), 우테대학교_축제.getId()); + } + } + + @Nested + class 검색 { + + @Test + void 축제의_식별자로_검색이_되어야_한다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("id", 테코대학교_축제.getId().toString(), pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminFestivalV1Response::id) + .containsExactly(테코대학교_축제.getId()); + } + + @Test + void 축제의_이름이_포함된_검색이_되어야_한다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("name", "테코대학교", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminFestivalV1Response::id) + .containsExactly(테코대학교_축제.getId(), 테코대학교_공연_없는_축제.getId()); + } + + @Test + void 학교의_이름이_포함된_검색이_되어야_한다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("schoolName", "우테대학교", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminFestivalV1Response::id) + .containsExactly(우테대학교_축제.getId()); + } + + @Test + void 검색_필터가_비어있으면_필터링이_적용되지_않는다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("", "글렌", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .hasSize(3); + } + + @Test + void 검색어가_비어있으면_필터링이_적용되지_않는다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("id", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .hasSize(3); + } + } + } +} diff --git a/backend/src/test/java/com/festago/admin/presentation/v1/AdminFestivalV1ControllerTest.java b/backend/src/test/java/com/festago/admin/presentation/v1/AdminFestivalV1ControllerTest.java index e43585c07..15a9545bd 100644 --- a/backend/src/test/java/com/festago/admin/presentation/v1/AdminFestivalV1ControllerTest.java +++ b/backend/src/test/java/com/festago/admin/presentation/v1/AdminFestivalV1ControllerTest.java @@ -3,14 +3,19 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.admin.application.AdminFestivalV1QueryService; +import com.festago.admin.dto.AdminFestivalV1Response; import com.festago.admin.dto.FestivalV1UpdateRequest; import com.festago.auth.domain.Role; +import com.festago.common.querydsl.SearchCondition; import com.festago.festival.application.command.FestivalCommandFacadeService; import com.festago.festival.dto.FestivalCreateRequest; import com.festago.festival.dto.command.FestivalCreateCommand; @@ -18,12 +23,14 @@ import com.festago.support.WithMockAuth; import jakarta.servlet.http.Cookie; import java.time.LocalDate; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageImpl; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; @@ -44,6 +51,9 @@ class AdminFestivalV1ControllerTest { @Autowired FestivalCommandFacadeService festivalCommandFacadeService; + @Autowired + AdminFestivalV1QueryService adminFestivalV1QueryService; + @Nested class 축제_생성 { @@ -172,4 +182,49 @@ class 올바른_주소로 { } } } + + @Nested + class 모든_축제_정보_조회 { + + final String uri = "/admin/api/v1/festivals"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_하면_200_응답과_학교_정보_목록이_반환된다() throws Exception { + // given + var expected = List.of( + new AdminFestivalV1Response(1L, "테코대학교 축제", "테코대학교", LocalDate.now(), LocalDate.now(), 0) + ); + given(adminFestivalV1QueryService.findAll(any(SearchCondition.class))) + .willReturn(new PageImpl<>(expected)); + + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.size()").value(1)); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } }