From 95dcfc2245221da20f885a20f6c243846112c6dc Mon Sep 17 00:00:00 2001 From: Chae-Yu Date: Mon, 9 Mar 2026 19:18:25 +0900 Subject: [PATCH 01/14] =?UTF-8?q?[refactor]=20=ED=86=B5=EC=9D=BC=EC=84=B1?= =?UTF-8?q?=20=EC=9C=84=ED=95=B4=20@Repository=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/amp/domain/notice/repository/NoticeRepository.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/amp/domain/notice/repository/NoticeRepository.java b/src/main/java/com/amp/domain/notice/repository/NoticeRepository.java index 90e0e23e..7540a8b8 100644 --- a/src/main/java/com/amp/domain/notice/repository/NoticeRepository.java +++ b/src/main/java/com/amp/domain/notice/repository/NoticeRepository.java @@ -8,11 +8,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; import java.util.Optional; -@Repository public interface NoticeRepository extends JpaRepository { Optional findById(Long id); From f9a7273448ca700e97b8f5fdc170afe3d19c8a97 Mon Sep 17 00:00:00 2001 From: Chae-Yu Date: Mon, 9 Mar 2026 19:34:41 +0900 Subject: [PATCH 02/14] =?UTF-8?q?[feat]=20=EA=B3=B5=EC=A7=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B5=9C?= =?UTF-8?q?=EB=8C=80=2020=EC=9E=A5=20=EC=B2=A8=EB=B6=80=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/SavedNoticesResponse.java | 9 +- .../organizer/NoticeCreateController.java | 10 +- .../organizer/NoticeUpdateController.java | 13 +- .../dto/request/NoticeCreateRequest.java | 3 - .../dto/request/NoticeUpdateRequest.java | 8 +- .../response/FestivalNoticeListResponse.java | 5 +- .../dto/response/NoticeDetailResponse.java | 5 +- .../com/amp/domain/notice/entity/Notice.java | 10 +- .../amp/domain/notice/entity/NoticeImage.java | 42 ++++++ .../notice/exception/NoticeErrorCode.java | 1 + .../repository/NoticeImageRepository.java | 7 + .../service/common/FestivalNoticeService.java | 13 +- .../service/organizer/NoticeService.java | 120 +++++++++-------- .../organizer/NoticeUpdateService.java | 126 +++++++++++++----- 14 files changed, 252 insertions(+), 120 deletions(-) create mode 100644 src/main/java/com/amp/domain/notice/entity/NoticeImage.java create mode 100644 src/main/java/com/amp/domain/notice/repository/NoticeImageRepository.java diff --git a/src/main/java/com/amp/domain/audience/dto/response/SavedNoticesResponse.java b/src/main/java/com/amp/domain/audience/dto/response/SavedNoticesResponse.java index f3e03529..2dee1e7c 100644 --- a/src/main/java/com/amp/domain/audience/dto/response/SavedNoticesResponse.java +++ b/src/main/java/com/amp/domain/audience/dto/response/SavedNoticesResponse.java @@ -1,10 +1,12 @@ package com.amp.domain.audience.dto.response; import com.amp.domain.notice.entity.Bookmark; +import com.amp.domain.notice.entity.NoticeImage; import com.amp.global.common.dto.response.PaginationResponse; import lombok.Builder; import lombok.Getter; +import java.util.Comparator; import java.util.List; @Getter @@ -22,7 +24,7 @@ public static class SavedAnnouncementDto { private String categoryName; private String content; private String title; - private String imageUrl; + private List imageUrls; public static SavedAnnouncementDto from(Bookmark bookmark) { return SavedAnnouncementDto.builder() @@ -32,7 +34,10 @@ public static SavedAnnouncementDto from(Bookmark bookmark) { .festivalTitle(bookmark.getNotice().getFestival().getTitle()) .categoryName(bookmark.getNotice().getFestivalCategory().getCategory().getCategoryName()) .title(bookmark.getNotice().getTitle()) - .imageUrl(bookmark.getNotice().getImageUrl()) + .imageUrls(bookmark.getNotice().getImages().stream() + .sorted(Comparator.comparingInt(NoticeImage::getImageOrder)) + .map(NoticeImage::getImageUrl) + .toList()) .build(); } } diff --git a/src/main/java/com/amp/domain/notice/controller/organizer/NoticeCreateController.java b/src/main/java/com/amp/domain/notice/controller/organizer/NoticeCreateController.java index 3e2a9944..16c3e81d 100644 --- a/src/main/java/com/amp/domain/notice/controller/organizer/NoticeCreateController.java +++ b/src/main/java/com/amp/domain/notice/controller/organizer/NoticeCreateController.java @@ -16,8 +16,11 @@ import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import org.springframework.security.access.prepost.PreAuthorize; +import java.util.List; + @RestController @RequestMapping("/api/v1/festivals") @Tag(name = "Notice") @@ -33,9 +36,10 @@ public class NoticeCreateController { @PostMapping(path = "/{festivalId}/notices", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> createNotice( - @PathVariable("festivalId") @Positive Long festivalId, - @ModelAttribute @Valid NoticeCreateRequest request) { - NoticeCreateResponse response = noticeService.createNotice(festivalId, request); + @PathVariable @Positive Long festivalId, + @RequestPart("noticeCreateRequest") @Valid NoticeCreateRequest noticeCreateRequest, + @RequestPart(value = "images", required = false) List images) { + NoticeCreateResponse response = noticeService.createNotice(festivalId, noticeCreateRequest, images); return ResponseEntity .status(SuccessStatus.NOTICE_CREATE_SUCCESS.getHttpStatus()) .body(BaseResponse.create(SuccessStatus.NOTICE_CREATE_SUCCESS.getMsg(), response)); diff --git a/src/main/java/com/amp/domain/notice/controller/organizer/NoticeUpdateController.java b/src/main/java/com/amp/domain/notice/controller/organizer/NoticeUpdateController.java index 2138d441..fcbcb2c0 100644 --- a/src/main/java/com/amp/domain/notice/controller/organizer/NoticeUpdateController.java +++ b/src/main/java/com/amp/domain/notice/controller/organizer/NoticeUpdateController.java @@ -15,8 +15,11 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import org.springframework.security.access.prepost.PreAuthorize; +import java.util.List; + @RestController @RequestMapping("/api/v1/notices") @Tag(name = "Notice") @@ -32,10 +35,11 @@ public class NoticeUpdateController { @PutMapping(path = "/{noticeId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> updateNotice( - @PathVariable("noticeId") @Positive Long noticeId, - @ModelAttribute @Valid NoticeUpdateRequest noticeUpdateRequest + @PathVariable @Positive Long noticeId, + @RequestPart("noticeUpdateRequest") @Valid NoticeUpdateRequest noticeUpdateRequest, + @RequestPart(value = "newImages", required = false) List newImages ) { - noticeUpdateService.updateNotice(noticeId, noticeUpdateRequest); + noticeUpdateService.updateNotice(noticeId, noticeUpdateRequest, newImages); return ResponseEntity .status(SuccessStatus.UPDATE_NOTICE_SUCCESS.getHttpStatus()) @@ -46,7 +50,7 @@ public ResponseEntity> updateNotice( @ApiErrorCodes(SwaggerResponseDescription.FAIL_TO_DELETE_NOTICE) @DeleteMapping("/{noticeId}") public ResponseEntity> deleteNotice( - @PathVariable("noticeId") @Positive Long noticeId + @PathVariable @Positive Long noticeId ) { noticeService.deleteNotice(noticeId); @@ -57,4 +61,3 @@ public ResponseEntity> deleteNotice( } } - diff --git a/src/main/java/com/amp/domain/notice/dto/request/NoticeCreateRequest.java b/src/main/java/com/amp/domain/notice/dto/request/NoticeCreateRequest.java index 0728e8c8..3a35d462 100644 --- a/src/main/java/com/amp/domain/notice/dto/request/NoticeCreateRequest.java +++ b/src/main/java/com/amp/domain/notice/dto/request/NoticeCreateRequest.java @@ -3,15 +3,12 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -import org.springframework.web.multipart.MultipartFile; public record NoticeCreateRequest( @NotBlank(message = "공지 제목은 필수값입니다.") @Size(max = 50, message = "공지 제목은 최대 50자까지 입력할 수 있습니다.") String title, @NotNull(message = "공지 카테고리 값은 필수값입니다.") Long categoryId, - MultipartFile image, @NotBlank(message = "공지 내용은 필수값입니다.") String content, @NotNull(message = "고정 여부 값은 필수 값입니다.") Boolean isPinned ) { } - diff --git a/src/main/java/com/amp/domain/notice/dto/request/NoticeUpdateRequest.java b/src/main/java/com/amp/domain/notice/dto/request/NoticeUpdateRequest.java index 442d9e21..d8612dd8 100644 --- a/src/main/java/com/amp/domain/notice/dto/request/NoticeUpdateRequest.java +++ b/src/main/java/com/amp/domain/notice/dto/request/NoticeUpdateRequest.java @@ -3,16 +3,16 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -import org.springframework.web.multipart.MultipartFile; + +import java.util.List; public record NoticeUpdateRequest( @NotNull(message = "페스티벌 아이디는 필수값입니다.") Long festivalId, @NotBlank(message = "공지 제목은 필수값입니다.") @Size(max = 50, message = "공지 제목은 최대 50자까지 입력할 수 있습니다.") String title, @NotNull(message = "공지 카테고리 값은 필수값입니다.") Long categoryId, - MultipartFile newImage, + List keepImageUrls, @NotBlank(message = "공지 내용은 필수값입니다.") String content, - boolean isPinned, - String previousImageUrl //기존 첨부이미지에서 이미지 수정 없이 업로드시 기존 이미지url 첨부 + @NotNull(message = "고정 여부 값은 필수 값입니다.") Boolean isPinned ) { } diff --git a/src/main/java/com/amp/domain/notice/dto/response/FestivalNoticeListResponse.java b/src/main/java/com/amp/domain/notice/dto/response/FestivalNoticeListResponse.java index 6cbab00a..e15f151e 100644 --- a/src/main/java/com/amp/domain/notice/dto/response/FestivalNoticeListResponse.java +++ b/src/main/java/com/amp/domain/notice/dto/response/FestivalNoticeListResponse.java @@ -1,14 +1,15 @@ package com.amp.domain.notice.dto.response; +import java.util.List; + public record FestivalNoticeListResponse( Long noticeId, String categoryName, String title, String content, - String imageUrl, + List imageUrls, boolean isPinned, boolean isSaved, String createdAt ) { } - diff --git a/src/main/java/com/amp/domain/notice/dto/response/NoticeDetailResponse.java b/src/main/java/com/amp/domain/notice/dto/response/NoticeDetailResponse.java index 5f206992..3e034ce4 100644 --- a/src/main/java/com/amp/domain/notice/dto/response/NoticeDetailResponse.java +++ b/src/main/java/com/amp/domain/notice/dto/response/NoticeDetailResponse.java @@ -2,6 +2,8 @@ import com.amp.global.common.dto.CategoryData; +import java.util.List; + public record NoticeDetailResponse( Long noticeId, Long festivalId, @@ -9,11 +11,10 @@ public record NoticeDetailResponse( CategoryData category, String title, String content, - String imageUrl, + List imageUrls, boolean isPinned, boolean isSaved, Author author, String createdAt ) { } - diff --git a/src/main/java/com/amp/domain/notice/entity/Notice.java b/src/main/java/com/amp/domain/notice/entity/Notice.java index 5d6f92f2..ea5c5ede 100644 --- a/src/main/java/com/amp/domain/notice/entity/Notice.java +++ b/src/main/java/com/amp/domain/notice/entity/Notice.java @@ -12,6 +12,7 @@ import org.hibernate.annotations.SQLRestriction; import java.time.LocalDateTime; +import java.util.List; @Entity @Table(name = "notice") @@ -42,8 +43,8 @@ public class Notice extends BaseTimeEntity { @Column(nullable = false, columnDefinition = "TEXT") private String content; - @Column(name = "image_url", length = 500) - private String imageUrl; + @OneToMany(mappedBy = "notice", cascade = CascadeType.ALL, orphanRemoval = true) + List images; @Column(name = "is_pinned", nullable = false) private Boolean isPinned = false; @@ -53,13 +54,12 @@ public class Notice extends BaseTimeEntity { @Builder public Notice(Festival festival, FestivalCategory festivalCategory, Organizer organizer, - String title, String content, String imageUrl, Boolean isPinned) { + String title, String content, Boolean isPinned) { this.festival = festival; this.festivalCategory = festivalCategory; this.organizer = organizer; this.title = title; this.content = content; - this.imageUrl = imageUrl; this.isPinned = isPinned != null ? isPinned : false; } @@ -70,13 +70,11 @@ public void delete() { public void update( String title, String content, - String imageUrl, Boolean isPinned, FestivalCategory festivalCategory ) { this.title = title; this.content = content; - this.imageUrl = imageUrl; this.isPinned = isPinned; this.festivalCategory = festivalCategory; } diff --git a/src/main/java/com/amp/domain/notice/entity/NoticeImage.java b/src/main/java/com/amp/domain/notice/entity/NoticeImage.java new file mode 100644 index 00000000..31310352 --- /dev/null +++ b/src/main/java/com/amp/domain/notice/entity/NoticeImage.java @@ -0,0 +1,42 @@ +package com.amp.domain.notice.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static jakarta.persistence.GenerationType.IDENTITY; + +@Entity +@Table(name = "notice_images") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NoticeImage { + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "notice_id") + private Notice notice; + + @Column(nullable = false, length = 500) + private String imageUrl; + + @Column(nullable = false) + private int imageOrder; + + private NoticeImage(Notice notice, String imageUrl, int imageOrder) { + this.notice = notice; + this.imageUrl = imageUrl; + this.imageOrder = imageOrder; + } + + public static NoticeImage of(Notice notice, String imageUrl, int imageOrder) { + return new NoticeImage(notice, imageUrl, imageOrder); + } + + public void updateOrder(int imageOrder) { + this.imageOrder = imageOrder; + } +} diff --git a/src/main/java/com/amp/domain/notice/exception/NoticeErrorCode.java b/src/main/java/com/amp/domain/notice/exception/NoticeErrorCode.java index fcb0666a..eaa465c0 100644 --- a/src/main/java/com/amp/domain/notice/exception/NoticeErrorCode.java +++ b/src/main/java/com/amp/domain/notice/exception/NoticeErrorCode.java @@ -11,6 +11,7 @@ public enum NoticeErrorCode implements ErrorCode { // 400 Bad Request NOTICE_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "NTC", "001", "이미 삭제된 공지입니다."), PINNED_NOTICE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "NTC", "002", "상단 고정 공지는 최대 3개까지 가능합니다."), + NOTICE_IMAGE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "NTC", "003", "이미지는 최대 20장까지 업로드 가능합니다."), // 403 Forbidden NOTICE_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "NTC", "001", "작성자 유저만 공지글을 삭제할 수 있습니다."), diff --git a/src/main/java/com/amp/domain/notice/repository/NoticeImageRepository.java b/src/main/java/com/amp/domain/notice/repository/NoticeImageRepository.java new file mode 100644 index 00000000..7dffdcd7 --- /dev/null +++ b/src/main/java/com/amp/domain/notice/repository/NoticeImageRepository.java @@ -0,0 +1,7 @@ +package com.amp.domain.notice.repository; + +import com.amp.domain.notice.entity.NoticeImage; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NoticeImageRepository extends JpaRepository { +} diff --git a/src/main/java/com/amp/domain/notice/service/common/FestivalNoticeService.java b/src/main/java/com/amp/domain/notice/service/common/FestivalNoticeService.java index 5801dea0..08d9a83f 100644 --- a/src/main/java/com/amp/domain/notice/service/common/FestivalNoticeService.java +++ b/src/main/java/com/amp/domain/notice/service/common/FestivalNoticeService.java @@ -6,6 +6,7 @@ import com.amp.domain.notice.dto.response.FestivalNoticeListResponse; import com.amp.domain.notice.dto.response.NoticeListResponse; import com.amp.domain.notice.entity.Notice; +import com.amp.domain.notice.entity.NoticeImage; import com.amp.domain.notice.exception.NoticeException; import com.amp.domain.notice.repository.BookmarkRepository; import com.amp.domain.notice.repository.NoticeRepository; @@ -22,10 +23,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; import static com.amp.global.common.dto.TimeFormatter.formatTimeAgo; @@ -57,12 +55,17 @@ public NoticeListResponse getFestivalNoticeList(Long festivalId, Long categoryId List announcements = noticePage.getContent().stream().map(notice -> { boolean isSaved = savedNoticeIds.contains(notice.getId()); + List imageUrls = notice.getImages().stream() + .sorted(Comparator.comparingInt(NoticeImage::getImageOrder)) + .map(NoticeImage::getImageUrl) + .toList(); + return new FestivalNoticeListResponse( notice.getId(), notice.getFestivalCategory().getCategory().getCategoryName(), notice.getTitle(), notice.getContent(), - notice.getImageUrl(), + imageUrls, notice.getIsPinned(), isSaved, formatTimeAgo(notice.getCreatedAt()) diff --git a/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java b/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java index a207fd1f..77324251 100644 --- a/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java +++ b/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java @@ -12,10 +12,12 @@ import com.amp.domain.notice.dto.response.NoticeCreateResponse; import com.amp.domain.notice.dto.response.NoticeDetailResponse; import com.amp.domain.notice.entity.Notice; +import com.amp.domain.notice.entity.NoticeImage; import com.amp.domain.notice.event.NoticeCreatedEvent; import com.amp.domain.notice.exception.NoticeErrorCode; import com.amp.domain.notice.exception.NoticeException; import com.amp.domain.notice.repository.BookmarkRepository; +import com.amp.domain.notice.repository.NoticeImageRepository; import com.amp.domain.notice.repository.NoticeRepository; import com.amp.domain.user.entity.Audience; import com.amp.domain.user.entity.Organizer; @@ -23,7 +25,6 @@ import com.amp.domain.user.repository.AudienceRepository; import com.amp.domain.user.repository.OrganizerRepository; import com.amp.global.exception.CustomException; -import com.amp.global.s3.S3ErrorCode; import com.amp.global.s3.S3Service; import com.amp.global.security.service.AuthService; import lombok.AllArgsConstructor; @@ -35,6 +36,9 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import java.util.ArrayList; +import java.util.List; + import static com.amp.global.common.dto.TimeFormatter.formatTimeAgo; @Service @@ -43,6 +47,7 @@ public class NoticeService { private final NoticeRepository noticeRepository; + private final NoticeImageRepository noticeImageRepository; private final BookmarkRepository bookmarkRepository; private final OrganizerRepository organizerRepository; private final AudienceRepository audienceRepository; @@ -54,7 +59,8 @@ public class NoticeService { private final AuthService authService; @Transactional - public NoticeCreateResponse createNotice(Long festivalId, NoticeCreateRequest request) { + public NoticeCreateResponse createNotice(Long festivalId, NoticeCreateRequest request, + List images) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); @@ -84,68 +90,62 @@ public NoticeCreateResponse createNotice(Long festivalId, NoticeCreateRequest re } } - if (!festivalCategory.getFestival().getId().equals(festival.getId())) { throw new NoticeException(FestivalCategoryErrorCode.NOTICE_CATEGORY_NOT_FOUND); } - String imageKey = null; - String imageUrl = null; - Notice notice; + List validImages = (images != null) + ? images.stream().filter(f -> f != null && !f.isEmpty()).toList() + : List.of(); - try { - if (request.image() != null && !request.image().isEmpty()) { - imageKey = uploadImage(request.image()); - imageUrl = s3Service.getPublicUrl(imageKey); - } + if (validImages.size() > 20) { + throw new NoticeException(NoticeErrorCode.NOTICE_IMAGE_LIMIT_EXCEEDED); + } - notice = Notice.builder() - .title(request.title()) - .content(request.content()) - .imageUrl(imageUrl) - .isPinned(request.isPinned()) - .organizer(organizer) - .festival(festival) - .festivalCategory(festivalCategory) - .build(); - - noticeRepository.save(notice); - - eventPublisher.publishEvent( - new NoticeCreatedEvent( - festivalCategory.getId(), - festivalCategory.getCategory().getCategoryName(), - notice.getFestival().getTitle(), - notice, - notice.getTitle(), - notice.getCreatedAt() - ) - ); + Notice notice = Notice.builder() + .title(request.title()) + .content(request.content()) + .isPinned(request.isPinned()) + .organizer(organizer) + .festival(festival) + .festivalCategory(festivalCategory) + .build(); - } catch (CustomException e) { - if (imageKey != null) { - s3Service.delete(imageKey); + noticeRepository.save(notice); + + List uploadedKeys = new ArrayList<>(); + try { + for (int i = 0; i < validImages.size(); i++) { + String key = s3Service.upload(validImages.get(i), "notices"); + uploadedKeys.add(key); + noticeImageRepository.save( + NoticeImage.of(notice, s3Service.getPublicUrl(key), i) + ); } + } catch (CustomException e) { + uploadedKeys.forEach(key -> { + try { s3Service.delete(key); } catch (Exception ignored) {} + }); throw e; - } catch (Exception e) { - if (imageKey != null) { - try { - s3Service.delete(imageKey); - } catch (Exception ignored) { - } - } + uploadedKeys.forEach(key -> { + try { s3Service.delete(key); } catch (Exception ignored) {} + }); throw new NoticeException(NoticeErrorCode.NOTICE_CREATE_FAIL); } - return new NoticeCreateResponse(notice.getId()); - } - private String uploadImage(MultipartFile image) { - try { - return s3Service.upload(image, "notices"); - } catch (Exception e) { - throw new CustomException(S3ErrorCode.S3_UPLOAD_FAILED); - } + eventPublisher.publishEvent( + new NoticeCreatedEvent( + festivalCategory.getId(), + festivalCategory.getCategory().getCategoryName(), + notice.getFestival().getTitle(), + notice, + notice.getTitle(), + notice.getCreatedAt() + ) + ); + + return new NoticeCreateResponse(notice.getId()); } public NoticeDetailResponse getNoticeDetail(Long noticeId) { @@ -160,7 +160,7 @@ public NoticeDetailResponse getNoticeDetail(Long noticeId) { boolean isSaved = getIsSaved(notice); CategoryData category = new CategoryData( - notice.getFestivalCategory().getId(), + notice.getFestivalCategory().getCategory().getId(), notice.getFestivalCategory().getCategory().getCategoryName(), notice.getFestivalCategory().getCategory().getCategoryCode() ); @@ -170,6 +170,11 @@ public NoticeDetailResponse getNoticeDetail(Long noticeId) { notice.getOrganizer().getOrganizerName() ); + List imageUrls = notice.getImages().stream() + .sorted(java.util.Comparator.comparingInt(NoticeImage::getImageOrder)) + .map(NoticeImage::getImageUrl) + .toList(); + return new NoticeDetailResponse( notice.getId(), notice.getFestival().getId(), @@ -177,7 +182,7 @@ public NoticeDetailResponse getNoticeDetail(Long noticeId) { category, notice.getTitle(), notice.getContent(), - notice.getImageUrl(), + imageUrls, notice.getIsPinned(), isSaved, author, @@ -208,6 +213,7 @@ public void deleteNotice(Long noticeId) { Notice notice = noticeRepository.findById(noticeId) .orElseThrow(() -> new NoticeException(NoticeErrorCode.NOTICE_NOT_FOUND)); + if (notice.getDeletedAt() != null) { throw new NoticeException(NoticeErrorCode.NOTICE_ALREADY_DELETED); } @@ -229,6 +235,14 @@ public void deleteNotice(Long noticeId) { throw new NoticeException(NoticeErrorCode.NOTICE_DELETE_FORBIDDEN); } + notice.getImages().forEach(image -> { + try { + s3Service.delete(s3Service.extractKey(image.getImageUrl())); + } catch (Exception e) { + log.warn("S3 이미지 삭제 실패: {}", image.getImageUrl(), e); + } + }); + notice.delete(); } diff --git a/src/main/java/com/amp/domain/notice/service/organizer/NoticeUpdateService.java b/src/main/java/com/amp/domain/notice/service/organizer/NoticeUpdateService.java index 486f8782..ee423e98 100644 --- a/src/main/java/com/amp/domain/notice/service/organizer/NoticeUpdateService.java +++ b/src/main/java/com/amp/domain/notice/service/organizer/NoticeUpdateService.java @@ -8,8 +8,10 @@ import com.amp.domain.festival.repository.FestivalRepository; import com.amp.domain.notice.dto.request.NoticeUpdateRequest; import com.amp.domain.notice.entity.Notice; +import com.amp.domain.notice.entity.NoticeImage; import com.amp.domain.notice.exception.NoticeErrorCode; import com.amp.domain.notice.exception.NoticeException; +import com.amp.domain.notice.repository.NoticeImageRepository; import com.amp.domain.notice.repository.NoticeRepository; import com.amp.domain.user.entity.Organizer; import com.amp.domain.user.exception.UserErrorCode; @@ -23,6 +25,12 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Service @Slf4j @@ -31,13 +39,15 @@ public class NoticeUpdateService { private final NoticeRepository noticeRepository; + private final NoticeImageRepository noticeImageRepository; private final OrganizerRepository organizerRepository; private final FestivalCategoryRepository festivalCategoryRepository; private final FestivalRepository festivalRepository; private final S3Service s3Service; @Transactional - public void updateNotice(Long noticeId, NoticeUpdateRequest request) { + public void updateNotice(Long noticeId, NoticeUpdateRequest request, + List newImages) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); @@ -55,9 +65,15 @@ public void updateNotice(Long noticeId, NoticeUpdateRequest request) { validateOrganizer(festival, organizer); Notice notice = noticeRepository.findById(noticeId) - .orElseThrow(() -> - new NoticeException(NoticeErrorCode.NOTICE_NOT_FOUND) - ); + .orElseThrow(() -> new NoticeException(NoticeErrorCode.NOTICE_NOT_FOUND)); + + if (notice.getDeletedAt() != null) { + throw new NoticeException(NoticeErrorCode.NOTICE_ALREADY_DELETED); + } + + if (!notice.getFestival().getId().equals(festival.getId())) { + throw new NoticeException(NoticeErrorCode.NOTICE_UPDATE_FORBIDDEN); + } boolean wasPinned = notice.getIsPinned(); boolean willBePinned = request.isPinned(); @@ -71,51 +87,91 @@ public void updateNotice(Long noticeId, NoticeUpdateRequest request) { } } - if (notice.getDeletedAt() != null) { - throw new NoticeException(NoticeErrorCode.NOTICE_ALREADY_DELETED); + FestivalCategory festivalCategory = festivalCategoryRepository + .findByMapping(request.festivalId(), request.categoryId()) + .orElseThrow(() -> new NoticeException(FestivalCategoryErrorCode.NOTICE_CATEGORY_NOT_FOUND)); + + if (!festivalCategory.getFestival().getId().equals(festival.getId())) { + throw new NoticeException(FestivalCategoryErrorCode.NOTICE_CATEGORY_NOT_FOUND); } - if (!notice.getFestival().getId().equals(festival.getId())) { - throw new NoticeException(NoticeErrorCode.NOTICE_UPDATE_FORBIDDEN); + notice.update(request.title(), request.content(), request.isPinned(), festivalCategory); + syncImages(notice, request.keepImageUrls(), newImages); + } + + private void syncImages(Notice notice, List keepImageUrls, + List newImages) { + + List keepUrls; + if (keepImageUrls != null) { + keepUrls = keepImageUrls; + } else { + keepUrls = List.of(); } - FestivalCategory festivalCategory = festivalCategoryRepository - .findById(request.categoryId()) - .orElseThrow(() -> - new NoticeException(FestivalCategoryErrorCode.NOTICE_CATEGORY_NOT_FOUND) - ); + List validNewImages; + if (newImages != null) { + validNewImages = newImages.stream().filter(f -> f != null && !f.isEmpty()).toList(); + } else { + validNewImages = List.of(); + } - if (!festivalCategory.getFestival().getId().equals(festival.getId())) { - throw new NoticeException(FestivalCategoryErrorCode.NOTICE_CATEGORY_NOT_FOUND); + if (keepUrls.size() + validNewImages.size() > 20) { + throw new NoticeException(NoticeErrorCode.NOTICE_IMAGE_LIMIT_EXCEEDED); } - String imageUrl = notice.getImageUrl(); - String newImageKey = null; + List currentImages = notice.getImages(); + Map imageMap = currentImages.stream() + .collect(Collectors.toMap(NoticeImage::getImageUrl, img -> img)); - try { - if (request.newImage() != null && !request.newImage().isEmpty()) { - newImageKey = s3Service.upload(request.newImage(), "notices"); - imageUrl = s3Service.getPublicUrl(newImageKey); + List imagesToDelete = currentImages.stream() + .filter(img -> !keepUrls.contains(img.getImageUrl())) + .toList(); + + imagesToDelete.forEach(img -> { + try { + s3Service.delete(s3Service.extractKey(img.getImageUrl())); + } catch (Exception e) { + log.warn("S3 이미지 삭제 실패: {}", img.getImageUrl(), e); } + }); + currentImages.removeAll(imagesToDelete); - notice.update( - request.title(), - request.content(), - imageUrl, - request.isPinned(), - festivalCategory - ); + List keptImages = keepUrls.stream() + .filter(imageMap::containsKey) + .map(imageMap::get) + .toList(); - } catch (CustomException e) { - if (newImageKey != null) { - s3Service.delete(newImageKey); + for (int i = 0; i < keptImages.size(); i++) { + keptImages.get(i).updateOrder(i); + } + + int startOrder = keptImages.size(); + List uploadedKeys = new ArrayList<>(); + + try { + for (int i = 0; i < validNewImages.size(); i++) { + String key = s3Service.upload(validNewImages.get(i), "notices"); + uploadedKeys.add(key); + noticeImageRepository.save( + NoticeImage.of(notice, s3Service.getPublicUrl(key), startOrder + i) + ); } + } catch (CustomException e) { + uploadedKeys.forEach(key -> { + try { + s3Service.delete(key); + } catch (Exception ignored) { + } + }); throw e; - } catch (Exception e) { - if (newImageKey != null) { - s3Service.delete(newImageKey); - } + uploadedKeys.forEach(key -> { + try { + s3Service.delete(key); + } catch (Exception ignored) { + } + }); throw new NoticeException(NoticeErrorCode.UPDATE_NOTICE_FAILED); } } From 7df53ac83e8a4c6dc1b695cc25a1b18d65e488b2 Mon Sep 17 00:00:00 2001 From: Chae-Yu Date: Mon, 9 Mar 2026 19:37:06 +0900 Subject: [PATCH 03/14] =?UTF-8?q?[refactor]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20=EB=B0=B0=EC=B9=98=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/amp/domain/notice/entity/Notice.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/amp/domain/notice/entity/Notice.java b/src/main/java/com/amp/domain/notice/entity/Notice.java index ea5c5ede..956de724 100644 --- a/src/main/java/com/amp/domain/notice/entity/Notice.java +++ b/src/main/java/com/amp/domain/notice/entity/Notice.java @@ -9,9 +9,11 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; import org.hibernate.annotations.SQLRestriction; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; @Entity @@ -44,7 +46,8 @@ public class Notice extends BaseTimeEntity { private String content; @OneToMany(mappedBy = "notice", cascade = CascadeType.ALL, orphanRemoval = true) - List images; + @BatchSize(size = 100) + List images = new ArrayList<>(); @Column(name = "is_pinned", nullable = false) private Boolean isPinned = false; From 846c9243081458a460df4b6479d84b08dc98855d Mon Sep 17 00:00:00 2001 From: Chae-Yu Date: Mon, 9 Mar 2026 19:37:25 +0900 Subject: [PATCH 04/14] =?UTF-8?q?[fix]=20multipart=20JSON=20=EC=97=AD?= =?UTF-8?q?=EC=A7=81=EB=A0=AC=ED=99=94=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=ED=85=80=20HttpMessageConverter=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...MultipartJackson2HttpMessageConverter.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/main/java/com/amp/global/config/MultipartJackson2HttpMessageConverter.java diff --git a/src/main/java/com/amp/global/config/MultipartJackson2HttpMessageConverter.java b/src/main/java/com/amp/global/config/MultipartJackson2HttpMessageConverter.java new file mode 100644 index 00000000..1f0d9de6 --- /dev/null +++ b/src/main/java/com/amp/global/config/MultipartJackson2HttpMessageConverter.java @@ -0,0 +1,31 @@ +package com.amp.global.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Type; + +@Component +public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter { + + public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) { + super(objectMapper, MediaType.APPLICATION_OCTET_STREAM); + } + + @Override + public boolean canWrite(Class clazz, MediaType mediaType) { + return false; + } + + @Override + public boolean canWrite(Type type, Class clazz, MediaType mediaType) { + return false; + } + + @Override + protected boolean canWrite(MediaType mediaType) { + return false; + } +} From 0a516cf6c06569e8f037090b325cfa9d8bc3973a Mon Sep 17 00:00:00 2001 From: Chae-Yu Date: Mon, 9 Mar 2026 20:40:28 +0900 Subject: [PATCH 05/14] =?UTF-8?q?[feat]=20=EC=B5=9C=EB=8C=80=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=ED=81=AC=EA=B8=B0=20=EC=A0=84=EC=86=A1?= =?UTF-8?q?=EB=9F=89=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 한 장당 5MB, 한 번에 요청 가능한 최대 전송량 105MB --- src/main/resources/application-prod.yml | 1 - src/main/resources/application.yml | 7 +++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index a4059d2a..4c759cb4 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -32,7 +32,6 @@ app: failure-redirect-uri: https://ampnotice.kr/login cors: - # ✅ 프로덕션 + 로컬 개발 환경 모두 허용 allowed-origins: https://www.ampnotice.kr,https://ampnotice.kr,https://www.ampnotice-host.kr,https://ampnotice-host.kr,http://localhost:5173,http://localhost:5174 allowed-methods: GET,POST,PUT,DELETE,PATCH,OPTIONS allowed-headers: "*" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 00bcb225..75eefcb2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,14 +2,14 @@ spring: profiles: active: ${SPRING_PROFILES_ACTIVE:local} include: secret - jackson: + Jackson: time-zone: Asia/Seoul deserialization: adjust-dates-to-context-time-zone: false servlet: multipart: - max-file-size: 10MB - max-request-size: 10MB + max-file-size: 5MB + max-request-size: 105MB data: redis: @@ -62,7 +62,6 @@ app: failure-redirect-uri: ${APP_OAUTH2_FAILURE_REDIRECT_URI:http://localhost:5173/login} cors: - # CORS allowed origins - 모든 도메인 포함 allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:8080,https://www.ampnotice.kr,https://ampnotice.kr,https://host.ampnotice.kr} allowed-methods: ${APP_CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,PATCH,OPTIONS} allowed-headers: ${APP_CORS_ALLOWED_HEADERS:*} From dd02a38520d82920e6caa9826fe8a0eec87c47d7 Mon Sep 17 00:00:00 2001 From: Chae-Yu Date: Mon, 9 Mar 2026 20:40:38 +0900 Subject: [PATCH 06/14] =?UTF-8?q?[test]=20=EA=B3=B5=EC=A7=80=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notice/service/NoticeServiceTest.java | 457 +++++++++--------- .../service/NoticeUpdateServiceTest.java | 368 ++++++++------ 2 files changed, 430 insertions(+), 395 deletions(-) diff --git a/src/test/java/com/amp/domain/notice/service/NoticeServiceTest.java b/src/test/java/com/amp/domain/notice/service/NoticeServiceTest.java index c81a416a..77711fca 100644 --- a/src/test/java/com/amp/domain/notice/service/NoticeServiceTest.java +++ b/src/test/java/com/amp/domain/notice/service/NoticeServiceTest.java @@ -6,18 +6,17 @@ import com.amp.domain.festival.entity.Festival; import com.amp.domain.festival.entity.FestivalStatus; import com.amp.domain.festival.repository.FestivalRepository; -import com.amp.domain.notice.service.common.FestivalNoticeService; import com.amp.domain.notice.dto.request.NoticeCreateRequest; import com.amp.domain.notice.dto.response.NoticeCreateResponse; import com.amp.domain.notice.dto.response.NoticeDetailResponse; -import com.amp.domain.notice.dto.response.NoticeListResponse; -import com.amp.domain.notice.entity.Bookmark; import com.amp.domain.notice.entity.Notice; +import com.amp.domain.notice.entity.NoticeImage; +import com.amp.domain.notice.exception.NoticeErrorCode; import com.amp.domain.notice.exception.NoticeException; import com.amp.domain.notice.repository.BookmarkRepository; +import com.amp.domain.notice.repository.NoticeImageRepository; import com.amp.domain.notice.repository.NoticeRepository; import com.amp.domain.notice.service.organizer.NoticeService; -import com.amp.domain.user.entity.Audience; import com.amp.domain.user.entity.Organizer; import com.amp.domain.user.exception.UserErrorCode; import com.amp.domain.user.repository.AudienceRepository; @@ -34,71 +33,47 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; - -import org.springframework.test.util.ReflectionTestUtils; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class NoticeServiceTest { - @Mock - private NoticeRepository noticeRepository; - - @Mock - private OrganizerRepository organizerRepository; - - @Mock - private AudienceRepository audienceRepository; - - @Mock - private BookmarkRepository bookmarkRepository; - - @Mock - private FestivalRepository festivalRepository; - - @Mock - private FestivalCategoryRepository festivalCategoryRepository; - - @Mock - private S3Service s3Service; - - @Mock - private ApplicationEventPublisher eventPublisher; - - @Mock - private AuthService authService; + @Mock private NoticeRepository noticeRepository; + @Mock private NoticeImageRepository noticeImageRepository; + @Mock private BookmarkRepository bookmarkRepository; + @Mock private OrganizerRepository organizerRepository; + @Mock private AudienceRepository audienceRepository; + @Mock private FestivalCategoryRepository festivalCategoryRepository; + @Mock private FestivalRepository festivalRepository; + @Mock private S3Service s3Service; + @Mock private ApplicationEventPublisher eventPublisher; + @Mock private AuthService authService; @InjectMocks private NoticeService noticeService; - @InjectMocks - private FestivalNoticeService festivalNoticeService; - private Festival festival; private Category category; private FestivalCategory festivalCategory; private Notice notice; - private Organizer author; - private Audience loginUser; - private Bookmark bookmark; + private Organizer organizer; @AfterEach void clearSecurityContext() { @@ -107,313 +82,317 @@ void clearSecurityContext() { @BeforeEach void setUp() { - String email = "loginUserMail@mail.com"; + organizer = Organizer.builder() + .id(1L) + .email("organizer@test.com") + .organizerName("주최자") + .build(); festival = Festival.builder() - .title("페스티벌 제목") + .title("페스티벌") .mainImageUrl("image.jpg") .location("서울") .startDate(LocalDate.now().minusDays(1)) .endDate(LocalDate.now().plusDays(1)) .status(FestivalStatus.ONGOING) .build(); - ReflectionTestUtils.setField(festival, "id", 1L); + ReflectionTestUtils.setField(festival, "organizer", organizer); category = Category.builder() .categoryName("공연") + .categoryCode("PERFORMANCE") .build(); + ReflectionTestUtils.setField(category, "id", 10L); festivalCategory = FestivalCategory.builder() .festival(festival) .category(category) .build(); - ReflectionTestUtils.setField(festivalCategory, "id", 1L); - loginUser = Audience.builder() - .id(1L) - .email(email) - .build(); - - author = Organizer.builder() - .id(2L) - .email("author@test.com") - .organizerName("작성자") - .build(); - - ReflectionTestUtils.setField(festival, "organizer", author); - notice = Notice.builder() .title("공지 제목") .content("공지 내용") - .festivalCategory(festivalCategory) + .isPinned(false) .festival(festival) - .organizer(author) + .festivalCategory(festivalCategory) + .organizer(organizer) .build(); - // @CreatedDate는 JPA 영속화 시에만 설정되므로 직접 주입 + ReflectionTestUtils.setField(notice, "id", 1L); ReflectionTestUtils.setField(notice, "createdAt", LocalDateTime.now()); - - bookmark = Bookmark.builder() - .notice(notice) - .audience(loginUser) - .build(); + ReflectionTestUtils.setField(notice, "images", new ArrayList<>()); } - @Test - @DisplayName("공지 작성 - 정상적인 주최자는 공지를 작성할 수 있다") - void createNoticeSuccess() { - // given - String email = "author@test.com"; - - Authentication auth = - new UsernamePasswordAuthenticationToken(email, null, List.of()); + private void setAuth(String email) { + Authentication auth = new UsernamePasswordAuthenticationToken(email, null, List.of()); SecurityContextHolder.getContext().setAuthentication(auth); - - NoticeCreateRequest request = new NoticeCreateRequest( - "공지 제목", - 1L, - null, - "내용", - true - ); - when(authService.isLoggedInUser(any())).thenReturn(true); + } - when(organizerRepository.findByEmail(email)) - .thenReturn(Optional.of(author)); - - when(festivalRepository.findById(1L)) - .thenReturn(Optional.of(festival)); - - when(festivalCategoryRepository.findByMapping(1L, 1L)) - .thenReturn(Optional.of(festivalCategory)); - - when(noticeRepository.save(any(Notice.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); + @Test + @DisplayName("공지 생성 - 이미지 없이 생성") + void createNoticeWithoutImagesSuccess() { + // given + setAuth(organizer.getEmail()); + NoticeCreateRequest request = new NoticeCreateRequest("제목", 10L, "내용", false); + when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); + when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); + when(festivalCategoryRepository.findByMapping(1L, 10L)).thenReturn(Optional.of(festivalCategory)); + when(noticeRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - NoticeCreateResponse response = - noticeService.createNotice(1L, request); + // when + NoticeCreateResponse response = noticeService.createNotice(1L, request, null); + // then assertThat(response).isNotNull(); + verify(noticeImageRepository, never()).save(any()); } @Test - @DisplayName("비로그인 사용자는 isSaved가 false여야 한다") - void notLoggedInUserShouldHaveIsSavedFalse() { + @DisplayName("공지 생성 - 이미지 3장 포함 생성") + void createNoticeWithImagesSuccess() { // given - when(noticeRepository.findById(1L)) - .thenReturn(Optional.of(notice)); + setAuth(organizer.getEmail()); + NoticeCreateRequest request = new NoticeCreateRequest("제목", 10L, "내용", false); + List images = List.of( + new MockMultipartFile("img1", "a.jpg", "image/jpeg", "data".getBytes()), + new MockMultipartFile("img2", "b.jpg", "image/jpeg", "data".getBytes()), + new MockMultipartFile("img3", "c.jpg", "image/jpeg", "data".getBytes()) + ); + when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); + when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); + when(festivalCategoryRepository.findByMapping(1L, 10L)).thenReturn(Optional.of(festivalCategory)); + when(noticeRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(s3Service.upload(any(), anyString())).thenReturn("key1", "key2", "key3"); + when(s3Service.getPublicUrl(anyString())).thenReturn("https://bucket.s3/img.jpg"); // when - NoticeDetailResponse response = - noticeService.getNoticeDetail(1L); + noticeService.createNotice(1L, request, images); // then - assertThat(response.isSaved()).isFalse(); + verify(noticeImageRepository, times(3)).save(any(NoticeImage.class)); } @Test - @DisplayName("공지 조회 시 존재하지 않으면 예외 발생") - void noticeNotFoundShouldThrowException() { + @DisplayName("공지 생성 - 이미지 21장이면 예외 발생") + void createNoticeImageLimitExceededThrowException() { // given - when(noticeRepository.findById(1L)) - .thenReturn(Optional.empty()); + setAuth(organizer.getEmail()); + NoticeCreateRequest request = new NoticeCreateRequest("제목", 10L, "내용", false); + List images = new ArrayList<>(); + for (int i = 0; i < 21; i++) { + images.add(new MockMultipartFile("img", "img.jpg", "image/jpeg", "data".getBytes())); + } + when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); + when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); + when(festivalCategoryRepository.findByMapping(1L, 10L)).thenReturn(Optional.of(festivalCategory)); - // then - assertThatThrownBy(() -> - noticeService.getNoticeDetail(1L) - ) + // when & then + assertThatThrownBy(() -> noticeService.createNotice(1L, request, images)) .isInstanceOf(NoticeException.class) - .hasMessage("존재하지 않는 공지 아이디입니다."); + .hasMessage(NoticeErrorCode.NOTICE_IMAGE_LIMIT_EXCEEDED.getMsg()); } @Test - @DisplayName("공지 삭제 - 존재하지 않는 공지면 예외 발생") - void deleteNoticeNoticeNotFoundThrowException() { + @DisplayName("공지 생성 - 고정 공지가 이미 3개면 예외 발생") + void createNoticePinnedLimitExceededThrowException() { // given - when(noticeRepository.findById(1L)) - .thenReturn(Optional.empty()); + setAuth(organizer.getEmail()); + NoticeCreateRequest request = new NoticeCreateRequest("제목", 10L, "내용", true); + when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); + when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); + when(festivalCategoryRepository.findByMapping(1L, 10L)).thenReturn(Optional.of(festivalCategory)); + when(noticeRepository.countByFestivalAndIsPinnedTrueAndDeletedAtIsNull(festival)).thenReturn(3L); - // then - assertThatThrownBy(() -> noticeService.deleteNotice(1L)) + // when & then + assertThatThrownBy(() -> noticeService.createNotice(1L, request, null)) .isInstanceOf(NoticeException.class) - .hasMessage("존재하지 않는 공지 아이디입니다."); + .hasMessage(NoticeErrorCode.PINNED_NOTICE_LIMIT_EXCEEDED.getMsg()); } @Test - @DisplayName("공지 삭제 - 이미 삭제된 공지면 예외 발생") - void deleteNoticeAlreadyDeletedThrowException() { + @DisplayName("공지 생성 - 다른 페스티벌 주최자는 생성 불가") + void createNoticeDifferentOrganizerThrowException() { // given - notice.delete(); + Organizer other = Organizer.builder().id(99L).email("other@test.com").build(); + setAuth(other.getEmail()); + NoticeCreateRequest request = new NoticeCreateRequest("제목", 10L, "내용", false); + when(organizerRepository.findByEmail(other.getEmail())).thenReturn(Optional.of(other)); + when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); - when(noticeRepository.findById(1L)) - .thenReturn(Optional.of(notice)); + // when & then + assertThatThrownBy(() -> noticeService.createNotice(1L, request, null)) + .isInstanceOf(CustomException.class) + .hasMessage(UserErrorCode.USER_NOT_AUTHORIZED.getMsg()); + } - String email = "author@test.com"; - Authentication auth = new UsernamePasswordAuthenticationToken(email, null, List.of()); - SecurityContextHolder.getContext().setAuthentication(auth); + @Test + @DisplayName("공지 생성 - S3 업로드 실패 시 이미 업로드된 이미지 롤백") + void createNoticeS3UploadFailRollbackUploadedImages() { + // given + setAuth(organizer.getEmail()); + NoticeCreateRequest request = new NoticeCreateRequest("제목", 10L, "내용", false); + List images = List.of( + new MockMultipartFile("img1", "a.jpg", "image/jpeg", "data".getBytes()), + new MockMultipartFile("img2", "b.jpg", "image/jpeg", "data".getBytes()) + ); + when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); + when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); + when(festivalCategoryRepository.findByMapping(1L, 10L)).thenReturn(Optional.of(festivalCategory)); + when(noticeRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(s3Service.upload(any(), anyString())) + .thenReturn("key1") + .thenThrow(new RuntimeException("S3 오류")); - // then - assertThatThrownBy(() -> noticeService.deleteNotice(1L)) - .isInstanceOf(NoticeException.class) - .hasMessage("이미 삭제된 공지입니다."); + // when & then + assertThatThrownBy(() -> noticeService.createNotice(1L, request, images)) + .isInstanceOf(NoticeException.class); + verify(s3Service).delete("key1"); } @Test - @DisplayName("공지 삭제 - 비로그인 사용자는 예외 발생") - void deleteNoticeNotLoggedInUserThrowException() { + @DisplayName("공지 상세 조회 - imageUrls가 imageOrder 순으로 반환된다") + void getNoticeDetailImageUrlsSortedByOrder() { // given - SecurityContextHolder.clearContext(); + List images = new ArrayList<>(); + images.add(NoticeImage.of(notice, "https://bucket/img1.jpg", 1)); + images.add(NoticeImage.of(notice, "https://bucket/img0.jpg", 0)); + images.add(NoticeImage.of(notice, "https://bucket/img2.jpg", 2)); + ReflectionTestUtils.setField(notice, "images", images); + when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); - when(noticeRepository.findById(1L)) - .thenReturn(Optional.of(notice)); + // when + NoticeDetailResponse response = noticeService.getNoticeDetail(1L); // then - assertThatThrownBy(() -> noticeService.deleteNotice(1L)) - .isInstanceOf(CustomException.class); + assertThat(response.imageUrls()).containsExactly( + "https://bucket/img0.jpg", + "https://bucket/img1.jpg", + "https://bucket/img2.jpg" + ); } @Test - @DisplayName("공지 삭제 - 작성자가 아닌 사용자는 삭제 불가") - void deleteNoticeNotAuthorThrowException() { + @DisplayName("공지 상세 조회 - categoryId는 Category.id를 반환한다 (FestivalCategory.id 아님)") + void getNoticeDetailCategoryIdIsFromCategory() { // given - String email = "loginUserMail@mail.com"; - - Authentication auth = - new UsernamePasswordAuthenticationToken(email, null, List.of()); - SecurityContextHolder.getContext().setAuthentication(auth); + when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); - when(authService.isLoggedInUser(any())).thenReturn(true); - - when(noticeRepository.findById(1L)) - .thenReturn(Optional.of(notice)); + // when + NoticeDetailResponse response = noticeService.getNoticeDetail(1L); - Organizer otherOrganizer = Organizer.builder() - .id(99L) - .email(email) - .build(); + // then + assertThat(response.category().getCategoryId()).isEqualTo(10L); // Category.id + } - when(organizerRepository.findByEmail(email)) - .thenReturn(Optional.of(otherOrganizer)); + @Test + @DisplayName("공지 상세 조회 - 존재하지 않는 공지면 예외 발생") + void getNoticeDetailNotFoundThrowException() { + // given + when(noticeRepository.findById(1L)).thenReturn(Optional.empty()); - // then - assertThatThrownBy(() -> noticeService.deleteNotice(1L)) + // when & then + assertThatThrownBy(() -> noticeService.getNoticeDetail(1L)) .isInstanceOf(NoticeException.class) - .hasMessage("작성자 유저만 공지글을 삭제할 수 있습니다."); + .hasMessage(NoticeErrorCode.NOTICE_NOT_FOUND.getMsg()); } @Test - @DisplayName("공지 삭제 - 작성자가 삭제하면 deletedAt이 설정된다") - void deleteNoticeAuthorSuccess() { + @DisplayName("공지 상세 조회 - 비로그인 사용자는 isSaved가 false") + void getNoticeDetailNotLoggedInIsSavedFalse() { // given - String email = "author@test.com"; + when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); - Authentication auth = - new UsernamePasswordAuthenticationToken(email, null, List.of()); - SecurityContextHolder.getContext().setAuthentication(auth); - - when(authService.isLoggedInUser(any())).thenReturn(true); + // when + NoticeDetailResponse response = noticeService.getNoticeDetail(1L); - when(noticeRepository.findById(1L)) - .thenReturn(Optional.of(notice)); + // then + assertThat(response.isSaved()).isFalse(); + } - when(organizerRepository.findByEmail(email)) - .thenReturn(Optional.of(author)); + @Test + @DisplayName("공지 삭제 - 이미지 있으면 S3 삭제 후 소프트 삭제") + void deleteNoticeWithImagesS3DeleteThenSoftDelete() { + // given + List images = new ArrayList<>(); + images.add(NoticeImage.of(notice, "https://bucket/notices/img1.jpg", 0)); + images.add(NoticeImage.of(notice, "https://bucket/notices/img2.jpg", 1)); + ReflectionTestUtils.setField(notice, "images", images); + setAuth(organizer.getEmail()); + when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); + when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); + when(s3Service.extractKey(anyString())).thenReturn("notices/img.jpg"); // when noticeService.deleteNotice(1L); // then + verify(s3Service, times(2)).delete(anyString()); assertThat(notice.getDeletedAt()).isNotNull(); } @Test - @DisplayName("페스티벌 공지 목록 조회 성공") - void getFestivalNoticeListSuccess() { + @DisplayName("공지 삭제 - 이미지 없어도 소프트 삭제 성공") + void deleteNoticeWithoutImagesSoftDeleteSuccess() { // given - Festival festival = mock(Festival.class); - - Category category = mock(Category.class); - when(category.getCategoryName()).thenReturn("전체"); - - FestivalCategory festivalCategory = mock(FestivalCategory.class); - when(festivalCategory.getCategory()).thenReturn(category); - - Notice notice = mock(Notice.class); - when(notice.getId()).thenReturn(1L); - when(notice.getTitle()).thenReturn("공지 제목"); - when(notice.getContent()).thenReturn("공지 내용"); - when(notice.getImageUrl()).thenReturn(null); - when(notice.getIsPinned()).thenReturn(true); - when(notice.getFestivalCategory()).thenReturn(festivalCategory); - when(notice.getCreatedAt()).thenReturn(LocalDateTime.now().minusMinutes(5)); - - Pageable pageable = PageRequest.of(0, 10); - Page noticePage = new PageImpl<>(List.of(notice), pageable, 1); - - when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); - when(noticeRepository.findNoticesByFilter(eq(festival), any(), any(Pageable.class))).thenReturn(noticePage); + setAuth(organizer.getEmail()); + when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); + when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); // when - NoticeListResponse response = - festivalNoticeService.getFestivalNoticeList(1L, 1L, 0, 10); + noticeService.deleteNotice(1L); // then - assertThat(response.announcements()).hasSize(1); - - var announcement = response.announcements().get(0); - assertThat(announcement.noticeId()).isEqualTo(1L); - assertThat(announcement.categoryName()).isEqualTo("전체"); - assertThat(announcement.title()).isEqualTo("공지 제목"); - assertThat(announcement.isPinned()).isTrue(); - assertThat(announcement.isSaved()).isFalse(); - assertThat(announcement.createdAt()).contains("분 전"); - - assertThat(response.paginationResponse().currentPage()).isEqualTo(0); - assertThat(response.paginationResponse().totalPages()).isEqualTo(1); - assertThat(response.paginationResponse().totalElements()).isEqualTo(1); - assertThat(response.paginationResponse().hasNext()).isFalse(); + verify(s3Service, never()).delete(anyString()); + assertThat(notice.getDeletedAt()).isNotNull(); } @Test - @DisplayName("페스티벌이 존재하지 않으면 예외 발생") - void getFestivalNoticeListFestivalNotFound() { + @DisplayName("공지 삭제 - 이미 삭제된 공지면 예외 발생") + void deleteNoticeAlreadyDeletedThrowException() { // given - when(festivalRepository.findById(1L)).thenReturn(Optional.empty()); + notice.delete(); + when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); // when & then - assertThatThrownBy(() -> - festivalNoticeService.getFestivalNoticeList(1L, 1L, 10, 20) - ).isInstanceOf(NoticeException.class); + assertThatThrownBy(() -> noticeService.deleteNotice(1L)) + .isInstanceOf(NoticeException.class) + .hasMessage(NoticeErrorCode.NOTICE_ALREADY_DELETED.getMsg()); } @Test - @DisplayName("비로그인 사용자일 경우 isSaved는 false") - void getFestivalNoticeListNotLoggedInIsSavedFalse() { + @DisplayName("공지 삭제 - 작성자가 아니면 예외 발생") + void deleteNoticeNotAuthorYhrowException() { // given - Festival festival = mock(Festival.class); - - FestivalCategory festivalCategory = mock(FestivalCategory.class); - Category category = mock(Category.class); - when(category.getCategoryName()).thenReturn("전체"); - when(festivalCategory.getCategory()).thenReturn(category); + Organizer other = Organizer.builder().id(99L).email("other@test.com").build(); + setAuth(other.getEmail()); + when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); + when(organizerRepository.findByEmail(other.getEmail())).thenReturn(Optional.of(other)); - Notice notice = mock(Notice.class); - when(notice.getFestivalCategory()).thenReturn(festivalCategory); - when(notice.getCreatedAt()).thenReturn(LocalDateTime.now().minusHours(1)); - - Pageable pageable = PageRequest.of(0, 10); - Page noticePage = new PageImpl<>(List.of(notice), pageable, 1); + // when & then + assertThatThrownBy(() -> noticeService.deleteNotice(1L)) + .isInstanceOf(NoticeException.class) + .hasMessage(NoticeErrorCode.NOTICE_DELETE_FORBIDDEN.getMsg()); + } - when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); - when(noticeRepository.findNoticesByFilter(eq(festival), any(), any(Pageable.class))).thenReturn(noticePage); + @Test + @DisplayName("공지 삭제 - S3 삭제 실패해도 소프트 삭제는 진행된다") + void deleteNoticeS3DeleteFailStillSoftDeleted() { + // given + List images = new ArrayList<>(); + images.add(NoticeImage.of(notice, "https://bucket/img.jpg", 0)); + ReflectionTestUtils.setField(notice, "images", images); + setAuth(organizer.getEmail()); + when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); + when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); + when(s3Service.extractKey(anyString())).thenReturn("notices/img.jpg"); + doThrow(new RuntimeException("S3 오류")).when(s3Service).delete(anyString()); // when - NoticeListResponse response = - festivalNoticeService.getFestivalNoticeList(1L, 1L, 0, 10); + noticeService.deleteNotice(1L); // then - assertThat(response.announcements().get(0).isSaved()).isFalse(); + assertThat(notice.getDeletedAt()).isNotNull(); } } diff --git a/src/test/java/com/amp/domain/notice/service/NoticeUpdateServiceTest.java b/src/test/java/com/amp/domain/notice/service/NoticeUpdateServiceTest.java index 1f90ad09..1002c6e4 100644 --- a/src/test/java/com/amp/domain/notice/service/NoticeUpdateServiceTest.java +++ b/src/test/java/com/amp/domain/notice/service/NoticeUpdateServiceTest.java @@ -8,7 +8,10 @@ import com.amp.domain.festival.repository.FestivalRepository; import com.amp.domain.notice.dto.request.NoticeUpdateRequest; import com.amp.domain.notice.entity.Notice; +import com.amp.domain.notice.entity.NoticeImage; +import com.amp.domain.notice.exception.NoticeErrorCode; import com.amp.domain.notice.exception.NoticeException; +import com.amp.domain.notice.repository.NoticeImageRepository; import com.amp.domain.notice.repository.NoticeRepository; import com.amp.domain.notice.service.organizer.NoticeUpdateService; import com.amp.domain.user.entity.Organizer; @@ -29,29 +32,29 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class NoticeUpdateServiceTest { - @Mock - private NoticeRepository noticeRepository; - @Mock - private OrganizerRepository organizerRepository; - @Mock - private FestivalCategoryRepository festivalCategoryRepository; - @Mock - private FestivalRepository festivalRepository; - @Mock - private S3Service s3Service; + @Mock private NoticeRepository noticeRepository; + @Mock private NoticeImageRepository noticeImageRepository; + @Mock private OrganizerRepository organizerRepository; + @Mock private FestivalCategoryRepository festivalCategoryRepository; + @Mock private FestivalRepository festivalRepository; + @Mock private S3Service s3Service; @InjectMocks private NoticeUpdateService noticeUpdateService; @@ -83,8 +86,10 @@ void setUp() { ReflectionTestUtils.setField(festival, "organizer", organizer); Category category = Category.builder() - .categoryName("공지") + .categoryName("공연") + .categoryCode("PERFORMANCE") .build(); + ReflectionTestUtils.setField(category, "id", 10L); festivalCategory = FestivalCategory.builder() .festival(festival) @@ -95,44 +100,83 @@ void setUp() { notice = Notice.builder() .title("기존 제목") .content("기존 내용") + .isPinned(false) .festival(festival) .festivalCategory(festivalCategory) .organizer(organizer) - .imageUrl("old-image-url") .build(); ReflectionTestUtils.setField(notice, "id", 1L); + ReflectionTestUtils.setField(notice, "images", new ArrayList<>()); + } + + private void setAuth(String email) { + Authentication auth = new UsernamePasswordAuthenticationToken(email, null, List.of()); + SecurityContextHolder.getContext().setAuthentication(auth); + } + + private void stubCommonMocks() { + when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); + when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); + when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); + when(festivalCategoryRepository.findByMapping(1L, 10L)).thenReturn(Optional.of(festivalCategory)); } @Test - @DisplayName("공지 수정 성공 - 주최자는 공지를 수정할 수 있다") - void updateNoticeSuccess() { + @DisplayName("공지 수정 - 비로그인 사용자는 수정 불가") + void updateNoticeNotLoggedInThrowException() { // given - Authentication auth = - new UsernamePasswordAuthenticationToken( - organizer.getEmail(), null, List.of()); - SecurityContextHolder.getContext().setAuthentication(auth); + SecurityContextHolder.clearContext(); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", false); - NoticeUpdateRequest request = new NoticeUpdateRequest( - 1L, // festivalId - "수정된 제목", - 1L, // categoryId - null, // newImage - "수정된 내용", - true, - "http://image.jpg" // previousImageUrl - ); + // when & then + assertThatThrownBy(() -> noticeUpdateService.updateNotice(1L, request, null)) + .isInstanceOf(CustomException.class) + .hasMessage(UserErrorCode.USER_NOT_FOUND.getMsg()); + } - when(organizerRepository.findByEmail(organizer.getEmail())) - .thenReturn(Optional.of(organizer)); - when(festivalRepository.findById(1L)) - .thenReturn(Optional.of(festival)); - when(noticeRepository.findById(1L)) - .thenReturn(Optional.of(notice)); - when(festivalCategoryRepository.findById(1L)) - .thenReturn(Optional.of(festivalCategory)); + @Test + @DisplayName("공지 수정 - 다른 페스티벌 주최자는 수정 불가") + void updateNoticeDifferentOrganizerThrowException() { + // given + Organizer other = Organizer.builder().id(99L).email("other@test.com").build(); + setAuth(other.getEmail()); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", false); + when(organizerRepository.findByEmail(other.getEmail())).thenReturn(Optional.of(other)); + when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); + + // when & then + assertThatThrownBy(() -> noticeUpdateService.updateNotice(1L, request, null)) + .isInstanceOf(CustomException.class) + .hasMessage(UserErrorCode.USER_NOT_AUTHORIZED.getMsg()); + } + + @Test + @DisplayName("공지 수정 - 삭제된 공지는 수정 불가") + void updateNoticeDeletedNoticeThrowException() { + // given + notice.delete(); + setAuth(organizer.getEmail()); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", false); + when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); + when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); + when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); + + // when & then + assertThatThrownBy(() -> noticeUpdateService.updateNotice(1L, request, null)) + .isInstanceOf(NoticeException.class) + .hasMessage(NoticeErrorCode.NOTICE_ALREADY_DELETED.getMsg()); + } + + @Test + @DisplayName("공지 수정 - 이미지 없이 텍스트만 수정") + void updateNoticeTextOnlySuccess() { + // given + setAuth(organizer.getEmail()); + stubCommonMocks(); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "수정된 제목", 10L, null, "수정된 내용", true); // when - noticeUpdateService.updateNotice(1L, request); + noticeUpdateService.updateNotice(1L, request, null); // then assertThat(notice.getTitle()).isEqualTo("수정된 제목"); @@ -141,159 +185,171 @@ void updateNoticeSuccess() { } @Test - @DisplayName("비로그인 사용자는 공지 수정 불가") - void updateNoticeNotLoggedInThrowException() { + @DisplayName("공지 수정 - keepImageUrls=null이면 기존 이미지 전체 삭제") + void updateNoticeKeepUrlsNullDeleteAllImages() { // given - SecurityContextHolder.clearContext(); + List images = new ArrayList<>(); + images.add(NoticeImage.of(notice, "https://bucket/img1.jpg", 0)); + images.add(NoticeImage.of(notice, "https://bucket/img2.jpg", 1)); + ReflectionTestUtils.setField(notice, "images", images); + setAuth(organizer.getEmail()); + stubCommonMocks(); + when(s3Service.extractKey(anyString())).thenReturn("notices/img.jpg"); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", false); - NoticeUpdateRequest request = new NoticeUpdateRequest( - 1L, "제목", 1L, null, "내용", false, null - ); + // when + noticeUpdateService.updateNotice(1L, request, null); // then - assertThatThrownBy(() -> - noticeUpdateService.updateNotice(1L, request)) - .isInstanceOf(CustomException.class) - .hasMessageContaining(UserErrorCode.USER_NOT_FOUND.getMsg()); + verify(s3Service, times(2)).delete(anyString()); + assertThat(notice.getImages()).isEmpty(); } @Test - @DisplayName("주최자가 아니면 공지 수정 불가") - void updateNoticeNotOrganizerThrowException() { + @DisplayName("공지 수정 - keepImageUrls로 일부 유지, 나머지 삭제") + void updateNoticeKeepSomeImagesDeleteOthers() { // given - Authentication auth = - new UsernamePasswordAuthenticationToken( - organizer.getEmail(), null, List.of()); - SecurityContextHolder.getContext().setAuthentication(auth); - - NoticeUpdateRequest request = - new NoticeUpdateRequest( - 1L, "제목", 1L, null, "내용", false, null); - - Organizer differentOrganizer = Organizer.builder() - .id(99L) - .email(organizer.getEmail()) - .build(); + List images = new ArrayList<>(); + images.add(NoticeImage.of(notice, "https://bucket/img1.jpg", 0)); + images.add(NoticeImage.of(notice, "https://bucket/img2.jpg", 1)); + images.add(NoticeImage.of(notice, "https://bucket/img3.jpg", 2)); + ReflectionTestUtils.setField(notice, "images", images); + setAuth(organizer.getEmail()); + stubCommonMocks(); + when(s3Service.extractKey("https://bucket/img2.jpg")).thenReturn("notices/img2.jpg"); + + // img1, img3 유지 → img2만 삭제 + List keepUrls = List.of("https://bucket/img1.jpg", "https://bucket/img3.jpg"); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, keepUrls, "내용", false); - when(organizerRepository.findByEmail(organizer.getEmail())) - .thenReturn(Optional.of(differentOrganizer)); - when(festivalRepository.findById(1L)) - .thenReturn(Optional.of(festival)); + // when + noticeUpdateService.updateNotice(1L, request, null); // then - assertThatThrownBy(() -> - noticeUpdateService.updateNotice(1L, request)) - .isInstanceOf(CustomException.class) - .hasMessageContaining(UserErrorCode.USER_NOT_AUTHORIZED.getMsg()); + verify(s3Service, times(1)).delete("notices/img2.jpg"); + assertThat(notice.getImages()).hasSize(2); } - @Test - @DisplayName("존재하지 않는 공지면 예외 발생") - void updateNoticeNoticeNotFoundThrowException() { + @DisplayName("공지 수정 - keepImageUrls 순서대로 imageOrder 재정렬") + void updateNoticeKeepUrlsReorderedByKeepUrlsOrder() { // given - Authentication auth = - new UsernamePasswordAuthenticationToken( - organizer.getEmail(), null, List.of()); - SecurityContextHolder.getContext().setAuthentication(auth); + NoticeImage imgA = NoticeImage.of(notice, "https://bucket/A.jpg", 0); + NoticeImage imgB = NoticeImage.of(notice, "https://bucket/B.jpg", 1); + NoticeImage imgC = NoticeImage.of(notice, "https://bucket/C.jpg", 2); + List images = new ArrayList<>(List.of(imgA, imgB, imgC)); + ReflectionTestUtils.setField(notice, "images", images); + setAuth(organizer.getEmail()); + stubCommonMocks(); + + // B → A → C 순서로 재정렬 요청 + List keepUrls = List.of("https://bucket/B.jpg", "https://bucket/A.jpg", "https://bucket/C.jpg"); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, keepUrls, "내용", false); - NoticeUpdateRequest request = - new NoticeUpdateRequest( - 1L, - "수정된 제목", - 1L, - null, - "수정된 내용", - true, - "http://image.jpg" - ); - when(organizerRepository.findByEmail(organizer.getEmail())) - .thenReturn(Optional.of(organizer)); - when(festivalRepository.findById(1L)) - .thenReturn(Optional.of(festival)); - when(noticeRepository.findById(1L)) - .thenReturn(Optional.empty()); + // when + noticeUpdateService.updateNotice(1L, request, null); // then - assertThatThrownBy(() -> - noticeUpdateService.updateNotice(1L, request)) - .isInstanceOf(NoticeException.class); + assertThat(imgB.getImageOrder()).isEqualTo(0); + assertThat(imgA.getImageOrder()).isEqualTo(1); + assertThat(imgC.getImageOrder()).isEqualTo(2); } @Test - @DisplayName("삭제된 공지는 수정할 수 없다") - void updateNoticeDeletedNoticeThrowException() { + @DisplayName("공지 수정 - 새 이미지는 유지 이미지 뒤 순서로 저장된다") + void updateNoticeNewImagesOrderAfterKeptImages() { // given - notice.delete(); + NoticeImage existing = NoticeImage.of(notice, "https://bucket/existing.jpg", 0); + List images = new ArrayList<>(List.of(existing)); + ReflectionTestUtils.setField(notice, "images", images); + setAuth(organizer.getEmail()); + stubCommonMocks(); + when(s3Service.upload(any(), anyString())).thenReturn("new-key"); + when(s3Service.getPublicUrl("new-key")).thenReturn("https://bucket/new.jpg"); + + List keepUrls = List.of("https://bucket/existing.jpg"); + List newImages = List.of( + new MockMultipartFile("img", "new.jpg", "image/jpeg", "data".getBytes()) + ); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, keepUrls, "내용", false); - Authentication auth = - new UsernamePasswordAuthenticationToken( - organizer.getEmail(), null, List.of()); - SecurityContextHolder.getContext().setAuthentication(auth); + // when + noticeUpdateService.updateNotice(1L, request, newImages); - NoticeUpdateRequest request = - new NoticeUpdateRequest( - 1L, - "수정된 제목", - 1L, - null, - "수정된 내용", - true, - "http://image.jpg" - ); - when(organizerRepository.findByEmail(organizer.getEmail())) - .thenReturn(Optional.of(organizer)); - when(festivalRepository.findById(1L)) - .thenReturn(Optional.of(festival)); - when(noticeRepository.findById(1L)) - .thenReturn(Optional.of(notice)); + // then - 기존 1장(order=0) 뒤에 새 이미지(order=1)로 저장 + verify(noticeImageRepository).save(argThat(img -> img.getImageOrder() == 1)); + } - // then - assertThatThrownBy(() -> - noticeUpdateService.updateNotice(1L, request)) + @Test + @DisplayName("공지 수정 - keep 2개 + new 19개 = 21장이면 예외 발생") + void updateNoticeImageTotalExceeds20ThrowException() { + // given + setAuth(organizer.getEmail()); + stubCommonMocks(); + List keepUrls = List.of("https://bucket/img1.jpg", "https://bucket/img2.jpg"); + List newImages = new ArrayList<>(); + for (int i = 0; i < 19; i++) { + newImages.add(new MockMultipartFile("img", "img.jpg", "image/jpeg", "data".getBytes())); + } + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, keepUrls, "내용", false); + + // when & then + assertThatThrownBy(() -> noticeUpdateService.updateNotice(1L, request, newImages)) + .isInstanceOf(NoticeException.class) + .hasMessage(NoticeErrorCode.NOTICE_IMAGE_LIMIT_EXCEEDED.getMsg()); + } + + @Test + @DisplayName("공지 수정 - 새 이미지 S3 업로드 실패 시 이미 업로드된 이미지 롤백") + void updateNoticeS3UploadFailRollbackUploadedImages() { + // given + setAuth(organizer.getEmail()); + stubCommonMocks(); + List newImages = List.of( + new MockMultipartFile("img1", "a.jpg", "image/jpeg", "data".getBytes()), + new MockMultipartFile("img2", "b.jpg", "image/jpeg", "data".getBytes()) + ); + when(s3Service.upload(any(), anyString())) + .thenReturn("key1") + .thenThrow(new RuntimeException("S3 오류")); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", false); + + // when & then + assertThatThrownBy(() -> noticeUpdateService.updateNotice(1L, request, newImages)) .isInstanceOf(NoticeException.class); + verify(s3Service).delete("key1"); } @Test - @DisplayName("이미지 포함 수정 시 S3 업로드가 수행된다") - void updateNoticeWithImageSuccess() { + @DisplayName("공지 수정 - 비고정 → 고정 전환 시 이미 3개면 예외 발생") + void updateNoticePinLimitExceededThrowException() { // given - Authentication auth = - new UsernamePasswordAuthenticationToken( - organizer.getEmail(), null, List.of()); - SecurityContextHolder.getContext().setAuthentication(auth); + setAuth(organizer.getEmail()); + when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); + when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); + when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); + when(noticeRepository.countByFestivalAndIsPinnedTrueAndDeletedAtIsNull(festival)).thenReturn(3L); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", true); + + // when & then + assertThatThrownBy(() -> noticeUpdateService.updateNotice(1L, request, null)) + .isInstanceOf(NoticeException.class) + .hasMessage(NoticeErrorCode.PINNED_NOTICE_LIMIT_EXCEEDED.getMsg()); + } - MockMultipartFile image = - new MockMultipartFile( - "image", "test.png", "image/png", "test".getBytes()); - - NoticeUpdateRequest request = - new NoticeUpdateRequest( - 1L, - "수정된 제목", - 1L, - image, - "수정된 내용", - true, - "http://image.jpg" - ); - when(organizerRepository.findByEmail(organizer.getEmail())) - .thenReturn(Optional.of(organizer)); - when(festivalRepository.findById(1L)) - .thenReturn(Optional.of(festival)); - when(noticeRepository.findById(1L)) - .thenReturn(Optional.of(notice)); - when(festivalCategoryRepository.findById(1L)) - .thenReturn(Optional.of(festivalCategory)); - when(s3Service.upload(any(), any())) - .thenReturn("new-image-key"); - when(s3Service.getPublicUrl("new-image-key")) - .thenReturn("new-image-url"); + @Test + @DisplayName("공지 수정 - 이미 고정 상태에서 고정 유지 시 카운트 체크 안 함") + void updateNoticeAlreadyPinnedNoCountCheck() { + // given + ReflectionTestUtils.setField(notice, "isPinned", true); + setAuth(organizer.getEmail()); + stubCommonMocks(); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", true); // when - noticeUpdateService.updateNotice(1L, request); + noticeUpdateService.updateNotice(1L, request, null); // then - assertThat(notice.getImageUrl()).isEqualTo("new-image-url"); + verify(noticeRepository, never()).countByFestivalAndIsPinnedTrueAndDeletedAtIsNull(any()); } } From 0ee81331b3e3c3156108487d641542af572d6fd0 Mon Sep 17 00:00:00 2001 From: Chae-Yu Date: Mon, 9 Mar 2026 21:11:57 +0900 Subject: [PATCH 07/14] =?UTF-8?q?[refactor]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=88=9C=EC=B0=A8=20=EC=97=85=EB=A1=9C=EB=93=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B3=91=EB=A0=AC=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/organizer/NoticeService.java | 40 ++++++++------ .../organizer/NoticeUpdateService.java | 54 ++++++++++--------- 2 files changed, 52 insertions(+), 42 deletions(-) diff --git a/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java b/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java index 77324251..7472747d 100644 --- a/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java +++ b/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java @@ -36,8 +36,11 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.stream.IntStream; import static com.amp.global.common.dto.TimeFormatter.formatTimeAgo; @@ -113,27 +116,30 @@ public NoticeCreateResponse createNotice(Long festivalId, NoticeCreateRequest re noticeRepository.save(notice); - List uploadedKeys = new ArrayList<>(); - try { - for (int i = 0; i < validImages.size(); i++) { - String key = s3Service.upload(validImages.get(i), "notices"); - uploadedKeys.add(key); - noticeImageRepository.save( - NoticeImage.of(notice, s3Service.getPublicUrl(key), i) - ); - } - } catch (CustomException e) { - uploadedKeys.forEach(key -> { - try { s3Service.delete(key); } catch (Exception ignored) {} - }); - throw e; - } catch (Exception e) { - uploadedKeys.forEach(key -> { + String[] keys = new String[validImages.size()]; + + List> futures = IntStream.range(0, validImages.size()) + .mapToObj(i -> CompletableFuture.runAsync( + () -> keys[i] = s3Service.upload(validImages.get(i), "notices") + )) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .exceptionally(ex -> null) + .join(); + + boolean anyFailed = futures.stream().anyMatch(CompletableFuture::isCompletedExceptionally); + if (anyFailed) { + Arrays.stream(keys).filter(Objects::nonNull).forEach(key -> { try { s3Service.delete(key); } catch (Exception ignored) {} }); throw new NoticeException(NoticeErrorCode.NOTICE_CREATE_FAIL); } + for (int i = 0; i < keys.length; i++) { + noticeImageRepository.save(NoticeImage.of(notice, s3Service.getPublicUrl(keys[i]), i)); + } + eventPublisher.publishEvent( new NoticeCreatedEvent( festivalCategory.getId(), diff --git a/src/main/java/com/amp/domain/notice/service/organizer/NoticeUpdateService.java b/src/main/java/com/amp/domain/notice/service/organizer/NoticeUpdateService.java index ee423e98..f3813972 100644 --- a/src/main/java/com/amp/domain/notice/service/organizer/NoticeUpdateService.java +++ b/src/main/java/com/amp/domain/notice/service/organizer/NoticeUpdateService.java @@ -27,10 +27,13 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; +import java.util.stream.IntStream; @Service @Slf4j @@ -147,33 +150,34 @@ private void syncImages(Notice notice, List keepImageUrls, } int startOrder = keptImages.size(); - List uploadedKeys = new ArrayList<>(); - - try { - for (int i = 0; i < validNewImages.size(); i++) { - String key = s3Service.upload(validNewImages.get(i), "notices"); - uploadedKeys.add(key); - noticeImageRepository.save( - NoticeImage.of(notice, s3Service.getPublicUrl(key), startOrder + i) - ); - } - } catch (CustomException e) { - uploadedKeys.forEach(key -> { - try { - s3Service.delete(key); - } catch (Exception ignored) { - } - }); - throw e; - } catch (Exception e) { - uploadedKeys.forEach(key -> { - try { - s3Service.delete(key); - } catch (Exception ignored) { - } + + if (validNewImages.isEmpty()) { + return; + } + + String[] keys = new String[validNewImages.size()]; + + List> futures = IntStream.range(0, validNewImages.size()) + .mapToObj(i -> CompletableFuture.runAsync( + () -> keys[i] = s3Service.upload(validNewImages.get(i), "notices") + )) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .exceptionally(ex -> null) + .join(); + + boolean anyFailed = futures.stream().anyMatch(CompletableFuture::isCompletedExceptionally); + if (anyFailed) { + Arrays.stream(keys).filter(Objects::nonNull).forEach(key -> { + try { s3Service.delete(key); } catch (Exception ignored) {} }); throw new NoticeException(NoticeErrorCode.UPDATE_NOTICE_FAILED); } + + for (int i = 0; i < keys.length; i++) { + noticeImageRepository.save(NoticeImage.of(notice, s3Service.getPublicUrl(keys[i]), startOrder + i)); + } } private boolean isLoggedInUser(Authentication authentication) { From 8f4fa67006edaac6a167716d8c0dd777f2d75422 Mon Sep 17 00:00:00 2001 From: Chae-Yu Date: Mon, 9 Mar 2026 23:09:00 +0900 Subject: [PATCH 08/14] =?UTF-8?q?[refactor]=20NoticeUpdateService=EC=99=80?= =?UTF-8?q?=20NoticeService=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=BD=94=EB=93=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../organizer/NoticeUpdateController.java | 4 +- .../service/organizer/NoticeService.java | 159 ++++++-- .../organizer/NoticeUpdateService.java | 194 ---------- .../notice/service/NoticeServiceTest.java | 271 ++++++++++++- .../service/NoticeUpdateServiceTest.java | 355 ------------------ 5 files changed, 398 insertions(+), 585 deletions(-) delete mode 100644 src/main/java/com/amp/domain/notice/service/organizer/NoticeUpdateService.java delete mode 100644 src/test/java/com/amp/domain/notice/service/NoticeUpdateServiceTest.java diff --git a/src/main/java/com/amp/domain/notice/controller/organizer/NoticeUpdateController.java b/src/main/java/com/amp/domain/notice/controller/organizer/NoticeUpdateController.java index fcbcb2c0..14be47a7 100644 --- a/src/main/java/com/amp/domain/notice/controller/organizer/NoticeUpdateController.java +++ b/src/main/java/com/amp/domain/notice/controller/organizer/NoticeUpdateController.java @@ -2,7 +2,6 @@ import com.amp.domain.notice.dto.request.NoticeUpdateRequest; import com.amp.domain.notice.service.organizer.NoticeService; -import com.amp.domain.notice.service.organizer.NoticeUpdateService; import com.amp.global.annotation.ApiErrorCodes; import com.amp.global.common.SuccessStatus; import com.amp.global.response.success.BaseResponse; @@ -27,7 +26,6 @@ @PreAuthorize("hasRole('ORGANIZER')") public class NoticeUpdateController { - private final NoticeUpdateService noticeUpdateService; private final NoticeService noticeService; @Operation(summary = "공지 수정/상단고정", description = "공지 수정 및 상단 고정 여부 선택 api") @@ -39,7 +37,7 @@ public ResponseEntity> updateNotice( @RequestPart("noticeUpdateRequest") @Valid NoticeUpdateRequest noticeUpdateRequest, @RequestPart(value = "newImages", required = false) List newImages ) { - noticeUpdateService.updateNotice(noticeId, noticeUpdateRequest, newImages); + noticeService.updateNotice(noticeId, noticeUpdateRequest, newImages); return ResponseEntity .status(SuccessStatus.UPDATE_NOTICE_SUCCESS.getHttpStatus()) diff --git a/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java b/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java index 7472747d..d30ca57c 100644 --- a/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java +++ b/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java @@ -7,6 +7,7 @@ import com.amp.domain.festival.exception.FestivalErrorCode; import com.amp.domain.festival.repository.FestivalRepository; import com.amp.domain.notice.dto.request.NoticeCreateRequest; +import com.amp.domain.notice.dto.request.NoticeUpdateRequest; import com.amp.domain.notice.dto.response.Author; import com.amp.global.common.dto.CategoryData; import com.amp.domain.notice.dto.response.NoticeCreateResponse; @@ -37,9 +38,12 @@ import org.springframework.web.multipart.MultipartFile; import java.util.Arrays; +import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; import java.util.stream.IntStream; import static com.amp.global.common.dto.TimeFormatter.formatTimeAgo; @@ -47,6 +51,7 @@ @Service @Slf4j @AllArgsConstructor +@Transactional(readOnly = true) public class NoticeService { private final NoticeRepository noticeRepository; @@ -116,25 +121,7 @@ public NoticeCreateResponse createNotice(Long festivalId, NoticeCreateRequest re noticeRepository.save(notice); - String[] keys = new String[validImages.size()]; - - List> futures = IntStream.range(0, validImages.size()) - .mapToObj(i -> CompletableFuture.runAsync( - () -> keys[i] = s3Service.upload(validImages.get(i), "notices") - )) - .toList(); - - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) - .exceptionally(ex -> null) - .join(); - - boolean anyFailed = futures.stream().anyMatch(CompletableFuture::isCompletedExceptionally); - if (anyFailed) { - Arrays.stream(keys).filter(Objects::nonNull).forEach(key -> { - try { s3Service.delete(key); } catch (Exception ignored) {} - }); - throw new NoticeException(NoticeErrorCode.NOTICE_CREATE_FAIL); - } + String[] keys = uploadImagesInParallel(validImages, NoticeErrorCode.NOTICE_CREATE_FAIL); for (int i = 0; i < keys.length; i++) { noticeImageRepository.save(NoticeImage.of(notice, s3Service.getPublicUrl(keys[i]), i)); @@ -156,13 +143,11 @@ public NoticeCreateResponse createNotice(Long festivalId, NoticeCreateRequest re public NoticeDetailResponse getNoticeDetail(Long noticeId) { - // 공지 조회 (존재 검증 포함) Notice notice = noticeRepository.findById(noticeId) .orElseThrow(() -> new NoticeException(NoticeErrorCode.NOTICE_NOT_FOUND) ); - // 로그인 여부에 따른 저장 여부 판단 boolean isSaved = getIsSaved(notice); CategoryData category = new CategoryData( @@ -177,7 +162,7 @@ public NoticeDetailResponse getNoticeDetail(Long noticeId) { ); List imageUrls = notice.getImages().stream() - .sorted(java.util.Comparator.comparingInt(NoticeImage::getImageOrder)) + .sorted(Comparator.comparingInt(NoticeImage::getImageOrder)) .map(NoticeImage::getImageUrl) .toList(); @@ -252,6 +237,136 @@ public void deleteNotice(Long noticeId) { notice.delete(); } + @Transactional + public void updateNotice(Long noticeId, NoticeUpdateRequest request, + List newImages) { + + Authentication authentication = + SecurityContextHolder.getContext().getAuthentication(); + + if (!authService.isLoggedInUser(authentication)) { + throw new CustomException(UserErrorCode.USER_NOT_FOUND); + } + + Organizer organizer = organizerRepository.findByEmail(authentication.getName()) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + Festival festival = festivalRepository.findById(request.festivalId()) + .orElseThrow(() -> new CustomException(FestivalErrorCode.FESTIVAL_NOT_FOUND)); + + validateOrganizer(festival, organizer); + + Notice notice = noticeRepository.findById(noticeId) + .orElseThrow(() -> new NoticeException(NoticeErrorCode.NOTICE_NOT_FOUND)); + + if (notice.getDeletedAt() != null) { + throw new NoticeException(NoticeErrorCode.NOTICE_ALREADY_DELETED); + } + + if (!notice.getFestival().getId().equals(festival.getId())) { + throw new NoticeException(NoticeErrorCode.NOTICE_UPDATE_FORBIDDEN); + } + + boolean wasPinned = notice.getIsPinned(); + boolean willBePinned = request.isPinned(); + + if (!wasPinned && willBePinned) { + long pinnedCount = + noticeRepository.countByFestivalAndIsPinnedTrueAndDeletedAtIsNull(festival); + + if (pinnedCount >= 3) { + throw new NoticeException(NoticeErrorCode.PINNED_NOTICE_LIMIT_EXCEEDED); + } + } + + FestivalCategory festivalCategory = festivalCategoryRepository + .findByMapping(request.festivalId(), request.categoryId()) + .orElseThrow(() -> new NoticeException(FestivalCategoryErrorCode.NOTICE_CATEGORY_NOT_FOUND)); + + if (!festivalCategory.getFestival().getId().equals(festival.getId())) { + throw new NoticeException(FestivalCategoryErrorCode.NOTICE_CATEGORY_NOT_FOUND); + } + + notice.update(request.title(), request.content(), request.isPinned(), festivalCategory); + syncImages(notice, request.keepImageUrls(), newImages); + } + + private void syncImages(Notice notice, List keepImageUrls, + List newImages) { + + List keepUrls = (keepImageUrls != null) ? keepImageUrls : List.of(); + + List validNewImages = (newImages != null) + ? newImages.stream().filter(f -> f != null && !f.isEmpty()).toList() + : List.of(); + + if (keepUrls.size() + validNewImages.size() > 20) { + throw new NoticeException(NoticeErrorCode.NOTICE_IMAGE_LIMIT_EXCEEDED); + } + + List currentImages = notice.getImages(); + Map imageMap = currentImages.stream() + .collect(Collectors.toMap(NoticeImage::getImageUrl, img -> img)); + + List imagesToDelete = currentImages.stream() + .filter(img -> !keepUrls.contains(img.getImageUrl())) + .toList(); + + imagesToDelete.forEach(img -> { + try { + s3Service.delete(s3Service.extractKey(img.getImageUrl())); + } catch (Exception e) { + log.warn("S3 이미지 삭제 실패: {}", img.getImageUrl(), e); + } + }); + currentImages.removeAll(imagesToDelete); + + List keptImages = keepUrls.stream() + .filter(imageMap::containsKey) + .map(imageMap::get) + .toList(); + + for (int i = 0; i < keptImages.size(); i++) { + keptImages.get(i).updateOrder(i); + } + + int startOrder = keptImages.size(); + + if (validNewImages.isEmpty()) { + return; + } + + String[] keys = uploadImagesInParallel(validNewImages, NoticeErrorCode.UPDATE_NOTICE_FAILED); + + for (int i = 0; i < keys.length; i++) { + noticeImageRepository.save(NoticeImage.of(notice, s3Service.getPublicUrl(keys[i]), startOrder + i)); + } + } + + private String[] uploadImagesInParallel(List images, NoticeErrorCode failCode) { + String[] keys = new String[images.size()]; + + List> futures = IntStream.range(0, images.size()) + .mapToObj(i -> CompletableFuture.runAsync( + () -> keys[i] = s3Service.upload(images.get(i), "notices") + )) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .exceptionally(ex -> null) + .join(); + + boolean anyFailed = futures.stream().anyMatch(CompletableFuture::isCompletedExceptionally); + if (anyFailed) { + Arrays.stream(keys).filter(Objects::nonNull).forEach(key -> { + try { s3Service.delete(key); } catch (Exception ignored) {} + }); + throw new NoticeException(failCode); + } + + return keys; + } + private void validateOrganizer(Festival festival, Organizer organizer) { if (!festival.getOrganizer().getId().equals(organizer.getId())) { throw new CustomException(UserErrorCode.USER_NOT_AUTHORIZED); diff --git a/src/main/java/com/amp/domain/notice/service/organizer/NoticeUpdateService.java b/src/main/java/com/amp/domain/notice/service/organizer/NoticeUpdateService.java deleted file mode 100644 index f3813972..00000000 --- a/src/main/java/com/amp/domain/notice/service/organizer/NoticeUpdateService.java +++ /dev/null @@ -1,194 +0,0 @@ -package com.amp.domain.notice.service.organizer; - -import com.amp.domain.category.entity.FestivalCategory; -import com.amp.domain.category.exception.FestivalCategoryErrorCode; -import com.amp.domain.category.repository.FestivalCategoryRepository; -import com.amp.domain.festival.entity.Festival; -import com.amp.domain.festival.exception.FestivalErrorCode; -import com.amp.domain.festival.repository.FestivalRepository; -import com.amp.domain.notice.dto.request.NoticeUpdateRequest; -import com.amp.domain.notice.entity.Notice; -import com.amp.domain.notice.entity.NoticeImage; -import com.amp.domain.notice.exception.NoticeErrorCode; -import com.amp.domain.notice.exception.NoticeException; -import com.amp.domain.notice.repository.NoticeImageRepository; -import com.amp.domain.notice.repository.NoticeRepository; -import com.amp.domain.user.entity.Organizer; -import com.amp.domain.user.exception.UserErrorCode; -import com.amp.domain.user.repository.OrganizerRepository; -import com.amp.global.exception.CustomException; -import com.amp.global.s3.S3Service; -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; - -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; -import java.util.stream.IntStream; - -@Service -@Slf4j -@AllArgsConstructor -@Transactional(readOnly = true) -public class NoticeUpdateService { - - private final NoticeRepository noticeRepository; - private final NoticeImageRepository noticeImageRepository; - private final OrganizerRepository organizerRepository; - private final FestivalCategoryRepository festivalCategoryRepository; - private final FestivalRepository festivalRepository; - private final S3Service s3Service; - - @Transactional - public void updateNotice(Long noticeId, NoticeUpdateRequest request, - List newImages) { - - Authentication authentication = - SecurityContextHolder.getContext().getAuthentication(); - - if (!isLoggedInUser(authentication)) { - throw new CustomException(UserErrorCode.USER_NOT_FOUND); - } - - Organizer organizer = organizerRepository.findByEmail(authentication.getName()) - .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); - - Festival festival = festivalRepository.findById(request.festivalId()) - .orElseThrow(() -> new CustomException(FestivalErrorCode.FESTIVAL_NOT_FOUND)); - - validateOrganizer(festival, organizer); - - Notice notice = noticeRepository.findById(noticeId) - .orElseThrow(() -> new NoticeException(NoticeErrorCode.NOTICE_NOT_FOUND)); - - if (notice.getDeletedAt() != null) { - throw new NoticeException(NoticeErrorCode.NOTICE_ALREADY_DELETED); - } - - if (!notice.getFestival().getId().equals(festival.getId())) { - throw new NoticeException(NoticeErrorCode.NOTICE_UPDATE_FORBIDDEN); - } - - boolean wasPinned = notice.getIsPinned(); - boolean willBePinned = request.isPinned(); - - if (!wasPinned && willBePinned) { - long pinnedCount = - noticeRepository.countByFestivalAndIsPinnedTrueAndDeletedAtIsNull(festival); - - if (pinnedCount >= 3) { - throw new NoticeException(NoticeErrorCode.PINNED_NOTICE_LIMIT_EXCEEDED); - } - } - - FestivalCategory festivalCategory = festivalCategoryRepository - .findByMapping(request.festivalId(), request.categoryId()) - .orElseThrow(() -> new NoticeException(FestivalCategoryErrorCode.NOTICE_CATEGORY_NOT_FOUND)); - - if (!festivalCategory.getFestival().getId().equals(festival.getId())) { - throw new NoticeException(FestivalCategoryErrorCode.NOTICE_CATEGORY_NOT_FOUND); - } - - notice.update(request.title(), request.content(), request.isPinned(), festivalCategory); - syncImages(notice, request.keepImageUrls(), newImages); - } - - private void syncImages(Notice notice, List keepImageUrls, - List newImages) { - - List keepUrls; - if (keepImageUrls != null) { - keepUrls = keepImageUrls; - } else { - keepUrls = List.of(); - } - - List validNewImages; - if (newImages != null) { - validNewImages = newImages.stream().filter(f -> f != null && !f.isEmpty()).toList(); - } else { - validNewImages = List.of(); - } - - if (keepUrls.size() + validNewImages.size() > 20) { - throw new NoticeException(NoticeErrorCode.NOTICE_IMAGE_LIMIT_EXCEEDED); - } - - List currentImages = notice.getImages(); - Map imageMap = currentImages.stream() - .collect(Collectors.toMap(NoticeImage::getImageUrl, img -> img)); - - List imagesToDelete = currentImages.stream() - .filter(img -> !keepUrls.contains(img.getImageUrl())) - .toList(); - - imagesToDelete.forEach(img -> { - try { - s3Service.delete(s3Service.extractKey(img.getImageUrl())); - } catch (Exception e) { - log.warn("S3 이미지 삭제 실패: {}", img.getImageUrl(), e); - } - }); - currentImages.removeAll(imagesToDelete); - - List keptImages = keepUrls.stream() - .filter(imageMap::containsKey) - .map(imageMap::get) - .toList(); - - for (int i = 0; i < keptImages.size(); i++) { - keptImages.get(i).updateOrder(i); - } - - int startOrder = keptImages.size(); - - if (validNewImages.isEmpty()) { - return; - } - - String[] keys = new String[validNewImages.size()]; - - List> futures = IntStream.range(0, validNewImages.size()) - .mapToObj(i -> CompletableFuture.runAsync( - () -> keys[i] = s3Service.upload(validNewImages.get(i), "notices") - )) - .toList(); - - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) - .exceptionally(ex -> null) - .join(); - - boolean anyFailed = futures.stream().anyMatch(CompletableFuture::isCompletedExceptionally); - if (anyFailed) { - Arrays.stream(keys).filter(Objects::nonNull).forEach(key -> { - try { s3Service.delete(key); } catch (Exception ignored) {} - }); - throw new NoticeException(NoticeErrorCode.UPDATE_NOTICE_FAILED); - } - - for (int i = 0; i < keys.length; i++) { - noticeImageRepository.save(NoticeImage.of(notice, s3Service.getPublicUrl(keys[i]), startOrder + i)); - } - } - - private boolean isLoggedInUser(Authentication authentication) { - return authentication != null && - authentication.isAuthenticated() && - !(authentication instanceof AnonymousAuthenticationToken); - } - - private void validateOrganizer(Festival festival, Organizer organizer) { - if (!festival.getOrganizer().getId().equals(organizer.getId())) { - throw new CustomException(UserErrorCode.USER_NOT_AUTHORIZED); - } - } -} diff --git a/src/test/java/com/amp/domain/notice/service/NoticeServiceTest.java b/src/test/java/com/amp/domain/notice/service/NoticeServiceTest.java index 77711fca..2c39b8ed 100644 --- a/src/test/java/com/amp/domain/notice/service/NoticeServiceTest.java +++ b/src/test/java/com/amp/domain/notice/service/NoticeServiceTest.java @@ -7,6 +7,7 @@ import com.amp.domain.festival.entity.FestivalStatus; import com.amp.domain.festival.repository.FestivalRepository; import com.amp.domain.notice.dto.request.NoticeCreateRequest; +import com.amp.domain.notice.dto.request.NoticeUpdateRequest; import com.amp.domain.notice.dto.response.NoticeCreateResponse; import com.amp.domain.notice.dto.response.NoticeDetailResponse; import com.amp.domain.notice.entity.Notice; @@ -55,16 +56,26 @@ @ExtendWith(MockitoExtension.class) class NoticeServiceTest { - @Mock private NoticeRepository noticeRepository; - @Mock private NoticeImageRepository noticeImageRepository; - @Mock private BookmarkRepository bookmarkRepository; - @Mock private OrganizerRepository organizerRepository; - @Mock private AudienceRepository audienceRepository; - @Mock private FestivalCategoryRepository festivalCategoryRepository; - @Mock private FestivalRepository festivalRepository; - @Mock private S3Service s3Service; - @Mock private ApplicationEventPublisher eventPublisher; - @Mock private AuthService authService; + @Mock + private NoticeRepository noticeRepository; + @Mock + private NoticeImageRepository noticeImageRepository; + @Mock + private BookmarkRepository bookmarkRepository; + @Mock + private OrganizerRepository organizerRepository; + @Mock + private AudienceRepository audienceRepository; + @Mock + private FestivalCategoryRepository festivalCategoryRepository; + @Mock + private FestivalRepository festivalRepository; + @Mock + private S3Service s3Service; + @Mock + private ApplicationEventPublisher eventPublisher; + @Mock + private AuthService authService; @InjectMocks private NoticeService noticeService; @@ -127,7 +138,13 @@ void setUp() { private void setAuth(String email) { Authentication auth = new UsernamePasswordAuthenticationToken(email, null, List.of()); SecurityContextHolder.getContext().setAuthentication(auth); - when(authService.isLoggedInUser(any())).thenReturn(true); + lenient().when(authService.isLoggedInUser(any())).thenReturn(true); } + + private void stubCommonMocks() { + when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); + when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); + when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); + when(festivalCategoryRepository.findByMapping(1L, 10L)).thenReturn(Optional.of(festivalCategory)); } @Test @@ -395,4 +412,236 @@ void deleteNoticeS3DeleteFailStillSoftDeleted() { // then assertThat(notice.getDeletedAt()).isNotNull(); } + + @Test + @DisplayName("공지 수정 - 비로그인 사용자는 수정 불가") + void updateNoticeNotLoggedInThrowException() { + // given + SecurityContextHolder.clearContext(); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", false); + + // when & then + assertThatThrownBy(() -> noticeService.updateNotice(1L, request, null)) + .isInstanceOf(CustomException.class) + .hasMessage(UserErrorCode.USER_NOT_FOUND.getMsg()); + } + + @Test + @DisplayName("공지 수정 - 다른 페스티벌 주최자는 수정 불가") + void updateNoticeDifferentOrganizerThrowException() { + // given + Organizer other = Organizer.builder().id(99L).email("other@test.com").build(); + setAuth(other.getEmail()); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", false); + when(organizerRepository.findByEmail(other.getEmail())).thenReturn(Optional.of(other)); + when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); + + // when & then + assertThatThrownBy(() -> noticeService.updateNotice(1L, request, null)) + .isInstanceOf(CustomException.class) + .hasMessage(UserErrorCode.USER_NOT_AUTHORIZED.getMsg()); + } + + @Test + @DisplayName("공지 수정 - 삭제된 공지는 수정 불가") + void updateNoticeDeletedNoticeThrowException() { + // given + notice.delete(); + setAuth(organizer.getEmail()); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", false); + when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); + when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); + when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); + + // when & then + assertThatThrownBy(() -> noticeService.updateNotice(1L, request, null)) + .isInstanceOf(NoticeException.class) + .hasMessage(NoticeErrorCode.NOTICE_ALREADY_DELETED.getMsg()); + } + + @Test + @DisplayName("공지 수정 - 이미지 없이 텍스트만 수정") + void updateNoticeTextOnlySuccess() { + // given + setAuth(organizer.getEmail()); + stubCommonMocks(); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "수정된 제목", 10L, null, "수정된 내용", true); + + // when + noticeService.updateNotice(1L, request, null); + + // then + assertThat(notice.getTitle()).isEqualTo("수정된 제목"); + assertThat(notice.getContent()).isEqualTo("수정된 내용"); + assertThat(notice.getIsPinned()).isTrue(); + } + + @Test + @DisplayName("공지 수정 - keepImageUrls=null이면 기존 이미지 전체 삭제") + void updateNoticeKeepUrlsNullDeleteAllImages() { + // given + List images = new ArrayList<>(); + images.add(NoticeImage.of(notice, "https://bucket/img1.jpg", 0)); + images.add(NoticeImage.of(notice, "https://bucket/img2.jpg", 1)); + ReflectionTestUtils.setField(notice, "images", images); + setAuth(organizer.getEmail()); + stubCommonMocks(); + when(s3Service.extractKey(anyString())).thenReturn("notices/img.jpg"); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", false); + + // when + noticeService.updateNotice(1L, request, null); + + // then + verify(s3Service, times(2)).delete(anyString()); + assertThat(notice.getImages()).isEmpty(); + } + + @Test + @DisplayName("공지 수정 - keepImageUrls로 일부 유지, 나머지 삭제") + void updateNoticeKeepSomeImagesDeleteOthers() { + // given + List images = new ArrayList<>(); + images.add(NoticeImage.of(notice, "https://bucket/img1.jpg", 0)); + images.add(NoticeImage.of(notice, "https://bucket/img2.jpg", 1)); + images.add(NoticeImage.of(notice, "https://bucket/img3.jpg", 2)); + ReflectionTestUtils.setField(notice, "images", images); + setAuth(organizer.getEmail()); + stubCommonMocks(); + when(s3Service.extractKey("https://bucket/img2.jpg")).thenReturn("notices/img2.jpg"); + + // img1, img3 유지 → img2만 삭제 + List keepUrls = List.of("https://bucket/img1.jpg", "https://bucket/img3.jpg"); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, keepUrls, "내용", false); + + // when + noticeService.updateNotice(1L, request, null); + + // then + verify(s3Service, times(1)).delete("notices/img2.jpg"); + assertThat(notice.getImages()).hasSize(2); + } + + @Test + @DisplayName("공지 수정 - keepImageUrls 순서대로 imageOrder 재정렬") + void updateNoticeKeepUrlsReorderedByKeepUrlsOrder() { + // given + NoticeImage imgA = NoticeImage.of(notice, "https://bucket/A.jpg", 0); + NoticeImage imgB = NoticeImage.of(notice, "https://bucket/B.jpg", 1); + NoticeImage imgC = NoticeImage.of(notice, "https://bucket/C.jpg", 2); + List images = new ArrayList<>(List.of(imgA, imgB, imgC)); + ReflectionTestUtils.setField(notice, "images", images); + setAuth(organizer.getEmail()); + stubCommonMocks(); + + // B → A → C 순서로 재정렬 요청 + List keepUrls = List.of("https://bucket/B.jpg", "https://bucket/A.jpg", "https://bucket/C.jpg"); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, keepUrls, "내용", false); + + // when + noticeService.updateNotice(1L, request, null); + + // then + assertThat(imgB.getImageOrder()).isEqualTo(0); + assertThat(imgA.getImageOrder()).isEqualTo(1); + assertThat(imgC.getImageOrder()).isEqualTo(2); + } + + @Test + @DisplayName("공지 수정 - 새 이미지는 유지 이미지 뒤 순서로 저장된다") + void updateNoticeNewImagesOrderAfterKeptImages() { + // given + NoticeImage existing = NoticeImage.of(notice, "https://bucket/existing.jpg", 0); + List images = new ArrayList<>(List.of(existing)); + ReflectionTestUtils.setField(notice, "images", images); + setAuth(organizer.getEmail()); + stubCommonMocks(); + when(s3Service.upload(any(), anyString())).thenReturn("new-key"); + when(s3Service.getPublicUrl("new-key")).thenReturn("https://bucket/new.jpg"); + + List keepUrls = List.of("https://bucket/existing.jpg"); + List newImages = List.of( + new MockMultipartFile("img", "new.jpg", "image/jpeg", "data".getBytes()) + ); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, keepUrls, "내용", false); + + // when + noticeService.updateNotice(1L, request, newImages); + + // then - 기존 1장(order=0) 뒤에 새 이미지(order=1)로 저장 + verify(noticeImageRepository).save(argThat(img -> img.getImageOrder() == 1)); + } + + @Test + @DisplayName("공지 수정 - keep 2개 + new 19개 = 21장이면 예외 발생") + void updateNoticeImageTotalExceeds20ThrowException() { + // given + setAuth(organizer.getEmail()); + stubCommonMocks(); + List keepUrls = List.of("https://bucket/img1.jpg", "https://bucket/img2.jpg"); + List newImages = new ArrayList<>(); + for (int i = 0; i < 19; i++) { + newImages.add(new MockMultipartFile("img", "img.jpg", "image/jpeg", "data".getBytes())); + } + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, keepUrls, "내용", false); + + // when & then + assertThatThrownBy(() -> noticeService.updateNotice(1L, request, newImages)) + .isInstanceOf(NoticeException.class) + .hasMessage(NoticeErrorCode.NOTICE_IMAGE_LIMIT_EXCEEDED.getMsg()); + } + + @Test + @DisplayName("공지 수정 - 새 이미지 S3 업로드 실패 시 이미 업로드된 이미지 롤백") + void updateNoticeS3UploadFailRollbackUploadedImages() { + // given + setAuth(organizer.getEmail()); + stubCommonMocks(); + List newImages = List.of( + new MockMultipartFile("img1", "a.jpg", "image/jpeg", "data".getBytes()), + new MockMultipartFile("img2", "b.jpg", "image/jpeg", "data".getBytes()) + ); + when(s3Service.upload(any(), anyString())) + .thenReturn("key1") + .thenThrow(new RuntimeException("S3 오류")); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", false); + + // when & then + assertThatThrownBy(() -> noticeService.updateNotice(1L, request, newImages)) + .isInstanceOf(NoticeException.class); + verify(s3Service).delete("key1"); + } + + @Test + @DisplayName("공지 수정 - 비고정 → 고정 전환 시 이미 3개면 예외 발생") + void updateNoticePinLimitExceededThrowException() { + // given + setAuth(organizer.getEmail()); + when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); + when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); + when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); + when(noticeRepository.countByFestivalAndIsPinnedTrueAndDeletedAtIsNull(festival)).thenReturn(3L); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", true); + + // when & then + assertThatThrownBy(() -> noticeService.updateNotice(1L, request, null)) + .isInstanceOf(NoticeException.class) + .hasMessage(NoticeErrorCode.PINNED_NOTICE_LIMIT_EXCEEDED.getMsg()); + } + + @Test + @DisplayName("공지 수정 - 이미 고정 상태에서 고정 유지 시 카운트 체크 안 함") + void updateNoticeAlreadyPinnedNoCountCheck() { + // given + ReflectionTestUtils.setField(notice, "isPinned", true); + setAuth(organizer.getEmail()); + stubCommonMocks(); + NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", true); + + // when + noticeService.updateNotice(1L, request, null); + + // then + verify(noticeRepository, never()).countByFestivalAndIsPinnedTrueAndDeletedAtIsNull(any()); + } } diff --git a/src/test/java/com/amp/domain/notice/service/NoticeUpdateServiceTest.java b/src/test/java/com/amp/domain/notice/service/NoticeUpdateServiceTest.java deleted file mode 100644 index 1002c6e4..00000000 --- a/src/test/java/com/amp/domain/notice/service/NoticeUpdateServiceTest.java +++ /dev/null @@ -1,355 +0,0 @@ -package com.amp.domain.notice.service; - -import com.amp.domain.category.entity.Category; -import com.amp.domain.category.entity.FestivalCategory; -import com.amp.domain.category.repository.FestivalCategoryRepository; -import com.amp.domain.festival.entity.Festival; -import com.amp.domain.festival.entity.FestivalStatus; -import com.amp.domain.festival.repository.FestivalRepository; -import com.amp.domain.notice.dto.request.NoticeUpdateRequest; -import com.amp.domain.notice.entity.Notice; -import com.amp.domain.notice.entity.NoticeImage; -import com.amp.domain.notice.exception.NoticeErrorCode; -import com.amp.domain.notice.exception.NoticeException; -import com.amp.domain.notice.repository.NoticeImageRepository; -import com.amp.domain.notice.repository.NoticeRepository; -import com.amp.domain.notice.service.organizer.NoticeUpdateService; -import com.amp.domain.user.entity.Organizer; -import com.amp.domain.user.exception.UserErrorCode; -import com.amp.domain.user.repository.OrganizerRepository; -import com.amp.global.exception.CustomException; -import com.amp.global.s3.S3Service; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.web.multipart.MultipartFile; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class NoticeUpdateServiceTest { - - @Mock private NoticeRepository noticeRepository; - @Mock private NoticeImageRepository noticeImageRepository; - @Mock private OrganizerRepository organizerRepository; - @Mock private FestivalCategoryRepository festivalCategoryRepository; - @Mock private FestivalRepository festivalRepository; - @Mock private S3Service s3Service; - - @InjectMocks - private NoticeUpdateService noticeUpdateService; - - private Festival festival; - private FestivalCategory festivalCategory; - private Notice notice; - private Organizer organizer; - - @AfterEach - void clearSecurityContext() { - SecurityContextHolder.clearContext(); - } - - @BeforeEach - void setUp() { - organizer = Organizer.builder() - .id(1L) - .email("organizer@test.com") - .build(); - - festival = Festival.builder() - .title("축제") - .status(FestivalStatus.ONGOING) - .startDate(LocalDate.now().minusDays(1)) - .endDate(LocalDate.now().plusDays(1)) - .build(); - ReflectionTestUtils.setField(festival, "id", 1L); - ReflectionTestUtils.setField(festival, "organizer", organizer); - - Category category = Category.builder() - .categoryName("공연") - .categoryCode("PERFORMANCE") - .build(); - ReflectionTestUtils.setField(category, "id", 10L); - - festivalCategory = FestivalCategory.builder() - .festival(festival) - .category(category) - .build(); - ReflectionTestUtils.setField(festivalCategory, "id", 1L); - - notice = Notice.builder() - .title("기존 제목") - .content("기존 내용") - .isPinned(false) - .festival(festival) - .festivalCategory(festivalCategory) - .organizer(organizer) - .build(); - ReflectionTestUtils.setField(notice, "id", 1L); - ReflectionTestUtils.setField(notice, "images", new ArrayList<>()); - } - - private void setAuth(String email) { - Authentication auth = new UsernamePasswordAuthenticationToken(email, null, List.of()); - SecurityContextHolder.getContext().setAuthentication(auth); - } - - private void stubCommonMocks() { - when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); - when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); - when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); - when(festivalCategoryRepository.findByMapping(1L, 10L)).thenReturn(Optional.of(festivalCategory)); - } - - @Test - @DisplayName("공지 수정 - 비로그인 사용자는 수정 불가") - void updateNoticeNotLoggedInThrowException() { - // given - SecurityContextHolder.clearContext(); - NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", false); - - // when & then - assertThatThrownBy(() -> noticeUpdateService.updateNotice(1L, request, null)) - .isInstanceOf(CustomException.class) - .hasMessage(UserErrorCode.USER_NOT_FOUND.getMsg()); - } - - @Test - @DisplayName("공지 수정 - 다른 페스티벌 주최자는 수정 불가") - void updateNoticeDifferentOrganizerThrowException() { - // given - Organizer other = Organizer.builder().id(99L).email("other@test.com").build(); - setAuth(other.getEmail()); - NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", false); - when(organizerRepository.findByEmail(other.getEmail())).thenReturn(Optional.of(other)); - when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); - - // when & then - assertThatThrownBy(() -> noticeUpdateService.updateNotice(1L, request, null)) - .isInstanceOf(CustomException.class) - .hasMessage(UserErrorCode.USER_NOT_AUTHORIZED.getMsg()); - } - - @Test - @DisplayName("공지 수정 - 삭제된 공지는 수정 불가") - void updateNoticeDeletedNoticeThrowException() { - // given - notice.delete(); - setAuth(organizer.getEmail()); - NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", false); - when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); - when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); - when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); - - // when & then - assertThatThrownBy(() -> noticeUpdateService.updateNotice(1L, request, null)) - .isInstanceOf(NoticeException.class) - .hasMessage(NoticeErrorCode.NOTICE_ALREADY_DELETED.getMsg()); - } - - @Test - @DisplayName("공지 수정 - 이미지 없이 텍스트만 수정") - void updateNoticeTextOnlySuccess() { - // given - setAuth(organizer.getEmail()); - stubCommonMocks(); - NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "수정된 제목", 10L, null, "수정된 내용", true); - - // when - noticeUpdateService.updateNotice(1L, request, null); - - // then - assertThat(notice.getTitle()).isEqualTo("수정된 제목"); - assertThat(notice.getContent()).isEqualTo("수정된 내용"); - assertThat(notice.getIsPinned()).isTrue(); - } - - @Test - @DisplayName("공지 수정 - keepImageUrls=null이면 기존 이미지 전체 삭제") - void updateNoticeKeepUrlsNullDeleteAllImages() { - // given - List images = new ArrayList<>(); - images.add(NoticeImage.of(notice, "https://bucket/img1.jpg", 0)); - images.add(NoticeImage.of(notice, "https://bucket/img2.jpg", 1)); - ReflectionTestUtils.setField(notice, "images", images); - setAuth(organizer.getEmail()); - stubCommonMocks(); - when(s3Service.extractKey(anyString())).thenReturn("notices/img.jpg"); - NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", false); - - // when - noticeUpdateService.updateNotice(1L, request, null); - - // then - verify(s3Service, times(2)).delete(anyString()); - assertThat(notice.getImages()).isEmpty(); - } - - @Test - @DisplayName("공지 수정 - keepImageUrls로 일부 유지, 나머지 삭제") - void updateNoticeKeepSomeImagesDeleteOthers() { - // given - List images = new ArrayList<>(); - images.add(NoticeImage.of(notice, "https://bucket/img1.jpg", 0)); - images.add(NoticeImage.of(notice, "https://bucket/img2.jpg", 1)); - images.add(NoticeImage.of(notice, "https://bucket/img3.jpg", 2)); - ReflectionTestUtils.setField(notice, "images", images); - setAuth(organizer.getEmail()); - stubCommonMocks(); - when(s3Service.extractKey("https://bucket/img2.jpg")).thenReturn("notices/img2.jpg"); - - // img1, img3 유지 → img2만 삭제 - List keepUrls = List.of("https://bucket/img1.jpg", "https://bucket/img3.jpg"); - NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, keepUrls, "내용", false); - - // when - noticeUpdateService.updateNotice(1L, request, null); - - // then - verify(s3Service, times(1)).delete("notices/img2.jpg"); - assertThat(notice.getImages()).hasSize(2); - } - - @Test - @DisplayName("공지 수정 - keepImageUrls 순서대로 imageOrder 재정렬") - void updateNoticeKeepUrlsReorderedByKeepUrlsOrder() { - // given - NoticeImage imgA = NoticeImage.of(notice, "https://bucket/A.jpg", 0); - NoticeImage imgB = NoticeImage.of(notice, "https://bucket/B.jpg", 1); - NoticeImage imgC = NoticeImage.of(notice, "https://bucket/C.jpg", 2); - List images = new ArrayList<>(List.of(imgA, imgB, imgC)); - ReflectionTestUtils.setField(notice, "images", images); - setAuth(organizer.getEmail()); - stubCommonMocks(); - - // B → A → C 순서로 재정렬 요청 - List keepUrls = List.of("https://bucket/B.jpg", "https://bucket/A.jpg", "https://bucket/C.jpg"); - NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, keepUrls, "내용", false); - - // when - noticeUpdateService.updateNotice(1L, request, null); - - // then - assertThat(imgB.getImageOrder()).isEqualTo(0); - assertThat(imgA.getImageOrder()).isEqualTo(1); - assertThat(imgC.getImageOrder()).isEqualTo(2); - } - - @Test - @DisplayName("공지 수정 - 새 이미지는 유지 이미지 뒤 순서로 저장된다") - void updateNoticeNewImagesOrderAfterKeptImages() { - // given - NoticeImage existing = NoticeImage.of(notice, "https://bucket/existing.jpg", 0); - List images = new ArrayList<>(List.of(existing)); - ReflectionTestUtils.setField(notice, "images", images); - setAuth(organizer.getEmail()); - stubCommonMocks(); - when(s3Service.upload(any(), anyString())).thenReturn("new-key"); - when(s3Service.getPublicUrl("new-key")).thenReturn("https://bucket/new.jpg"); - - List keepUrls = List.of("https://bucket/existing.jpg"); - List newImages = List.of( - new MockMultipartFile("img", "new.jpg", "image/jpeg", "data".getBytes()) - ); - NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, keepUrls, "내용", false); - - // when - noticeUpdateService.updateNotice(1L, request, newImages); - - // then - 기존 1장(order=0) 뒤에 새 이미지(order=1)로 저장 - verify(noticeImageRepository).save(argThat(img -> img.getImageOrder() == 1)); - } - - @Test - @DisplayName("공지 수정 - keep 2개 + new 19개 = 21장이면 예외 발생") - void updateNoticeImageTotalExceeds20ThrowException() { - // given - setAuth(organizer.getEmail()); - stubCommonMocks(); - List keepUrls = List.of("https://bucket/img1.jpg", "https://bucket/img2.jpg"); - List newImages = new ArrayList<>(); - for (int i = 0; i < 19; i++) { - newImages.add(new MockMultipartFile("img", "img.jpg", "image/jpeg", "data".getBytes())); - } - NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, keepUrls, "내용", false); - - // when & then - assertThatThrownBy(() -> noticeUpdateService.updateNotice(1L, request, newImages)) - .isInstanceOf(NoticeException.class) - .hasMessage(NoticeErrorCode.NOTICE_IMAGE_LIMIT_EXCEEDED.getMsg()); - } - - @Test - @DisplayName("공지 수정 - 새 이미지 S3 업로드 실패 시 이미 업로드된 이미지 롤백") - void updateNoticeS3UploadFailRollbackUploadedImages() { - // given - setAuth(organizer.getEmail()); - stubCommonMocks(); - List newImages = List.of( - new MockMultipartFile("img1", "a.jpg", "image/jpeg", "data".getBytes()), - new MockMultipartFile("img2", "b.jpg", "image/jpeg", "data".getBytes()) - ); - when(s3Service.upload(any(), anyString())) - .thenReturn("key1") - .thenThrow(new RuntimeException("S3 오류")); - NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", false); - - // when & then - assertThatThrownBy(() -> noticeUpdateService.updateNotice(1L, request, newImages)) - .isInstanceOf(NoticeException.class); - verify(s3Service).delete("key1"); - } - - @Test - @DisplayName("공지 수정 - 비고정 → 고정 전환 시 이미 3개면 예외 발생") - void updateNoticePinLimitExceededThrowException() { - // given - setAuth(organizer.getEmail()); - when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); - when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); - when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); - when(noticeRepository.countByFestivalAndIsPinnedTrueAndDeletedAtIsNull(festival)).thenReturn(3L); - NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", true); - - // when & then - assertThatThrownBy(() -> noticeUpdateService.updateNotice(1L, request, null)) - .isInstanceOf(NoticeException.class) - .hasMessage(NoticeErrorCode.PINNED_NOTICE_LIMIT_EXCEEDED.getMsg()); - } - - @Test - @DisplayName("공지 수정 - 이미 고정 상태에서 고정 유지 시 카운트 체크 안 함") - void updateNoticeAlreadyPinnedNoCountCheck() { - // given - ReflectionTestUtils.setField(notice, "isPinned", true); - setAuth(organizer.getEmail()); - stubCommonMocks(); - NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", true); - - // when - noticeUpdateService.updateNotice(1L, request, null); - - // then - verify(noticeRepository, never()).countByFestivalAndIsPinnedTrueAndDeletedAtIsNull(any()); - } -} From a4612a58b796a9eb66c4b863d6a86b1ee43d0734 Mon Sep 17 00:00:00 2001 From: Chae-Yu Date: Mon, 9 Mar 2026 23:44:44 +0900 Subject: [PATCH 09/14] =?UTF-8?q?[chore]=20pr=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD=20=EA=B8=B0=EB=8A=A5=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .coderabbit.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index fea1a2c0..5246758a 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -11,7 +11,7 @@ tone_instructions: | # 리뷰 설정 reviews: profile: 'assertive' - high_level_summary: true + high_level_summary: false review_status: true changed_files_summary: true collapse_walkthrough: true From dea8594c9c66d32d4337d85704e76cff9f9ea481 Mon Sep 17 00:00:00 2001 From: Chae-Yu Date: Tue, 10 Mar 2026 00:18:09 +0900 Subject: [PATCH 10/14] =?UTF-8?q?[refactor]=20private=20=EC=A0=91=EA=B7=BC?= =?UTF-8?q?=20=EC=A0=9C=EC=96=B4=EC=9E=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/amp/domain/notice/entity/Notice.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/amp/domain/notice/entity/Notice.java b/src/main/java/com/amp/domain/notice/entity/Notice.java index 956de724..e06eb984 100644 --- a/src/main/java/com/amp/domain/notice/entity/Notice.java +++ b/src/main/java/com/amp/domain/notice/entity/Notice.java @@ -47,7 +47,7 @@ public class Notice extends BaseTimeEntity { @OneToMany(mappedBy = "notice", cascade = CascadeType.ALL, orphanRemoval = true) @BatchSize(size = 100) - List images = new ArrayList<>(); + private List images = new ArrayList<>(); @Column(name = "is_pinned", nullable = false) private Boolean isPinned = false; From 7ffe54aaaa367a2955dde99250781b88736ff927 Mon Sep 17 00:00:00 2001 From: Chae-Yu Date: Tue, 10 Mar 2026 00:24:49 +0900 Subject: [PATCH 11/14] =?UTF-8?q?[refactor]=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EC=BB=A4=EB=B0=8B=20=EC=84=B1=EA=B3=B5=20=ED=9B=84?= =?UTF-8?q?=EC=97=90=EB=A7=8C=20S3=EC=97=90=EC=84=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/organizer/NoticeService.java | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java b/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java index d30ca57c..72179510 100644 --- a/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java +++ b/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java @@ -35,6 +35,8 @@ import org.springframework.security.core.context.SecurityContextHolder; 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.util.Arrays; @@ -226,15 +228,13 @@ public void deleteNotice(Long noticeId) { throw new NoticeException(NoticeErrorCode.NOTICE_DELETE_FORBIDDEN); } - notice.getImages().forEach(image -> { - try { - s3Service.delete(s3Service.extractKey(image.getImageUrl())); - } catch (Exception e) { - log.warn("S3 이미지 삭제 실패: {}", image.getImageUrl(), e); - } - }); + List imageKeys = notice.getImages().stream() + .map(img -> s3Service.extractKey(img.getImageUrl())) + .toList(); notice.delete(); + + deleteS3AfterCommit(imageKeys); } @Transactional @@ -312,14 +312,12 @@ private void syncImages(Notice notice, List keepImageUrls, .filter(img -> !keepUrls.contains(img.getImageUrl())) .toList(); - imagesToDelete.forEach(img -> { - try { - s3Service.delete(s3Service.extractKey(img.getImageUrl())); - } catch (Exception e) { - log.warn("S3 이미지 삭제 실패: {}", img.getImageUrl(), e); - } - }); + List keysToDelete = imagesToDelete.stream() + .map(img -> s3Service.extractKey(img.getImageUrl())) + .toList(); + currentImages.removeAll(imagesToDelete); + deleteS3AfterCommit(keysToDelete); List keptImages = keepUrls.stream() .filter(imageMap::containsKey) @@ -367,6 +365,22 @@ private String[] uploadImagesInParallel(List images, NoticeErrorC return keys; } + private void deleteS3AfterCommit(List keys) { + if (keys.isEmpty()) return; + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + keys.forEach(key -> { + try { + s3Service.delete(key); + } catch (Exception e) { + log.warn("S3 이미지 삭제 실패: {}", key, e); + } + }); + } + }); + } + private void validateOrganizer(Festival festival, Organizer organizer) { if (!festival.getOrganizer().getId().equals(organizer.getId())) { throw new CustomException(UserErrorCode.USER_NOT_AUTHORIZED); From 0ee453581d67d58695c91af08636ee3d601a5528 Mon Sep 17 00:00:00 2001 From: Chae-Yu Date: Wed, 11 Mar 2026 11:02:41 +0900 Subject: [PATCH 12/14] =?UTF-8?q?[refactor]=20deletedAt=20!=3D=20null=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EA=B2=80=EC=A6=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notice/service/organizer/NoticeService.java | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java b/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java index 72179510..c3ce023a 100644 --- a/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java +++ b/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java @@ -207,10 +207,6 @@ public void deleteNotice(Long noticeId) { .orElseThrow(() -> new NoticeException(NoticeErrorCode.NOTICE_NOT_FOUND)); - if (notice.getDeletedAt() != null) { - throw new NoticeException(NoticeErrorCode.NOTICE_ALREADY_DELETED); - } - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); @@ -259,10 +255,6 @@ public void updateNotice(Long noticeId, NoticeUpdateRequest request, Notice notice = noticeRepository.findById(noticeId) .orElseThrow(() -> new NoticeException(NoticeErrorCode.NOTICE_NOT_FOUND)); - if (notice.getDeletedAt() != null) { - throw new NoticeException(NoticeErrorCode.NOTICE_ALREADY_DELETED); - } - if (!notice.getFestival().getId().equals(festival.getId())) { throw new NoticeException(NoticeErrorCode.NOTICE_UPDATE_FORBIDDEN); } From 91dcc02dfdaaa9c1b556855ff98113949c6a6991 Mon Sep 17 00:00:00 2001 From: Chae-Yu Date: Wed, 11 Mar 2026 11:13:14 +0900 Subject: [PATCH 13/14] =?UTF-8?q?[refactor]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20DB=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=A0=20?= =?UTF-8?q?=EB=95=8C=20=EB=B6=80=EB=AA=A8=20=EC=BB=AC=EB=A0=89=EC=85=98(no?= =?UTF-8?q?tice.getImages())=EB=8F=84=20=EB=8F=99=EC=8B=9C=EC=97=90=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/amp/domain/notice/entity/Notice.java | 4 ++++ .../domain/notice/service/organizer/NoticeService.java | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/amp/domain/notice/entity/Notice.java b/src/main/java/com/amp/domain/notice/entity/Notice.java index e06eb984..edeb2473 100644 --- a/src/main/java/com/amp/domain/notice/entity/Notice.java +++ b/src/main/java/com/amp/domain/notice/entity/Notice.java @@ -70,6 +70,10 @@ public void delete() { this.deletedAt = LocalDateTime.now(); } + public void addImage(NoticeImage image) { + this.images.add(image); + } + public void update( String title, String content, diff --git a/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java b/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java index c3ce023a..2e1307ba 100644 --- a/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java +++ b/src/main/java/com/amp/domain/notice/service/organizer/NoticeService.java @@ -126,7 +126,9 @@ public NoticeCreateResponse createNotice(Long festivalId, NoticeCreateRequest re String[] keys = uploadImagesInParallel(validImages, NoticeErrorCode.NOTICE_CREATE_FAIL); for (int i = 0; i < keys.length; i++) { - noticeImageRepository.save(NoticeImage.of(notice, s3Service.getPublicUrl(keys[i]), i)); + NoticeImage image = NoticeImage.of(notice, s3Service.getPublicUrl(keys[i]), i); + notice.addImage(image); + noticeImageRepository.save(image); } eventPublisher.publishEvent( @@ -329,7 +331,9 @@ private void syncImages(Notice notice, List keepImageUrls, String[] keys = uploadImagesInParallel(validNewImages, NoticeErrorCode.UPDATE_NOTICE_FAILED); for (int i = 0; i < keys.length; i++) { - noticeImageRepository.save(NoticeImage.of(notice, s3Service.getPublicUrl(keys[i]), startOrder + i)); + NoticeImage image = NoticeImage.of(notice, s3Service.getPublicUrl(keys[i]), startOrder + i); + notice.addImage(image); + noticeImageRepository.save(image); } } From e021ec40a9a2c6cc9a2972492007e3e0e07b9aff Mon Sep 17 00:00:00 2001 From: Chae-Yu Date: Wed, 11 Mar 2026 11:14:01 +0900 Subject: [PATCH 14/14] =?UTF-8?q?[test]=20S3=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=EC=9D=B4=20=ED=95=84=EC=9A=94=ED=95=9C=204?= =?UTF-8?q?=EA=B0=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=97=90=20MockedStatic?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notice/service/NoticeServiceTest.java | 92 +++++++++++++------ 1 file changed, 63 insertions(+), 29 deletions(-) diff --git a/src/test/java/com/amp/domain/notice/service/NoticeServiceTest.java b/src/test/java/com/amp/domain/notice/service/NoticeServiceTest.java index 2c39b8ed..fc7eaddc 100644 --- a/src/test/java/com/amp/domain/notice/service/NoticeServiceTest.java +++ b/src/test/java/com/amp/domain/notice/service/NoticeServiceTest.java @@ -47,6 +47,10 @@ import java.util.List; import java.util.Optional; +import org.mockito.MockedStatic; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -342,11 +346,19 @@ void deleteNoticeWithImagesS3DeleteThenSoftDelete() { when(s3Service.extractKey(anyString())).thenReturn("notices/img.jpg"); // when - noticeService.deleteNotice(1L); - - // then - verify(s3Service, times(2)).delete(anyString()); - assertThat(notice.getDeletedAt()).isNotNull(); + try (MockedStatic tsm = + mockStatic(TransactionSynchronizationManager.class)) { + tsm.when(() -> TransactionSynchronizationManager.registerSynchronization(any())) + .thenAnswer(invocation -> { + invocation.getArgument(0).afterCommit(); + return null; + }); + noticeService.deleteNotice(1L); + + // then + verify(s3Service, times(2)).delete(anyString()); + assertThat(notice.getDeletedAt()).isNotNull(); + } } @Test @@ -366,16 +378,15 @@ void deleteNoticeWithoutImagesSoftDeleteSuccess() { } @Test - @DisplayName("공지 삭제 - 이미 삭제된 공지면 예외 발생") + @DisplayName("공지 삭제 - 존재하지 않는 공지면 예외 발생 (@SQLRestriction으로 삭제된 공지는 조회 불가)") void deleteNoticeAlreadyDeletedThrowException() { - // given - notice.delete(); - when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); + // given - @SQLRestriction("deleted_at IS NULL")으로 삭제된 공지는 findById에서 empty 반환 + when(noticeRepository.findById(1L)).thenReturn(Optional.empty()); // when & then assertThatThrownBy(() -> noticeService.deleteNotice(1L)) .isInstanceOf(NoticeException.class) - .hasMessage(NoticeErrorCode.NOTICE_ALREADY_DELETED.getMsg()); + .hasMessage(NoticeErrorCode.NOTICE_NOT_FOUND.getMsg()); } @Test @@ -407,10 +418,18 @@ void deleteNoticeS3DeleteFailStillSoftDeleted() { doThrow(new RuntimeException("S3 오류")).when(s3Service).delete(anyString()); // when - noticeService.deleteNotice(1L); - - // then - assertThat(notice.getDeletedAt()).isNotNull(); + try (MockedStatic tsm = + mockStatic(TransactionSynchronizationManager.class)) { + tsm.when(() -> TransactionSynchronizationManager.registerSynchronization(any())) + .thenAnswer(invocation -> { + invocation.getArgument(0).afterCommit(); + return null; + }); + noticeService.deleteNotice(1L); + + // then + assertThat(notice.getDeletedAt()).isNotNull(); + } } @Test @@ -443,20 +462,19 @@ void updateNoticeDifferentOrganizerThrowException() { } @Test - @DisplayName("공지 수정 - 삭제된 공지는 수정 불가") + @DisplayName("공지 수정 - 삭제된 공지는 수정 불가 (@SQLRestriction으로 삭제된 공지는 조회 불가)") void updateNoticeDeletedNoticeThrowException() { - // given - notice.delete(); + // given - @SQLRestriction("deleted_at IS NULL")으로 삭제된 공지는 findById에서 empty 반환 setAuth(organizer.getEmail()); NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", false); when(organizerRepository.findByEmail(organizer.getEmail())).thenReturn(Optional.of(organizer)); when(festivalRepository.findById(1L)).thenReturn(Optional.of(festival)); - when(noticeRepository.findById(1L)).thenReturn(Optional.of(notice)); + when(noticeRepository.findById(1L)).thenReturn(Optional.empty()); // when & then assertThatThrownBy(() -> noticeService.updateNotice(1L, request, null)) .isInstanceOf(NoticeException.class) - .hasMessage(NoticeErrorCode.NOTICE_ALREADY_DELETED.getMsg()); + .hasMessage(NoticeErrorCode.NOTICE_NOT_FOUND.getMsg()); } @Test @@ -490,11 +508,19 @@ void updateNoticeKeepUrlsNullDeleteAllImages() { NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, null, "내용", false); // when - noticeService.updateNotice(1L, request, null); - - // then - verify(s3Service, times(2)).delete(anyString()); - assertThat(notice.getImages()).isEmpty(); + try (MockedStatic tsm = + mockStatic(TransactionSynchronizationManager.class)) { + tsm.when(() -> TransactionSynchronizationManager.registerSynchronization(any())) + .thenAnswer(invocation -> { + invocation.getArgument(0).afterCommit(); + return null; + }); + noticeService.updateNotice(1L, request, null); + + // then + verify(s3Service, times(2)).delete(anyString()); + assertThat(notice.getImages()).isEmpty(); + } } @Test @@ -515,11 +541,19 @@ void updateNoticeKeepSomeImagesDeleteOthers() { NoticeUpdateRequest request = new NoticeUpdateRequest(1L, "제목", 10L, keepUrls, "내용", false); // when - noticeService.updateNotice(1L, request, null); - - // then - verify(s3Service, times(1)).delete("notices/img2.jpg"); - assertThat(notice.getImages()).hasSize(2); + try (MockedStatic tsm = + mockStatic(TransactionSynchronizationManager.class)) { + tsm.when(() -> TransactionSynchronizationManager.registerSynchronization(any())) + .thenAnswer(invocation -> { + invocation.getArgument(0).afterCommit(); + return null; + }); + noticeService.updateNotice(1L, request, null); + + // then + verify(s3Service, times(1)).delete("notices/img2.jpg"); + assertThat(notice.getImages()).hasSize(2); + } } @Test