diff --git a/src/main/java/com/amp/domain/audience/controller/AudienceController.java b/src/main/java/com/amp/domain/audience/controller/AudienceController.java index 911ca21a..91bbb0e9 100644 --- a/src/main/java/com/amp/domain/audience/controller/AudienceController.java +++ b/src/main/java/com/amp/domain/audience/controller/AudienceController.java @@ -43,8 +43,7 @@ public class AudienceController { @PreAuthorize("hasRole('AUDIENCE')") public ResponseEntity> getMyPage( @AuthenticationPrincipal CustomUserPrincipal principal) { - Long userId = principal.getUserId(); - AudienceMyPageResponse response = audienceService.getMyPage(userId); + AudienceMyPageResponse response = audienceService.getMyPage(principal.getEmail()); return ResponseEntity .status(SuccessStatus.USER_PROFILE_RETRIEVED.getHttpStatus()) .body(BaseResponse.of(SuccessStatus.USER_PROFILE_RETRIEVED, response)); diff --git a/src/main/java/com/amp/domain/audience/service/AudienceService.java b/src/main/java/com/amp/domain/audience/service/AudienceService.java index 6349a127..b4696c12 100644 --- a/src/main/java/com/amp/domain/audience/service/AudienceService.java +++ b/src/main/java/com/amp/domain/audience/service/AudienceService.java @@ -17,8 +17,8 @@ public class AudienceService { private final AudienceRepository audienceRepository; - public AudienceMyPageResponse getMyPage(Long userId) { - Audience audience = audienceRepository.findById(userId) + public AudienceMyPageResponse getMyPage(String email) { + Audience audience = audienceRepository.findByEmail(email) .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); return AudienceMyPageResponse.from(audience); } diff --git a/src/main/java/com/amp/domain/congestion/repository/StageCongestionRepository.java b/src/main/java/com/amp/domain/congestion/repository/StageCongestionRepository.java index 93d0db33..ced273c7 100644 --- a/src/main/java/com/amp/domain/congestion/repository/StageCongestionRepository.java +++ b/src/main/java/com/amp/domain/congestion/repository/StageCongestionRepository.java @@ -27,4 +27,6 @@ SELECT MAX(sc2.id) "ORDER BY sc.measuredAt DESC") Optional findLatestByStageId(@Param("stageId") Long stageId); + void deleteByStageIdIn(List stageIds); + } diff --git a/src/main/java/com/amp/domain/congestion/service/StageService.java b/src/main/java/com/amp/domain/congestion/service/StageService.java index 3f3015aa..bbb78d9f 100644 --- a/src/main/java/com/amp/domain/congestion/service/StageService.java +++ b/src/main/java/com/amp/domain/congestion/service/StageService.java @@ -3,6 +3,8 @@ import com.amp.domain.festival.entity.Festival; import com.amp.domain.congestion.dto.request.StageRequest; import com.amp.domain.congestion.entity.Stage; +import com.amp.domain.congestion.repository.StageCongestionRepository; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,8 +17,11 @@ @Service @Transactional +@RequiredArgsConstructor public class StageService { + private final StageCongestionRepository stageCongestionRepository; + public void syncStages(Festival festival, List requests) { if (requests == null || requests.isEmpty()) { return; @@ -29,6 +34,15 @@ public void syncStages(Festival festival, List requests) { Set requestIds = requests.stream() .map(StageRequest::getId).filter(Objects::nonNull).collect(Collectors.toSet()); + List removedStageIds = existStages.stream() + .map(Stage::getId) + .filter(id -> !requestIds.contains(id)) + .collect(Collectors.toList()); + + if (!removedStageIds.isEmpty()) { + stageCongestionRepository.deleteByStageIdIn(removedStageIds); + } + existStages.removeIf(stage -> !requestIds.contains(stage.getId())); for (StageRequest request : requests) { diff --git a/src/main/java/com/amp/domain/festival/controller/organizer/FestivalController.java b/src/main/java/com/amp/domain/festival/controller/organizer/FestivalController.java index 27d62fa1..5daeabc1 100644 --- a/src/main/java/com/amp/domain/festival/controller/organizer/FestivalController.java +++ b/src/main/java/com/amp/domain/festival/controller/organizer/FestivalController.java @@ -52,10 +52,10 @@ public ResponseEntity> getFestivalDetail( @Operation(summary = "공연 수정") @ApiErrorCodes(SwaggerResponseDescription.FAIL_TO_UPDATE_FESTIVAL) - @PatchMapping("/{festivalId}") + @PutMapping(value = "/{festivalId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> updateFestival( @PathVariable Long festivalId, - @RequestBody @Valid FestivalUpdateRequest request) { + @ModelAttribute @Valid FestivalUpdateRequest request) { FestivalUpdateResponse response = festivalService.updateFestival(festivalId, request); return ResponseEntity .status(SuccessStatus.FESTIVAL_UPDATE_SUCCESS.getHttpStatus()) diff --git a/src/main/java/com/amp/domain/festival/dto/request/FestivalUpdateRequest.java b/src/main/java/com/amp/domain/festival/dto/request/FestivalUpdateRequest.java index 63048669..510de6af 100644 --- a/src/main/java/com/amp/domain/festival/dto/request/FestivalUpdateRequest.java +++ b/src/main/java/com/amp/domain/festival/dto/request/FestivalUpdateRequest.java @@ -1,17 +1,14 @@ package com.amp.domain.festival.dto.request; -import com.amp.domain.congestion.dto.request.StageRequest; -import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; - -import java.util.List; +import org.springframework.web.multipart.MultipartFile; public record FestivalUpdateRequest( @NotBlank(message = "공연명은 필수입니다.") String title, @NotBlank(message = "공연 장소는 필수입니다.") String location, - @Valid @NotEmpty(message = "공연 일시는 필수입니다.") List schedules, - @Valid @NotEmpty(message = "1개 이상의 무대/부스 정보는 필수입니다.") List stages, - @NotEmpty(message = "최소 1개의 카테고리를 선택해야 합니다.") List activeCategoryIds + MultipartFile mainImage, + @NotBlank(message = "공연 일시는 필수입니다.") String schedules, + @NotBlank(message = "1개 이상의 무대/부스 정보는 필수입니다.") String stages, + @NotBlank(message = "1개 이상의 카테고리 선택은 필수입니다.") String activeCategoryIds ) { } diff --git a/src/main/java/com/amp/domain/festival/dto/response/FestivalInfoResponse.java b/src/main/java/com/amp/domain/festival/dto/response/FestivalInfoResponse.java index e7a9b6df..9ff316d0 100644 --- a/src/main/java/com/amp/domain/festival/dto/response/FestivalInfoResponse.java +++ b/src/main/java/com/amp/domain/festival/dto/response/FestivalInfoResponse.java @@ -13,7 +13,7 @@ public record FestivalInfoResponse( String location, String period, Boolean isWishlist, - Long dday, + Long dDay, List activeCategories) { public static FestivalInfoResponse from(Festival festival, Boolean isWishlist) { return new FestivalInfoResponse( diff --git a/src/main/java/com/amp/domain/festival/entity/Festival.java b/src/main/java/com/amp/domain/festival/entity/Festival.java index 891cd6e9..b2b55624 100644 --- a/src/main/java/com/amp/domain/festival/entity/Festival.java +++ b/src/main/java/com/amp/domain/festival/entity/Festival.java @@ -60,6 +60,7 @@ public class Festival extends BaseTimeEntity { private List stages = new ArrayList<>(); @OneToMany(mappedBy = "festival", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("category.id ASC") private List festivalCategories = new ArrayList<>(); @Column(name = "deleted_at") @@ -108,4 +109,8 @@ public void updateDates(LocalDate startDate, LocalDate endDate) { public void updateStartTime(LocalTime startTime) { this.startTime = startTime; } + + public void updateMainImage(String mainImageUrl) { + this.mainImageUrl = mainImageUrl; + } } diff --git a/src/main/java/com/amp/domain/festival/service/organizer/FestivalService.java b/src/main/java/com/amp/domain/festival/service/organizer/FestivalService.java index 2f52e146..4fc101b3 100644 --- a/src/main/java/com/amp/domain/festival/service/organizer/FestivalService.java +++ b/src/main/java/com/amp/domain/festival/service/organizer/FestivalService.java @@ -20,7 +20,6 @@ import com.amp.domain.congestion.service.StageService; import com.amp.domain.user.entity.Organizer; import com.amp.domain.user.entity.User; -import com.amp.global.annotation.LogExecutionTime; import com.amp.global.common.CommonErrorCode; import com.amp.global.exception.CustomException; import com.amp.global.s3.S3ErrorCode; @@ -32,6 +31,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; import java.time.LocalDate; @@ -63,10 +64,9 @@ public class FestivalService { public FestivalCreateResponse createFestival(FestivalCreateRequest request) { User user = authService.getCurrentUser(); - if (!(user instanceof Organizer)) { + if (!(user instanceof Organizer organizer)) { throw new CustomException(CommonErrorCode.FORBIDDEN); } - Organizer organizer = (Organizer) user; List schedules = parseJson( request.schedules(), @@ -74,30 +74,20 @@ public FestivalCreateResponse createFestival(FestivalCreateRequest request) { }, FestivalErrorCode.INVALID_SCHEDULE_FORMAT ); - List stages = parseJson( request.stages(), new TypeReference>() { }, FestivalErrorCode.INVALID_STAGE_FORMAT ); - List activeCategoryIds = parseJson( - request.activeCategoryIds(), + normalizeJsonArray(request.activeCategoryIds()), new TypeReference>() { }, FestivalErrorCode.INVALID_CATEGORY_FORMAT ); - if (schedules == null || schedules.isEmpty()) { - throw new CustomException(FestivalErrorCode.SCHEDULES_REQUIRED); - } - if (stages == null || stages.isEmpty()) { - throw new CustomException(FestivalErrorCode.STAGES_REQUIRED); - } - if (activeCategoryIds == null || activeCategoryIds.isEmpty()) { - throw new CustomException(CategoryErrorCode.CATEGORY_REQUIRED); - } + checkNullField(schedules, stages, activeCategoryIds); ScheduleRequest earliestSchedule = schedules.stream() .min(Comparator.comparing(ScheduleRequest::getFestivalDate) @@ -170,11 +160,61 @@ public FestivalUpdateResponse updateFestival(Long festivalId, FestivalUpdateRequ validateOrganizer(festival, user); + List schedules = parseJson( + request.schedules(), + new TypeReference>() { + }, + FestivalErrorCode.INVALID_SCHEDULE_FORMAT + ); + List stages = parseJson( + request.stages(), + new TypeReference>() { + }, + FestivalErrorCode.INVALID_STAGE_FORMAT + ); + List activeCategoryIds = parseJson( + normalizeJsonArray(request.activeCategoryIds()), + new TypeReference>() { + }, + FestivalErrorCode.INVALID_CATEGORY_FORMAT + ); + + checkNullField(schedules, stages, activeCategoryIds); + festival.updateInfo(request.title(), request.location()); - scheduleService.syncSchedules(festival, request.schedules()); - stageService.syncStages(festival, request.stages()); - categoryService.syncCategories(festival, request.activeCategoryIds()); + scheduleService.syncSchedules(festival, schedules); + stageService.syncStages(festival, stages); + categoryService.syncCategories(festival, activeCategoryIds); + + if (request.mainImage() != null && !request.mainImage().isEmpty()) { + String oldKey = s3Service.extractKey(festival.getMainImageUrl()); + String newKey = uploadImage(request.mainImage()); + + festival.updateMainImage(s3Service.getPublicUrl(newKey)); + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + s3Service.delete(oldKey); + } catch (Exception e) { + log.error("[S3] 커밋 후 구 이미지 삭제 실패 — key: {}, 원인: {}", oldKey, e.getMessage()); + } + } + + @Override + public void afterCompletion(int status) { + if (status == STATUS_ROLLED_BACK) { + try { + s3Service.delete(newKey); + } catch (Exception e) { + log.error("[S3] 롤백 후 새 이미지 삭제 실패 — key: {}, 원인: {}", newKey, e.getMessage()); + } + } + } + }); + } LocalDate startDate = calculateDate(festival.getSchedules(), FestivalSchedule::getFestivalDate, true); LocalDate endDate = calculateDate(festival.getSchedules(), FestivalSchedule::getFestivalDate, false); @@ -197,8 +237,6 @@ public void deleteFestival(Long festivalId) { festivalScheduleRepository.softDeleteByFestivalId(festivalId); stageRepository.softDeleteByFestivalId(festivalId); festivalCategoryRepository.softDeleteByFestivalId(festivalId); - - festivalRepository.softDeleteById(festivalId); } @@ -218,7 +256,6 @@ private LocalTime calculateTime(List schedules, Function ti .orElseThrow(() -> new CustomException(FestivalErrorCode.INVALID_SCHEDULE_FORMAT)); } - @LogExecutionTime("이미지 업로드") private String uploadImage(MultipartFile image) { try { return s3Service.upload(image, "festivals"); @@ -227,10 +264,17 @@ private String uploadImage(MultipartFile image) { } } + private String normalizeJsonArray(String value) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.startsWith("[") ? trimmed : "[" + trimmed + "]"; + } + private T parseJson(String json, TypeReference typeReference, FestivalErrorCode errorCode) { if (json == null || json.trim().isEmpty()) { return null; } + try { return objectMapper.readValue(json, typeReference); } catch (Exception e) { @@ -243,10 +287,21 @@ private Festival findFestival(Long id) { .orElseThrow(() -> new CustomException(FestivalErrorCode.FESTIVAL_NOT_FOUND)); } - private void validateOrganizer(Festival festival, User user) { if (!festival.getOrganizer().getId().equals(user.getId())) { throw new CustomException(CommonErrorCode.FORBIDDEN); } } + + private static void checkNullField(List schedules, List stages, List activeCategoryIds) { + if (schedules == null || schedules.isEmpty()) { + throw new CustomException(FestivalErrorCode.SCHEDULES_REQUIRED); + } + if (stages == null || stages.isEmpty()) { + throw new CustomException(FestivalErrorCode.STAGES_REQUIRED); + } + if (activeCategoryIds == null || activeCategoryIds.isEmpty()) { + throw new CustomException(CategoryErrorCode.CATEGORY_REQUIRED); + } + } } diff --git a/src/main/java/com/amp/global/s3/S3ErrorCode.java b/src/main/java/com/amp/global/s3/S3ErrorCode.java index 366f85f4..42521034 100644 --- a/src/main/java/com/amp/global/s3/S3ErrorCode.java +++ b/src/main/java/com/amp/global/s3/S3ErrorCode.java @@ -13,6 +13,7 @@ public enum S3ErrorCode implements ErrorCode { INVALID_IMAGE_IMAGE(HttpStatus.BAD_REQUEST, "S3", "001", "이미지 파일만 업로드 가능합니다."), FILE_NAME_NOT_FOUND(HttpStatus.BAD_REQUEST, "S3", "002", "파일명이 없습니다."), INVALID_DIRECTORY_ROUTE(HttpStatus.BAD_REQUEST, "S3", "003", "잘못된 디렉토리 경로입니다."), + INVALID_IMAGE_URL(HttpStatus.BAD_REQUEST, "S3", "004", "유효하지 않은 이미지 URL입니다."), // 500 Internal Server Error S3_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "S3", "001", "S3 업로드를 실패하였습니다."), diff --git a/src/main/java/com/amp/global/s3/S3Service.java b/src/main/java/com/amp/global/s3/S3Service.java index 81bcd473..8cbae736 100644 --- a/src/main/java/com/amp/global/s3/S3Service.java +++ b/src/main/java/com/amp/global/s3/S3Service.java @@ -2,7 +2,6 @@ import com.amp.global.exception.CustomException; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; @@ -82,5 +81,16 @@ public String getPublicUrl(String key) { s3Properties.getRegion() ) + key; } -} + public String extractKey(String publicUrl) { + String baseUrl = String.format( + s3Properties.getBaseUrl(), + s3Properties.getBucket(), + s3Properties.getRegion() + ); + if (publicUrl == null || !publicUrl.startsWith(baseUrl)) { + throw new CustomException(S3ErrorCode.INVALID_IMAGE_URL); + } + return publicUrl.substring(baseUrl.length()); + } +} diff --git a/src/test/java/com/amp/domain/congestion/service/CongestionCalculateServiceTest.java b/src/test/java/com/amp/domain/congestion/service/CongestionCalculateServiceTest.java index 667e3bad..4e060992 100644 --- a/src/test/java/com/amp/domain/congestion/service/CongestionCalculateServiceTest.java +++ b/src/test/java/com/amp/domain/congestion/service/CongestionCalculateServiceTest.java @@ -69,7 +69,7 @@ void calculateWeightedAverageTest() { .willReturn(Optional.of(FestivalSchedule.builder() .festival(festival) .festivalDate(LocalDate.now()) - .festivalTime(LocalTime.of(20, 0)) + .festivalTime(LocalTime.of(0, 0)) .build())); given(stageRepository.findById(stageId)).willReturn(Optional.of(stage)); given(reportRepository.findRecentReports(eq(stageId), any())).willReturn(List.of(report1, report2)); diff --git a/src/test/java/com/amp/domain/festival/dto/request/FestivalUpdateRequestValidationTest.java b/src/test/java/com/amp/domain/festival/dto/request/FestivalUpdateRequestValidationTest.java index e6bd687d..e54a02b6 100644 --- a/src/test/java/com/amp/domain/festival/dto/request/FestivalUpdateRequestValidationTest.java +++ b/src/test/java/com/amp/domain/festival/dto/request/FestivalUpdateRequestValidationTest.java @@ -1,9 +1,5 @@ package com.amp.domain.festival.dto.request; -import com.amp.domain.congestion.dto.request.StageRequest; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validation; import jakarta.validation.Validator; @@ -12,7 +8,6 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import java.util.List; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -21,52 +16,24 @@ class FestivalUpdateRequestValidationTest { private static Validator validator; - private static ObjectMapper objectMapper; + + private static final String VALID_SCHEDULES = "[{\"festivalDate\":\"2026-08-01\",\"festivalTime\":\"18:00\"}]"; + private static final String VALID_STAGES = "[{\"title\":\"메인 무대\"}]"; + private static final String VALID_CATEGORY_IDS = "[1]"; @BeforeAll - static void setUp() throws Exception { + static void setUp() { validator = Validation.buildDefaultValidatorFactory().getValidator(); - objectMapper = new ObjectMapper() - .registerModule(new JavaTimeModule()) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - } - - private ScheduleRequest schedule() throws Exception { - return objectMapper.readValue( - "{\"festivalDate\":\"2026-08-01\",\"festivalTime\":\"18:00\"}", - ScheduleRequest.class - ); - } - - private StageRequest validStage() throws Exception { - return objectMapper.readValue("{\"title\":\"메인 무대\"}", StageRequest.class); - } - - private StageRequest stageWith(String title, String location) throws Exception { - String json = location != null - ? String.format("{\"title\":\"%s\",\"location\":\"%s\"}", title, location) - : String.format("{\"title\":\"%s\"}", title); - return objectMapper.readValue(json, StageRequest.class); } private FestivalUpdateRequest requestWith( String title, String location, - List schedules, - List stages, - List activeCategoryIds + String schedules, + String stages, + String activeCategoryIds ) { - return new FestivalUpdateRequest(title, location, schedules, stages, activeCategoryIds); - } - - private FestivalUpdateRequest validRequest() throws Exception { - return requestWith( - "테스트 공연", - "고양시 일산서구", - List.of(schedule()), - List.of(validStage()), - List.of(1L) - ); + return new FestivalUpdateRequest(title, location, null, schedules, stages, activeCategoryIds); } private boolean hasViolationOn(Set> violations, String field) { @@ -80,18 +47,18 @@ class TitleValidation { @Test @DisplayName("title이 null이면 유효성 검증에 실패한다") - void failsWhenTitleIsNull() throws Exception { + void failsWhenTitleIsNull() { Set> violations = validator.validate( - requestWith(null, "고양시 일산서구", List.of(schedule()), List.of(validStage()), List.of(1L)) + requestWith(null, "고양시 일산서구", VALID_SCHEDULES, VALID_STAGES, VALID_CATEGORY_IDS) ); assertThat(hasViolationOn(violations, "title")).isTrue(); } @Test @DisplayName("title이 빈 문자열이면 유효성 검증에 실패한다") - void failsWhenTitleIsBlank() throws Exception { + void failsWhenTitleIsBlank() { Set> violations = validator.validate( - requestWith("", "고양시 일산서구", List.of(schedule()), List.of(validStage()), List.of(1L)) + requestWith("", "고양시 일산서구", VALID_SCHEDULES, VALID_STAGES, VALID_CATEGORY_IDS) ); assertThat(hasViolationOn(violations, "title")).isTrue(); } @@ -103,102 +70,73 @@ class LocationValidation { @Test @DisplayName("location이 null이면 유효성 검증에 실패한다") - void failsWhenLocationIsNull() throws Exception { + void failsWhenLocationIsNull() { Set> violations = validator.validate( - requestWith("테스트 공연", null, List.of(schedule()), List.of(validStage()), List.of(1L)) + requestWith("테스트 공연", null, VALID_SCHEDULES, VALID_STAGES, VALID_CATEGORY_IDS) ); assertThat(hasViolationOn(violations, "location")).isTrue(); } @Test @DisplayName("location이 빈 문자열이면 유효성 검증에 실패한다") - void failsWhenLocationIsBlank() throws Exception { + void failsWhenLocationIsBlank() { Set> violations = validator.validate( - requestWith("테스트 공연", "", List.of(schedule()), List.of(validStage()), List.of(1L)) + requestWith("테스트 공연", "", VALID_SCHEDULES, VALID_STAGES, VALID_CATEGORY_IDS) ); assertThat(hasViolationOn(violations, "location")).isTrue(); } } @Nested - @DisplayName("schedules 리스트 검증") + @DisplayName("schedules 검증") class SchedulesValidation { @Test @DisplayName("schedules가 null이면 유효성 검증에 실패한다") - void failsWhenSchedulesIsNull() throws Exception { - Set> violations = validator.validate( - requestWith("테스트 공연", "고양시 일산서구", null, List.of(validStage()), List.of(1L)) - ); - assertThat(hasViolationOn(violations, "schedules")).isTrue(); - } - - @Test - @DisplayName("schedules가 빈 리스트이면 유효성 검증에 실패한다") - void failsWhenSchedulesIsEmpty() throws Exception { - Set> violations = validator.validate( - requestWith("테스트 공연", "고양시 일산서구", List.of(), List.of(validStage()), List.of(1L)) - ); - assertThat(hasViolationOn(violations, "schedules")).isTrue(); - } - } - - @Nested - @DisplayName("schedule 내부 필드 검증 - @Valid 캐스케이드") - class ScheduleFieldValidation { - - @Test - @DisplayName("festivalDate가 null이면 유효성 검증에 실패한다") - void failsWhenFestivalDateIsNull() throws Exception { - ScheduleRequest noDate = objectMapper.readValue( - "{\"festivalTime\":\"18:00\"}", ScheduleRequest.class - ); + void failsWhenSchedulesIsNull() { Set> violations = validator.validate( - requestWith("테스트 공연", "고양시 일산서구", List.of(noDate), List.of(validStage()), List.of(1L)) + requestWith("테스트 공연", "고양시 일산서구", null, VALID_STAGES, VALID_CATEGORY_IDS) ); assertThat(hasViolationOn(violations, "schedules")).isTrue(); } @Test - @DisplayName("festivalTime이 null이면 유효성 검증에 실패한다") - void failsWhenFestivalTimeIsNull() throws Exception { - ScheduleRequest noTime = objectMapper.readValue( - "{\"festivalDate\":\"2026-08-01\"}", ScheduleRequest.class - ); + @DisplayName("schedules가 빈 문자열이면 유효성 검증에 실패한다") + void failsWhenSchedulesIsBlank() { Set> violations = validator.validate( - requestWith("테스트 공연", "고양시 일산서구", List.of(noTime), List.of(validStage()), List.of(1L)) + requestWith("테스트 공연", "고양시 일산서구", "", VALID_STAGES, VALID_CATEGORY_IDS) ); assertThat(hasViolationOn(violations, "schedules")).isTrue(); } } @Nested - @DisplayName("stages 리스트 검증") - class InvalidStagesList { + @DisplayName("stages 검증") + class StagesValidation { @Test @DisplayName("stages가 null이면 유효성 검증에 실패한다") - void failsWhenStagesIsNull() throws Exception { + void failsWhenStagesIsNull() { Set> violations = validator.validate( - requestWith("테스트 공연", "고양시 일산서구", List.of(schedule()), null, List.of(1L)) + requestWith("테스트 공연", "고양시 일산서구", VALID_SCHEDULES, null, VALID_CATEGORY_IDS) ); assertThat(hasViolationOn(violations, "stages")).isTrue(); } @Test - @DisplayName("stages가 빈 리스트이면 유효성 검증에 실패한다") - void failsWhenStagesIsEmpty() throws Exception { + @DisplayName("stages가 빈 문자열이면 유효성 검증에 실패한다") + void failsWhenStagesIsBlank() { Set> violations = validator.validate( - requestWith("테스트 공연", "고양시 일산서구", List.of(schedule()), List.of(), List.of(1L)) + requestWith("테스트 공연", "고양시 일산서구", VALID_SCHEDULES, "", VALID_CATEGORY_IDS) ); assertThat(hasViolationOn(violations, "stages")).isTrue(); } @Test @DisplayName("stages 위반 시 에러 메시지가 올바르다") - void violationMessageIsCorrect() throws Exception { + void violationMessageIsCorrect() { Set> violations = validator.validate( - requestWith("테스트 공연", "고양시 일산서구", List.of(schedule()), null, List.of(1L)) + requestWith("테스트 공연", "고양시 일산서구", VALID_SCHEDULES, null, VALID_CATEGORY_IDS) ); String message = violations.stream() .filter(v -> v.getPropertyPath().toString().equals("stages")) @@ -209,67 +147,24 @@ void violationMessageIsCorrect() throws Exception { } } - @Nested - @DisplayName("stage 내부 필드 검증 - @Valid 캐스케이드") - class StageFieldValidation { - - @Test - @DisplayName("stage의 title이 null이면 유효성 검증에 실패한다") - void failsWhenStageTitleIsNull() throws Exception { - StageRequest nullTitle = objectMapper.readValue("{}", StageRequest.class); - Set> violations = validator.validate( - requestWith("테스트 공연", "고양시 일산서구", List.of(schedule()), List.of(nullTitle), List.of(1L)) - ); - assertThat(hasViolationOn(violations, "stages")).isTrue(); - } - - @Test - @DisplayName("stage의 title이 빈 문자열이면 유효성 검증에 실패한다") - void failsWhenStageTitleIsBlank() throws Exception { - StageRequest blankTitle = objectMapper.readValue("{\"title\":\"\"}", StageRequest.class); - Set> violations = validator.validate( - requestWith("테스트 공연", "고양시 일산서구", List.of(schedule()), List.of(blankTitle), List.of(1L)) - ); - assertThat(hasViolationOn(violations, "stages")).isTrue(); - } - - @Test - @DisplayName("title만 있고 location이 없어도 유효성 검증을 통과한다 (location은 선택)") - void passesWhenStageHasTitleOnly() throws Exception { - Set> violations = validator.validate( - requestWith("테스트 공연", "고양시 일산서구", List.of(schedule()), List.of(stageWith("메인 무대", null)), List.of(1L)) - ); - assertThat(hasViolationOn(violations, "stages")).isFalse(); - } - - @Test - @DisplayName("title과 location이 모두 있으면 유효성 검증을 통과한다") - void passesWhenStageHasTitleAndLocation() throws Exception { - Set> violations = validator.validate( - requestWith("테스트 공연", "고양시 일산서구", List.of(schedule()), List.of(stageWith("메인 무대", "A구역")), List.of(1L)) - ); - assertThat(hasViolationOn(violations, "stages")).isFalse(); - } - } - @Nested @DisplayName("activeCategoryIds 검증") class ActiveCategoryIdsValidation { @Test @DisplayName("activeCategoryIds가 null이면 유효성 검증에 실패한다") - void failsWhenCategoryIdsIsNull() throws Exception { + void failsWhenCategoryIdsIsNull() { Set> violations = validator.validate( - requestWith("테스트 공연", "고양시 일산서구", List.of(schedule()), List.of(validStage()), null) + requestWith("테스트 공연", "고양시 일산서구", VALID_SCHEDULES, VALID_STAGES, null) ); assertThat(hasViolationOn(violations, "activeCategoryIds")).isTrue(); } @Test - @DisplayName("activeCategoryIds가 빈 리스트이면 유효성 검증에 실패한다") - void failsWhenCategoryIdsIsEmpty() throws Exception { + @DisplayName("activeCategoryIds가 빈 문자열이면 유효성 검증에 실패한다") + void failsWhenCategoryIdsIsBlank() { Set> violations = validator.validate( - requestWith("테스트 공연", "고양시 일산서구", List.of(schedule()), List.of(validStage()), List.of()) + requestWith("테스트 공연", "고양시 일산서구", VALID_SCHEDULES, VALID_STAGES, "") ); assertThat(hasViolationOn(violations, "activeCategoryIds")).isTrue(); }