From 13d6f67af83d3a0ee8bb54c3e89aaa3e46c2c37f Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Tue, 30 Jun 2026 15:57:33 +0900 Subject: [PATCH 01/46] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 임시로 작성된 플레이리스트 엔티티를 정식으로 구현하고, 객체 생성 방식을 개선했습니다. - `@Setter` 대신 `@Builder`와 `protected` 기본 생성자를 도입하여 객체 생성을 제어하고 불변성을 강화했습니다. - 정적 팩토리 메서드 `of`를 추가하여 플레이리스트 생성 시 소유자, 제목, 설명을 필수로 받고 구독자 수를 0으로 초기화하도록 했습니다. - `@NotNull`, `@Size`와 같은 JPA 유효성 검증 어노테이션 대신 생성자 내에서 `Assert`를 사용하여 객체 생성 시점에 필수 필드 유효성을 검증하도록 변경했습니다. - `title`, `description`, `subscriberCount` 컬럼 정의를 명확히 하고, `subscriberCount`의 기본값 설정을 생성 로직으로 이동했습니다. --- .../team5/mopl/playlist/entity/Playlist.java | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/playlist/entity/Playlist.java b/src/main/java/com/codeit/team5/mopl/playlist/entity/Playlist.java index d61fb02f..78ada9b7 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/entity/Playlist.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/entity/Playlist.java @@ -2,6 +2,7 @@ import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; import com.codeit.team5.mopl.user.entity.User; +import io.jsonwebtoken.lang.Assert; import jakarta.persistence.AttributeOverride; import jakarta.persistence.AttributeOverrides; import jakarta.persistence.Column; @@ -12,42 +13,50 @@ import jakarta.persistence.Table; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.OnDelete; import org.hibernate.annotations.OnDeleteAction; @Getter -@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity -@Table(name = "playlists", schema = "public") -@AttributeOverrides({ - @AttributeOverride(name = "createdAt", - column = @Column(nullable = false)), - @AttributeOverride(name = "updatedAt", - column = @Column(nullable = false))}) +@Table(name = "playlists") public class Playlist extends BaseUpdatableEntity { - @NotNull - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @OnDelete(action = OnDeleteAction.CASCADE) + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "owner_id", nullable = false) private User owner; - @Size(max = 255) - @NotNull - @Column(name = "title", nullable = false) + @Column(name = "title", nullable = false, length = 255) private String title; - @NotNull @Column(name = "description", nullable = false, length = Integer.MAX_VALUE) private String description; - @NotNull - @ColumnDefault("0") @Column(name = "subscriber_count", nullable = false) private Integer subscriberCount; - + @Builder(access = AccessLevel.PRIVATE) + private Playlist(User owner, String title, String description, Integer subscriberCount) { + Assert.hasText(title, "제목을 입력해주세요."); + Assert.hasText(description, "상세 설명을 입력해주세요."); + this.owner = owner; + this.title = title; + this.description = description; + this.subscriberCount = subscriberCount; + } + + public static Playlist of(User owner, String title, String description) { + return Playlist.builder() + .owner(owner) + .title(title) + .description(description) + .subscriberCount(0) + .build(); + } } From 6a445ce8365ea5feff067cfa0f65cd91668f138b Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Tue, 30 Jun 2026 17:41:37 +0900 Subject: [PATCH 02/46] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD=20DTO=20=EB=B0=8F=20=EB=A7=A4=ED=8D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 사용자 요약 정보를 담는 UserSummary 레코드 DTO를 추가했습니다. - User 엔티티를 UserSummary DTO로 변환하는 UserSummaryMapper 인터페이스를 MapStruct 기반으로 구현했습니다. - MapStruct의 전역 설정을 정의하는 GlobalMapperConfig 인터페이스를 추가하여 일관된 매핑 전략을 적용했습니다. - 사용자 정보 중 핵심적인 요약 데이터를 효율적으로 제공하기 위함입니다. - 엔티티-DTO 변환 로직의 가독성과 유지보수성을 향상시키기 위해 MapStruct를 도입했습니다. --- .../mopl/global/mapper/GlobalMapperConfig.java | 15 +++++++++++++++ .../team5/mopl/user/dto/response/UserSummary.java | 10 ++++++++++ .../team5/mopl/user/mapper/UserSummaryMapper.java | 14 ++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/global/mapper/GlobalMapperConfig.java create mode 100644 src/main/java/com/codeit/team5/mopl/user/dto/response/UserSummary.java create mode 100644 src/main/java/com/codeit/team5/mopl/user/mapper/UserSummaryMapper.java diff --git a/src/main/java/com/codeit/team5/mopl/global/mapper/GlobalMapperConfig.java b/src/main/java/com/codeit/team5/mopl/global/mapper/GlobalMapperConfig.java new file mode 100644 index 00000000..062f0081 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/global/mapper/GlobalMapperConfig.java @@ -0,0 +1,15 @@ +package com.codeit.team5.mopl.global.mapper; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.MapperConfig; +import org.mapstruct.MappingConstants; +import org.mapstruct.NullValuePropertyMappingStrategy; +import org.mapstruct.ReportingPolicy; + +@MapperConfig(componentModel = MappingConstants.ComponentModel.SPRING, + unmappedTargetPolicy = ReportingPolicy.IGNORE, + nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE, + injectionStrategy = InjectionStrategy.CONSTRUCTOR) +public interface GlobalMapperConfig { + +} diff --git a/src/main/java/com/codeit/team5/mopl/user/dto/response/UserSummary.java b/src/main/java/com/codeit/team5/mopl/user/dto/response/UserSummary.java new file mode 100644 index 00000000..90c1a983 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/user/dto/response/UserSummary.java @@ -0,0 +1,10 @@ +package com.codeit.team5.mopl.user.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.UUID; + +public record UserSummary(@JsonProperty("userId")UUID id, + String name, + String profileImageUrl) { + +} diff --git a/src/main/java/com/codeit/team5/mopl/user/mapper/UserSummaryMapper.java b/src/main/java/com/codeit/team5/mopl/user/mapper/UserSummaryMapper.java new file mode 100644 index 00000000..1c359be1 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/user/mapper/UserSummaryMapper.java @@ -0,0 +1,14 @@ +package com.codeit.team5.mopl.user.mapper; + +import com.codeit.team5.mopl.global.mapper.GlobalMapperConfig; +import com.codeit.team5.mopl.user.dto.response.UserSummary; +import com.codeit.team5.mopl.user.entity.User; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(config = GlobalMapperConfig.class) +public interface UserSummaryMapper { + + @Mapping(target = "profileImageUrl", source = "profileImage.url") + UserSummary toDto(User user); +} From bdae7b40b119fc096a0e821007c68706fcd66337 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Tue, 30 Jun 2026 17:42:15 +0900 Subject: [PATCH 03/46] =?UTF-8?q?fix:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20N+1=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - findByEmail 메서드에 @EntityGraph를 적용하여 User 엔티티 조회 시 profileImage를 즉시 로딩하도록 변경했습니다. - 사용자 정보 조회 시 발생하던 N+1 쿼리 문제를 해결하고 애플리케이션 성능을 개선합니다. --- .../com/codeit/team5/mopl/user/repository/UserRepository.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java b/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java index f3db86c5..44353172 100644 --- a/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java +++ b/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java @@ -13,6 +13,7 @@ public interface UserRepository extends JpaRepository, UserQueryRepository { boolean existsByEmail(String email); + @EntityGraph(attributePaths = {"profileImage"}) Optional findByEmail(String email); // refresh Token 재발급/저장을 직렬화하기 위함 From 460ea3f1fb0ec75a2bf9945e63e0b524190398cc Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Tue, 30 Jun 2026 17:42:56 +0900 Subject: [PATCH 04/46] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B4=80=EB=A0=A8=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 플레이리스트 도메인에서 발생할 수 있는 비즈니스 예외 처리를 위한 클래스 3종을 추가했습니다. - `PlaylistNotFoundException`: 특정 ID의 플레이리스트를 찾을 수 없을 때 발생합니다. - `PlaylistUserNotFoundException`: 플레이리스트 작업 시 해당 유저를 찾을 수 없을 때 발생합니다. - `PlaylistAccessDeniedException`: 플레이리스트에 대한 접근 권한이 없을 때 발생합니다. - 모든 예외 클래스는 `BusinessException`을 상속받아 일관된 방식으로 예외를 처리하도록 구현했습니다. --- .../exception/PlaylistAccessDeniedException.java | 14 ++++++++++++++ .../exception/PlaylistNotFoundException.java | 14 ++++++++++++++ .../exception/PlaylistUserNotFoundException.java | 13 +++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistAccessDeniedException.java create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistNotFoundException.java create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistUserNotFoundException.java diff --git a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistAccessDeniedException.java b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistAccessDeniedException.java new file mode 100644 index 00000000..6f2dfa4e --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistAccessDeniedException.java @@ -0,0 +1,14 @@ +package com.codeit.team5.mopl.playlist.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import java.util.Map; +import java.util.UUID; +import org.springframework.http.HttpStatus; + +public class PlaylistAccessDeniedException extends BusinessException { + + public PlaylistAccessDeniedException(UUID id, String email) { + super(HttpStatus.FORBIDDEN, "플레이리스트 접근 권한이 없습니다.", + Map.of("playlistId", id, "email", email)); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistNotFoundException.java b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistNotFoundException.java new file mode 100644 index 00000000..8823e009 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistNotFoundException.java @@ -0,0 +1,14 @@ +package com.codeit.team5.mopl.playlist.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import java.util.Map; +import java.util.UUID; +import org.springframework.http.HttpStatus; + +public class PlaylistNotFoundException extends BusinessException { + + public PlaylistNotFoundException(UUID id) { + super(HttpStatus.BAD_REQUEST, "플레이리스트를 찾을 수 없습니다.", + Map.of("playlistId", id)); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistUserNotFoundException.java b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistUserNotFoundException.java new file mode 100644 index 00000000..3f358f19 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistUserNotFoundException.java @@ -0,0 +1,13 @@ +package com.codeit.team5.mopl.playlist.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import java.util.Map; +import org.springframework.http.HttpStatus; + +public class PlaylistUserNotFoundException extends BusinessException { + + public PlaylistUserNotFoundException(String email) { + super(HttpStatus.BAD_REQUEST, "유저를 찾을 수 없습니다.", + Map.of("email", email)); + } +} From 6dbc0e0f49c049f922682583ffebff6816f86d15 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Tue, 30 Jun 2026 17:43:33 +0900 Subject: [PATCH 05/46] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - `PlaylistService` 클래스를 추가하여 플레이리스트의 핵심 비즈니스 로직을 구현했습니다. - 플레이리스트 생성, 단건 조회, 수정, 삭제 기능을 제공합니다. - 플레이리스트 생성 시 사용자 존재 여부를 확인하고, 수정 및 삭제 시에는 플레이리스트 존재 여부와 소유자 검증을 수행하여 데이터 무결성과 보안을 강화했습니다. - 관련 예외 처리 로직을 포함하여 서비스 안정성을 높였습니다. --- .../playlist/service/PlaylistService.java | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java diff --git a/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java new file mode 100644 index 00000000..5895f3b7 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java @@ -0,0 +1,65 @@ +package com.codeit.team5.mopl.playlist.service; + +import com.codeit.team5.mopl.playlist.dto.request.PlaylistCreateRequest; +import com.codeit.team5.mopl.playlist.dto.request.PlaylistUpdateRequest; +import com.codeit.team5.mopl.playlist.dto.response.PlaylistResponse; +import com.codeit.team5.mopl.playlist.entity.Playlist; +import com.codeit.team5.mopl.playlist.exception.PlaylistAccessDeniedException; +import com.codeit.team5.mopl.playlist.exception.PlaylistNotFoundException; +import com.codeit.team5.mopl.playlist.exception.PlaylistUserNotFoundException; +import com.codeit.team5.mopl.playlist.mapper.PlaylistMapper; +import com.codeit.team5.mopl.playlist.repository.PlaylistRepository; +import com.codeit.team5.mopl.user.entity.User; +import com.codeit.team5.mopl.user.repository.UserRepository; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class PlaylistService { + + private final PlaylistMapper mapper; + private final PlaylistRepository repository; + private final UserRepository userRepository; + + @Transactional + public PlaylistResponse create(String email, PlaylistCreateRequest request) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new PlaylistUserNotFoundException(email)); + Playlist playlist = Playlist.of(user, request.title(), request.description()); + return mapper.toDto(playlist); + } + + public PlaylistResponse find(UUID id) { + return mapper.toDto(findById(id)); + } + + @Transactional + public PlaylistResponse update(UUID id, String email, PlaylistUpdateRequest request) { + validateOwner(id, email); + Playlist playlist = findById(id); + playlist.updateTitle(request.title()); + playlist.updateDescription(request.description()); + return mapper.toDto(playlist); + } + + @Transactional + public void delete(UUID id, String email) { + validateOwner(id, email); + repository.deleteByIdDirectly(id); + } + + private Playlist findById(UUID id) { + return repository.findOwnerById(id).orElseThrow(() -> new PlaylistNotFoundException(id)); + } + + private void validateOwner(UUID id, String email) { + if (repository.existsByIdAndOwnerEmail(id, email)) { + return; + } + throw new PlaylistAccessDeniedException(id, email); + } +} From c748766f5bf17a4e8bfbb467518083b02f59089b Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Tue, 30 Jun 2026 17:43:50 +0900 Subject: [PATCH 06/46] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20DTO=20=EB=B0=8F=20=EB=A7=A4?= =?UTF-8?q?=ED=8D=BC=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - `PlaylistResponse` DTO를 추가하여 플레이리스트 정보를 클라이언트에 전달하기 위한 응답 형식을 정의했습니다. - `PlaylistCreateRequest` 및 `PlaylistUpdateRequest` DTO를 추가하여 플레이리스트 생성 및 수정을 위한 요청 형식을 정의했습니다. - `PlaylistMapper` 인터페이스를 추가하여 MapStruct를 이용해 `Playlist` 엔티티를 `PlaylistResponse` DTO로 변환하는 매퍼를 정의했습니다. - 이 변경사항들은 향후 플레이리스트 CRUD 기능 구현의 기반을 마련합니다. --- .../dto/request/PlaylistCreateRequest.java | 8 ++++++++ .../dto/request/PlaylistUpdateRequest.java | 5 +++++ .../playlist/dto/response/PlaylistResponse.java | 15 +++++++++++++++ .../mopl/playlist/mapper/PlaylistMapper.java | 13 +++++++++++++ 4 files changed, 41 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCreateRequest.java create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistUpdateRequest.java create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/dto/response/PlaylistResponse.java create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/mapper/PlaylistMapper.java diff --git a/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCreateRequest.java b/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCreateRequest.java new file mode 100644 index 00000000..0438b820 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCreateRequest.java @@ -0,0 +1,8 @@ +package com.codeit.team5.mopl.playlist.dto.request; + +import jakarta.validation.constraints.NotEmpty; + +public record PlaylistCreateRequest(@NotEmpty String title, + @NotEmpty String description) { + +} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistUpdateRequest.java b/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistUpdateRequest.java new file mode 100644 index 00000000..870ca7ef --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistUpdateRequest.java @@ -0,0 +1,5 @@ +package com.codeit.team5.mopl.playlist.dto.request; + +public record PlaylistUpdateRequest(String title, String description) { + +} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/dto/response/PlaylistResponse.java b/src/main/java/com/codeit/team5/mopl/playlist/dto/response/PlaylistResponse.java new file mode 100644 index 00000000..c09537bb --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/dto/response/PlaylistResponse.java @@ -0,0 +1,15 @@ +package com.codeit.team5.mopl.playlist.dto.response; + +import com.codeit.team5.mopl.user.dto.response.UserSummary; +import java.time.Instant; +import java.util.UUID; + +public record PlaylistResponse(UUID id, + UserSummary owner, + Instant updatedAt, + String title, + String description, + Integer subscriberCount, + boolean subscribedByMe) { + +} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/mapper/PlaylistMapper.java b/src/main/java/com/codeit/team5/mopl/playlist/mapper/PlaylistMapper.java new file mode 100644 index 00000000..bc8c6e21 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/mapper/PlaylistMapper.java @@ -0,0 +1,13 @@ +package com.codeit.team5.mopl.playlist.mapper; + +import com.codeit.team5.mopl.global.mapper.GlobalMapperConfig; +import com.codeit.team5.mopl.playlist.dto.response.PlaylistResponse; +import com.codeit.team5.mopl.playlist.entity.Playlist; +import com.codeit.team5.mopl.user.mapper.UserSummaryMapper; +import org.mapstruct.Mapper; + +@Mapper(config = GlobalMapperConfig.class, uses = {UserSummaryMapper.class}) +public interface PlaylistMapper { + + PlaylistResponse toDto(Playlist playlist); +} From 7710d58406566fe2c443c1e6a50871c3116993df Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Tue, 30 Jun 2026 17:44:05 +0900 Subject: [PATCH 07/46] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=A6=AC=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=B4=88=EA=B8=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - PlaylistRepository 인터페이스를 추가하여 플레이리스트 엔티티의 영속성 계층을 정의했습니다. - JpaRepository를 상속받아 기본적인 CRUD 기능을 제공합니다. - @EntityGraph를 사용하여 Playlist 조회 시 owner 및 owner.profileImage를 즉시 로딩하는 findOwnerById 메서드를 추가했습니다. - 특정 ID와 소유자 이메일로 플레이리스트 존재 여부를 확인하는 existsByIdAndOwnerEmail 메서드를 추가했습니다. - @Modifying과 @Query를 사용하여 ID로 플레이리스트를 직접 삭제하는 deleteByIdDirectly 메서드를 추가했습니다. --- .../repository/PlaylistRepository.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepository.java diff --git a/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepository.java b/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepository.java new file mode 100644 index 00000000..1d22282a --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepository.java @@ -0,0 +1,21 @@ +package com.codeit.team5.mopl.playlist.repository; + +import com.codeit.team5.mopl.playlist.entity.Playlist; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +public interface PlaylistRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"owner", "owner.profileImage"}) + Optional findOwnerById(UUID id); + + boolean existsByIdAndOwnerEmail(UUID id, String email); + + @Modifying + @Query("DELETE FROM Playlist p WHERE p.id = :id") + void deleteByIdDirectly(UUID id); +} From f43b8e3499fc93c2a7ef2671901ca4e73eaf1720 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Tue, 30 Jun 2026 17:44:58 +0900 Subject: [PATCH 08/46] =?UTF-8?q?refactor:=20Playlist=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=ED=95=84=EB=93=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - Playlist 엔티티에 `updateTitle`, `updateDescription` 메서드를 추가하여 필드 업데이트 로직을 캡슐화했습니다. - `updateIfChanged` 헬퍼 메서드를 통해 새로운 값이 유효하고 기존 값과 다를 경우에만 업데이트가 수행되도록 하여 불필요한 변경을 방지합니다. - 엔티티의 불변성을 강화하고, 필드 변경에 대한 제어권을 높여 데이터 무결성을 확보합니다. - 기존 `lombok.Setter` 사용을 지양하고, 엔티티의 비즈니스 로직을 엔티티 내부에 응집시켰습니다. - 엔티티에 직접 적용되었던 `@NotNull`, `@Size` 등의 유효성 검사 어노테이션을 제거했습니다. --- .../team5/mopl/playlist/entity/Playlist.java | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/playlist/entity/Playlist.java b/src/main/java/com/codeit/team5/mopl/playlist/entity/Playlist.java index 78ada9b7..271a5dd1 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/entity/Playlist.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/entity/Playlist.java @@ -3,24 +3,18 @@ import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; import com.codeit.team5.mopl.user.entity.User; import io.jsonwebtoken.lang.Assert; -import jakarta.persistence.AttributeOverride; -import jakarta.persistence.AttributeOverrides; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; +import java.util.function.Consumer; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; -import org.hibernate.annotations.ColumnDefault; -import org.hibernate.annotations.OnDelete; -import org.hibernate.annotations.OnDeleteAction; +import org.springframework.util.StringUtils; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -59,4 +53,19 @@ public static Playlist of(User owner, String title, String description) { .subscriberCount(0) .build(); } + + public void updateTitle(String title) { + updateIfChanged(this.title, title, value -> this.title = value); + } + + public void updateDescription(String description) { + updateIfChanged(this.description, description, value -> this.description = value); + } + + private void updateIfChanged(String oldValue, String newValue, Consumer update) { + if (!StringUtils.hasText(newValue) || oldValue.equals(newValue)) { + return; + } + update.accept(newValue); + } } From b9bd252f2eaf9704e8de918790f192806fafcb9d Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Tue, 30 Jun 2026 17:54:53 +0900 Subject: [PATCH 09/46] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20API=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=B4=88=EA=B8=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - PlaylistController를 추가하여 플레이리스트 관련 REST API 엔드포인트를 정의했습니다. - 플레이리스트 생성, 조회, 수정, 삭제 기능을 제공합니다. - GET /{id}: 특정 플레이리스트를 ID로 조회합니다. - POST /: 새로운 플레이리스트를 생성합니다. - PATCH /{id}: 기존 플레이리스트의 정보를 수정합니다. - DELETE /{id}: 특정 플레이리스트를 삭제합니다. - Principal 객체를 활용하여 사용자 인증 정보를 기반으로 작업을 수행합니다. --- .../controller/PlaylistController.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java diff --git a/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java b/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java new file mode 100644 index 00000000..2ce8705c --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java @@ -0,0 +1,53 @@ +package com.codeit.team5.mopl.playlist.controller; + +import com.codeit.team5.mopl.playlist.dto.request.PlaylistCreateRequest; +import com.codeit.team5.mopl.playlist.dto.request.PlaylistUpdateRequest; +import com.codeit.team5.mopl.playlist.dto.response.PlaylistResponse; +import com.codeit.team5.mopl.playlist.service.PlaylistService; +import java.security.Principal; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/playlists") +@RequiredArgsConstructor +public class PlaylistController { + + private final PlaylistService service; + + @GetMapping("/{id}") + public ResponseEntity find(@PathVariable UUID id) { + return ResponseEntity.status(HttpStatus.OK).body(service.find(id)); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity create(Principal principal, + @RequestBody PlaylistCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(service.create(principal.getName(), request)); + } + + @PatchMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity update(Principal principal, @PathVariable UUID id, @RequestBody + PlaylistUpdateRequest request) { + return ResponseEntity.status(HttpStatus.OK) + .body(service.update(id, principal.getName(), request)); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(Principal principal, @PathVariable UUID id) { + service.delete(id, principal.getName()); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } +} From 6d3c4ee8a4ddc502a85ae9404202ab2efa892745 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Wed, 1 Jul 2026 10:03:53 +0900 Subject: [PATCH 10/46] =?UTF-8?q?feat:=20=EA=B5=AC=EB=8F=85=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 플레이리스트 구독 기능 구현을 위한 핵심 엔티티 및 예외 클래스를 추가했습니다. - Subscription 엔티티는 Playlist와 User 간의 구독 관계를 나타내며, 불변(immutable) 객체로 설계되었습니다. - SubscriptionPlaylistNotFoundException과 SubscriptionUserNotFoundException은 구독 대상(플레이리스트 또는 유저)을 찾지 못했을 때 발생하는 비즈니스 예외를 처리합니다. --- .../subscription/entity/Subscription.java | 45 +++++++++++++++++++ ...SubscriptionPlaylistNotFoundException.java | 13 ++++++ .../SubscriptionUserNotFoundException.java | 12 +++++ 3 files changed, 70 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/subscription/entity/Subscription.java create mode 100644 src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionPlaylistNotFoundException.java create mode 100644 src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionUserNotFoundException.java diff --git a/src/main/java/com/codeit/team5/mopl/subscription/entity/Subscription.java b/src/main/java/com/codeit/team5/mopl/subscription/entity/Subscription.java new file mode 100644 index 00000000..0a4e01b9 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/subscription/entity/Subscription.java @@ -0,0 +1,45 @@ +package com.codeit.team5.mopl.subscription.entity; + +import com.codeit.team5.mopl.global.entity.BaseEntity; +import com.codeit.team5.mopl.playlist.entity.Playlist; +import com.codeit.team5.mopl.user.entity.User; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.Immutable; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Immutable +@Entity +@Table(name = "playlist_subscriptions") +public class Subscription extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "playlist_id", nullable = false) + private Playlist playlist; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "subscriber_id", nullable = false) + private User subscriber; + + private Subscription(Playlist playlist, User subscriber) { + this.playlist = playlist; + this.subscriber = subscriber; + } + + public static Subscription of(Playlist playlist, User subscriber) { + return new Subscription(playlist, subscriber); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionPlaylistNotFoundException.java b/src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionPlaylistNotFoundException.java new file mode 100644 index 00000000..bbbdefff --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionPlaylistNotFoundException.java @@ -0,0 +1,13 @@ +package com.codeit.team5.mopl.subscription.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import java.util.Map; +import java.util.UUID; +import org.springframework.http.HttpStatus; + +public class SubscriptionPlaylistNotFoundException extends BusinessException { + + public SubscriptionPlaylistNotFoundException(UUID playlistId) { + super(HttpStatus.NOT_FOUND, "플레이리스트를 찾을 수 없습니다.", Map.of("playlistId", playlistId)); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionUserNotFoundException.java b/src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionUserNotFoundException.java new file mode 100644 index 00000000..b2f3904c --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionUserNotFoundException.java @@ -0,0 +1,12 @@ +package com.codeit.team5.mopl.subscription.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import java.util.Map; +import org.springframework.http.HttpStatus; + +public class SubscriptionUserNotFoundException extends BusinessException { + + public SubscriptionUserNotFoundException(String email) { + super(HttpStatus.NOT_FOUND, "유저를 찾을 수 없습니다.", Map.of("email", email)); + } +} From 8bbcce164099c5c2d51990c263069e6f28621d3d Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Wed, 1 Jul 2026 10:04:35 +0900 Subject: [PATCH 11/46] =?UTF-8?q?feat:=20=EA=B5=AC=EB=8F=85=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20API=20=EB=B0=8F=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 플레이리스트 구독 생성 API를 구현했습니다. - 구독 생성 시 플레이리스트와 사용자를 확인하는 로직을 추가했습니다. - 구독 생성에 필요한 Repository, Service, Controller를 구현했습니다. --- .../controller/SubscriptionController.java | 26 ++++++++++++++ .../repository/SubscriptionRepository.java | 9 +++++ .../service/SubscriptionService.java | 35 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/subscription/controller/SubscriptionController.java create mode 100644 src/main/java/com/codeit/team5/mopl/subscription/repository/SubscriptionRepository.java create mode 100644 src/main/java/com/codeit/team5/mopl/subscription/service/SubscriptionService.java diff --git a/src/main/java/com/codeit/team5/mopl/subscription/controller/SubscriptionController.java b/src/main/java/com/codeit/team5/mopl/subscription/controller/SubscriptionController.java new file mode 100644 index 00000000..1c65eced --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/subscription/controller/SubscriptionController.java @@ -0,0 +1,26 @@ +package com.codeit.team5.mopl.subscription.controller; + +import com.codeit.team5.mopl.subscription.service.SubscriptionService; +import java.security.Principal; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class SubscriptionController { + + private final SubscriptionService service; + + @PostMapping("/playlists/{playlistId}/subscription") + public ResponseEntity create(@PathVariable UUID playlistId, Principal principal) { + service.create(playlistId, principal.getName()); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/subscription/repository/SubscriptionRepository.java b/src/main/java/com/codeit/team5/mopl/subscription/repository/SubscriptionRepository.java new file mode 100644 index 00000000..317c9958 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/subscription/repository/SubscriptionRepository.java @@ -0,0 +1,9 @@ +package com.codeit.team5.mopl.subscription.repository; + +import com.codeit.team5.mopl.subscription.entity.Subscription; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SubscriptionRepository extends JpaRepository { + +} diff --git a/src/main/java/com/codeit/team5/mopl/subscription/service/SubscriptionService.java b/src/main/java/com/codeit/team5/mopl/subscription/service/SubscriptionService.java new file mode 100644 index 00000000..e7dfba41 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/subscription/service/SubscriptionService.java @@ -0,0 +1,35 @@ +package com.codeit.team5.mopl.subscription.service; + +import com.codeit.team5.mopl.playlist.entity.Playlist; +import com.codeit.team5.mopl.playlist.repository.PlaylistRepository; +import com.codeit.team5.mopl.subscription.entity.Subscription; +import com.codeit.team5.mopl.subscription.exception.SubscriptionPlaylistNotFoundException; +import com.codeit.team5.mopl.subscription.exception.SubscriptionUserNotFoundException; +import com.codeit.team5.mopl.subscription.repository.SubscriptionRepository; +import com.codeit.team5.mopl.user.entity.User; +import com.codeit.team5.mopl.user.repository.UserRepository; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class SubscriptionService { + + private final SubscriptionRepository repository; + private final UserRepository userRepository; + private final PlaylistRepository playlistRepository; + + @Transactional + public void create(UUID playlistId, String email) { + if (!playlistRepository.existsById(playlistId)) { + throw new SubscriptionPlaylistNotFoundException(playlistId); + } + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new SubscriptionUserNotFoundException(email)); + Playlist playlist = playlistRepository.getReferenceById(playlistId); + repository.save(Subscription.of(playlist, user)); + } +} From 7cf541ce0db31d83b986a8c220b1d2e9c5d08b76 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Wed, 1 Jul 2026 10:25:38 +0900 Subject: [PATCH 12/46] =?UTF-8?q?feat:=20=EA=B5=AC=EB=8F=85=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EA=B8=B0=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 --- 본문 --- - 플레이리스트 구독 취소 API를 추가했습니다. - 구독 취소 시 해당 구독 정보를 삭제합니다. - 구독 정보가 존재하지 않으면 `SubscriptionNotFoundException`을 발생시킵니다. --- .../controller/SubscriptionController.java | 8 ++++++++ .../exception/SubscriptionNotFoundException.java | 14 ++++++++++++++ .../repository/SubscriptionRepository.java | 7 +++++++ .../subscription/service/SubscriptionService.java | 10 ++++++++++ 4 files changed, 39 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionNotFoundException.java diff --git a/src/main/java/com/codeit/team5/mopl/subscription/controller/SubscriptionController.java b/src/main/java/com/codeit/team5/mopl/subscription/controller/SubscriptionController.java index 1c65eced..e5195706 100644 --- a/src/main/java/com/codeit/team5/mopl/subscription/controller/SubscriptionController.java +++ b/src/main/java/com/codeit/team5/mopl/subscription/controller/SubscriptionController.java @@ -4,8 +4,10 @@ import java.security.Principal; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.apache.coyote.Response; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -23,4 +25,10 @@ public ResponseEntity create(@PathVariable UUID playlistId, Principal prin service.create(playlistId, principal.getName()); return ResponseEntity.status(HttpStatus.CREATED).build(); } + + @DeleteMapping("/playlists/{playlistId}/subscription") + public ResponseEntity delete(@PathVariable UUID playlistId, Principal principal) { + service.delete(playlistId, principal.getName()); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } } diff --git a/src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionNotFoundException.java b/src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionNotFoundException.java new file mode 100644 index 00000000..aaaf653a --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionNotFoundException.java @@ -0,0 +1,14 @@ +package com.codeit.team5.mopl.subscription.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import java.util.Map; +import java.util.UUID; +import org.springframework.http.HttpStatus; + +public class SubscriptionNotFoundException extends BusinessException { + + public SubscriptionNotFoundException(UUID playlistId, String email) { + super(HttpStatus.NOT_FOUND, "유저의 플레이리스트를 찾을 수 없습니다.", + Map.of("playlistId", playlistId, "email", email)); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/subscription/repository/SubscriptionRepository.java b/src/main/java/com/codeit/team5/mopl/subscription/repository/SubscriptionRepository.java index 317c9958..7aaec1b1 100644 --- a/src/main/java/com/codeit/team5/mopl/subscription/repository/SubscriptionRepository.java +++ b/src/main/java/com/codeit/team5/mopl/subscription/repository/SubscriptionRepository.java @@ -3,7 +3,14 @@ import com.codeit.team5.mopl.subscription.entity.Subscription; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; public interface SubscriptionRepository extends JpaRepository { + boolean existsBySubscriberEmailAndPlaylistId(String email, UUID playlistId); + + @Modifying + @Query("delete from Subscription s where s.subscriber.email = :email and s.playlist.id = :playlistId") + void deleteBySubscriberEmailAndPlaylistIdDirectly(String email, UUID playlistId); } diff --git a/src/main/java/com/codeit/team5/mopl/subscription/service/SubscriptionService.java b/src/main/java/com/codeit/team5/mopl/subscription/service/SubscriptionService.java index e7dfba41..5c384d51 100644 --- a/src/main/java/com/codeit/team5/mopl/subscription/service/SubscriptionService.java +++ b/src/main/java/com/codeit/team5/mopl/subscription/service/SubscriptionService.java @@ -3,6 +3,7 @@ import com.codeit.team5.mopl.playlist.entity.Playlist; import com.codeit.team5.mopl.playlist.repository.PlaylistRepository; import com.codeit.team5.mopl.subscription.entity.Subscription; +import com.codeit.team5.mopl.subscription.exception.SubscriptionNotFoundException; import com.codeit.team5.mopl.subscription.exception.SubscriptionPlaylistNotFoundException; import com.codeit.team5.mopl.subscription.exception.SubscriptionUserNotFoundException; import com.codeit.team5.mopl.subscription.repository.SubscriptionRepository; @@ -32,4 +33,13 @@ public void create(UUID playlistId, String email) { Playlist playlist = playlistRepository.getReferenceById(playlistId); repository.save(Subscription.of(playlist, user)); } + + @Transactional + public void delete(UUID playlistId, String email) { + if (repository.existsBySubscriberEmailAndPlaylistId(email, playlistId)) { + repository.deleteBySubscriberEmailAndPlaylistIdDirectly(email, playlistId); + return; + } + throw new SubscriptionNotFoundException(playlistId, email); + } } From a6203bfea6e0e4236749e08daf398e66fef8d868 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Wed, 1 Jul 2026 23:56:44 +0900 Subject: [PATCH 13/46] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=BB=A4=EC=84=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EA=B5=AC=ED=98=84=20---=20-=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EC=9A=B4=20=ED=94=8C=EB=A0=88=EC=9D=B4=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=96=88=EC=8A=B5=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.=20-=20=EA=B8=B0=EC=A1=B4=20=EC=98=A4=ED=94=84?= =?UTF-8?q?=EC=85=8B=20=EA=B8=B0=EB=B0=98=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EC=85=98=EC=9D=98=20=ED=95=9C=EA=B3=84?= =?UTF-8?q?=EB=A5=BC=20=EA=B7=B9=EB=B3=B5=ED=95=98=EA=B3=A0=20=EB=8C=80?= =?UTF-8?q?=EA=B7=9C=EB=AA=A8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=85=8B=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=97=90=20=ED=9A=A8=EC=9C=A8=EC=A0=81?= =?UTF-8?q?=EC=9D=B8=20=EC=BB=A4=EC=84=9C=20=EA=B8=B0=EB=B0=98=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=EC=9D=84=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=ED=95=A9=EB=8B=88=EB=8B=A4.=20-=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EA=B8=B0=EC=A4=80(`PlaylistSortBy`)=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BB=A4=EC=84=9C=20=EA=B0=92=EC=9D=98=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=EC=84=B1=EC=9D=84=20=EA=B2=80=EC=A6=9D=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EA=B3=BC=20=EA=B4=80=EB=A0=A8=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=EB=A5=BC=20=EC=A0=95=EC=9D=98=ED=95=98=EC=97=AC=20API?= =?UTF-8?q?=EC=9D=98=20=EC=95=88=EC=A0=95=EC=84=B1=EC=9D=84=20=EB=86=92?= =?UTF-8?q?=EC=98=80=EC=8A=B5=EB=8B=88=EB=8B=A4.=20-=20=EC=9D=B4=EB=A5=BC?= =?UTF-8?q?=20=ED=86=B5=ED=95=B4=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=EB=8A=94=20=EB=8D=94=20=EB=B9=A0=EB=A5=B4=EA=B3=A0=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=80=EB=90=9C=20=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=94=8C=EB=A0=88=EC=9D=B4=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20=ED=83=90=EC=83=89?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=9E=88=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playlist/constant/PlaylistSortBy.java | 36 +++++++++++++++++++ .../controller/PlaylistController.java | 27 ++++++++++---- .../playlist/dto/PlaylistCursorCommand.java | 12 +++++++ .../dto/request/PlaylistCursorRequest.java | 17 +++++++++ .../PlaylistIncorrectSortByException.java | 12 +++++++ .../PlaylistSortByMismatchException.java | 14 ++++++++ .../mopl/playlist/mapper/PlaylistMapper.java | 24 ++++++++++++- .../query/PlaylistQueryRepository.java | 10 ++++++ 8 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/constant/PlaylistSortBy.java create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/dto/PlaylistCursorCommand.java create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCursorRequest.java create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistIncorrectSortByException.java create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistSortByMismatchException.java create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepository.java diff --git a/src/main/java/com/codeit/team5/mopl/playlist/constant/PlaylistSortBy.java b/src/main/java/com/codeit/team5/mopl/playlist/constant/PlaylistSortBy.java new file mode 100644 index 00000000..8908cbc3 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/constant/PlaylistSortBy.java @@ -0,0 +1,36 @@ +package com.codeit.team5.mopl.playlist.constant; + +import com.codeit.team5.mopl.playlist.exception.PlaylistIncorrectSortByException; +import com.codeit.team5.mopl.playlist.exception.PlaylistSortByMismatchException; +import java.time.Instant; +import java.util.function.Function; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PlaylistSortBy { + UPDATED_AT("updatedAt", Instant::parse), + SUBSCRIBE_COUNT("subscribeCount", Integer::parseInt), + ; + private final String sortByType; + private final Function parser; + + public static PlaylistSortBy from(String text) { + for (PlaylistSortBy value : PlaylistSortBy.values()) { + if (value.getSortByType().equalsIgnoreCase(text)) { + return value; + } + } + throw new PlaylistIncorrectSortByException(text); + } + + public Object parse(String cursor) { + try { + return parser.apply(cursor); + } catch (Exception ex) { + throw new PlaylistSortByMismatchException(sortByType, cursor); + } + + } +} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java b/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java index 2ce8705c..c01567dd 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java @@ -1,23 +1,28 @@ package com.codeit.team5.mopl.playlist.controller; -import com.codeit.team5.mopl.playlist.dto.request.PlaylistCreateRequest; -import com.codeit.team5.mopl.playlist.dto.request.PlaylistUpdateRequest; -import com.codeit.team5.mopl.playlist.dto.response.PlaylistResponse; -import com.codeit.team5.mopl.playlist.service.PlaylistService; import java.security.Principal; import java.util.UUID; -import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.codeit.team5.mopl.global.dto.CursorResponse; +import com.codeit.team5.mopl.playlist.dto.request.PlaylistCreateRequest; +import com.codeit.team5.mopl.playlist.dto.request.PlaylistCursorRequest; +import com.codeit.team5.mopl.playlist.dto.request.PlaylistUpdateRequest; +import com.codeit.team5.mopl.playlist.dto.response.PlaylistResponse; +import com.codeit.team5.mopl.playlist.mapper.PlaylistMapper; +import com.codeit.team5.mopl.playlist.service.PlaylistService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; @RestController @RequestMapping("/api/playlists") @@ -25,12 +30,20 @@ public class PlaylistController { private final PlaylistService service; + private final PlaylistMapper mapper; @GetMapping("/{id}") public ResponseEntity find(@PathVariable UUID id) { return ResponseEntity.status(HttpStatus.OK).body(service.find(id)); } + @GetMapping + public ResponseEntity> findCursor( + @ModelAttribute @Valid PlaylistCursorRequest request) { + return ResponseEntity.status(HttpStatus.OK) + .body(service.findByCursor(mapper.toCommand(request))); + } + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity create(Principal principal, @RequestBody PlaylistCreateRequest request) { @@ -39,8 +52,8 @@ public ResponseEntity create(Principal principal, } @PatchMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity update(Principal principal, @PathVariable UUID id, @RequestBody - PlaylistUpdateRequest request) { + public ResponseEntity update(Principal principal, @PathVariable UUID id, + @RequestBody PlaylistUpdateRequest request) { return ResponseEntity.status(HttpStatus.OK) .body(service.update(id, principal.getName(), request)); } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/dto/PlaylistCursorCommand.java b/src/main/java/com/codeit/team5/mopl/playlist/dto/PlaylistCursorCommand.java new file mode 100644 index 00000000..0cde3990 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/dto/PlaylistCursorCommand.java @@ -0,0 +1,12 @@ +package com.codeit.team5.mopl.playlist.dto; + +import com.codeit.team5.mopl.playlist.constant.PlaylistSortBy; +import org.springframework.data.domain.Sort; +import java.util.UUID; +import lombok.Builder; + +@Builder +public record PlaylistCursorCommand(String keywordLike, UUID ownerIdEqual, UUID subscriberIdEqual, + Object cursor, UUID idAfter, Integer limit, Sort.Direction sortDirection, + PlaylistSortBy sortBy) { +} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCursorRequest.java b/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCursorRequest.java new file mode 100644 index 00000000..1e92d180 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCursorRequest.java @@ -0,0 +1,17 @@ +package com.codeit.team5.mopl.playlist.dto.request; + +import java.util.UUID; +import org.springframework.data.domain.Sort; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record PlaylistCursorRequest(String keywordLike, + UUID ownerIdEqual, + UUID subscriberIdEqual, + String cursor, + UUID idAfter, + @NotNull @Positive Integer limit, + @NotNull Sort.Direction sortDirection, + @NotNull String sortBy) { + +} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistIncorrectSortByException.java b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistIncorrectSortByException.java new file mode 100644 index 00000000..c4f4a8df --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistIncorrectSortByException.java @@ -0,0 +1,12 @@ +package com.codeit.team5.mopl.playlist.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import java.util.Map; +import org.springframework.http.HttpStatus; + +public class PlaylistIncorrectSortByException extends BusinessException { + + public PlaylistIncorrectSortByException(String sortBy) { + super(HttpStatus.BAD_REQUEST, "SortBy 입력값이 올바르지 않습니다.", Map.of("incorrectSortBy", sortBy)); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistSortByMismatchException.java b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistSortByMismatchException.java new file mode 100644 index 00000000..73d808af --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistSortByMismatchException.java @@ -0,0 +1,14 @@ +package com.codeit.team5.mopl.playlist.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import java.util.Map; +import org.springframework.http.HttpStatus; + +public class PlaylistSortByMismatchException extends BusinessException { + + public PlaylistSortByMismatchException(String sortByType, String cursor) { + super(HttpStatus.BAD_REQUEST, + "정렬 기준과 커서 타입이 일치하지 않습니다. (허용 포맷: updatedAt -> 예: 2026-07-01T10:00:00Z, subscribeCount -> 숫자)", + Map.of("sortBy", sortByType, "cursor", cursor)); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/mapper/PlaylistMapper.java b/src/main/java/com/codeit/team5/mopl/playlist/mapper/PlaylistMapper.java index bc8c6e21..f992cd5d 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/mapper/PlaylistMapper.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/mapper/PlaylistMapper.java @@ -1,13 +1,35 @@ package com.codeit.team5.mopl.playlist.mapper; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import com.codeit.team5.mopl.global.mapper.GlobalMapperConfig; +import com.codeit.team5.mopl.playlist.constant.PlaylistSortBy; +import com.codeit.team5.mopl.playlist.dto.PlaylistCursorCommand; +import com.codeit.team5.mopl.playlist.dto.request.PlaylistCursorRequest; import com.codeit.team5.mopl.playlist.dto.response.PlaylistResponse; import com.codeit.team5.mopl.playlist.entity.Playlist; import com.codeit.team5.mopl.user.mapper.UserSummaryMapper; -import org.mapstruct.Mapper; +import jakarta.inject.Named; @Mapper(config = GlobalMapperConfig.class, uses = {UserSummaryMapper.class}) public interface PlaylistMapper { PlaylistResponse toDto(Playlist playlist); + + @Mapping(target = "sortBy", source = "sortBy", qualifiedByName = "toSortBy") + @Mapping(target = "cursor", source = "cursor", qualifiedByName = "toCursor") + PlaylistCursorCommand toCommand(PlaylistCursorRequest request); + + @Named("toCursor") + default Object parseCursor(PlaylistCursorRequest request) { + if (request.cursor() == null) { + return null; + } + return toSortBy(request.sortBy()).parse(request.cursor()); + } + + @Named("toSortBy") + default PlaylistSortBy toSortBy(String sortBy) { + return PlaylistSortBy.from(sortBy); + } } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepository.java b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepository.java new file mode 100644 index 00000000..f3698e22 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepository.java @@ -0,0 +1,10 @@ +package com.codeit.team5.mopl.playlist.repository.query; + +import com.codeit.team5.mopl.playlist.dto.PlaylistCursorCommand; +import com.codeit.team5.mopl.playlist.entity.Playlist; +import java.util.List; + +public interface PlaylistQueryRepository { + + List findByCursor(PlaylistCursorCommand request); +} From 9e77f6cb39962e50870cedd4e04664644864ab86 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Thu, 2 Jul 2026 15:34:09 +0900 Subject: [PATCH 14/46] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=BB=A4=EC=84=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 플레이리스트 목록을 효율적으로 조회하기 위한 커서 기반 페이지네이션을 구현했습니다. - QueryDSL을 활용하여 동적으로 정렬 및 커서 조건을 적용하는 `PlaylistQueryRepository`를 추가했습니다. - `PlaylistService`에 `findByCursor` 메서드를 추가하여 커서 조회 로직을 처리하고, `PlaylistMapper`를 통해 `CursorResponse`를 생성하도록 했습니다. - `PlaylistMapper`에 `List`를 `List`로 변환하는 메서드와 `CursorResponse`를 생성하는 헬퍼 메서드를 추가했습니다. --- .../mopl/playlist/mapper/PlaylistMapper.java | 30 ++++- .../repository/PlaylistRepository.java | 5 +- .../query/PlaylistQueryRepository.java | 2 + .../query/PlaylistQueryRepositoryImpl.java | 108 ++++++++++++++++++ .../playlist/service/PlaylistService.java | 16 +++ 5 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java diff --git a/src/main/java/com/codeit/team5/mopl/playlist/mapper/PlaylistMapper.java b/src/main/java/com/codeit/team5/mopl/playlist/mapper/PlaylistMapper.java index f992cd5d..1b54d247 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/mapper/PlaylistMapper.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/mapper/PlaylistMapper.java @@ -1,7 +1,12 @@ package com.codeit.team5.mopl.playlist.mapper; +import java.util.List; +import org.mapstruct.IterableMapping; import org.mapstruct.Mapper; import org.mapstruct.Mapping; +import org.mapstruct.Named; +import org.mapstruct.NullValueMappingStrategy; +import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.global.mapper.GlobalMapperConfig; import com.codeit.team5.mopl.playlist.constant.PlaylistSortBy; import com.codeit.team5.mopl.playlist.dto.PlaylistCursorCommand; @@ -9,15 +14,17 @@ import com.codeit.team5.mopl.playlist.dto.response.PlaylistResponse; import com.codeit.team5.mopl.playlist.entity.Playlist; import com.codeit.team5.mopl.user.mapper.UserSummaryMapper; -import jakarta.inject.Named; @Mapper(config = GlobalMapperConfig.class, uses = {UserSummaryMapper.class}) public interface PlaylistMapper { PlaylistResponse toDto(Playlist playlist); + @IterableMapping(nullValueMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT) + List toDto(List playlists); + @Mapping(target = "sortBy", source = "sortBy", qualifiedByName = "toSortBy") - @Mapping(target = "cursor", source = "cursor", qualifiedByName = "toCursor") + @Mapping(target = "cursor", source = "request", qualifiedByName = "toCursor") PlaylistCursorCommand toCommand(PlaylistCursorRequest request); @Named("toCursor") @@ -32,4 +39,23 @@ default Object parseCursor(PlaylistCursorRequest request) { default PlaylistSortBy toSortBy(String sortBy) { return PlaylistSortBy.from(sortBy); } + + default CursorResponse toCursorResponse(List data, + PlaylistCursorCommand dto, boolean hasNext, long totalCount) { + String nextCursor = null; + String nextIdAfter = null; + if (!data.isEmpty()) { + Playlist last = data.get(data.size() - 1); + nextIdAfter = last.getId().toString(); + nextCursor = switch (dto.sortBy()) { + case UPDATED_AT -> last.getUpdatedAt().toString(); + case SUBSCRIBE_COUNT -> String.valueOf(last.getSubscriberCount()); + }; + } + + return CursorResponse.builder().data(toDto(data)).nextCursor(nextCursor) + .nextIdAfter(nextIdAfter).hasNext(hasNext).totalCount(totalCount) + .sortBy(dto.sortBy().getSortByType()).sortDirection(dto.sortDirection().name()) + .build(); + } } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepository.java b/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepository.java index 1d22282a..5c91edf5 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepository.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepository.java @@ -1,14 +1,15 @@ package com.codeit.team5.mopl.playlist.repository; -import com.codeit.team5.mopl.playlist.entity.Playlist; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import com.codeit.team5.mopl.playlist.entity.Playlist; +import com.codeit.team5.mopl.playlist.repository.query.PlaylistQueryRepository; -public interface PlaylistRepository extends JpaRepository { +public interface PlaylistRepository extends JpaRepository, PlaylistQueryRepository { @EntityGraph(attributePaths = {"owner", "owner.profileImage"}) Optional findOwnerById(UUID id); diff --git a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepository.java b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepository.java index f3698e22..4ab89477 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepository.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepository.java @@ -7,4 +7,6 @@ public interface PlaylistQueryRepository { List findByCursor(PlaylistCursorCommand request); + + long countByCommand(PlaylistCursorCommand request); } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java new file mode 100644 index 00000000..99e74062 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java @@ -0,0 +1,108 @@ +package com.codeit.team5.mopl.playlist.repository.query; + +import static com.codeit.team5.mopl.playlist.entity.QPlaylist.playlist; +import static com.codeit.team5.mopl.user.entity.QUser.user; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; +import com.codeit.team5.mopl.playlist.constant.PlaylistSortBy; +import com.codeit.team5.mopl.playlist.dto.PlaylistCursorCommand; +import com.codeit.team5.mopl.playlist.entity.Playlist; +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Ops; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class PlaylistQueryRepositoryImpl implements PlaylistQueryRepository { + + private final JPAQueryFactory queryFactory; + + private final Map SORT_PATH_MAP = + Map.of(PlaylistSortBy.UPDATED_AT, cursorBuilder(playlist.updatedAt), + PlaylistSortBy.SUBSCRIBE_COUNT, cursorBuilder(playlist.subscriberCount)); + + + @Override + public List findByCursor(PlaylistCursorCommand request) { + return queryFactory.selectFrom(playlist).leftJoin(playlist.owner, user).fetchJoin() + .where(keywordLikeQuery(request), ownerQuery(request), subscriberQuery(request), + cursorQuery(request)) + .orderBy(orderSpecifiers(request)).limit(request.limit() + 1).fetch(); + } + + @Override + public long countByCommand(PlaylistCursorCommand request) { + Long count = queryFactory.select(playlist.count()) + .from(playlist) + .where(keywordLikeQuery(request), ownerQuery(request), subscriberQuery(request)) + .fetchOne(); + return count != null ? count : 0L; + } + + private OrderSpecifier[] orderSpecifiers(PlaylistCursorCommand request) { + boolean isAsc = request.sortDirection().isAscending(); + OrderSpecifier primary = switch (request.sortBy()) { + case UPDATED_AT -> isAsc ? playlist.updatedAt.asc() : playlist.updatedAt.desc(); + case SUBSCRIBE_COUNT -> isAsc ? playlist.subscriberCount.asc() + : playlist.subscriberCount.desc(); + }; + OrderSpecifier secondary = isAsc ? playlist.id.asc() : playlist.id.desc(); + return new OrderSpecifier[] {primary, secondary}; + } + + private BooleanExpression cursorQuery(PlaylistCursorCommand request) { + Object cursor = request.cursor(); + UUID idAfter = request.idAfter(); + if (cursor == null || idAfter == null) { + return null; + } + + boolean isAsc = request.sortDirection().isAscending(); + return SORT_PATH_MAP.get(request.sortBy()).build(cursor, idAfter, isAsc); + } + + private BooleanExpression subscriberQuery(PlaylistCursorCommand request) { + if (request.subscriberIdEqual() == null) { + return null; + } + return playlist.owner.id.eq(request.subscriberIdEqual()); + } + + private BooleanExpression ownerQuery(PlaylistCursorCommand request) { + if (request.ownerIdEqual() == null) { + return null; + } + return playlist.owner.id.eq(request.ownerIdEqual()); + } + + private BooleanExpression keywordLikeQuery(PlaylistCursorCommand request) { + if (!StringUtils.hasText(request.keywordLike())) { + return null; + } + return playlist.description.containsIgnoreCase(request.keywordLike()) + .or(playlist.title.containsIgnoreCase(request.keywordLike())); + } + + @FunctionalInterface + private interface CursorConditionBuilder { + BooleanExpression build(Object cursor, UUID idAfter, boolean isAsc); + } + + private CursorConditionBuilder cursorBuilder(Expression path) { + return (cursor, idAfter, isAsc) -> { + Expression cursorConst = Expressions.constant(cursor); + BooleanExpression gt = Expressions.predicate(Ops.GT, path, cursorConst); + BooleanExpression lt = Expressions.predicate(Ops.LT, path, cursorConst); + BooleanExpression eq = Expressions.predicate(Ops.EQ, path, cursorConst); + return isAsc ? gt.or(eq.and(playlist.id.gt(idAfter))) + : lt.or(eq.and(playlist.id.lt(idAfter))); + }; + } +} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java index 5895f3b7..5ea06ef9 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java @@ -1,5 +1,8 @@ package com.codeit.team5.mopl.playlist.service; +import com.codeit.team5.mopl.global.dto.CursorResponse; +import com.codeit.team5.mopl.playlist.constant.PlaylistSortBy; +import com.codeit.team5.mopl.playlist.dto.PlaylistCursorCommand; import com.codeit.team5.mopl.playlist.dto.request.PlaylistCreateRequest; import com.codeit.team5.mopl.playlist.dto.request.PlaylistUpdateRequest; import com.codeit.team5.mopl.playlist.dto.response.PlaylistResponse; @@ -11,8 +14,13 @@ import com.codeit.team5.mopl.playlist.repository.PlaylistRepository; import com.codeit.team5.mopl.user.entity.User; import com.codeit.team5.mopl.user.repository.UserRepository; +import java.util.List; +import java.util.Map; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.ScrollPosition.Direction; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -52,6 +60,14 @@ public void delete(UUID id, String email) { repository.deleteByIdDirectly(id); } + public CursorResponse findByCursor(PlaylistCursorCommand dto) { + List playlists = repository.findByCursor(dto); + boolean hasNext = playlists.size() > dto.limit(); + List data = hasNext ? playlists.subList(0, dto.limit()) : playlists; + long totalCount = repository.countByCommand(dto); + return mapper.toCursorResponse(data, dto, hasNext, totalCount); + } + private Playlist findById(UUID id) { return repository.findOwnerById(id).orElseThrow(() -> new PlaylistNotFoundException(id)); } From 89d084309fa19ef48843a5c283e28305d9a8ea90 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Thu, 2 Jul 2026 15:34:28 +0900 Subject: [PATCH 15/46] =?UTF-8?q?refactor:=20CursorResponse=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EB=B0=A9=ED=96=A5=20=EA=B0=92=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - CursorResponse 레코드의 sortDirection() 메서드를 오버라이드하여 정렬 방향 값을 정규화했습니다. - "ASC", "ASCENDING", "DESC", "DESCENDING" 등 다양한 대소문자 및 약어 형태의 입력에 대해 일관된 "ASCENDING" 또는 "DESCENDING" 값을 반환하도록 하여, 정렬 방향 처리의 견고성과 일관성을 높였습니다. - null 값에 대한 처리 로직도 추가되었습니다. --- .../team5/mopl/global/dto/CursorResponse.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/codeit/team5/mopl/global/dto/CursorResponse.java b/src/main/java/com/codeit/team5/mopl/global/dto/CursorResponse.java index 5a83318f..8be4b37e 100644 --- a/src/main/java/com/codeit/team5/mopl/global/dto/CursorResponse.java +++ b/src/main/java/com/codeit/team5/mopl/global/dto/CursorResponse.java @@ -10,5 +10,19 @@ public record CursorResponse(List data, boolean hasNext, long totalCount, String sortBy, - String sortDirection) { + String sortDirection) { + + @Override + public String sortDirection() { + if (sortDirection == null) { + return null; + } + if (sortDirection.equalsIgnoreCase("ASC") || sortDirection.equalsIgnoreCase("ASCENDING")) { + return "ASCENDING"; + } + if (sortDirection.equalsIgnoreCase("DESC") || sortDirection.equalsIgnoreCase("DESCENDING")) { + return "DESCENDING"; + } + return sortDirection; + } } From f0c66fa1db5e1416a19698be667d19d4d0e95e1a Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Thu, 2 Jul 2026 21:18:08 +0900 Subject: [PATCH 16/46] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=95=AD=EB=AA=A9=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20DTO=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 플레이리스트에 포함될 콘텐츠를 관리하기 위한 PlaylistItem 엔티티를 추가했습니다. - PlaylistItem은 불변(Immutable) 엔티티로 설계되었으며, 콘텐츠가 플레이리스트에 추가된 시점(added_at)을 기록합니다. - 특정 플레이리스트와 해당 플레이리스트에 속한 모든 콘텐츠 목록을 함께 전달하기 위한 PlaylistContentsDto 레코드를 추가했습니다. - 이는 플레이리스트 상세 조회 시 필요한 데이터를 효율적으로 제공하기 위함입니다. --- .../playlist/dto/PlaylistContentsDto.java | 8 ++++ .../mopl/playlist/entity/PlaylistItem.java | 44 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/dto/PlaylistContentsDto.java create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/entity/PlaylistItem.java diff --git a/src/main/java/com/codeit/team5/mopl/playlist/dto/PlaylistContentsDto.java b/src/main/java/com/codeit/team5/mopl/playlist/dto/PlaylistContentsDto.java new file mode 100644 index 00000000..dcf0b1c5 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/dto/PlaylistContentsDto.java @@ -0,0 +1,8 @@ +package com.codeit.team5.mopl.playlist.dto; + +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.playlist.entity.Playlist; +import java.util.List; + +public record PlaylistContentsDto(Playlist playlist, List contents) { +} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/entity/PlaylistItem.java b/src/main/java/com/codeit/team5/mopl/playlist/entity/PlaylistItem.java new file mode 100644 index 00000000..4be46e98 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/entity/PlaylistItem.java @@ -0,0 +1,44 @@ +package com.codeit.team5.mopl.playlist.entity; + +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.global.entity.BaseEntity; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Immutable; + +@Getter +@Entity +@Table(name = "playlist_items") +@Immutable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AttributeOverride(name = "createdAt", column = @Column(name = "added_at")) +public class PlaylistItem extends BaseEntity { + + @NotNull + @Column(name = "playlist_id") + private UUID playlistId; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + private PlaylistItem(UUID playlistId, Content content) { + this.playlistId = playlistId; + this.content = content; + } + + public static PlaylistItem of(UUID playlistId, Content content) { + return new PlaylistItem(playlistId, content); + } +} From 252331a9c599d7cafe221053786cca301c7c3f44 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Thu, 2 Jul 2026 21:18:25 +0900 Subject: [PATCH 17/46] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9D=91=EB=8B=B5=EC=97=90=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EB=AA=A9=EB=A1=9D=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 플레이리스트 단건 및 목록 조회 시 응답 DTO에 해당 플레이리스트에 포함된 콘텐츠 목록을 추가했습니다. - 이를 위해 `PlaylistContentsDto`를 도입하여 플레이리스트 엔티티와 콘텐츠 목록을 함께 묶어 처리하도록 변경했습니다. - `PlaylistMapper`와 `PlaylistService`를 업데이트하여 새로운 DTO를 사용하고, `PlaylistQueryRepository`에서 콘텐츠 정보를 함께 조회하도록 쿼리를 수정했습니다. - `PlaylistResponse`에 `List contents` 필드를 추가했습니다. --- .../dto/response/PlaylistResponse.java | 6 +- .../mopl/playlist/mapper/PlaylistMapper.java | 14 +++-- .../query/PlaylistQueryRepository.java | 10 +++- .../query/PlaylistQueryRepositoryImpl.java | 57 ++++++++++++++++++- .../playlist/service/PlaylistService.java | 16 +++--- 5 files changed, 83 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/playlist/dto/response/PlaylistResponse.java b/src/main/java/com/codeit/team5/mopl/playlist/dto/response/PlaylistResponse.java index c09537bb..1b712d68 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/dto/response/PlaylistResponse.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/dto/response/PlaylistResponse.java @@ -1,7 +1,9 @@ package com.codeit.team5.mopl.playlist.dto.response; +import com.codeit.team5.mopl.content.dto.response.ContentResponse; import com.codeit.team5.mopl.user.dto.response.UserSummary; import java.time.Instant; +import java.util.List; import java.util.UUID; public record PlaylistResponse(UUID id, @@ -10,6 +12,6 @@ public record PlaylistResponse(UUID id, String title, String description, Integer subscriberCount, - boolean subscribedByMe) { - + boolean subscribedByMe, + List contents) { } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/mapper/PlaylistMapper.java b/src/main/java/com/codeit/team5/mopl/playlist/mapper/PlaylistMapper.java index 1b54d247..b36aa7c4 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/mapper/PlaylistMapper.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/mapper/PlaylistMapper.java @@ -15,13 +15,19 @@ import com.codeit.team5.mopl.playlist.entity.Playlist; import com.codeit.team5.mopl.user.mapper.UserSummaryMapper; -@Mapper(config = GlobalMapperConfig.class, uses = {UserSummaryMapper.class}) +import com.codeit.team5.mopl.content.mapper.ContentMapper; +import com.codeit.team5.mopl.playlist.dto.PlaylistContentsDto; + +@Mapper(config = GlobalMapperConfig.class, uses = {UserSummaryMapper.class, ContentMapper.class}) public interface PlaylistMapper { + @Mapping(target = ".", source = "playlist") + PlaylistResponse toDto(PlaylistContentsDto dto); + PlaylistResponse toDto(Playlist playlist); @IterableMapping(nullValueMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT) - List toDto(List playlists); + List toDto(List playlists); @Mapping(target = "sortBy", source = "sortBy", qualifiedByName = "toSortBy") @Mapping(target = "cursor", source = "request", qualifiedByName = "toCursor") @@ -40,12 +46,12 @@ default PlaylistSortBy toSortBy(String sortBy) { return PlaylistSortBy.from(sortBy); } - default CursorResponse toCursorResponse(List data, + default CursorResponse toCursorResponse(List data, PlaylistCursorCommand dto, boolean hasNext, long totalCount) { String nextCursor = null; String nextIdAfter = null; if (!data.isEmpty()) { - Playlist last = data.get(data.size() - 1); + Playlist last = data.get(data.size() - 1).playlist(); nextIdAfter = last.getId().toString(); nextCursor = switch (dto.sortBy()) { case UPDATED_AT -> last.getUpdatedAt().toString(); diff --git a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepository.java b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepository.java index 4ab89477..15b13379 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepository.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepository.java @@ -1,12 +1,16 @@ package com.codeit.team5.mopl.playlist.repository.query; -import com.codeit.team5.mopl.playlist.dto.PlaylistCursorCommand; -import com.codeit.team5.mopl.playlist.entity.Playlist; import java.util.List; +import java.util.Optional; +import java.util.UUID; +import com.codeit.team5.mopl.playlist.dto.PlaylistContentsDto; +import com.codeit.team5.mopl.playlist.dto.PlaylistCursorCommand; public interface PlaylistQueryRepository { - List findByCursor(PlaylistCursorCommand request); + Optional findByIdWithContents(UUID id); + + List findByCursor(PlaylistCursorCommand request); long countByCommand(PlaylistCursorCommand request); } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java index 99e74062..d0d64b11 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java @@ -1,15 +1,24 @@ package com.codeit.team5.mopl.playlist.repository.query; +import static com.codeit.team5.mopl.binarycontent.entity.QBinaryContent.binaryContent; +import static com.codeit.team5.mopl.content.entity.QContent.content; import static com.codeit.team5.mopl.playlist.entity.QPlaylist.playlist; +import static com.codeit.team5.mopl.playlist.entity.QPlaylistItem.playlistItem; import static com.codeit.team5.mopl.user.entity.QUser.user; + +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; +import java.util.stream.Collectors; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; import com.codeit.team5.mopl.playlist.constant.PlaylistSortBy; +import com.codeit.team5.mopl.playlist.dto.PlaylistContentsDto; import com.codeit.team5.mopl.playlist.dto.PlaylistCursorCommand; import com.codeit.team5.mopl.playlist.entity.Playlist; +import com.codeit.team5.mopl.playlist.entity.PlaylistItem; import com.querydsl.core.types.Expression; import com.querydsl.core.types.Ops; import com.querydsl.core.types.OrderSpecifier; @@ -28,13 +37,57 @@ public class PlaylistQueryRepositoryImpl implements PlaylistQueryRepository { Map.of(PlaylistSortBy.UPDATED_AT, cursorBuilder(playlist.updatedAt), PlaylistSortBy.SUBSCRIBE_COUNT, cursorBuilder(playlist.subscriberCount)); + @Override + public Optional findByIdWithContents(UUID id) { + Playlist foundPlaylist = queryFactory.selectFrom(playlist) + .leftJoin(playlist.owner, user).fetchJoin() + .leftJoin(user.profileImage, binaryContent).fetchJoin() + .where(playlist.id.eq(id)) + .fetchOne(); + + if (foundPlaylist == null) { + return Optional.empty(); + } + + List items = queryFactory.selectFrom(playlistItem) + .leftJoin(playlistItem.content, content).fetchJoin() + .where(playlistItem.playlistId.eq(id)) + .orderBy(playlistItem.createdAt.asc()) + .fetch(); + + return Optional.of(new PlaylistContentsDto(foundPlaylist, + items.stream().map(PlaylistItem::getContent).toList())); + } @Override - public List findByCursor(PlaylistCursorCommand request) { - return queryFactory.selectFrom(playlist).leftJoin(playlist.owner, user).fetchJoin() + public List findByCursor(PlaylistCursorCommand request) { + List playlists = queryFactory.selectFrom(playlist) + .leftJoin(playlist.owner, user).fetchJoin() + .leftJoin(user.profileImage, binaryContent).fetchJoin() .where(keywordLikeQuery(request), ownerQuery(request), subscriberQuery(request), cursorQuery(request)) .orderBy(orderSpecifiers(request)).limit(request.limit() + 1).fetch(); + + if (playlists.isEmpty()) { + return Collections.emptyList(); + } + + List playlistIds = playlists.stream().map(Playlist::getId).toList(); + + List items = queryFactory.selectFrom(playlistItem) + .leftJoin(playlistItem.content, content).fetchJoin() + .where(playlistItem.playlistId.in(playlistIds)) + .orderBy(playlistItem.createdAt.asc()) + .fetch(); + + Map> contentsByPlaylistId = + items.stream().collect(Collectors.groupingBy(PlaylistItem::getPlaylistId, + Collectors.mapping(PlaylistItem::getContent, Collectors.toList()))); + + return playlists.stream() + .map(p -> new PlaylistContentsDto(p, + contentsByPlaylistId.getOrDefault(p.getId(), Collections.emptyList()))) + .toList(); } @Override diff --git a/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java index 5ea06ef9..d274c7e6 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java @@ -1,7 +1,7 @@ package com.codeit.team5.mopl.playlist.service; import com.codeit.team5.mopl.global.dto.CursorResponse; -import com.codeit.team5.mopl.playlist.constant.PlaylistSortBy; +import com.codeit.team5.mopl.playlist.dto.PlaylistContentsDto; import com.codeit.team5.mopl.playlist.dto.PlaylistCursorCommand; import com.codeit.team5.mopl.playlist.dto.request.PlaylistCreateRequest; import com.codeit.team5.mopl.playlist.dto.request.PlaylistUpdateRequest; @@ -15,12 +15,8 @@ import com.codeit.team5.mopl.user.entity.User; import com.codeit.team5.mopl.user.repository.UserRepository; import java.util.List; -import java.util.Map; import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.ScrollPosition; -import org.springframework.data.domain.ScrollPosition.Direction; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -42,7 +38,9 @@ public PlaylistResponse create(String email, PlaylistCreateRequest request) { } public PlaylistResponse find(UUID id) { - return mapper.toDto(findById(id)); + PlaylistContentsDto dto = repository.findByIdWithContents(id) + .orElseThrow(() -> new PlaylistNotFoundException(id)); + return mapper.toDto(dto); } @Transactional @@ -61,15 +59,15 @@ public void delete(UUID id, String email) { } public CursorResponse findByCursor(PlaylistCursorCommand dto) { - List playlists = repository.findByCursor(dto); + List playlists = repository.findByCursor(dto); boolean hasNext = playlists.size() > dto.limit(); - List data = hasNext ? playlists.subList(0, dto.limit()) : playlists; + List data = hasNext ? playlists.subList(0, dto.limit()) : playlists; long totalCount = repository.countByCommand(dto); return mapper.toCursorResponse(data, dto, hasNext, totalCount); } private Playlist findById(UUID id) { - return repository.findOwnerById(id).orElseThrow(() -> new PlaylistNotFoundException(id)); + return repository.findById(id).orElseThrow(() -> new PlaylistNotFoundException(id)); } private void validateOwner(UUID id, String email) { From 30ee1671d1971fb7fad77eab5fb449fb9ccef9b8 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 09:42:00 +0900 Subject: [PATCH 18/46] =?UTF-8?q?chore:=20.gitignore=EC=97=90=20IDE=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - IDE(예: VS Code) 관련 설정 파일 및 빌드 결과물(bin/)이 Git에 의해 추적되지 않도록 .gitignore에 항목을 추가했습니다. - 이를 통해 개발 환경 간 불필요한 충돌을 방지하고 저장소를 깔끔하게 유지할 수 있습니다. --- .gitignore | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1ab1566b..f8d1e1a3 100644 --- a/.gitignore +++ b/.gitignore @@ -78,4 +78,9 @@ $RECYCLE.BIN/ ### 로컬 업로드 파일 디렉토리 ### uploads/ -uploads-test/ \ No newline at end of file +uploads-test/ + +### .vscode ### +bin/ +.vscode/ +.settings/ \ No newline at end of file From 4a365c21040bf086e337ca1ac905834006fd2fd6 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 09:43:27 +0900 Subject: [PATCH 19/46] =?UTF-8?q?refactor:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - `PlaylistQueryRepositoryImpl`에서 플레이리스트 아이템 및 연관 콘텐츠 조회 로직을 `fetchPlaylistItemsWithContents` 헬퍼 메서드로 통합하고 최적화했습니다. - 콘텐츠 썸네일, 통계, 태그 정보를 `fetchJoin`을 사용하여 한 번의 쿼리로 함께 로드하도록 개선하여 N+1 쿼리 문제를 방지하고 성능을 향상시켰습니다. - `Subscription` 엔티티의 `playlist` 및 `subscriber` 필드에 `@NotNull` 어노테이션을 추가하여 JPA 표준에 맞는 방식으로 필수 연관 관계를 명시했습니다. 기존 `@JoinColumn(nullable = false)`를 대체합니다. - 사용되지 않는 임포트 문을 제거하고 임포트 순서를 정리하여 코드 가독성을 높였습니다. --- .../query/PlaylistQueryRepositoryImpl.java | 46 ++++++++++--------- .../controller/SubscriptionController.java | 5 +- .../subscription/entity/Subscription.java | 13 ++---- .../ContentChatSubscribeHandler.java | 5 +- 4 files changed, 33 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java index d0d64b11..be103dca 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java @@ -2,10 +2,12 @@ import static com.codeit.team5.mopl.binarycontent.entity.QBinaryContent.binaryContent; import static com.codeit.team5.mopl.content.entity.QContent.content; +import static com.codeit.team5.mopl.content.entity.QContentStats.contentStats; +import static com.codeit.team5.mopl.content.entity.QContentTag.contentTag; import static com.codeit.team5.mopl.playlist.entity.QPlaylist.playlist; import static com.codeit.team5.mopl.playlist.entity.QPlaylistItem.playlistItem; +import static com.codeit.team5.mopl.tag.entity.QTag.tag; import static com.codeit.team5.mopl.user.entity.QUser.user; - import java.util.Collections; import java.util.List; import java.util.Map; @@ -14,6 +16,7 @@ import java.util.stream.Collectors; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; +import com.codeit.team5.mopl.content.entity.Content; import com.codeit.team5.mopl.playlist.constant.PlaylistSortBy; import com.codeit.team5.mopl.playlist.dto.PlaylistContentsDto; import com.codeit.team5.mopl.playlist.dto.PlaylistCursorCommand; @@ -39,21 +42,15 @@ public class PlaylistQueryRepositoryImpl implements PlaylistQueryRepository { @Override public Optional findByIdWithContents(UUID id) { - Playlist foundPlaylist = queryFactory.selectFrom(playlist) - .leftJoin(playlist.owner, user).fetchJoin() - .leftJoin(user.profileImage, binaryContent).fetchJoin() - .where(playlist.id.eq(id)) - .fetchOne(); + Playlist foundPlaylist = queryFactory.selectFrom(playlist).leftJoin(playlist.owner, user) + .fetchJoin().leftJoin(user.profileImage, binaryContent).fetchJoin() + .where(playlist.id.eq(id)).fetchOne(); if (foundPlaylist == null) { return Optional.empty(); } - List items = queryFactory.selectFrom(playlistItem) - .leftJoin(playlistItem.content, content).fetchJoin() - .where(playlistItem.playlistId.eq(id)) - .orderBy(playlistItem.createdAt.asc()) - .fetch(); + List items = fetchPlaylistItemsWithContents(List.of(id)); return Optional.of(new PlaylistContentsDto(foundPlaylist, items.stream().map(PlaylistItem::getContent).toList())); @@ -61,9 +58,8 @@ public Optional findByIdWithContents(UUID id) { @Override public List findByCursor(PlaylistCursorCommand request) { - List playlists = queryFactory.selectFrom(playlist) - .leftJoin(playlist.owner, user).fetchJoin() - .leftJoin(user.profileImage, binaryContent).fetchJoin() + List playlists = queryFactory.selectFrom(playlist).leftJoin(playlist.owner, user) + .fetchJoin().leftJoin(user.profileImage, binaryContent).fetchJoin() .where(keywordLikeQuery(request), ownerQuery(request), subscriberQuery(request), cursorQuery(request)) .orderBy(orderSpecifiers(request)).limit(request.limit() + 1).fetch(); @@ -74,13 +70,9 @@ public List findByCursor(PlaylistCursorCommand request) { List playlistIds = playlists.stream().map(Playlist::getId).toList(); - List items = queryFactory.selectFrom(playlistItem) - .leftJoin(playlistItem.content, content).fetchJoin() - .where(playlistItem.playlistId.in(playlistIds)) - .orderBy(playlistItem.createdAt.asc()) - .fetch(); + List items = fetchPlaylistItemsWithContents(playlistIds); - Map> contentsByPlaylistId = + Map> contentsByPlaylistId = items.stream().collect(Collectors.groupingBy(PlaylistItem::getPlaylistId, Collectors.mapping(PlaylistItem::getContent, Collectors.toList()))); @@ -92,13 +84,23 @@ public List findByCursor(PlaylistCursorCommand request) { @Override public long countByCommand(PlaylistCursorCommand request) { - Long count = queryFactory.select(playlist.count()) - .from(playlist) + Long count = queryFactory.select(playlist.count()).from(playlist) .where(keywordLikeQuery(request), ownerQuery(request), subscriberQuery(request)) .fetchOne(); return count != null ? count : 0L; } + private List fetchPlaylistItemsWithContents(List playlistIds) { + return queryFactory.selectFrom(playlistItem).distinct() + .leftJoin(playlistItem.content, content).fetchJoin() + .leftJoin(content.thumbnail, binaryContent).fetchJoin() + .leftJoin(content.stats, contentStats).fetchJoin() + .leftJoin(content.contentTags, contentTag).fetchJoin() + .leftJoin(contentTag.tag, tag).fetchJoin() + .where(playlistItem.playlistId.in(playlistIds)) + .orderBy(playlistItem.createdAt.asc()).fetch(); + } + private OrderSpecifier[] orderSpecifiers(PlaylistCursorCommand request) { boolean isAsc = request.sortDirection().isAscending(); OrderSpecifier primary = switch (request.sortBy()) { diff --git a/src/main/java/com/codeit/team5/mopl/subscription/controller/SubscriptionController.java b/src/main/java/com/codeit/team5/mopl/subscription/controller/SubscriptionController.java index e5195706..ca8fd8a5 100644 --- a/src/main/java/com/codeit/team5/mopl/subscription/controller/SubscriptionController.java +++ b/src/main/java/com/codeit/team5/mopl/subscription/controller/SubscriptionController.java @@ -1,10 +1,7 @@ package com.codeit.team5.mopl.subscription.controller; -import com.codeit.team5.mopl.subscription.service.SubscriptionService; import java.security.Principal; import java.util.UUID; -import lombok.RequiredArgsConstructor; -import org.apache.coyote.Response; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -12,6 +9,8 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.codeit.team5.mopl.subscription.service.SubscriptionService; +import lombok.RequiredArgsConstructor; @RestController @RequestMapping("/api") diff --git a/src/main/java/com/codeit/team5/mopl/subscription/entity/Subscription.java b/src/main/java/com/codeit/team5/mopl/subscription/entity/Subscription.java index 0a4e01b9..32cc7290 100644 --- a/src/main/java/com/codeit/team5/mopl/subscription/entity/Subscription.java +++ b/src/main/java/com/codeit/team5/mopl/subscription/entity/Subscription.java @@ -1,10 +1,9 @@ package com.codeit.team5.mopl.subscription.entity; +import org.hibernate.annotations.Immutable; import com.codeit.team5.mopl.global.entity.BaseEntity; import com.codeit.team5.mopl.playlist.entity.Playlist; import com.codeit.team5.mopl.user.entity.User; -import jakarta.persistence.AttributeOverride; -import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; @@ -14,10 +13,6 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; -import org.hibernate.annotations.Immutable; -import org.hibernate.annotations.OnDelete; -import org.hibernate.annotations.OnDeleteAction; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -26,12 +21,14 @@ @Table(name = "playlist_subscriptions") public class Subscription extends BaseEntity { + @NotNull @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "playlist_id", nullable = false) + @JoinColumn(name = "playlist_id") private Playlist playlist; + @NotNull @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "subscriber_id", nullable = false) + @JoinColumn(name = "subscriber_id") private User subscriber; private Subscription(Playlist playlist, User subscriber) { diff --git a/src/main/java/com/codeit/team5/mopl/watcher/command/subscribe/ContentChatSubscribeHandler.java b/src/main/java/com/codeit/team5/mopl/watcher/command/subscribe/ContentChatSubscribeHandler.java index 1640dcf7..26857930 100644 --- a/src/main/java/com/codeit/team5/mopl/watcher/command/subscribe/ContentChatSubscribeHandler.java +++ b/src/main/java/com/codeit/team5/mopl/watcher/command/subscribe/ContentChatSubscribeHandler.java @@ -1,12 +1,11 @@ package com.codeit.team5.mopl.watcher.command.subscribe; +import java.util.UUID; +import org.springframework.stereotype.Component; import com.codeit.team5.mopl.global.web.ws.stomp.constant.StompConstants; import com.codeit.team5.mopl.global.web.ws.stomp.handler.AbstractStompSubscribeHandler; import com.codeit.team5.mopl.global.web.ws.stomp.store.WebSocketSessionStore; -import com.codeit.team5.mopl.watcher.exception.WatchingSessionNotFoundException; import com.codeit.team5.mopl.watcher.service.WatchingSessionService; -import java.util.UUID; -import org.springframework.stereotype.Component; @Component public class ContentChatSubscribeHandler extends AbstractStompSubscribeHandler { From 24aca46cc6670178a1e547fbd226378fadeee8aa Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 10:18:44 +0900 Subject: [PATCH 20/46] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=BD=98=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 플레이리스트에 콘텐츠를 추가하는 API를 구현했습니다. - 플레이리스트에서 콘텐츠를 삭제하는 API를 구현했습니다. - 각 기능은 플레이리스트 소유자만 접근 가능하도록 권한 검증을 추가했습니다. - 존재하지 않는 콘텐츠나 플레이리스트 아이템에 대한 예외 처리를 추가했습니다. --- .../controller/PlaylistController.java | 16 ++++++++++ .../playlist/service/PlaylistService.java | 30 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java b/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java index c01567dd..89b198c7 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java @@ -5,6 +5,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; @@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.codeit.team5.mopl.auth.security.details.MoplPrincipal; import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.playlist.dto.request.PlaylistCreateRequest; import com.codeit.team5.mopl.playlist.dto.request.PlaylistCursorRequest; @@ -63,4 +65,18 @@ public ResponseEntity delete(Principal principal, @PathVariable UUID id) { service.delete(id, principal.getName()); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } + + @PostMapping("/{playlistId}/contents/{contentId}") + public ResponseEntity addContent(Principal principal, + @PathVariable UUID playlistId, @PathVariable UUID contentId) { + service.addContent(principal.getName(), playlistId, contentId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @DeleteMapping("/{playlistId}/contents/{contentId}") + public ResponseEntity removeContent(Principal principal, + @PathVariable UUID playlistId, @PathVariable UUID contentId) { + service.removeContent(principal.getName(), playlistId, contentId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java index d274c7e6..e89d7608 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java @@ -1,5 +1,7 @@ package com.codeit.team5.mopl.playlist.service; +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.repository.ContentRepository; import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.playlist.dto.PlaylistContentsDto; import com.codeit.team5.mopl.playlist.dto.PlaylistCursorCommand; @@ -7,10 +9,13 @@ import com.codeit.team5.mopl.playlist.dto.request.PlaylistUpdateRequest; import com.codeit.team5.mopl.playlist.dto.response.PlaylistResponse; import com.codeit.team5.mopl.playlist.entity.Playlist; +import com.codeit.team5.mopl.playlist.entity.PlaylistItem; import com.codeit.team5.mopl.playlist.exception.PlaylistAccessDeniedException; +import com.codeit.team5.mopl.playlist.exception.PlaylistContentNotFoundException; import com.codeit.team5.mopl.playlist.exception.PlaylistNotFoundException; import com.codeit.team5.mopl.playlist.exception.PlaylistUserNotFoundException; import com.codeit.team5.mopl.playlist.mapper.PlaylistMapper; +import com.codeit.team5.mopl.playlist.repository.PlaylistItemRepository; import com.codeit.team5.mopl.playlist.repository.PlaylistRepository; import com.codeit.team5.mopl.user.entity.User; import com.codeit.team5.mopl.user.repository.UserRepository; @@ -28,6 +33,8 @@ public class PlaylistService { private final PlaylistMapper mapper; private final PlaylistRepository repository; private final UserRepository userRepository; + private final PlaylistItemRepository playlistItemRepository; + private final ContentRepository contentRepository; @Transactional public PlaylistResponse create(String email, PlaylistCreateRequest request) { @@ -66,6 +73,29 @@ public CursorResponse findByCursor(PlaylistCursorCommand dto) return mapper.toCursorResponse(data, dto, hasNext, totalCount); } + @Transactional + public void addContent(String email, UUID playlistId, UUID contentId) { + validateOwner(playlistId, email); + if (!contentRepository.existsById(contentId)) { + throw new PlaylistContentNotFoundException(contentId); + } + Content content = contentRepository.getReferenceById(contentId); + PlaylistItem playlistItem = PlaylistItem.of(playlistId, content); + playlistItemRepository.save(playlistItem); + } + + @Transactional + public void removeContent(String email, UUID playlistId, UUID contentId) { + validateOwner(playlistId, email); + if (!contentRepository.existsById(contentId)) { + throw new PlaylistContentNotFoundException(contentId); + } + if (!playlistItemRepository.existsByPlaylistIdAndContentId(playlistId, contentId)) { + throw new PlaylistItemNotFoundException(playlistId, contentId); + } + playlistItemRepository.deleteByPlaylistIdAndContentIdDirectly(playlistId, contentId); + } + private Playlist findById(UUID id) { return repository.findById(id).orElseThrow(() -> new PlaylistNotFoundException(id)); } From 3987dac6e003908bd99641694c13e0d7012e64b8 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 10:26:27 +0900 Subject: [PATCH 21/46] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B4=80=EB=A0=A8=20Repository?= =?UTF-8?q?=20=EB=B0=8F=20Exception=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - PlaylistItemRepository 인터페이스를 추가하여 플레이리스트 항목의 존재 여부 확인 및 직접 삭제 기능을 구현했습니다. - PlaylistContentNotFoundException 및 PlaylistItemNotFoundException 클래스를 추가하여 콘텐츠 및 플레이리스트 항목을 찾을 수 없을 때 발생하는 예외 처리를 정의했습니다. - PlaylistControllerApi 인터페이스에 플레이리스트 관련 API 명세를 추가했습니다. --- .../controller/api/PlaylistControllerApi.java | 43 +++++++++++++++++++ .../PlaylistContentNotFoundException.java | 17 ++++++++ .../PlaylistItemNotFoundException.java | 18 ++++++++ .../repository/PlaylistItemRepository.java | 16 +++++++ 4 files changed, 94 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/controller/api/PlaylistControllerApi.java create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistContentNotFoundException.java create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistItemNotFoundException.java create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistItemRepository.java diff --git a/src/main/java/com/codeit/team5/mopl/playlist/controller/api/PlaylistControllerApi.java b/src/main/java/com/codeit/team5/mopl/playlist/controller/api/PlaylistControllerApi.java new file mode 100644 index 00000000..40a68618 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/controller/api/PlaylistControllerApi.java @@ -0,0 +1,43 @@ +package com.codeit.team5.mopl.playlist.controller.api; + +import java.security.Principal; +import java.util.UUID; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import com.codeit.team5.mopl.global.dto.CursorResponse; +import com.codeit.team5.mopl.playlist.dto.request.PlaylistCreateRequest; +import com.codeit.team5.mopl.playlist.dto.request.PlaylistCursorRequest; +import com.codeit.team5.mopl.playlist.dto.request.PlaylistUpdateRequest; +import com.codeit.team5.mopl.playlist.dto.response.PlaylistResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "플레이리스트 관리") +public interface PlaylistControllerApi { + + @Operation(summary = "플레이리스트 단건 조회") + ResponseEntity find(@PathVariable UUID id); + + @Operation(summary = "플레이리스트 목록 조회 (커서 페이지네이션)") + ResponseEntity> findCursor(@ModelAttribute PlaylistCursorRequest request); + + @Operation(summary = "플레이리스트 생성", description = "생성한 플레이리스트는 API 요청자 본인의 플레이리스트로 생성됩니다.") + ResponseEntity create(Principal principal, @RequestBody PlaylistCreateRequest request); + + @Operation(summary = "플레이리스트 수정", description = "플레이리스트 소유자만 수정할 수 있습니다.") + ResponseEntity update(Principal principal, @PathVariable UUID id, @RequestBody PlaylistUpdateRequest request); + + @Operation(summary = "플레이리스트 삭제", description = "플레이리스트 소유자만 삭제할 수 있습니다.") + ResponseEntity delete(Principal principal, @PathVariable UUID id); + + @Operation(summary = "플레이리스트에 콘텐츠 추가", description = "플레이리스트 소유자만 콘텐츠를 추가할 수 있습니다.") + ResponseEntity addContent(Principal principal, @PathVariable UUID playlistId, @PathVariable UUID contentId); + + @Operation(summary = "플레이리스트에서 콘텐츠 삭제", description = "플레이리스트 소유자만 콘텐츠를 삭제할 수 있습니다.") + ResponseEntity removeContent(Principal principal, @PathVariable UUID playlistId, @PathVariable UUID contentId); +} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistContentNotFoundException.java b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistContentNotFoundException.java new file mode 100644 index 00000000..3f02d28f --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistContentNotFoundException.java @@ -0,0 +1,17 @@ +package com.codeit.team5.mopl.playlist.exception; + +import java.util.Map; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import com.codeit.team5.mopl.global.exception.BusinessException; + +/** + * PlaylistContentNotFoundException + */ +public class PlaylistContentNotFoundException extends BusinessException { + + public PlaylistContentNotFoundException(UUID contentId) { + super(HttpStatus.BAD_REQUEST, "컨텐츠를 찾을 수 없습니다.", Map.of("contentId", contentId)); + } + +} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistItemNotFoundException.java b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistItemNotFoundException.java new file mode 100644 index 00000000..229d035b --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistItemNotFoundException.java @@ -0,0 +1,18 @@ +package com.codeit.team5.mopl.playlist.exception; + +import java.util.Map; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import com.codeit.team5.mopl.global.exception.BusinessException; + +/** + * PlaylistItemNotFoundException + */ +public class PlaylistItemNotFoundException extends BusinessException { + + public PlaylistItemNotFoundException(UUID playlistId, UUID contentId) { + super(HttpStatus.BAD_REQUEST, "플레이리스트에 추가된 컨텐츠를 찾을 수 없습니다.", + Map.of("playlistId", playlistId, "contentId", contentId)); + } + +} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistItemRepository.java b/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistItemRepository.java new file mode 100644 index 00000000..f9162217 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistItemRepository.java @@ -0,0 +1,16 @@ +package com.codeit.team5.mopl.playlist.repository; + +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import com.codeit.team5.mopl.playlist.entity.PlaylistItem; + +public interface PlaylistItemRepository extends JpaRepository { + + boolean existsByPlaylistIdAndContentId(UUID playlistId, UUID contentId); + + @Modifying + @Query("delete from PlaylistItem p where p.playlistId = :playlistId and p.content.id = :contentId") + void deleteByPlaylistIdAndContentIdDirectly(UUID playlistId, UUID contentId); +} From 5f7b27319bafadad463a257100d440f194844186 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 10:26:40 +0900 Subject: [PATCH 22/46] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B4=80=EB=A0=A8=20API=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - PlaylistController가 PlaylistControllerApi 인터페이스를 구현하도록 변경했습니다. - 이를 통해 API 명세에 따른 컨트롤러 구현을 명확히 하고, 향후 확장성을 고려했습니다. - 또한, PlaylistService에 PlaylistItemNotFoundException을 추가하여 관련 예외 처리를 개선했습니다. --- .../controller/PlaylistController.java | 20 ++++++++++++------- .../playlist/service/PlaylistService.java | 1 + 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java b/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java index 89b198c7..d9ce1f57 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java @@ -5,7 +5,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; @@ -15,8 +14,8 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.codeit.team5.mopl.auth.security.details.MoplPrincipal; import com.codeit.team5.mopl.global.dto.CursorResponse; +import com.codeit.team5.mopl.playlist.controller.api.PlaylistControllerApi; import com.codeit.team5.mopl.playlist.dto.request.PlaylistCreateRequest; import com.codeit.team5.mopl.playlist.dto.request.PlaylistCursorRequest; import com.codeit.team5.mopl.playlist.dto.request.PlaylistUpdateRequest; @@ -29,16 +28,18 @@ @RestController @RequestMapping("/api/playlists") @RequiredArgsConstructor -public class PlaylistController { +public class PlaylistController implements PlaylistControllerApi { private final PlaylistService service; private final PlaylistMapper mapper; + @Override @GetMapping("/{id}") public ResponseEntity find(@PathVariable UUID id) { return ResponseEntity.status(HttpStatus.OK).body(service.find(id)); } + @Override @GetMapping public ResponseEntity> findCursor( @ModelAttribute @Valid PlaylistCursorRequest request) { @@ -46,6 +47,7 @@ public ResponseEntity> findCursor( .body(service.findByCursor(mapper.toCommand(request))); } + @Override @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity create(Principal principal, @RequestBody PlaylistCreateRequest request) { @@ -53,6 +55,7 @@ public ResponseEntity create(Principal principal, .body(service.create(principal.getName(), request)); } + @Override @PatchMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity update(Principal principal, @PathVariable UUID id, @RequestBody PlaylistUpdateRequest request) { @@ -60,22 +63,25 @@ public ResponseEntity update(Principal principal, @PathVariabl .body(service.update(id, principal.getName(), request)); } + @Override @DeleteMapping("/{id}") public ResponseEntity delete(Principal principal, @PathVariable UUID id) { service.delete(id, principal.getName()); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } + @Override @PostMapping("/{playlistId}/contents/{contentId}") - public ResponseEntity addContent(Principal principal, - @PathVariable UUID playlistId, @PathVariable UUID contentId) { + public ResponseEntity addContent(Principal principal, @PathVariable UUID playlistId, + @PathVariable UUID contentId) { service.addContent(principal.getName(), playlistId, contentId); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } + @Override @DeleteMapping("/{playlistId}/contents/{contentId}") - public ResponseEntity removeContent(Principal principal, - @PathVariable UUID playlistId, @PathVariable UUID contentId) { + public ResponseEntity removeContent(Principal principal, @PathVariable UUID playlistId, + @PathVariable UUID contentId) { service.removeContent(principal.getName(), playlistId, contentId); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java index e89d7608..47b219f2 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java @@ -12,6 +12,7 @@ import com.codeit.team5.mopl.playlist.entity.PlaylistItem; import com.codeit.team5.mopl.playlist.exception.PlaylistAccessDeniedException; import com.codeit.team5.mopl.playlist.exception.PlaylistContentNotFoundException; +import com.codeit.team5.mopl.playlist.exception.PlaylistItemNotFoundException; import com.codeit.team5.mopl.playlist.exception.PlaylistNotFoundException; import com.codeit.team5.mopl.playlist.exception.PlaylistUserNotFoundException; import com.codeit.team5.mopl.playlist.mapper.PlaylistMapper; From fa50132978a6be59e43dafebb38d3d2c8d2dbb77 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 10:26:51 +0900 Subject: [PATCH 23/46] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=EB=8F=85=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 플레이리스트 구독 및 구독 취소 기능을 위한 API 인터페이스와 컨트롤러를 추가했습니다. - Swagger 어노테이션을 사용하여 API 문서를 생성하도록 설정했습니다. --- .../controller/SubscriptionController.java | 3 ++- .../api/SubscriptionControllerApi.java | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/codeit/team5/mopl/subscription/controller/api/SubscriptionControllerApi.java diff --git a/src/main/java/com/codeit/team5/mopl/subscription/controller/SubscriptionController.java b/src/main/java/com/codeit/team5/mopl/subscription/controller/SubscriptionController.java index ca8fd8a5..bc582924 100644 --- a/src/main/java/com/codeit/team5/mopl/subscription/controller/SubscriptionController.java +++ b/src/main/java/com/codeit/team5/mopl/subscription/controller/SubscriptionController.java @@ -9,13 +9,14 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.codeit.team5.mopl.subscription.controller.api.SubscriptionControllerApi; import com.codeit.team5.mopl.subscription.service.SubscriptionService; import lombok.RequiredArgsConstructor; @RestController @RequestMapping("/api") @RequiredArgsConstructor -public class SubscriptionController { +public class SubscriptionController implements SubscriptionControllerApi { private final SubscriptionService service; diff --git a/src/main/java/com/codeit/team5/mopl/subscription/controller/api/SubscriptionControllerApi.java b/src/main/java/com/codeit/team5/mopl/subscription/controller/api/SubscriptionControllerApi.java new file mode 100644 index 00000000..d0f28114 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/subscription/controller/api/SubscriptionControllerApi.java @@ -0,0 +1,20 @@ +package com.codeit.team5.mopl.subscription.controller.api; + +import java.security.Principal; +import java.util.UUID; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "플레이리스트 관리") +public interface SubscriptionControllerApi { + + @Operation(summary = "플레이리스트 구독") + ResponseEntity create(@PathVariable UUID playlistId, Principal principal); + + @Operation(summary = "플레이리스트 구독 취소") + ResponseEntity delete(@PathVariable UUID playlistId, Principal principal); +} From 430aca53027b2a597bd992bb1193fd44c0079bb1 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 11:02:52 +0900 Subject: [PATCH 24/46] =?UTF-8?q?test:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B4=80=EB=A0=A8=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 플레이리스트 생성, 조회, 수정, 삭제 기능에 대한 서비스 테스트 코드를 추가했습니다. - 플레이리스트 및 플레이리스트 아이템의 CRUD 동작을 검증하는 리포지토리 테스트 코드를 추가했습니다. - 플레이리스트 조회 시 콘텐츠 정보까지 함께 조회하는 쿼리 테스트 코드를 추가했습니다. --- .../PlaylistItemRepositoryTest.java | 83 +++++ .../PlaylistQueryRepositoryTest.java | 121 +++++++ .../repository/PlaylistRepositoryTest.java | 67 ++++ .../playlist/service/PlaylistServiceTest.java | 303 ++++++++++++++++++ 4 files changed, 574 insertions(+) create mode 100644 src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistItemRepositoryTest.java create mode 100644 src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistQueryRepositoryTest.java create mode 100644 src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepositoryTest.java create mode 100644 src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java diff --git a/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistItemRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistItemRepositoryTest.java new file mode 100644 index 00000000..3ae738e0 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistItemRepositoryTest.java @@ -0,0 +1,83 @@ +package com.codeit.team5.mopl.playlist.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.codeit.team5.mopl.global.support.base.BaseRepositoryTest; +import com.codeit.team5.mopl.playlist.entity.Playlist; +import com.codeit.team5.mopl.playlist.entity.PlaylistItem; +import com.codeit.team5.mopl.user.entity.User; +import com.codeit.team5.mopl.user.repository.UserRepository; + +class PlaylistItemRepositoryTest extends BaseRepositoryTest { + + @Autowired + private PlaylistRepository playlistRepository; + + @Autowired + private PlaylistItemRepository playlistItemRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ContentRepository contentRepository; + + private User user; + private Playlist playlist; + private Content content; + + @BeforeEach + void setUp() { + user = User.create("test@example.com", "password", "Test User"); + userRepository.save(user); + + playlist = Playlist.of(user, "Test Playlist", "Description"); + playlistRepository.save(playlist); + + content = Content.createByAdmin(ContentType.MOVIE, "Movie Title", "Desc"); + contentRepository.save(content); + + flush(); + clear(); + queryInspector.clear(); + } + + @Test + @DisplayName("플레이리스트 아이템 직접 삭제 및 존재 확인") + void playlistItemOperations() { + // given + PlaylistItem item = PlaylistItem.of(playlist.getId(), content); + playlistItemRepository.save(item); + + flush(); + clear(); + queryInspector.clear(); + + // when + boolean exists = playlistItemRepository.existsByPlaylistIdAndContentId(playlist.getId(), content.getId()); + ensureQueryCount(1); + + // then + assertThat(exists).isTrue(); + + // when + queryInspector.clear(); + playlistItemRepository.deleteByPlaylistIdAndContentIdDirectly(playlist.getId(), content.getId()); + flush(); + clear(); + + // then + queryInspector.clear(); + boolean existsAfterDelete = playlistItemRepository.existsByPlaylistIdAndContentId(playlist.getId(), content.getId()); + ensureQueryCount(1); + assertThat(existsAfterDelete).isFalse(); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistQueryRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistQueryRepositoryTest.java new file mode 100644 index 00000000..109dabce --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistQueryRepositoryTest.java @@ -0,0 +1,121 @@ +package com.codeit.team5.mopl.playlist.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Sort; +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.codeit.team5.mopl.global.support.base.BaseRepositoryTest; +import com.codeit.team5.mopl.playlist.constant.PlaylistSortBy; +import com.codeit.team5.mopl.playlist.dto.PlaylistContentsDto; +import com.codeit.team5.mopl.playlist.dto.PlaylistCursorCommand; +import com.codeit.team5.mopl.playlist.entity.Playlist; +import com.codeit.team5.mopl.playlist.entity.PlaylistItem; +import com.codeit.team5.mopl.playlist.repository.query.PlaylistQueryRepositoryImpl; +import com.codeit.team5.mopl.user.entity.User; +import com.codeit.team5.mopl.user.repository.UserRepository; + +class PlaylistQueryRepositoryTest extends BaseRepositoryTest { + + @Autowired + private PlaylistRepository playlistRepository; + + @Autowired + private PlaylistQueryRepositoryImpl playlistQueryRepository; + + @Autowired + private PlaylistItemRepository playlistItemRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ContentRepository contentRepository; + + private User user; + private Playlist playlist; + + @BeforeEach + void setUp() { + user = User.create("test@example.com", "password", "Test User"); + userRepository.save(user); + + playlist = Playlist.of(user, "Test Playlist", "Description"); + playlistRepository.save(playlist); + + flush(); + clear(); + queryInspector.clear(); + } + + @Test + @DisplayName("콘텐츠와 함께 단건 조회") + void findByIdWithContents() { + // given + Content content = Content.createByAdmin(ContentType.MOVIE, "Movie Title", "Desc"); + contentRepository.save(content); + + PlaylistItem item = PlaylistItem.of(playlist.getId(), content); + playlistItemRepository.save(item); + + flush(); + clear(); + queryInspector.clear(); + + // when + Optional dtoOpt = + playlistQueryRepository.findByIdWithContents(playlist.getId()); + ensureQueryCount(2); // 1. playlist fetch, 2. playlistItem + content fetch + + // then + assertThat(dtoOpt).isPresent(); + PlaylistContentsDto dto = dtoOpt.get(); + assertThat(dto.playlist().getId()).isEqualTo(playlist.getId()); + assertThat(dto.contents()).hasSize(1); + assertThat(dto.contents().get(0).getTitle()).isEqualTo("Movie Title"); + } + + @Test + @DisplayName("커서 기반 다건 조회") + void findByCursor() { + // given + PlaylistCursorCommand command = PlaylistCursorCommand.builder().limit(10) + .sortDirection(Sort.Direction.DESC).sortBy(PlaylistSortBy.UPDATED_AT).build(); + + flush(); + clear(); + queryInspector.clear(); + + // when + List result = playlistQueryRepository.findByCursor(command); + ensureQueryCount(2); // 1. playlist fetch, 2. playlistItem + content fetch + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).playlist().getId()).isEqualTo(playlist.getId()); + } + + @Test + @DisplayName("커서 커맨드 기반 카운트 조회") + void countByCommand() { + // given + PlaylistCursorCommand command = PlaylistCursorCommand.builder().keywordLike("Test").build(); + + flush(); + clear(); + queryInspector.clear(); + + // when + long count = playlistQueryRepository.countByCommand(command); + ensureQueryCount(1); + + // then + assertThat(count).isEqualTo(1L); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepositoryTest.java new file mode 100644 index 00000000..1d884f63 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepositoryTest.java @@ -0,0 +1,67 @@ +package com.codeit.team5.mopl.playlist.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import com.codeit.team5.mopl.global.support.base.BaseRepositoryTest; +import com.codeit.team5.mopl.playlist.entity.Playlist; +import com.codeit.team5.mopl.user.entity.User; +import com.codeit.team5.mopl.user.repository.UserRepository; + +class PlaylistRepositoryTest extends BaseRepositoryTest { + + @Autowired + private PlaylistRepository playlistRepository; + + @Autowired + private UserRepository userRepository; + + private User user; + private Playlist playlist; + + @BeforeEach + void setUp() { + user = User.create("test@example.com", "password", "Test User"); + userRepository.save(user); + + playlist = Playlist.of(user, "Test Playlist", "Description"); + playlistRepository.save(playlist); + + flush(); + clear(); + queryInspector.clear(); + } + + @Test + @DisplayName("이메일과 아이디로 플레이리스트 소유 여부 확인") + void existsByIdAndOwnerEmail() { + // when + boolean exists = playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), user.getEmail()); + ensureQueryCount(1); + + queryInspector.clear(); + boolean notExists = playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), "wrong@email.com"); + ensureQueryCount(1); + + // then + assertThat(exists).isTrue(); + assertThat(notExists).isFalse(); + } + + @Test + @DisplayName("직접 플레이리스트 삭제") + void deleteByIdDirectly() { + // when + playlistRepository.deleteByIdDirectly(playlist.getId()); + flush(); + clear(); + + // then + Optional found = playlistRepository.findById(playlist.getId()); + assertThat(found).isEmpty(); + } + +} diff --git a/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java b/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java new file mode 100644 index 00000000..ae300377 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java @@ -0,0 +1,303 @@ +package com.codeit.team5.mopl.playlist.service; + +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.BDDMockito.given; +import static org.mockito.Mockito.verify; +import java.util.Collections; +import java.util.Optional; +import java.util.UUID; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.mapper.ContentMapperImpl; +import com.codeit.team5.mopl.content.mapper.util.ContentUtilsMapperImpl; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.codeit.team5.mopl.playlist.dto.PlaylistContentsDto; +import com.codeit.team5.mopl.playlist.dto.request.PlaylistCreateRequest; +import com.codeit.team5.mopl.playlist.dto.request.PlaylistUpdateRequest; +import com.codeit.team5.mopl.playlist.dto.response.PlaylistResponse; +import com.codeit.team5.mopl.playlist.entity.Playlist; +import com.codeit.team5.mopl.playlist.entity.PlaylistItem; +import com.codeit.team5.mopl.playlist.exception.PlaylistAccessDeniedException; +import com.codeit.team5.mopl.playlist.exception.PlaylistContentNotFoundException; +import com.codeit.team5.mopl.playlist.exception.PlaylistItemNotFoundException; +import com.codeit.team5.mopl.playlist.exception.PlaylistNotFoundException; +import com.codeit.team5.mopl.playlist.exception.PlaylistUserNotFoundException; +import com.codeit.team5.mopl.playlist.mapper.PlaylistMapperImpl; +import com.codeit.team5.mopl.playlist.repository.PlaylistItemRepository; +import com.codeit.team5.mopl.playlist.repository.PlaylistRepository; +import com.codeit.team5.mopl.user.entity.User; +import com.codeit.team5.mopl.user.mapper.UserSummaryMapperImpl; +import com.codeit.team5.mopl.user.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +class PlaylistServiceTest { + + private PlaylistService playlistService; + + @Mock + private PlaylistRepository playlistRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private PlaylistItemRepository playlistItemRepository; + + @Mock + private ContentRepository contentRepository; + + private User user; + private Playlist playlist; + + @BeforeEach + void setUp() { + ContentUtilsMapperImpl contentUtilsMapper = new ContentUtilsMapperImpl(); + ContentMapperImpl contentMapper = new ContentMapperImpl(); + ReflectionTestUtils.setField(contentMapper, "contentUtilsMapper", contentUtilsMapper); + UserSummaryMapperImpl userSummaryMapper = new UserSummaryMapperImpl(); + PlaylistMapperImpl playlistMapper = + new PlaylistMapperImpl(userSummaryMapper, contentMapper); + + playlistService = new PlaylistService(playlistMapper, playlistRepository, userRepository, + playlistItemRepository, contentRepository); + + user = User.create("test@example.com", "password", "Test User"); + ReflectionTestUtils.setField(user, "id", UUID.randomUUID()); + + playlist = Playlist.of(user, "My Playlist", "Description"); + ReflectionTestUtils.setField(playlist, "id", UUID.randomUUID()); + } + + @Test + @DisplayName("플레이리스트 생성 성공") + void create_success() { + // given + PlaylistCreateRequest request = new PlaylistCreateRequest("New Title", "New Description"); + given(userRepository.findByEmail(anyString())).willReturn(Optional.of(user)); + + // when + PlaylistResponse response = playlistService.create(user.getEmail(), request); + + // then + assertThat(response).isNotNull(); + assertThat(response.title()).isEqualTo("New Title"); + assertThat(response.description()).isEqualTo("New Description"); + } + + @Test + @DisplayName("플레이리스트 생성 실패 - 유저를 찾을 수 없음") + void create_fail_userNotFound() { + // given + PlaylistCreateRequest request = new PlaylistCreateRequest("Title", "Desc"); + given(userRepository.findByEmail(anyString())).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> playlistService.create("wrong@email.com", request)) + .isInstanceOf(PlaylistUserNotFoundException.class); + } + + @Test + @DisplayName("플레이리스트 단건 조회 성공") + void find_success() { + // given + PlaylistContentsDto dto = new PlaylistContentsDto(playlist, Collections.emptyList()); + given(playlistRepository.findByIdWithContents(playlist.getId())) + .willReturn(Optional.of(dto)); + + // when + PlaylistResponse response = playlistService.find(playlist.getId()); + + // then + assertThat(response).isNotNull(); + assertThat(response.title()).isEqualTo(playlist.getTitle()); + } + + @Test + @DisplayName("플레이리스트 단건 조회 실패 - 존재하지 않음") + void find_fail_notFound() { + // given + UUID id = UUID.randomUUID(); + given(playlistRepository.findByIdWithContents(id)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> playlistService.find(id)) + .isInstanceOf(PlaylistNotFoundException.class); + } + + @Test + @DisplayName("플레이리스트 수정 성공") + void update_success() { + // given + PlaylistUpdateRequest request = new PlaylistUpdateRequest("Updated Title", "Updated Desc"); + given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), user.getEmail())) + .willReturn(true); + given(playlistRepository.findById(playlist.getId())).willReturn(Optional.of(playlist)); + + // when + PlaylistResponse response = + playlistService.update(playlist.getId(), user.getEmail(), request); + + // then + assertThat(response.title()).isEqualTo("Updated Title"); + assertThat(response.description()).isEqualTo("Updated Desc"); + } + + @Test + @DisplayName("플레이리스트 수정 실패 - 권한 없음") + void update_fail_accessDenied() { + // given + PlaylistUpdateRequest request = new PlaylistUpdateRequest("Updated Title", "Updated Desc"); + given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), "other@email.com")) + .willReturn(false); + + // when & then + assertThatThrownBy( + () -> playlistService.update(playlist.getId(), "other@email.com", request)) + .isInstanceOf(PlaylistAccessDeniedException.class); + } + + @Test + @DisplayName("플레이리스트 삭제 성공") + void delete_success() { + // given + given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), user.getEmail())) + .willReturn(true); + + // when + playlistService.delete(playlist.getId(), user.getEmail()); + + // then + verify(playlistRepository).deleteByIdDirectly(playlist.getId()); + } + + @Test + @DisplayName("플레이리스트 삭제 실패 - 권한 없음") + void delete_fail_accessDenied() { + // given + given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), "other@email.com")) + .willReturn(false); + + // when & then + assertThatThrownBy(() -> playlistService.delete(playlist.getId(), "other@email.com")) + .isInstanceOf(PlaylistAccessDeniedException.class); + } + + @Test + @DisplayName("콘텐츠 추가 성공") + void addContent_success() { + // given + UUID contentId = UUID.randomUUID(); + Content content = Content.createByAdmin(ContentType.MOVIE, "Content Title", "Desc"); + ReflectionTestUtils.setField(content, "id", contentId); + + given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), user.getEmail())) + .willReturn(true); + given(contentRepository.existsById(contentId)).willReturn(true); + given(contentRepository.getReferenceById(contentId)).willReturn(content); + + // when + playlistService.addContent(user.getEmail(), playlist.getId(), contentId); + + // then + verify(playlistItemRepository).save(any(PlaylistItem.class)); + } + + @Test + @DisplayName("콘텐츠 삭제 성공") + void removeContent_success() { + // given + UUID contentId = UUID.randomUUID(); + given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), user.getEmail())) + .willReturn(true); + given(contentRepository.existsById(contentId)).willReturn(true); + given(playlistItemRepository.existsByPlaylistIdAndContentId(playlist.getId(), contentId)) + .willReturn(true); + + // when + playlistService.removeContent(user.getEmail(), playlist.getId(), contentId); + + // then + verify(playlistItemRepository).deleteByPlaylistIdAndContentIdDirectly(playlist.getId(), + contentId); + } + + @Test + @DisplayName("콘텐츠 추가 실패 - 권한 없음") + void addContent_fail_accessDenied() { + // given + UUID contentId = UUID.randomUUID(); + given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), "other@email.com")) + .willReturn(false); + + // when & then + assertThatThrownBy(() -> playlistService.addContent("other@email.com", playlist.getId(), contentId)) + .isInstanceOf(PlaylistAccessDeniedException.class); + } + + @Test + @DisplayName("콘텐츠 추가 실패 - 콘텐츠를 찾을 수 없음") + void addContent_fail_contentNotFound() { + // given + UUID contentId = UUID.randomUUID(); + given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), user.getEmail())) + .willReturn(true); + given(contentRepository.existsById(contentId)).willReturn(false); + + // when & then + assertThatThrownBy(() -> playlistService.addContent(user.getEmail(), playlist.getId(), contentId)) + .isInstanceOf(PlaylistContentNotFoundException.class); + } + + @Test + @DisplayName("콘텐츠 삭제 실패 - 권한 없음") + void removeContent_fail_accessDenied() { + // given + UUID contentId = UUID.randomUUID(); + given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), "other@email.com")) + .willReturn(false); + + // when & then + assertThatThrownBy(() -> playlistService.removeContent("other@email.com", playlist.getId(), contentId)) + .isInstanceOf(PlaylistAccessDeniedException.class); + } + + @Test + @DisplayName("콘텐츠 삭제 실패 - 콘텐츠를 찾을 수 없음") + void removeContent_fail_contentNotFound() { + // given + UUID contentId = UUID.randomUUID(); + given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), user.getEmail())) + .willReturn(true); + given(contentRepository.existsById(contentId)).willReturn(false); + + // when & then + assertThatThrownBy(() -> playlistService.removeContent(user.getEmail(), playlist.getId(), contentId)) + .isInstanceOf(PlaylistContentNotFoundException.class); + } + + @Test + @DisplayName("콘텐츠 삭제 실패 - 플레이리스트에 해당 콘텐츠가 없음") + void removeContent_fail_playlistItemNotFound() { + // given + UUID contentId = UUID.randomUUID(); + given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), user.getEmail())) + .willReturn(true); + given(contentRepository.existsById(contentId)).willReturn(true); + given(playlistItemRepository.existsByPlaylistIdAndContentId(playlist.getId(), contentId)) + .willReturn(false); + + // when & then + assertThatThrownBy(() -> playlistService.removeContent(user.getEmail(), playlist.getId(), contentId)) + .isInstanceOf(PlaylistItemNotFoundException.class); + } +} From c3e4a17d19103fb25058062902ecc48cfe47d6ae Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 13:11:58 +0900 Subject: [PATCH 25/46] =?UTF-8?q?test:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 플레이리스트 관련 API 엔드포인트에 대한 테스트 코드를 작성했습니다. - 단건 조회, 커서 기반 조회, 생성, 수정, 삭제, 아이템 추가/삭제 등 다양한 시나리오를 테스트합니다. - 각 API별 성공 및 실패 케이스를 포함하여 안정성을 확보했습니다. - 예외 처리 로직에 대한 테스트도 포함되었습니다. --- .../controller/PlaylistControllerTest.java | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 src/test/java/com/codeit/team5/mopl/playlist/controller/PlaylistControllerTest.java diff --git a/src/test/java/com/codeit/team5/mopl/playlist/controller/PlaylistControllerTest.java b/src/test/java/com/codeit/team5/mopl/playlist/controller/PlaylistControllerTest.java new file mode 100644 index 00000000..a73b3afe --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/playlist/controller/PlaylistControllerTest.java @@ -0,0 +1,372 @@ +package com.codeit.team5.mopl.playlist.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.security.Principal; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Sort; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import com.codeit.team5.mopl.TestGlobalExceptionHandlerConfig; +import com.codeit.team5.mopl.auth.filter.JwtAuthenticationFilter; +import com.codeit.team5.mopl.global.dto.CursorResponse; +import com.codeit.team5.mopl.global.exception.GlobalExceptionHandler; +import com.codeit.team5.mopl.playlist.dto.PlaylistCursorCommand; +import com.codeit.team5.mopl.playlist.dto.request.PlaylistCreateRequest; +import com.codeit.team5.mopl.playlist.dto.request.PlaylistCursorRequest; +import com.codeit.team5.mopl.playlist.dto.request.PlaylistUpdateRequest; +import com.codeit.team5.mopl.playlist.dto.response.PlaylistResponse; +import com.codeit.team5.mopl.playlist.exception.PlaylistAccessDeniedException; +import com.codeit.team5.mopl.playlist.exception.PlaylistContentNotFoundException; +import com.codeit.team5.mopl.playlist.exception.PlaylistIncorrectSortByException; +import com.codeit.team5.mopl.playlist.exception.PlaylistItemNotFoundException; +import com.codeit.team5.mopl.playlist.exception.PlaylistNotFoundException; +import com.codeit.team5.mopl.playlist.exception.PlaylistSortByMismatchException; +import com.codeit.team5.mopl.playlist.mapper.PlaylistMapper; +import com.codeit.team5.mopl.playlist.service.PlaylistService; +import com.codeit.team5.mopl.user.dto.response.UserSummary; +import com.fasterxml.jackson.databind.ObjectMapper; + +@WebMvcTest(controllers = PlaylistController.class, + excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, + classes = JwtAuthenticationFilter.class)) +@AutoConfigureMockMvc(addFilters = false) +@Import({GlobalExceptionHandler.class, TestGlobalExceptionHandlerConfig.class}) +class PlaylistControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private PlaylistService playlistService; + + @MockitoBean + private PlaylistMapper playlistMapper; + + @Captor + private ArgumentCaptor createRequestCaptor; + + @Captor + private ArgumentCaptor updateRequestCaptor; + + @Captor + private ArgumentCaptor cursorRequestCaptor; + + private Principal principal; + private UUID playlistId; + private UUID contentId; + private PlaylistResponse playlistResponse; + + @BeforeEach + void setUp() { + principal = Mockito.mock(Principal.class); + given(principal.getName()).willReturn("user@test.com"); + + playlistId = UUID.randomUUID(); + contentId = UUID.randomUUID(); + + UserSummary userSummary = new UserSummary(UUID.randomUUID(), "Test User", null); + + playlistResponse = new PlaylistResponse(playlistId, userSummary, Instant.now(), + "My Playlist", "Description", 0, false, List.of()); + } + + @Test + @DisplayName("단건 조회") + @WithMockUser + void find() throws Exception { + // given + given(playlistService.find(playlistId)).willReturn(playlistResponse); + + // when & then + mockMvc.perform(get("/api/playlists/{id}", playlistId)).andDo(print()) + .andExpect(status().isOk()).andExpect(jsonPath("$.id").value(playlistId.toString())) + .andExpect(jsonPath("$.title").value("My Playlist")); + } + + @Test + @DisplayName("단건 조회 - 실패 (플레이리스트 없음)") + @WithMockUser + void find_fail_PlaylistNotFoundException() throws Exception { + // given + given(playlistService.find(playlistId)) + .willThrow(new PlaylistNotFoundException(playlistId)); + + // when & then + mockMvc.perform(get("/api/playlists/{id}", playlistId)).andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType").value("PlaylistNotFoundException")) + .andExpect(jsonPath("$.message").value("플레이리스트를 찾을 수 없습니다.")); + } + + @Test + @DisplayName("커서 기반 조회") + @WithMockUser + void findCursor() throws Exception { + // given + CursorResponse cursorResponse = CursorResponse.builder() + .data(List.of(playlistResponse)).hasNext(false).totalCount(1L).build(); + PlaylistCursorCommand command = PlaylistCursorCommand.builder().limit(10).build(); + + given(playlistMapper.toCommand(any(PlaylistCursorRequest.class))).willReturn(command); + given(playlistService.findByCursor(command)).willReturn(cursorResponse); + + // when & then + mockMvc.perform(get("/api/playlists").param("limit", "10").param("sortBy", "UPDATED_AT") + .param("sortDirection", "DESC")).andDo(print()).andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(playlistId.toString())); + + verify(playlistMapper).toCommand(cursorRequestCaptor.capture()); + PlaylistCursorRequest captured = cursorRequestCaptor.getValue(); + assertThat(captured.limit()).isEqualTo(10); + assertThat(captured.sortBy()).isEqualTo("UPDATED_AT"); + assertThat(captured.sortDirection()).isEqualTo(Sort.Direction.DESC); + } + + @ParameterizedTest + @CsvSource(value = { + // limit 검증 + "null, DESC, UPDATED_AT, limit", "-5, DESC, UPDATED_AT, limit", + "0, DESC, UPDATED_AT, limit", "not_a_number, DESC, UPDATED_AT, limit", + // sortDirection 검증 + "10, null, UPDATED_AT, sortDirection", "10, INVALID, UPDATED_AT, sortDirection", + // sortBy 검증 + "10, DESC, null, sortBy"}, nullValues = {"null"}) + @DisplayName("커서 기반 조회 - 실패 (입력값 유효성 검증)") + @WithMockUser + void findCursor_fail_validation(String limit, String sortDirection, String sortBy, + String errorField) throws Exception { + var requestBuilder = get("/api/playlists"); + + if (limit != null) { + requestBuilder.param("limit", limit); + } + if (sortDirection != null) { + requestBuilder.param("sortDirection", sortDirection); + } + if (sortBy != null) { + requestBuilder.param("sortBy", sortBy); + } + + mockMvc.perform(requestBuilder).andDo(print()).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType").value("INVALID_INPUT")) + .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) + .andExpect(jsonPath("$.details." + errorField).exists()); + } + + @Test + @DisplayName("커서 기반 조회 - 실패 (선택 파라미터 UUID 포맷 오류)") + @WithMockUser + void findCursor_fail_invalid_uuid_format() throws Exception { + mockMvc.perform(get("/api/playlists").param("limit", "10").param("sortBy", "UPDATED_AT") + .param("sortDirection", "DESC").param("ownerIdEqual", "not_a_uuid")).andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType").value("INVALID_INPUT")) + .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) + .andExpect(jsonPath("$.details.ownerIdEqual").exists()); + } + + @Test + @DisplayName("커서 기반 조회 - 실패 (유효하지 않은 정렬 기준)") + @WithMockUser + void findCursor_fail_PlaylistIncorrectSortByException() throws Exception { + // given + willThrow(new PlaylistIncorrectSortByException("INVALID_SORT")).given(playlistMapper) + .toCommand(any()); + + // when & then + mockMvc.perform(get("/api/playlists").param("limit", "10").param("sortBy", "INVALID_SORT") + .param("sortDirection", "DESC")).andDo(print()).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType").value("PlaylistIncorrectSortByException")) + .andExpect(jsonPath("$.message").value("SortBy 입력값이 올바르지 않습니다.")); + } + + @ParameterizedTest + @CsvSource({"UPDATED_AT, not_a_date", "SUBSCRIBE_COUNT, not_a_number"}) + @DisplayName("커서 기반 조회 - 실패 (정렬 기준과 커서 타입 불일치)") + @WithMockUser + void findCursor_fail_PlaylistSortByMismatchException(String sortBy, String cursor) + throws Exception { + // given + willThrow(new PlaylistSortByMismatchException(sortBy, cursor)).given(playlistMapper) + .toCommand(any()); + + // when & then + mockMvc.perform(get("/api/playlists").param("limit", "10").param("sortBy", sortBy) + .param("sortDirection", "DESC").param("cursor", cursor)).andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType").value("PlaylistSortByMismatchException")) + .andExpect(jsonPath("$.message").value( + "정렬 기준과 커서 타입이 일치하지 않습니다. (허용 포맷: updatedAt -> 예: 2026-07-01T10:00:00Z, subscribeCount -> 숫자)")); + } + + @Test + @DisplayName("플레이리스트 생성") + @WithMockUser(username = "user@test.com") + void create() throws Exception { + // given + PlaylistCreateRequest request = new PlaylistCreateRequest("New Playlist", "Desc"); + given(playlistService.create(eq("user@test.com"), any(PlaylistCreateRequest.class))) + .willReturn(playlistResponse); + + // when & then + mockMvc.perform(post("/api/playlists").with(csrf()).principal(principal) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))).andDo(print()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(playlistId.toString())); + + verify(playlistService).create(eq("user@test.com"), createRequestCaptor.capture()); + PlaylistCreateRequest captured = createRequestCaptor.getValue(); + assertThat(captured.title()).isEqualTo("New Playlist"); + assertThat(captured.description()).isEqualTo("Desc"); + } + + @Test + @DisplayName("플레이리스트 수정") + @WithMockUser(username = "user@test.com") + void update() throws Exception { + // given + PlaylistUpdateRequest request = + new PlaylistUpdateRequest("Updated Playlist", "Updated Desc"); + given(playlistService.update(eq(playlistId), eq("user@test.com"), + any(PlaylistUpdateRequest.class))).willReturn(playlistResponse); + + // when & then + mockMvc.perform(patch("/api/playlists/{id}", playlistId).with(csrf()).principal(principal) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))).andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(playlistId.toString())); + + verify(playlistService).update(eq(playlistId), eq("user@test.com"), + updateRequestCaptor.capture()); + PlaylistUpdateRequest captured = updateRequestCaptor.getValue(); + assertThat(captured.title()).isEqualTo("Updated Playlist"); + assertThat(captured.description()).isEqualTo("Updated Desc"); + } + + @Test + @DisplayName("플레이리스트 삭제") + @WithMockUser(username = "user@test.com") + void deletePlaylist() throws Exception { + // given + willDoNothing().given(playlistService).delete(playlistId, "user@test.com"); + + // when & then + mockMvc.perform(delete("/api/playlists/{id}", playlistId).with(csrf()).principal(principal)) + .andDo(print()).andExpect(status().isNoContent()); + } + + @Test + @DisplayName("플레이리스트 삭제 - 실패 (권한 없음)") + @WithMockUser(username = "user@test.com") + void deletePlaylist_fail_PlaylistAccessDeniedException() throws Exception { + // given + willThrow(new PlaylistAccessDeniedException(playlistId, "user@test.com")) + .given(playlistService).delete(playlistId, "user@test.com"); + + // when & then + mockMvc.perform(delete("/api/playlists/{id}", playlistId).with(csrf()).principal(principal)) + .andDo(print()).andExpect(status().isForbidden()) + .andExpect(jsonPath("$.exceptionType").value("PlaylistAccessDeniedException")) + .andExpect(jsonPath("$.message").value("플레이리스트 접근 권한이 없습니다.")); + } + + @Test + @DisplayName("플레이리스트 아이템 추가") + @WithMockUser(username = "user@test.com") + void addContent() throws Exception { + // given + willDoNothing().given(playlistService).addContent("user@test.com", playlistId, contentId); + + // when & then + mockMvc.perform( + post("/api/playlists/{playlistId}/contents/{contentId}", playlistId, contentId) + .with(csrf()).principal(principal)) + .andDo(print()).andExpect(status().isNoContent()); + } + + @Test + @DisplayName("플레이리스트 아이템 추가 - 실패 (컨텐츠 없음)") + @WithMockUser(username = "user@test.com") + void addContent_fail_PlaylistContentNotFoundException() throws Exception { + // given + willThrow(new PlaylistContentNotFoundException(contentId)).given(playlistService) + .addContent("user@test.com", playlistId, contentId); + + // when & then + mockMvc.perform( + post("/api/playlists/{playlistId}/contents/{contentId}", playlistId, contentId) + .with(csrf()).principal(principal)) + .andDo(print()).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType").value("PlaylistContentNotFoundException")) + .andExpect(jsonPath("$.message").value("컨텐츠를 찾을 수 없습니다.")); + } + + @Test + @DisplayName("플레이리스트 아이템 삭제") + @WithMockUser(username = "user@test.com") + void removeContent() throws Exception { + // given + willDoNothing().given(playlistService).removeContent("user@test.com", playlistId, + contentId); + + // when & then + mockMvc.perform( + delete("/api/playlists/{playlistId}/contents/{contentId}", playlistId, contentId) + .with(csrf()).principal(principal)) + .andDo(print()).andExpect(status().isNoContent()); + } + + @Test + @DisplayName("플레이리스트 아이템 삭제 - 실패 (아이템 없음)") + @WithMockUser(username = "user@test.com") + void removeContent_fail_PlaylistItemNotFoundException() throws Exception { + // given + willThrow(new PlaylistItemNotFoundException(playlistId, contentId)).given(playlistService) + .removeContent("user@test.com", playlistId, contentId); + + // when & then + mockMvc.perform( + delete("/api/playlists/{playlistId}/contents/{contentId}", playlistId, contentId) + .with(csrf()).principal(principal)) + .andDo(print()).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType").value("PlaylistItemNotFoundException")) + .andExpect(jsonPath("$.message").value("플레이리스트에 추가된 컨텐츠를 찾을 수 없습니다.")); + } +} From b65b4b0225cbbe3c7314105e2b0ca2ebcdd772b4 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 14:41:08 +0900 Subject: [PATCH 26/46] =?UTF-8?q?fix:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=95=84=EC=9D=B4=ED=85=9C?= =?UTF-8?q?=EC=9D=98=20position=20=EC=BB=AC=EB=9F=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 플레이리스트 아이템 테이블에서 더 이상 사용되지 않는 position 컬럼을 삭제합니다. - 이 컬럼은 이전 마이그레이션에서 추가되었으나, 실제 로직에서는 사용되지 않아 제거합니다. --- .../resources/db/migration/V17__remove_playlist_items_col.sql | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/main/resources/db/migration/V17__remove_playlist_items_col.sql diff --git a/src/main/resources/db/migration/V17__remove_playlist_items_col.sql b/src/main/resources/db/migration/V17__remove_playlist_items_col.sql new file mode 100644 index 00000000..78eb0b69 --- /dev/null +++ b/src/main/resources/db/migration/V17__remove_playlist_items_col.sql @@ -0,0 +1 @@ +alter table playlist_items drop column "position"; From 2e1c63b9548177762abe1c27e148b3b76e4aeb23 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 14:46:04 +0900 Subject: [PATCH 27/46] =?UTF-8?q?fix:=20PlaylistRepository=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - PlaylistRepository에서 owner 정보를 함께 조회하는 findOwnerById 메서드를 제거했습니다. - 해당 메서드는 더 이상 사용되지 않아 코드의 불필요한 부분을 정리했습니다. --- .../team5/mopl/playlist/repository/PlaylistRepository.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepository.java b/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepository.java index 5c91edf5..63434711 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepository.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepository.java @@ -1,8 +1,6 @@ package com.codeit.team5.mopl.playlist.repository; -import java.util.Optional; import java.util.UUID; -import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -11,9 +9,6 @@ public interface PlaylistRepository extends JpaRepository, PlaylistQueryRepository { - @EntityGraph(attributePaths = {"owner", "owner.profileImage"}) - Optional findOwnerById(UUID id); - boolean existsByIdAndOwnerEmail(UUID id, String email); @Modifying From a71837ec82624b730648f8aac2fb720295c998b0 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 15:22:24 +0900 Subject: [PATCH 28/46] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EA=B7=B8=EB=9E=98=ED=94=84=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용자 조회 시 연관된 엔티티를 함께 로딩하도록 EntityGraph를 추가했습니다. - 이를 통해 N+1 문제를 방지하고 성능을 개선합니다. --- .../com/codeit/team5/mopl/user/repository/UserRepository.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java b/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java index 44353172..481fad21 100644 --- a/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java +++ b/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java @@ -6,6 +6,7 @@ import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; From 0ecdf06d34f7573ee1216186bdd7d16b12a9288f Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 16:27:34 +0900 Subject: [PATCH 29/46] =?UTF-8?q?fix:=20MapStruct=20unmapped=20target=20po?= =?UTF-8?q?licy=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MapStruct 설정에서 unmappedTargetPolicy를 IGNORE에서 WARN으로 변경했습니다. - 이를 통해 매핑되지 않은 필드가 있을 경우 경고를 발생시켜 잠재적인 오류를 조기에 발견하고 수정할 수 있도록 합니다. --- .../com/codeit/team5/mopl/global/mapper/GlobalMapperConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/codeit/team5/mopl/global/mapper/GlobalMapperConfig.java b/src/main/java/com/codeit/team5/mopl/global/mapper/GlobalMapperConfig.java index 062f0081..eca31f1b 100644 --- a/src/main/java/com/codeit/team5/mopl/global/mapper/GlobalMapperConfig.java +++ b/src/main/java/com/codeit/team5/mopl/global/mapper/GlobalMapperConfig.java @@ -7,7 +7,7 @@ import org.mapstruct.ReportingPolicy; @MapperConfig(componentModel = MappingConstants.ComponentModel.SPRING, - unmappedTargetPolicy = ReportingPolicy.IGNORE, + unmappedTargetPolicy = ReportingPolicy.WARN, nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE, injectionStrategy = InjectionStrategy.CONSTRUCTOR) public interface GlobalMapperConfig { From c3e9807859d4ef347249ddc0b005d4cfc0e83774 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 16:28:24 +0900 Subject: [PATCH 30/46] =?UTF-8?q?fix:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B4=80=EB=A0=A8=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=EC=84=B1=20=EA=B2=80=EC=82=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - PlaylistNotFoundException 및 PlaylistUserNotFoundException의 HTTP 상태 코드를 NOT_FOUND로 변경했습니다. - PlaylistCursorRequest의 limit 필드에 최대값 50을 적용하여 유효성 검사를 강화했습니다. - Playlist 엔티티에서 불필요한 Assert 구문을 제거했습니다. - PlaylistItem 엔티티의 playlistId와 content 필드에 nullable=false 제약 조건을 추가했습니다. - PlaylistController의 create 메서드에 @Valid 어노테이션을 추가하여 요청 DTO의 유효성 검사를 활성화했습니다. - PlaylistQueryRepositoryImpl에서 SORT_PATH_MAP 필드 이름을 cursorBuilder로 변경했습니다. - PlaylistService의 create 메서드에서 플레이리스트 저장 로직을 추가했습니다. - PlaylistCreataionViolationException 클래스를 추가하여 플레이리스트 생성 시 발생하는 위반 사항에 대한 예외 처리를 정의했습니다. --- .../mopl/playlist/controller/PlaylistController.java | 2 +- .../playlist/dto/request/PlaylistCursorRequest.java | 3 ++- .../codeit/team5/mopl/playlist/entity/Playlist.java | 3 --- .../team5/mopl/playlist/entity/PlaylistItem.java | 4 ++-- .../PlaylistCreataionViolationException.java | 12 ++++++++++++ .../exception/PlaylistNotFoundException.java | 2 +- .../exception/PlaylistUserNotFoundException.java | 2 +- .../query/PlaylistQueryRepositoryImpl.java | 4 ++-- .../team5/mopl/playlist/service/PlaylistService.java | 1 + 9 files changed, 22 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistCreataionViolationException.java diff --git a/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java b/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java index d9ce1f57..4b8c9130 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java @@ -50,7 +50,7 @@ public ResponseEntity> findCursor( @Override @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity create(Principal principal, - @RequestBody PlaylistCreateRequest request) { + @RequestBody @Valid PlaylistCreateRequest request) { return ResponseEntity.status(HttpStatus.CREATED) .body(service.create(principal.getName(), request)); } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCursorRequest.java b/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCursorRequest.java index 1e92d180..e477f12b 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCursorRequest.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCursorRequest.java @@ -2,6 +2,7 @@ import java.util.UUID; import org.springframework.data.domain.Sort; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; @@ -10,7 +11,7 @@ public record PlaylistCursorRequest(String keywordLike, UUID subscriberIdEqual, String cursor, UUID idAfter, - @NotNull @Positive Integer limit, + @NotNull @Positive @Max(50) Integer limit, @NotNull Sort.Direction sortDirection, @NotNull String sortBy) { diff --git a/src/main/java/com/codeit/team5/mopl/playlist/entity/Playlist.java b/src/main/java/com/codeit/team5/mopl/playlist/entity/Playlist.java index 271a5dd1..a2e16d94 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/entity/Playlist.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/entity/Playlist.java @@ -2,7 +2,6 @@ import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; import com.codeit.team5.mopl.user.entity.User; -import io.jsonwebtoken.lang.Assert; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -37,8 +36,6 @@ public class Playlist extends BaseUpdatableEntity { @Builder(access = AccessLevel.PRIVATE) private Playlist(User owner, String title, String description, Integer subscriberCount) { - Assert.hasText(title, "제목을 입력해주세요."); - Assert.hasText(description, "상세 설명을 입력해주세요."); this.owner = owner; this.title = title; this.description = description; diff --git a/src/main/java/com/codeit/team5/mopl/playlist/entity/PlaylistItem.java b/src/main/java/com/codeit/team5/mopl/playlist/entity/PlaylistItem.java index 4be46e98..1cc65986 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/entity/PlaylistItem.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/entity/PlaylistItem.java @@ -25,12 +25,12 @@ public class PlaylistItem extends BaseEntity { @NotNull - @Column(name = "playlist_id") + @Column(name = "playlist_id", nullable = false) private UUID playlistId; @NotNull @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "content_id") + @JoinColumn(name = "content_id", nullable = false) private Content content; private PlaylistItem(UUID playlistId, Content content) { diff --git a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistCreataionViolationException.java b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistCreataionViolationException.java new file mode 100644 index 00000000..6f073f3d --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistCreataionViolationException.java @@ -0,0 +1,12 @@ +package com.codeit.team5.mopl.playlist.exception; + +import java.util.Map; +import org.springframework.http.HttpStatus; +import com.codeit.team5.mopl.global.exception.BusinessException; + +public class PlaylistCreataionViolationException extends BusinessException { + + public PlaylistCreataionViolationException(String message, Map details) { + super(HttpStatus.BAD_REQUEST, message, details); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistNotFoundException.java b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistNotFoundException.java index 8823e009..1728c473 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistNotFoundException.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistNotFoundException.java @@ -8,7 +8,7 @@ public class PlaylistNotFoundException extends BusinessException { public PlaylistNotFoundException(UUID id) { - super(HttpStatus.BAD_REQUEST, "플레이리스트를 찾을 수 없습니다.", + super(HttpStatus.NOT_FOUND, "플레이리스트를 찾을 수 없습니다.", Map.of("playlistId", id)); } } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistUserNotFoundException.java b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistUserNotFoundException.java index 3f358f19..d0a75c36 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistUserNotFoundException.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistUserNotFoundException.java @@ -7,7 +7,7 @@ public class PlaylistUserNotFoundException extends BusinessException { public PlaylistUserNotFoundException(String email) { - super(HttpStatus.BAD_REQUEST, "유저를 찾을 수 없습니다.", + super(HttpStatus.NOT_FOUND, "유저를 찾을 수 없습니다.", Map.of("email", email)); } } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java index be103dca..3546a31d 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java @@ -36,7 +36,7 @@ public class PlaylistQueryRepositoryImpl implements PlaylistQueryRepository { private final JPAQueryFactory queryFactory; - private final Map SORT_PATH_MAP = + private final Map cursorBuilder = Map.of(PlaylistSortBy.UPDATED_AT, cursorBuilder(playlist.updatedAt), PlaylistSortBy.SUBSCRIBE_COUNT, cursorBuilder(playlist.subscriberCount)); @@ -120,7 +120,7 @@ private BooleanExpression cursorQuery(PlaylistCursorCommand request) { } boolean isAsc = request.sortDirection().isAscending(); - return SORT_PATH_MAP.get(request.sortBy()).build(cursor, idAfter, isAsc); + return cursorBuilder.get(request.sortBy()).build(cursor, idAfter, isAsc); } private BooleanExpression subscriberQuery(PlaylistCursorCommand request) { diff --git a/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java index 47b219f2..b87d0652 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java @@ -42,6 +42,7 @@ public PlaylistResponse create(String email, PlaylistCreateRequest request) { User user = userRepository.findByEmail(email) .orElseThrow(() -> new PlaylistUserNotFoundException(email)); Playlist playlist = Playlist.of(user, request.title(), request.description()); + repository.save(playlist); return mapper.toDto(playlist); } From 62c287ee079a5fda2300d595e36be9ff900d303d Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 16:28:42 +0900 Subject: [PATCH 31/46] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=EB=8F=85=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 플레이리스트 구독 시 이미 구독 중인 경우 발생하는 예외를 처리합니다. - SubscriptionAlreadyExistsException 클래스를 새로 추가하여 중복 구독 시 BAD_REQUEST와 함께 적절한 메시지를 반환하도록 구현했습니다. - 기존 SubscriptionNotFoundException의 메시지를 "구독 정보를 찾을 수 없습니다."로 수정하여 더 명확하게 변경했습니다. - Swagger 태그 이름을 "플레이리스트 관리"에서 "플레이리스트 구독 관리"로 변경하여 API의 역할을 명확히 했습니다. --- .../api/SubscriptionControllerApi.java | 2 +- .../SubscriptionAlreadyExistsException.java | 18 ++++++++++++++++++ .../SubscriptionNotFoundException.java | 2 +- .../service/SubscriptionService.java | 4 ++++ 4 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionAlreadyExistsException.java diff --git a/src/main/java/com/codeit/team5/mopl/subscription/controller/api/SubscriptionControllerApi.java b/src/main/java/com/codeit/team5/mopl/subscription/controller/api/SubscriptionControllerApi.java index d0f28114..217800cd 100644 --- a/src/main/java/com/codeit/team5/mopl/subscription/controller/api/SubscriptionControllerApi.java +++ b/src/main/java/com/codeit/team5/mopl/subscription/controller/api/SubscriptionControllerApi.java @@ -9,7 +9,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -@Tag(name = "플레이리스트 관리") +@Tag(name = "플레이리스트 구독 관리") public interface SubscriptionControllerApi { @Operation(summary = "플레이리스트 구독") diff --git a/src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionAlreadyExistsException.java b/src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionAlreadyExistsException.java new file mode 100644 index 00000000..d6b140f3 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionAlreadyExistsException.java @@ -0,0 +1,18 @@ +package com.codeit.team5.mopl.subscription.exception; + +import java.util.Map; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import com.codeit.team5.mopl.global.exception.BusinessException; + +/** + * SubscriptionAlreadyExistsException + */ +public class SubscriptionAlreadyExistsException extends BusinessException { + + public SubscriptionAlreadyExistsException(String email, UUID playlistId) { + super(HttpStatus.BAD_REQUEST, "이미 플레이리스트를 구독하고 있습니다.", + Map.of("email", email, "playlistId", playlistId)); + } + +} diff --git a/src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionNotFoundException.java b/src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionNotFoundException.java index aaaf653a..992b25f6 100644 --- a/src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionNotFoundException.java +++ b/src/main/java/com/codeit/team5/mopl/subscription/exception/SubscriptionNotFoundException.java @@ -8,7 +8,7 @@ public class SubscriptionNotFoundException extends BusinessException { public SubscriptionNotFoundException(UUID playlistId, String email) { - super(HttpStatus.NOT_FOUND, "유저의 플레이리스트를 찾을 수 없습니다.", + super(HttpStatus.NOT_FOUND, "구독 정보를 찾을 수 없습니다.", Map.of("playlistId", playlistId, "email", email)); } } diff --git a/src/main/java/com/codeit/team5/mopl/subscription/service/SubscriptionService.java b/src/main/java/com/codeit/team5/mopl/subscription/service/SubscriptionService.java index 5c384d51..2a2d5376 100644 --- a/src/main/java/com/codeit/team5/mopl/subscription/service/SubscriptionService.java +++ b/src/main/java/com/codeit/team5/mopl/subscription/service/SubscriptionService.java @@ -3,6 +3,7 @@ import com.codeit.team5.mopl.playlist.entity.Playlist; import com.codeit.team5.mopl.playlist.repository.PlaylistRepository; import com.codeit.team5.mopl.subscription.entity.Subscription; +import com.codeit.team5.mopl.subscription.exception.SubscriptionAlreadyExistsException; import com.codeit.team5.mopl.subscription.exception.SubscriptionNotFoundException; import com.codeit.team5.mopl.subscription.exception.SubscriptionPlaylistNotFoundException; import com.codeit.team5.mopl.subscription.exception.SubscriptionUserNotFoundException; @@ -30,6 +31,9 @@ public void create(UUID playlistId, String email) { } User user = userRepository.findByEmail(email) .orElseThrow(() -> new SubscriptionUserNotFoundException(email)); + if (repository.existsBySubscriberEmailAndPlaylistId(email, playlistId)) { + throw new SubscriptionAlreadyExistsException(email, playlistId); + } Playlist playlist = playlistRepository.getReferenceById(playlistId); repository.save(Subscription.of(playlist, user)); } From a97f3fc657fda9946d30dd7207934e165623885e Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 16:34:02 +0900 Subject: [PATCH 32/46] =?UTF-8?q?test:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 플레이리스트 조회, 생성, 수정, 삭제, 아이템 추가/삭제 관련 테스트 코드를 작성했습니다. - 다양한 예외 상황에 대한 테스트 케이스를 포함하여 안정성을 높였습니다. - 커서 기반 조회 시 입력값 유 --- .../controller/PlaylistControllerTest.java | 620 +++++++++--------- .../playlist/service/PlaylistServiceTest.java | 2 + 2 files changed, 319 insertions(+), 303 deletions(-) diff --git a/src/test/java/com/codeit/team5/mopl/playlist/controller/PlaylistControllerTest.java b/src/test/java/com/codeit/team5/mopl/playlist/controller/PlaylistControllerTest.java index a73b3afe..780cd9b8 100644 --- a/src/test/java/com/codeit/team5/mopl/playlist/controller/PlaylistControllerTest.java +++ b/src/test/java/com/codeit/team5/mopl/playlist/controller/PlaylistControllerTest.java @@ -39,7 +39,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import com.codeit.team5.mopl.TestGlobalExceptionHandlerConfig; -import com.codeit.team5.mopl.auth.filter.JwtAuthenticationFilter; +import com.codeit.team5.mopl.auth.jwt.JwtAuthenticationFilter; import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.global.exception.GlobalExceptionHandler; import com.codeit.team5.mopl.playlist.dto.PlaylistCursorCommand; @@ -59,314 +59,328 @@ import com.fasterxml.jackson.databind.ObjectMapper; @WebMvcTest(controllers = PlaylistController.class, - excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, - classes = JwtAuthenticationFilter.class)) + excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, + classes = JwtAuthenticationFilter.class)) @AutoConfigureMockMvc(addFilters = false) @Import({GlobalExceptionHandler.class, TestGlobalExceptionHandlerConfig.class}) class PlaylistControllerTest { - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @MockitoBean - private PlaylistService playlistService; - - @MockitoBean - private PlaylistMapper playlistMapper; - - @Captor - private ArgumentCaptor createRequestCaptor; - - @Captor - private ArgumentCaptor updateRequestCaptor; - - @Captor - private ArgumentCaptor cursorRequestCaptor; - - private Principal principal; - private UUID playlistId; - private UUID contentId; - private PlaylistResponse playlistResponse; - - @BeforeEach - void setUp() { - principal = Mockito.mock(Principal.class); - given(principal.getName()).willReturn("user@test.com"); - - playlistId = UUID.randomUUID(); - contentId = UUID.randomUUID(); - - UserSummary userSummary = new UserSummary(UUID.randomUUID(), "Test User", null); - - playlistResponse = new PlaylistResponse(playlistId, userSummary, Instant.now(), - "My Playlist", "Description", 0, false, List.of()); - } - - @Test - @DisplayName("단건 조회") - @WithMockUser - void find() throws Exception { - // given - given(playlistService.find(playlistId)).willReturn(playlistResponse); - - // when & then - mockMvc.perform(get("/api/playlists/{id}", playlistId)).andDo(print()) - .andExpect(status().isOk()).andExpect(jsonPath("$.id").value(playlistId.toString())) - .andExpect(jsonPath("$.title").value("My Playlist")); - } - - @Test - @DisplayName("단건 조회 - 실패 (플레이리스트 없음)") - @WithMockUser - void find_fail_PlaylistNotFoundException() throws Exception { - // given - given(playlistService.find(playlistId)) - .willThrow(new PlaylistNotFoundException(playlistId)); - - // when & then - mockMvc.perform(get("/api/playlists/{id}", playlistId)).andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.exceptionType").value("PlaylistNotFoundException")) - .andExpect(jsonPath("$.message").value("플레이리스트를 찾을 수 없습니다.")); - } - - @Test - @DisplayName("커서 기반 조회") - @WithMockUser - void findCursor() throws Exception { - // given - CursorResponse cursorResponse = CursorResponse.builder() - .data(List.of(playlistResponse)).hasNext(false).totalCount(1L).build(); - PlaylistCursorCommand command = PlaylistCursorCommand.builder().limit(10).build(); - - given(playlistMapper.toCommand(any(PlaylistCursorRequest.class))).willReturn(command); - given(playlistService.findByCursor(command)).willReturn(cursorResponse); - - // when & then - mockMvc.perform(get("/api/playlists").param("limit", "10").param("sortBy", "UPDATED_AT") - .param("sortDirection", "DESC")).andDo(print()).andExpect(status().isOk()) - .andExpect(jsonPath("$.data[0].id").value(playlistId.toString())); - - verify(playlistMapper).toCommand(cursorRequestCaptor.capture()); - PlaylistCursorRequest captured = cursorRequestCaptor.getValue(); - assertThat(captured.limit()).isEqualTo(10); - assertThat(captured.sortBy()).isEqualTo("UPDATED_AT"); - assertThat(captured.sortDirection()).isEqualTo(Sort.Direction.DESC); - } - - @ParameterizedTest - @CsvSource(value = { - // limit 검증 - "null, DESC, UPDATED_AT, limit", "-5, DESC, UPDATED_AT, limit", - "0, DESC, UPDATED_AT, limit", "not_a_number, DESC, UPDATED_AT, limit", - // sortDirection 검증 - "10, null, UPDATED_AT, sortDirection", "10, INVALID, UPDATED_AT, sortDirection", - // sortBy 검증 - "10, DESC, null, sortBy"}, nullValues = {"null"}) - @DisplayName("커서 기반 조회 - 실패 (입력값 유효성 검증)") - @WithMockUser - void findCursor_fail_validation(String limit, String sortDirection, String sortBy, - String errorField) throws Exception { - var requestBuilder = get("/api/playlists"); - - if (limit != null) { - requestBuilder.param("limit", limit); + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private PlaylistService playlistService; + + @MockitoBean + private PlaylistMapper playlistMapper; + + @Captor + private ArgumentCaptor createRequestCaptor; + + @Captor + private ArgumentCaptor updateRequestCaptor; + + @Captor + private ArgumentCaptor cursorRequestCaptor; + + private Principal principal; + private UUID playlistId; + private UUID contentId; + private PlaylistResponse playlistResponse; + + @BeforeEach + void setUp() { + principal = Mockito.mock(Principal.class); + given(principal.getName()).willReturn("user@test.com"); + + playlistId = UUID.randomUUID(); + contentId = UUID.randomUUID(); + + UserSummary userSummary = new UserSummary(UUID.randomUUID(), "Test User", null); + + playlistResponse = new PlaylistResponse(playlistId, userSummary, Instant.now(), + "My Playlist", "Description", 0, false, List.of()); + } + + @Test + @DisplayName("단건 조회") + @WithMockUser + void find() throws Exception { + // given + given(playlistService.find(playlistId)).willReturn(playlistResponse); + + // when & then + mockMvc.perform(get("/api/playlists/{id}", playlistId)).andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(playlistId.toString())) + .andExpect(jsonPath("$.title").value("My Playlist")); + } + + @Test + @DisplayName("단건 조회 - 실패 (플레이리스트 없음)") + @WithMockUser + void find_fail_PlaylistNotFoundException() throws Exception { + // given + given(playlistService.find(playlistId)) + .willThrow(new PlaylistNotFoundException(playlistId)); + + // when & then + mockMvc.perform(get("/api/playlists/{id}", playlistId)).andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.exceptionType") + .value("PlaylistNotFoundException")) + .andExpect(jsonPath("$.message").value("플레이리스트를 찾을 수 없습니다.")); + } + + @Test + @DisplayName("커서 기반 조회") + @WithMockUser + void findCursor() throws Exception { + // given + CursorResponse cursorResponse = CursorResponse + .builder().data(List.of(playlistResponse)) + .hasNext(false).totalCount(1L).build(); + PlaylistCursorCommand command = PlaylistCursorCommand.builder().limit(10).build(); + + given(playlistMapper.toCommand(any(PlaylistCursorRequest.class))) + .willReturn(command); + given(playlistService.findByCursor(command)).willReturn(cursorResponse); + + // when & then + mockMvc.perform(get("/api/playlists").param("limit", "10") + .param("sortBy", "UPDATED_AT").param("sortDirection", "DESC")) + .andDo(print()).andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(playlistId.toString())); + + verify(playlistMapper).toCommand(cursorRequestCaptor.capture()); + PlaylistCursorRequest captured = cursorRequestCaptor.getValue(); + assertThat(captured.limit()).isEqualTo(10); + assertThat(captured.sortBy()).isEqualTo("UPDATED_AT"); + assertThat(captured.sortDirection()).isEqualTo(Sort.Direction.DESC); + } + + @ParameterizedTest + @CsvSource(value = { + // limit 검증 + "null, DESC, UPDATED_AT, limit", "-5, DESC, UPDATED_AT, limit", + "0, DESC, UPDATED_AT, limit", "not_a_number, DESC, UPDATED_AT, limit", + // sortDirection 검증 + "10, null, UPDATED_AT, sortDirection", + "10, INVALID, UPDATED_AT, sortDirection", + // sortBy 검증 + "10, DESC, null, sortBy"}, nullValues = {"null"}) + @DisplayName("커서 기반 조회 - 실패 (입력값 유효성 검증)") + @WithMockUser + void findCursor_fail_validation(String limit, String sortDirection, String sortBy, + String errorField) throws Exception { + var requestBuilder = get("/api/playlists"); + + if (limit != null) { + requestBuilder.param("limit", limit); + } + if (sortDirection != null) { + requestBuilder.param("sortDirection", sortDirection); + } + if (sortBy != null) { + requestBuilder.param("sortBy", sortBy); + } + + mockMvc.perform(requestBuilder).andDo(print()).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType").value("INVALID_INPUT")) + .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) + .andExpect(jsonPath("$.details." + errorField).exists()); } - if (sortDirection != null) { - requestBuilder.param("sortDirection", sortDirection); + + @Test + @DisplayName("커서 기반 조회 - 실패 (선택 파라미터 UUID 포맷 오류)") + @WithMockUser + void findCursor_fail_invalid_uuid_format() throws Exception { + mockMvc.perform(get("/api/playlists").param("limit", "10") + .param("sortBy", "UPDATED_AT").param("sortDirection", "DESC") + .param("ownerIdEqual", "not_a_uuid")).andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType").value("INVALID_INPUT")) + .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) + .andExpect(jsonPath("$.details.ownerIdEqual").exists()); + } + + @Test + @DisplayName("커서 기반 조회 - 실패 (유효하지 않은 정렬 기준)") + @WithMockUser + void findCursor_fail_PlaylistIncorrectSortByException() throws Exception { + // given + willThrow(new PlaylistIncorrectSortByException("INVALID_SORT")) + .given(playlistMapper).toCommand(any()); + + // when & then + mockMvc.perform(get("/api/playlists").param("limit", "10") + .param("sortBy", "INVALID_SORT").param("sortDirection", "DESC")) + .andDo(print()).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType") + .value("PlaylistIncorrectSortByException")) + .andExpect(jsonPath("$.message").value("SortBy 입력값이 올바르지 않습니다.")); + } + + @ParameterizedTest + @CsvSource({"UPDATED_AT, not_a_date", "SUBSCRIBE_COUNT, not_a_number"}) + @DisplayName("커서 기반 조회 - 실패 (정렬 기준과 커서 타입 불일치)") + @WithMockUser + void findCursor_fail_PlaylistSortByMismatchException(String sortBy, String cursor) + throws Exception { + // given + willThrow(new PlaylistSortByMismatchException(sortBy, cursor)).given(playlistMapper) + .toCommand(any()); + + // when & then + mockMvc.perform(get("/api/playlists").param("limit", "10").param("sortBy", sortBy) + .param("sortDirection", "DESC").param("cursor", cursor)) + .andDo(print()).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType") + .value("PlaylistSortByMismatchException")) + .andExpect(jsonPath("$.message").value( + "정렬 기준과 커서 타입이 일치하지 않습니다. (허용 포맷: updatedAt -> 예: 2026-07-01T10:00:00Z, subscribeCount -> 숫자)")); } - if (sortBy != null) { - requestBuilder.param("sortBy", sortBy); + + @Test + @DisplayName("플레이리스트 생성") + @WithMockUser(username = "user@test.com") + void create() throws Exception { + // given + PlaylistCreateRequest request = new PlaylistCreateRequest("New Playlist", "Desc"); + given(playlistService.create(eq("user@test.com"), any(PlaylistCreateRequest.class))) + .willReturn(playlistResponse); + + // when & then + mockMvc.perform(post("/api/playlists").with(csrf()).principal(principal) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))).andDo(print()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(playlistId.toString())); + + verify(playlistService).create(eq("user@test.com"), createRequestCaptor.capture()); + PlaylistCreateRequest captured = createRequestCaptor.getValue(); + assertThat(captured.title()).isEqualTo("New Playlist"); + assertThat(captured.description()).isEqualTo("Desc"); + } + + @Test + @DisplayName("플레이리스트 수정") + @WithMockUser(username = "user@test.com") + void update() throws Exception { + // given + PlaylistUpdateRequest request = + new PlaylistUpdateRequest("Updated Playlist", "Updated Desc"); + given(playlistService.update(eq(playlistId), eq("user@test.com"), + any(PlaylistUpdateRequest.class))).willReturn(playlistResponse); + + // when & then + mockMvc.perform(patch("/api/playlists/{id}", playlistId).with(csrf()) + .principal(principal).contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))).andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(playlistId.toString())); + + verify(playlistService).update(eq(playlistId), eq("user@test.com"), + updateRequestCaptor.capture()); + PlaylistUpdateRequest captured = updateRequestCaptor.getValue(); + assertThat(captured.title()).isEqualTo("Updated Playlist"); + assertThat(captured.description()).isEqualTo("Updated Desc"); + } + + @Test + @DisplayName("플레이리스트 삭제") + @WithMockUser(username = "user@test.com") + void deletePlaylist() throws Exception { + // given + willDoNothing().given(playlistService).delete(playlistId, "user@test.com"); + + // when & then + mockMvc.perform(delete("/api/playlists/{id}", playlistId).with(csrf()) + .principal(principal)).andDo(print()) + .andExpect(status().isNoContent()); } - mockMvc.perform(requestBuilder).andDo(print()).andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.exceptionType").value("INVALID_INPUT")) - .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) - .andExpect(jsonPath("$.details." + errorField).exists()); - } - - @Test - @DisplayName("커서 기반 조회 - 실패 (선택 파라미터 UUID 포맷 오류)") - @WithMockUser - void findCursor_fail_invalid_uuid_format() throws Exception { - mockMvc.perform(get("/api/playlists").param("limit", "10").param("sortBy", "UPDATED_AT") - .param("sortDirection", "DESC").param("ownerIdEqual", "not_a_uuid")).andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.exceptionType").value("INVALID_INPUT")) - .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) - .andExpect(jsonPath("$.details.ownerIdEqual").exists()); - } - - @Test - @DisplayName("커서 기반 조회 - 실패 (유효하지 않은 정렬 기준)") - @WithMockUser - void findCursor_fail_PlaylistIncorrectSortByException() throws Exception { - // given - willThrow(new PlaylistIncorrectSortByException("INVALID_SORT")).given(playlistMapper) - .toCommand(any()); - - // when & then - mockMvc.perform(get("/api/playlists").param("limit", "10").param("sortBy", "INVALID_SORT") - .param("sortDirection", "DESC")).andDo(print()).andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.exceptionType").value("PlaylistIncorrectSortByException")) - .andExpect(jsonPath("$.message").value("SortBy 입력값이 올바르지 않습니다.")); - } - - @ParameterizedTest - @CsvSource({"UPDATED_AT, not_a_date", "SUBSCRIBE_COUNT, not_a_number"}) - @DisplayName("커서 기반 조회 - 실패 (정렬 기준과 커서 타입 불일치)") - @WithMockUser - void findCursor_fail_PlaylistSortByMismatchException(String sortBy, String cursor) - throws Exception { - // given - willThrow(new PlaylistSortByMismatchException(sortBy, cursor)).given(playlistMapper) - .toCommand(any()); - - // when & then - mockMvc.perform(get("/api/playlists").param("limit", "10").param("sortBy", sortBy) - .param("sortDirection", "DESC").param("cursor", cursor)).andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.exceptionType").value("PlaylistSortByMismatchException")) - .andExpect(jsonPath("$.message").value( - "정렬 기준과 커서 타입이 일치하지 않습니다. (허용 포맷: updatedAt -> 예: 2026-07-01T10:00:00Z, subscribeCount -> 숫자)")); - } - - @Test - @DisplayName("플레이리스트 생성") - @WithMockUser(username = "user@test.com") - void create() throws Exception { - // given - PlaylistCreateRequest request = new PlaylistCreateRequest("New Playlist", "Desc"); - given(playlistService.create(eq("user@test.com"), any(PlaylistCreateRequest.class))) - .willReturn(playlistResponse); - - // when & then - mockMvc.perform(post("/api/playlists").with(csrf()).principal(principal) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))).andDo(print()) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.id").value(playlistId.toString())); - - verify(playlistService).create(eq("user@test.com"), createRequestCaptor.capture()); - PlaylistCreateRequest captured = createRequestCaptor.getValue(); - assertThat(captured.title()).isEqualTo("New Playlist"); - assertThat(captured.description()).isEqualTo("Desc"); - } - - @Test - @DisplayName("플레이리스트 수정") - @WithMockUser(username = "user@test.com") - void update() throws Exception { - // given - PlaylistUpdateRequest request = - new PlaylistUpdateRequest("Updated Playlist", "Updated Desc"); - given(playlistService.update(eq(playlistId), eq("user@test.com"), - any(PlaylistUpdateRequest.class))).willReturn(playlistResponse); - - // when & then - mockMvc.perform(patch("/api/playlists/{id}", playlistId).with(csrf()).principal(principal) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))).andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(playlistId.toString())); - - verify(playlistService).update(eq(playlistId), eq("user@test.com"), - updateRequestCaptor.capture()); - PlaylistUpdateRequest captured = updateRequestCaptor.getValue(); - assertThat(captured.title()).isEqualTo("Updated Playlist"); - assertThat(captured.description()).isEqualTo("Updated Desc"); - } - - @Test - @DisplayName("플레이리스트 삭제") - @WithMockUser(username = "user@test.com") - void deletePlaylist() throws Exception { - // given - willDoNothing().given(playlistService).delete(playlistId, "user@test.com"); - - // when & then - mockMvc.perform(delete("/api/playlists/{id}", playlistId).with(csrf()).principal(principal)) - .andDo(print()).andExpect(status().isNoContent()); - } - - @Test - @DisplayName("플레이리스트 삭제 - 실패 (권한 없음)") - @WithMockUser(username = "user@test.com") - void deletePlaylist_fail_PlaylistAccessDeniedException() throws Exception { - // given - willThrow(new PlaylistAccessDeniedException(playlistId, "user@test.com")) - .given(playlistService).delete(playlistId, "user@test.com"); - - // when & then - mockMvc.perform(delete("/api/playlists/{id}", playlistId).with(csrf()).principal(principal)) - .andDo(print()).andExpect(status().isForbidden()) - .andExpect(jsonPath("$.exceptionType").value("PlaylistAccessDeniedException")) - .andExpect(jsonPath("$.message").value("플레이리스트 접근 권한이 없습니다.")); - } - - @Test - @DisplayName("플레이리스트 아이템 추가") - @WithMockUser(username = "user@test.com") - void addContent() throws Exception { - // given - willDoNothing().given(playlistService).addContent("user@test.com", playlistId, contentId); - - // when & then - mockMvc.perform( - post("/api/playlists/{playlistId}/contents/{contentId}", playlistId, contentId) - .with(csrf()).principal(principal)) - .andDo(print()).andExpect(status().isNoContent()); - } - - @Test - @DisplayName("플레이리스트 아이템 추가 - 실패 (컨텐츠 없음)") - @WithMockUser(username = "user@test.com") - void addContent_fail_PlaylistContentNotFoundException() throws Exception { - // given - willThrow(new PlaylistContentNotFoundException(contentId)).given(playlistService) - .addContent("user@test.com", playlistId, contentId); - - // when & then - mockMvc.perform( - post("/api/playlists/{playlistId}/contents/{contentId}", playlistId, contentId) - .with(csrf()).principal(principal)) - .andDo(print()).andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.exceptionType").value("PlaylistContentNotFoundException")) - .andExpect(jsonPath("$.message").value("컨텐츠를 찾을 수 없습니다.")); - } - - @Test - @DisplayName("플레이리스트 아이템 삭제") - @WithMockUser(username = "user@test.com") - void removeContent() throws Exception { - // given - willDoNothing().given(playlistService).removeContent("user@test.com", playlistId, - contentId); - - // when & then - mockMvc.perform( - delete("/api/playlists/{playlistId}/contents/{contentId}", playlistId, contentId) - .with(csrf()).principal(principal)) - .andDo(print()).andExpect(status().isNoContent()); - } - - @Test - @DisplayName("플레이리스트 아이템 삭제 - 실패 (아이템 없음)") - @WithMockUser(username = "user@test.com") - void removeContent_fail_PlaylistItemNotFoundException() throws Exception { - // given - willThrow(new PlaylistItemNotFoundException(playlistId, contentId)).given(playlistService) - .removeContent("user@test.com", playlistId, contentId); - - // when & then - mockMvc.perform( - delete("/api/playlists/{playlistId}/contents/{contentId}", playlistId, contentId) - .with(csrf()).principal(principal)) - .andDo(print()).andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.exceptionType").value("PlaylistItemNotFoundException")) - .andExpect(jsonPath("$.message").value("플레이리스트에 추가된 컨텐츠를 찾을 수 없습니다.")); - } + @Test + @DisplayName("플레이리스트 삭제 - 실패 (권한 없음)") + @WithMockUser(username = "user@test.com") + void deletePlaylist_fail_PlaylistAccessDeniedException() throws Exception { + // given + willThrow(new PlaylistAccessDeniedException(playlistId, "user@test.com")) + .given(playlistService).delete(playlistId, "user@test.com"); + + // when & then + mockMvc.perform(delete("/api/playlists/{id}", playlistId).with(csrf()) + .principal(principal)).andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.exceptionType") + .value("PlaylistAccessDeniedException")) + .andExpect(jsonPath("$.message").value("플레이리스트 접근 권한이 없습니다.")); + } + + @Test + @DisplayName("플레이리스트 아이템 추가") + @WithMockUser(username = "user@test.com") + void addContent() throws Exception { + // given + willDoNothing().given(playlistService).addContent("user@test.com", playlistId, + contentId); + + // when & then + mockMvc.perform(post("/api/playlists/{playlistId}/contents/{contentId}", playlistId, + contentId).with(csrf()).principal(principal)).andDo(print()) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("플레이리스트 아이템 추가 - 실패 (컨텐츠 없음)") + @WithMockUser(username = "user@test.com") + void addContent_fail_PlaylistContentNotFoundException() throws Exception { + // given + willThrow(new PlaylistContentNotFoundException(contentId)).given(playlistService) + .addContent("user@test.com", playlistId, contentId); + + // when & then + mockMvc.perform(post("/api/playlists/{playlistId}/contents/{contentId}", playlistId, + contentId).with(csrf()).principal(principal)).andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType") + .value("PlaylistContentNotFoundException")) + .andExpect(jsonPath("$.message").value("컨텐츠를 찾을 수 없습니다.")); + } + + @Test + @DisplayName("플레이리스트 아이템 삭제") + @WithMockUser(username = "user@test.com") + void removeContent() throws Exception { + // given + willDoNothing().given(playlistService).removeContent("user@test.com", playlistId, + contentId); + + // when & then + mockMvc.perform(delete("/api/playlists/{playlistId}/contents/{contentId}", + playlistId, contentId).with(csrf()).principal(principal)) + .andDo(print()).andExpect(status().isNoContent()); + } + + @Test + @DisplayName("플레이리스트 아이템 삭제 - 실패 (아이템 없음)") + @WithMockUser(username = "user@test.com") + void removeContent_fail_PlaylistItemNotFoundException() throws Exception { + // given + willThrow(new PlaylistItemNotFoundException(playlistId, contentId)) + .given(playlistService) + .removeContent("user@test.com", playlistId, contentId); + + // when & then + mockMvc.perform(delete("/api/playlists/{playlistId}/contents/{contentId}", + playlistId, contentId).with(csrf()).principal(principal)) + .andDo(print()).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType") + .value("PlaylistItemNotFoundException")) + .andExpect(jsonPath("$.message") + .value("플레이리스트에 추가된 컨텐츠를 찾을 수 없습니다.")); + } } diff --git a/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java b/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java index ae300377..065c5d6a 100644 --- a/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java @@ -92,6 +92,8 @@ void create_success() { assertThat(response).isNotNull(); assertThat(response.title()).isEqualTo("New Title"); assertThat(response.description()).isEqualTo("New Description"); + + verify(playlistRepository).save(any(Playlist.class)); } @Test From 5eb3de636735feed9cf67e3944946511751a6660 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 17:19:41 +0900 Subject: [PATCH 33/46] =?UTF-8?q?refactor:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=BB=A4=EC=84=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 플레이리스트 정렬 기준에 따른 CursorConditionBuilder 생성 로직을 Map에서 Switch 문으로 변경했습니다. - 불필요한 contentTag와 tag 조인을 제거하여 쿼리 성능을 개선했습니다. --- .../query/PlaylistQueryRepositoryImpl.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java index 3546a31d..ccc11288 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java @@ -36,10 +36,6 @@ public class PlaylistQueryRepositoryImpl implements PlaylistQueryRepository { private final JPAQueryFactory queryFactory; - private final Map cursorBuilder = - Map.of(PlaylistSortBy.UPDATED_AT, cursorBuilder(playlist.updatedAt), - PlaylistSortBy.SUBSCRIBE_COUNT, cursorBuilder(playlist.subscriberCount)); - @Override public Optional findByIdWithContents(UUID id) { Playlist foundPlaylist = queryFactory.selectFrom(playlist).leftJoin(playlist.owner, user) @@ -91,12 +87,10 @@ public long countByCommand(PlaylistCursorCommand request) { } private List fetchPlaylistItemsWithContents(List playlistIds) { - return queryFactory.selectFrom(playlistItem).distinct() + return queryFactory.selectFrom(playlistItem) .leftJoin(playlistItem.content, content).fetchJoin() .leftJoin(content.thumbnail, binaryContent).fetchJoin() .leftJoin(content.stats, contentStats).fetchJoin() - .leftJoin(content.contentTags, contentTag).fetchJoin() - .leftJoin(contentTag.tag, tag).fetchJoin() .where(playlistItem.playlistId.in(playlistIds)) .orderBy(playlistItem.createdAt.asc()).fetch(); } @@ -120,7 +114,13 @@ private BooleanExpression cursorQuery(PlaylistCursorCommand request) { } boolean isAsc = request.sortDirection().isAscending(); - return cursorBuilder.get(request.sortBy()).build(cursor, idAfter, isAsc); + + CursorConditionBuilder builder = switch (request.sortBy()) { + case UPDATED_AT -> cursorBuilder(playlist.updatedAt); + case SUBSCRIBE_COUNT -> cursorBuilder(playlist.subscriberCount); + }; + + return builder.build(cursor, idAfter, isAsc); } private BooleanExpression subscriberQuery(PlaylistCursorCommand request) { From 7af0d75bd9c9bfd4a6e875969adeffdf775e97d0 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 18:19:12 +0900 Subject: [PATCH 34/46] =?UTF-8?q?test:=20=EA=B5=AC=EB=8F=85=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=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - SubscriptionRepositoryTest: 구독 존재 여부 확인 및 삭제 테스트 추가 - SubscriptionControllerTest: 구독 생성 및 삭제 API 테스트 추가 - SubscriptionServiceTest: 구독 생성 및 삭제 로직 테스트 추가 --- .../SubscriptionControllerTest.java | 115 +++++++++++++ .../SubscriptionRepositoryTest.java | 80 +++++++++ .../service/SubscriptionServiceTest.java | 154 ++++++++++++++++++ 3 files changed, 349 insertions(+) create mode 100644 src/test/java/com/codeit/team5/mopl/subscription/controller/SubscriptionControllerTest.java create mode 100644 src/test/java/com/codeit/team5/mopl/subscription/repository/SubscriptionRepositoryTest.java create mode 100644 src/test/java/com/codeit/team5/mopl/subscription/service/SubscriptionServiceTest.java diff --git a/src/test/java/com/codeit/team5/mopl/subscription/controller/SubscriptionControllerTest.java b/src/test/java/com/codeit/team5/mopl/subscription/controller/SubscriptionControllerTest.java new file mode 100644 index 00000000..67390726 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/subscription/controller/SubscriptionControllerTest.java @@ -0,0 +1,115 @@ +package com.codeit.team5.mopl.subscription.controller; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import com.codeit.team5.mopl.TestGlobalExceptionHandlerConfig; +import com.codeit.team5.mopl.auth.jwt.JwtAuthenticationFilter; +import com.codeit.team5.mopl.global.exception.GlobalExceptionHandler; +import com.codeit.team5.mopl.subscription.exception.SubscriptionAlreadyExistsException; +import com.codeit.team5.mopl.subscription.exception.SubscriptionNotFoundException; +import com.codeit.team5.mopl.subscription.exception.SubscriptionPlaylistNotFoundException; +import com.codeit.team5.mopl.subscription.service.SubscriptionService; + +@WebMvcTest(controllers = SubscriptionController.class, + excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, + classes = JwtAuthenticationFilter.class)) +@AutoConfigureMockMvc(addFilters = false) // Disable security filters for simple unit test +@Import({GlobalExceptionHandler.class, TestGlobalExceptionHandlerConfig.class}) +class SubscriptionControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private SubscriptionService subscriptionService; + + // To mock the Principal for addFilters=false + private final String testEmail = "test@example.com"; + private final java.security.Principal mockPrincipal = () -> testEmail; + + @Test + @DisplayName("플레이리스트 구독 성공") + void createSubscription_success() throws Exception { + UUID playlistId = UUID.randomUUID(); + + doNothing().when(subscriptionService).create(eq(playlistId), eq(testEmail)); + + mockMvc.perform(post("/api/playlists/{playlistId}/subscription", playlistId) + .principal(mockPrincipal)).andDo(print()) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("플레이리스트 구독 실패 - 이미 구독 중") + void createSubscription_fail_alreadyExists() throws Exception { + UUID playlistId = UUID.randomUUID(); + + doThrow(new SubscriptionAlreadyExistsException(testEmail, playlistId)) + .when(subscriptionService).create(eq(playlistId), eq(testEmail)); + + mockMvc.perform(post("/api/playlists/{playlistId}/subscription", playlistId) + .principal(mockPrincipal)).andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType") + .value("SubscriptionAlreadyExistsException")); + } + + @Test + @DisplayName("플레이리스트 구독 실패 - 플레이리스트를 찾을 수 없음") + void createSubscription_fail_playlistNotFound() throws Exception { + UUID playlistId = UUID.randomUUID(); + + doThrow(new SubscriptionPlaylistNotFoundException(playlistId)) + .when(subscriptionService).create(eq(playlistId), eq(testEmail)); + + mockMvc.perform(post("/api/playlists/{playlistId}/subscription", playlistId) + .principal(mockPrincipal)).andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.exceptionType") + .value("SubscriptionPlaylistNotFoundException")); + } + + @Test + @DisplayName("플레이리스트 구독 취소 성공") + void deleteSubscription_success() throws Exception { + UUID playlistId = UUID.randomUUID(); + + doNothing().when(subscriptionService).delete(eq(playlistId), eq(testEmail)); + + mockMvc.perform(delete("/api/playlists/{playlistId}/subscription", playlistId) + .principal(mockPrincipal)).andDo(print()) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("플레이리스트 구독 취소 실패 - 구독 내역이 없음") + void deleteSubscription_fail_notFound() throws Exception { + UUID playlistId = UUID.randomUUID(); + + doThrow(new SubscriptionNotFoundException(playlistId, testEmail)) + .when(subscriptionService).delete(eq(playlistId), eq(testEmail)); + + mockMvc.perform(delete("/api/playlists/{playlistId}/subscription", playlistId) + .principal(mockPrincipal)).andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.exceptionType") + .value("SubscriptionNotFoundException")); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/subscription/repository/SubscriptionRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/subscription/repository/SubscriptionRepositoryTest.java new file mode 100644 index 00000000..d388e502 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/subscription/repository/SubscriptionRepositoryTest.java @@ -0,0 +1,80 @@ +package com.codeit.team5.mopl.subscription.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.codeit.team5.mopl.global.support.base.BaseRepositoryTest; +import com.codeit.team5.mopl.playlist.entity.Playlist; +import com.codeit.team5.mopl.playlist.repository.PlaylistRepository; +import com.codeit.team5.mopl.subscription.entity.Subscription; +import com.codeit.team5.mopl.user.entity.User; +import com.codeit.team5.mopl.user.repository.UserRepository; + +class SubscriptionRepositoryTest extends BaseRepositoryTest { + + @Autowired + private SubscriptionRepository subscriptionRepository; + + @Autowired + private PlaylistRepository playlistRepository; + + @Autowired + private UserRepository userRepository; + + private User user; + private Playlist playlist; + private Subscription subscription; + + @BeforeEach + void setUp() { + user = User.create("test@example.com", "password", "Test User"); + userRepository.save(user); + + playlist = Playlist.of(user, "Test Playlist", "Description"); + playlistRepository.save(playlist); + + subscription = Subscription.of(playlist, user); + subscriptionRepository.save(subscription); + + flush(); + clear(); + queryInspector.clear(); + } + + @Test + @DisplayName("구독자 이메일과 플레이리스트 아이디로 구독 존재 여부 확인") + void existsBySubscriberEmailAndPlaylistId() { + // when + boolean exists = subscriptionRepository.existsBySubscriberEmailAndPlaylistId(user.getEmail(), playlist.getId()); + ensureQueryCount(1); + + queryInspector.clear(); + boolean notExists = subscriptionRepository.existsBySubscriberEmailAndPlaylistId("wrong@email.com", playlist.getId()); + ensureQueryCount(1); + + // then + assertThat(exists).isTrue(); + assertThat(notExists).isFalse(); + } + + @Test + @DisplayName("구독자 이메일과 플레이리스트 아이디로 구독 삭제 (Query 어노테이션)") + void deleteBySubscriberEmailAndPlaylistIdDirectly() { + // when + subscriptionRepository.deleteBySubscriberEmailAndPlaylistIdDirectly(user.getEmail(), playlist.getId()); + ensureQueryCount(1); // delete 쿼리 발생 확인 + + flush(); + clear(); + + // then + boolean exists = subscriptionRepository.existsBySubscriberEmailAndPlaylistId(user.getEmail(), playlist.getId()); + assertThat(exists).isFalse(); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/subscription/service/SubscriptionServiceTest.java b/src/test/java/com/codeit/team5/mopl/subscription/service/SubscriptionServiceTest.java new file mode 100644 index 00000000..8b151ad6 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/subscription/service/SubscriptionServiceTest.java @@ -0,0 +1,154 @@ +package com.codeit.team5.mopl.subscription.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import java.util.Optional; +import java.util.UUID; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import com.codeit.team5.mopl.playlist.entity.Playlist; +import com.codeit.team5.mopl.playlist.repository.PlaylistRepository; +import com.codeit.team5.mopl.subscription.entity.Subscription; +import com.codeit.team5.mopl.subscription.exception.SubscriptionAlreadyExistsException; +import com.codeit.team5.mopl.subscription.exception.SubscriptionNotFoundException; +import com.codeit.team5.mopl.subscription.exception.SubscriptionPlaylistNotFoundException; +import com.codeit.team5.mopl.subscription.exception.SubscriptionUserNotFoundException; +import com.codeit.team5.mopl.subscription.repository.SubscriptionRepository; +import com.codeit.team5.mopl.user.entity.User; +import com.codeit.team5.mopl.user.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +class SubscriptionServiceTest { + + private SubscriptionService subscriptionService; + + @Mock + private SubscriptionRepository subscriptionRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private PlaylistRepository playlistRepository; + + private User user; + private Playlist playlist; + + @BeforeEach + void setUp() { + subscriptionService = + new SubscriptionService(subscriptionRepository, userRepository, playlistRepository); + + user = User.create("test@example.com", "password", "Test User"); + ReflectionTestUtils.setField(user, "id", UUID.randomUUID()); + + playlist = Playlist.of(user, "Test Playlist", "Description"); + ReflectionTestUtils.setField(playlist, "id", UUID.randomUUID()); + } + + @Test + @DisplayName("구독 생성 성공") + void create_success() { + // given + UUID playlistId = playlist.getId(); + String email = user.getEmail(); + + given(playlistRepository.existsById(playlistId)).willReturn(true); + given(userRepository.findByEmail(email)).willReturn(Optional.of(user)); + given(subscriptionRepository.existsBySubscriberEmailAndPlaylistId(email, playlistId)) + .willReturn(false); + given(playlistRepository.getReferenceById(playlistId)).willReturn(playlist); + + // when + subscriptionService.create(playlistId, email); + + // then + verify(subscriptionRepository).save(any(Subscription.class)); + } + + @Test + @DisplayName("구독 생성 실패 - 플레이리스트를 찾을 수 없음") + void create_fail_playlistNotFound() { + // given + UUID playlistId = UUID.randomUUID(); + String email = user.getEmail(); + + given(playlistRepository.existsById(playlistId)).willReturn(false); + + // when & then + assertThatThrownBy(() -> subscriptionService.create(playlistId, email)) + .isInstanceOf(SubscriptionPlaylistNotFoundException.class); + } + + @Test + @DisplayName("구독 생성 실패 - 유저를 찾을 수 없음") + void create_fail_userNotFound() { + // given + UUID playlistId = playlist.getId(); + String email = "notfound@email.com"; + + given(playlistRepository.existsById(playlistId)).willReturn(true); + given(userRepository.findByEmail(email)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> subscriptionService.create(playlistId, email)) + .isInstanceOf(SubscriptionUserNotFoundException.class); + } + + @Test + @DisplayName("구독 생성 실패 - 이미 구독 중") + void create_fail_alreadyExists() { + // given + UUID playlistId = playlist.getId(); + String email = user.getEmail(); + + given(playlistRepository.existsById(playlistId)).willReturn(true); + given(userRepository.findByEmail(email)).willReturn(Optional.of(user)); + given(subscriptionRepository.existsBySubscriberEmailAndPlaylistId(email, playlistId)) + .willReturn(true); + + // when & then + assertThatThrownBy(() -> subscriptionService.create(playlistId, email)) + .isInstanceOf(SubscriptionAlreadyExistsException.class); + } + + @Test + @DisplayName("구독 취소(삭제) 성공") + void delete_success() { + // given + UUID playlistId = playlist.getId(); + String email = user.getEmail(); + + given(subscriptionRepository.existsBySubscriberEmailAndPlaylistId(email, playlistId)) + .willReturn(true); + + // when + subscriptionService.delete(playlistId, email); + + // then + verify(subscriptionRepository).deleteBySubscriberEmailAndPlaylistIdDirectly(email, + playlistId); + } + + @Test + @DisplayName("구독 취소 실패 - 구독 내역을 찾을 수 없음") + void delete_fail_notFound() { + // given + UUID playlistId = playlist.getId(); + String email = user.getEmail(); + + given(subscriptionRepository.existsBySubscriberEmailAndPlaylistId(email, playlistId)) + .willReturn(false); + + // when & then + assertThatThrownBy(() -> subscriptionService.delete(playlistId, email)) + .isInstanceOf(SubscriptionNotFoundException.class); + } +} From 54eefb906a39e3662969a357ef116b016fb62756 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 20:36:33 +0900 Subject: [PATCH 35/46] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20?= =?UTF-8?q?=EA=B5=AC=EB=8F=85=20=EC=97=AC=EB=B6=80=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 플레이리스트 단건 조회 및 목록 조회 시, 현재 사용자가 해당 플레이리스트를 구독 중인지 여부를 반환하도록 수정했습니다. - 이를 위해 `PlaylistContentsDto`에 `subscribedByMe` 필드를 추가하고, QueryDSL 쿼리에서 구독 여부를 확인하는 로직을 추가했습니다. - `PlaylistQueryRepository` 및 `PlaylistService`의 관련 메서드 시그니처를 변경하여 `userId`를 받도록 수정했습니다. - 컨트롤러에서 `AuthenticationPrincipal`을 통해 현재 사용자 ID를 가져와 서비스로 전달하도록 수정했습니다. - `PlaylistRepository`의 `existsByIdAndOwnerEmail` 메서드를 `existsByIdAndOwnerId`로 변경하여 사용자 ID 기반으로 소유자 여부를 확인하도록 수정했습니다. - `PlaylistAccessDeniedException` 및 `PlaylistUserNotFoundException`의 생성자 파라미터를 `email`에서 `userId`로 변경했습니다. --- .../controller/PlaylistController.java | 40 ++++++----- .../controller/api/PlaylistControllerApi.java | 28 +++++--- .../playlist/dto/PlaylistContentsDto.java | 2 +- .../PlaylistAccessDeniedException.java | 4 +- .../PlaylistUserNotFoundException.java | 6 +- .../repository/PlaylistRepository.java | 10 ++- .../query/PlaylistQueryRepository.java | 4 +- .../query/PlaylistQueryRepositoryImpl.java | 68 ++++++++++++------- .../playlist/service/PlaylistService.java | 61 +++++++++-------- 9 files changed, 131 insertions(+), 92 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java b/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java index 4b8c9130..02e77401 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java @@ -1,10 +1,10 @@ package com.codeit.team5.mopl.playlist.controller; -import java.security.Principal; import java.util.UUID; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.codeit.team5.mopl.auth.security.details.MoplPrincipal; import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.playlist.controller.api.PlaylistControllerApi; import com.codeit.team5.mopl.playlist.dto.request.PlaylistCreateRequest; @@ -35,54 +36,59 @@ public class PlaylistController implements PlaylistControllerApi { @Override @GetMapping("/{id}") - public ResponseEntity find(@PathVariable UUID id) { - return ResponseEntity.status(HttpStatus.OK).body(service.find(id)); + public ResponseEntity find(@AuthenticationPrincipal MoplPrincipal principal, + @PathVariable UUID id) { + UUID userId = principal.getId(); + return ResponseEntity.status(HttpStatus.OK).body(service.find(id, userId)); } @Override @GetMapping public ResponseEntity> findCursor( + @AuthenticationPrincipal MoplPrincipal principal, @ModelAttribute @Valid PlaylistCursorRequest request) { + UUID userId = principal.getId(); return ResponseEntity.status(HttpStatus.OK) - .body(service.findByCursor(mapper.toCommand(request))); + .body(service.findByCursor(mapper.toCommand(request), userId)); } @Override @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity create(Principal principal, + public ResponseEntity create(@AuthenticationPrincipal MoplPrincipal principal, @RequestBody @Valid PlaylistCreateRequest request) { return ResponseEntity.status(HttpStatus.CREATED) - .body(service.create(principal.getName(), request)); + .body(service.create(principal.getId(), request)); } @Override @PatchMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity update(Principal principal, @PathVariable UUID id, - @RequestBody PlaylistUpdateRequest request) { + public ResponseEntity update(@AuthenticationPrincipal MoplPrincipal principal, + @PathVariable UUID id, @RequestBody PlaylistUpdateRequest request) { return ResponseEntity.status(HttpStatus.OK) - .body(service.update(id, principal.getName(), request)); + .body(service.update(id, principal.getId(), request)); } @Override @DeleteMapping("/{id}") - public ResponseEntity delete(Principal principal, @PathVariable UUID id) { - service.delete(id, principal.getName()); + public ResponseEntity delete(@AuthenticationPrincipal MoplPrincipal principal, + @PathVariable UUID id) { + service.delete(id, principal.getId()); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } @Override @PostMapping("/{playlistId}/contents/{contentId}") - public ResponseEntity addContent(Principal principal, @PathVariable UUID playlistId, - @PathVariable UUID contentId) { - service.addContent(principal.getName(), playlistId, contentId); + public ResponseEntity addContent(@AuthenticationPrincipal MoplPrincipal principal, + @PathVariable UUID playlistId, @PathVariable UUID contentId) { + service.addContent(principal.getId(), playlistId, contentId); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } @Override @DeleteMapping("/{playlistId}/contents/{contentId}") - public ResponseEntity removeContent(Principal principal, @PathVariable UUID playlistId, - @PathVariable UUID contentId) { - service.removeContent(principal.getName(), playlistId, contentId); + public ResponseEntity removeContent(@AuthenticationPrincipal MoplPrincipal principal, + @PathVariable UUID playlistId, @PathVariable UUID contentId) { + service.removeContent(principal.getId(), playlistId, contentId); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/controller/api/PlaylistControllerApi.java b/src/main/java/com/codeit/team5/mopl/playlist/controller/api/PlaylistControllerApi.java index 40a68618..21d167d5 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/controller/api/PlaylistControllerApi.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/controller/api/PlaylistControllerApi.java @@ -1,43 +1,49 @@ package com.codeit.team5.mopl.playlist.controller.api; -import java.security.Principal; import java.util.UUID; - import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; - +import com.codeit.team5.mopl.auth.security.details.MoplPrincipal; import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.playlist.dto.request.PlaylistCreateRequest; import com.codeit.team5.mopl.playlist.dto.request.PlaylistCursorRequest; import com.codeit.team5.mopl.playlist.dto.request.PlaylistUpdateRequest; import com.codeit.team5.mopl.playlist.dto.response.PlaylistResponse; - import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "플레이리스트 관리") public interface PlaylistControllerApi { @Operation(summary = "플레이리스트 단건 조회") - ResponseEntity find(@PathVariable UUID id); + ResponseEntity find(@Parameter(hidden = true) MoplPrincipal principal, + @PathVariable UUID id); @Operation(summary = "플레이리스트 목록 조회 (커서 페이지네이션)") - ResponseEntity> findCursor(@ModelAttribute PlaylistCursorRequest request); + ResponseEntity> findCursor( + @Parameter(hidden = true) MoplPrincipal principal, + @ModelAttribute PlaylistCursorRequest request); @Operation(summary = "플레이리스트 생성", description = "생성한 플레이리스트는 API 요청자 본인의 플레이리스트로 생성됩니다.") - ResponseEntity create(Principal principal, @RequestBody PlaylistCreateRequest request); + ResponseEntity create(@Parameter(hidden = true) MoplPrincipal principal, + @RequestBody PlaylistCreateRequest request); @Operation(summary = "플레이리스트 수정", description = "플레이리스트 소유자만 수정할 수 있습니다.") - ResponseEntity update(Principal principal, @PathVariable UUID id, @RequestBody PlaylistUpdateRequest request); + ResponseEntity update(@Parameter(hidden = true) MoplPrincipal principal, + @PathVariable UUID id, @RequestBody PlaylistUpdateRequest request); @Operation(summary = "플레이리스트 삭제", description = "플레이리스트 소유자만 삭제할 수 있습니다.") - ResponseEntity delete(Principal principal, @PathVariable UUID id); + ResponseEntity delete(@Parameter(hidden = true) MoplPrincipal principal, + @PathVariable UUID playlistId); @Operation(summary = "플레이리스트에 콘텐츠 추가", description = "플레이리스트 소유자만 콘텐츠를 추가할 수 있습니다.") - ResponseEntity addContent(Principal principal, @PathVariable UUID playlistId, @PathVariable UUID contentId); + ResponseEntity addContent(@Parameter(hidden = true) MoplPrincipal principal, + @PathVariable UUID playlistId, @PathVariable UUID contentId); @Operation(summary = "플레이리스트에서 콘텐츠 삭제", description = "플레이리스트 소유자만 콘텐츠를 삭제할 수 있습니다.") - ResponseEntity removeContent(Principal principal, @PathVariable UUID playlistId, @PathVariable UUID contentId); + ResponseEntity removeContent(@Parameter(hidden = true) MoplPrincipal principal, + @PathVariable UUID playlistId, @PathVariable UUID contentId); } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/dto/PlaylistContentsDto.java b/src/main/java/com/codeit/team5/mopl/playlist/dto/PlaylistContentsDto.java index dcf0b1c5..087ab087 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/dto/PlaylistContentsDto.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/dto/PlaylistContentsDto.java @@ -4,5 +4,5 @@ import com.codeit.team5.mopl.playlist.entity.Playlist; import java.util.List; -public record PlaylistContentsDto(Playlist playlist, List contents) { +public record PlaylistContentsDto(Playlist playlist, List contents, boolean subscribedByMe) { } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistAccessDeniedException.java b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistAccessDeniedException.java index 6f2dfa4e..c798dbe2 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistAccessDeniedException.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistAccessDeniedException.java @@ -7,8 +7,8 @@ public class PlaylistAccessDeniedException extends BusinessException { - public PlaylistAccessDeniedException(UUID id, String email) { + public PlaylistAccessDeniedException(UUID id, UUID userId) { super(HttpStatus.FORBIDDEN, "플레이리스트 접근 권한이 없습니다.", - Map.of("playlistId", id, "email", email)); + Map.of("playlistId", id, "userId", userId)); } } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistUserNotFoundException.java b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistUserNotFoundException.java index d0a75c36..6200e81d 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistUserNotFoundException.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistUserNotFoundException.java @@ -4,10 +4,12 @@ import java.util.Map; import org.springframework.http.HttpStatus; +import java.util.UUID; + public class PlaylistUserNotFoundException extends BusinessException { - public PlaylistUserNotFoundException(String email) { + public PlaylistUserNotFoundException(UUID userId) { super(HttpStatus.NOT_FOUND, "유저를 찾을 수 없습니다.", - Map.of("email", email)); + Map.of("userId", userId)); } } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepository.java b/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepository.java index 63434711..5879a620 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepository.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepository.java @@ -9,9 +9,17 @@ public interface PlaylistRepository extends JpaRepository, PlaylistQueryRepository { - boolean existsByIdAndOwnerEmail(UUID id, String email); + boolean existsByIdAndOwnerId(UUID id, UUID ownerId); @Modifying @Query("DELETE FROM Playlist p WHERE p.id = :id") void deleteByIdDirectly(UUID id); + + @Modifying + @Query("UPDATE Playlist p SET p.subscriberCount = p.subscriberCount + 1 WHERE p.id = :id") + void increaseSubscribeCount(UUID id); + + @Modifying + @Query("UPDATE Playlist p SET p.subscriberCount = p.subscriberCount - 1 WHERE p.id = :id AND p.subscriberCount > 0") + void decreaseSubscribeCount(UUID id); } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepository.java b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepository.java index 15b13379..f22f0304 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepository.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepository.java @@ -8,9 +8,9 @@ public interface PlaylistQueryRepository { - Optional findByIdWithContents(UUID id); + Optional findByIdWithContents(UUID id, UUID userId); - List findByCursor(PlaylistCursorCommand request); + List findByCursor(PlaylistCursorCommand request, UUID userId); long countByCommand(PlaylistCursorCommand request); } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java index ccc11288..038982d0 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java @@ -3,10 +3,9 @@ import static com.codeit.team5.mopl.binarycontent.entity.QBinaryContent.binaryContent; import static com.codeit.team5.mopl.content.entity.QContent.content; import static com.codeit.team5.mopl.content.entity.QContentStats.contentStats; -import static com.codeit.team5.mopl.content.entity.QContentTag.contentTag; import static com.codeit.team5.mopl.playlist.entity.QPlaylist.playlist; import static com.codeit.team5.mopl.playlist.entity.QPlaylistItem.playlistItem; -import static com.codeit.team5.mopl.tag.entity.QTag.tag; +import static com.codeit.team5.mopl.subscription.entity.QSubscription.subscription; import static com.codeit.team5.mopl.user.entity.QUser.user; import java.util.Collections; import java.util.List; @@ -17,16 +16,18 @@ import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; import com.codeit.team5.mopl.content.entity.Content; -import com.codeit.team5.mopl.playlist.constant.PlaylistSortBy; import com.codeit.team5.mopl.playlist.dto.PlaylistContentsDto; import com.codeit.team5.mopl.playlist.dto.PlaylistCursorCommand; import com.codeit.team5.mopl.playlist.entity.Playlist; import com.codeit.team5.mopl.playlist.entity.PlaylistItem; +import com.querydsl.core.Tuple; import com.querydsl.core.types.Expression; import com.querydsl.core.types.Ops; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; @@ -37,44 +38,38 @@ public class PlaylistQueryRepositoryImpl implements PlaylistQueryRepository { private final JPAQueryFactory queryFactory; @Override - public Optional findByIdWithContents(UUID id) { - Playlist foundPlaylist = queryFactory.selectFrom(playlist).leftJoin(playlist.owner, user) - .fetchJoin().leftJoin(user.profileImage, binaryContent).fetchJoin() - .where(playlist.id.eq(id)).fetchOne(); - - if (foundPlaylist == null) { + public Optional findByIdWithContents(UUID id, UUID userId) { + Tuple result = selectPlaylistWithSubscription(userId).where(playlist.id.eq(id)).fetchOne(); + if (result == null) { return Optional.empty(); } - + Playlist foundPlaylist = result.get(playlist); + boolean subscribedByMe = Boolean.TRUE.equals(result.get(subscribedByMeSubquery(userId))); List items = fetchPlaylistItemsWithContents(List.of(id)); - return Optional.of(new PlaylistContentsDto(foundPlaylist, - items.stream().map(PlaylistItem::getContent).toList())); + items.stream().map(PlaylistItem::getContent).toList(), subscribedByMe)); } @Override - public List findByCursor(PlaylistCursorCommand request) { - List playlists = queryFactory.selectFrom(playlist).leftJoin(playlist.owner, user) - .fetchJoin().leftJoin(user.profileImage, binaryContent).fetchJoin() + public List findByCursor(PlaylistCursorCommand request, UUID userId) { + List results = selectPlaylistWithSubscription(userId) .where(keywordLikeQuery(request), ownerQuery(request), subscriberQuery(request), cursorQuery(request)) .orderBy(orderSpecifiers(request)).limit(request.limit() + 1).fetch(); - if (playlists.isEmpty()) { + if (results.isEmpty()) { return Collections.emptyList(); } - List playlistIds = playlists.stream().map(Playlist::getId).toList(); - + List playlistIds = + results.stream().map(t -> t.get(playlist)).map(Playlist::getId).toList(); List items = fetchPlaylistItemsWithContents(playlistIds); - Map> contentsByPlaylistId = items.stream().collect(Collectors.groupingBy(PlaylistItem::getPlaylistId, Collectors.mapping(PlaylistItem::getContent, Collectors.toList()))); + BooleanExpression subscribedByMeExpr = subscribedByMeSubquery(userId); - return playlists.stream() - .map(p -> new PlaylistContentsDto(p, - contentsByPlaylistId.getOrDefault(p.getId(), Collections.emptyList()))) + return results.stream().map(t -> mapToDto(t, contentsByPlaylistId, subscribedByMeExpr)) .toList(); } @@ -87,9 +82,8 @@ public long countByCommand(PlaylistCursorCommand request) { } private List fetchPlaylistItemsWithContents(List playlistIds) { - return queryFactory.selectFrom(playlistItem) - .leftJoin(playlistItem.content, content).fetchJoin() - .leftJoin(content.thumbnail, binaryContent).fetchJoin() + return queryFactory.selectFrom(playlistItem).leftJoin(playlistItem.content, content) + .fetchJoin().leftJoin(content.thumbnail, binaryContent).fetchJoin() .leftJoin(content.stats, contentStats).fetchJoin() .where(playlistItem.playlistId.in(playlistIds)) .orderBy(playlistItem.createdAt.asc()).fetch(); @@ -127,7 +121,9 @@ private BooleanExpression subscriberQuery(PlaylistCursorCommand request) { if (request.subscriberIdEqual() == null) { return null; } - return playlist.owner.id.eq(request.subscriberIdEqual()); + return JPAExpressions.selectOne().from(subscription).where(subscription.playlist.id + .eq(playlist.id).and(subscription.subscriber.id.eq(request.subscriberIdEqual()))) + .exists(); } private BooleanExpression ownerQuery(PlaylistCursorCommand request) { @@ -145,6 +141,26 @@ private BooleanExpression keywordLikeQuery(PlaylistCursorCommand request) { .or(playlist.title.containsIgnoreCase(request.keywordLike())); } + private PlaylistContentsDto mapToDto(Tuple t, Map> contentsByPlaylistId, + BooleanExpression subscribedByMeExpr) { + Playlist p = t.get(playlist); + return new PlaylistContentsDto(p, + contentsByPlaylistId.getOrDefault(p.getId(), Collections.emptyList()), + Boolean.TRUE.equals(t.get(subscribedByMeExpr))); + } + + private BooleanExpression subscribedByMeSubquery(UUID userId) { + return JPAExpressions.selectOne().from(subscription).where( + subscription.playlist.id.eq(playlist.id).and(subscription.subscriber.id.eq(userId))) + .exists(); + } + + private JPAQuery selectPlaylistWithSubscription(UUID userId) { + return queryFactory.select(playlist, subscribedByMeSubquery(userId)).from(playlist) + .leftJoin(playlist.owner, user).fetchJoin() + .leftJoin(user.profileImage, binaryContent).fetchJoin(); + } + @FunctionalInterface private interface CursorConditionBuilder { BooleanExpression build(Object cursor, UUID idAfter, boolean isAsc); diff --git a/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java index b87d0652..f2fa8714 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java @@ -1,5 +1,9 @@ package com.codeit.team5.mopl.playlist.service; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.codeit.team5.mopl.content.entity.Content; import com.codeit.team5.mopl.content.repository.ContentRepository; import com.codeit.team5.mopl.global.dto.CursorResponse; @@ -20,11 +24,7 @@ import com.codeit.team5.mopl.playlist.repository.PlaylistRepository; import com.codeit.team5.mopl.user.entity.User; import com.codeit.team5.mopl.user.repository.UserRepository; -import java.util.List; -import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @Transactional(readOnly = true) @@ -38,37 +38,36 @@ public class PlaylistService { private final ContentRepository contentRepository; @Transactional - public PlaylistResponse create(String email, PlaylistCreateRequest request) { - User user = userRepository.findByEmail(email) - .orElseThrow(() -> new PlaylistUserNotFoundException(email)); + public PlaylistResponse create(UUID userId, PlaylistCreateRequest request) { + User user = userRepository.findWithProfileImageById(userId) + .orElseThrow(() -> new PlaylistUserNotFoundException(userId)); Playlist playlist = Playlist.of(user, request.title(), request.description()); repository.save(playlist); return mapper.toDto(playlist); } - public PlaylistResponse find(UUID id) { - PlaylistContentsDto dto = repository.findByIdWithContents(id) - .orElseThrow(() -> new PlaylistNotFoundException(id)); - return mapper.toDto(dto); + public PlaylistResponse find(UUID id, UUID userId) { + return mapper.toDto(findById(id, userId)); } @Transactional - public PlaylistResponse update(UUID id, String email, PlaylistUpdateRequest request) { - validateOwner(id, email); - Playlist playlist = findById(id); + public PlaylistResponse update(UUID id, UUID userId, PlaylistUpdateRequest request) { + validateOwner(id, userId); + PlaylistContentsDto dto = findById(id, userId); + Playlist playlist = dto.playlist(); playlist.updateTitle(request.title()); playlist.updateDescription(request.description()); - return mapper.toDto(playlist); + return mapper.toDto(dto); } @Transactional - public void delete(UUID id, String email) { - validateOwner(id, email); + public void delete(UUID id, UUID userId) { + validateOwner(id, userId); repository.deleteByIdDirectly(id); } - public CursorResponse findByCursor(PlaylistCursorCommand dto) { - List playlists = repository.findByCursor(dto); + public CursorResponse findByCursor(PlaylistCursorCommand dto, UUID userId) { + List playlists = repository.findByCursor(dto, userId); boolean hasNext = playlists.size() > dto.limit(); List data = hasNext ? playlists.subList(0, dto.limit()) : playlists; long totalCount = repository.countByCommand(dto); @@ -76,8 +75,8 @@ public CursorResponse findByCursor(PlaylistCursorCommand dto) } @Transactional - public void addContent(String email, UUID playlistId, UUID contentId) { - validateOwner(playlistId, email); + public void addContent(UUID userId, UUID playlistId, UUID contentId) { + validateOwner(playlistId, userId); if (!contentRepository.existsById(contentId)) { throw new PlaylistContentNotFoundException(contentId); } @@ -87,8 +86,8 @@ public void addContent(String email, UUID playlistId, UUID contentId) { } @Transactional - public void removeContent(String email, UUID playlistId, UUID contentId) { - validateOwner(playlistId, email); + public void removeContent(UUID userId, UUID playlistId, UUID contentId) { + validateOwner(playlistId, userId); if (!contentRepository.existsById(contentId)) { throw new PlaylistContentNotFoundException(contentId); } @@ -98,14 +97,16 @@ public void removeContent(String email, UUID playlistId, UUID contentId) { playlistItemRepository.deleteByPlaylistIdAndContentIdDirectly(playlistId, contentId); } - private Playlist findById(UUID id) { - return repository.findById(id).orElseThrow(() -> new PlaylistNotFoundException(id)); - } - - private void validateOwner(UUID id, String email) { - if (repository.existsByIdAndOwnerEmail(id, email)) { + private void validateOwner(UUID id, UUID userId) { + if (repository.existsByIdAndOwnerId(id, userId)) { return; } - throw new PlaylistAccessDeniedException(id, email); + throw new PlaylistAccessDeniedException(id, userId); + } + + private PlaylistContentsDto findById(UUID id, UUID userId) { + PlaylistContentsDto dto = repository.findByIdWithContents(id, userId) + .orElseThrow(() -> new PlaylistNotFoundException(id)); + return dto; } } From c86eed6d4f2ae5b8ebf315adc2975c95d7b34330 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 20:37:05 +0900 Subject: [PATCH 36/46] =?UTF-8?q?fix:=20=EA=B5=AC=EB=8F=85=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EC=8B=9C=20=EA=B5=AC=EB=8F=85=EC=9E=90=20=EC=88=98?= =?UTF-8?q?=20=EA=B0=90=EC=86=8C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구독 취소 시 해당 플레이리스트의 구독자 수를 감소시키는 로직을 추가했습니다. - 이를 통해 플레이리스트의 구독자 수를 정확하게 관리할 수 있습니다. --- .../team5/mopl/subscription/service/SubscriptionService.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/codeit/team5/mopl/subscription/service/SubscriptionService.java b/src/main/java/com/codeit/team5/mopl/subscription/service/SubscriptionService.java index 2a2d5376..29838f80 100644 --- a/src/main/java/com/codeit/team5/mopl/subscription/service/SubscriptionService.java +++ b/src/main/java/com/codeit/team5/mopl/subscription/service/SubscriptionService.java @@ -36,12 +36,14 @@ public void create(UUID playlistId, String email) { } Playlist playlist = playlistRepository.getReferenceById(playlistId); repository.save(Subscription.of(playlist, user)); + playlistRepository.increaseSubscribeCount(playlistId); } @Transactional public void delete(UUID playlistId, String email) { if (repository.existsBySubscriberEmailAndPlaylistId(email, playlistId)) { repository.deleteBySubscriberEmailAndPlaylistIdDirectly(email, playlistId); + playlistRepository.decreaseSubscribeCount(playlistId); return; } throw new SubscriptionNotFoundException(playlistId, email); From cee790bba2e1edbcadf1c51269d593218d2fafa7 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 20:37:37 +0900 Subject: [PATCH 37/46] =?UTF-8?q?refactor:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=86=8C=EC=9C=A0=EC=9E=90=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 플레이리스트 관련 기능에서 소유자 인증 시 이메일 대신 사용자 ID를 사용하도록 변경했습니다. - 이는 사용자 ID가 더 고유하고 안정적인 식별자이기 때문입니다. - 관련 테스트 코드(Repository, Controller, Service, QueryRepository)를 수정하여 변경 사항을 반영했습니다. --- .../controller/PlaylistControllerTest.java | 82 +++++++++---------- .../PlaylistQueryRepositoryTest.java | 4 +- .../repository/PlaylistRepositoryTest.java | 9 +- .../playlist/service/PlaylistServiceTest.java | 64 +++++++-------- 4 files changed, 80 insertions(+), 79 deletions(-) diff --git a/src/test/java/com/codeit/team5/mopl/playlist/controller/PlaylistControllerTest.java b/src/test/java/com/codeit/team5/mopl/playlist/controller/PlaylistControllerTest.java index 780cd9b8..885fe432 100644 --- a/src/test/java/com/codeit/team5/mopl/playlist/controller/PlaylistControllerTest.java +++ b/src/test/java/com/codeit/team5/mopl/playlist/controller/PlaylistControllerTest.java @@ -15,7 +15,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import java.security.Principal; +import com.codeit.team5.mopl.auth.security.details.MoplPrincipal; import java.time.Instant; import java.util.List; import java.util.UUID; @@ -62,9 +62,19 @@ excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = JwtAuthenticationFilter.class)) @AutoConfigureMockMvc(addFilters = false) -@Import({GlobalExceptionHandler.class, TestGlobalExceptionHandlerConfig.class}) +@Import({GlobalExceptionHandler.class, TestGlobalExceptionHandlerConfig.class, PlaylistControllerTest.TestSecurityConfig.class}) class PlaylistControllerTest { + @org.springframework.boot.test.context.TestConfiguration + static class TestSecurityConfig implements org.springframework.web.servlet.config.annotation.WebMvcConfigurer { + @Override + public void addArgumentResolvers(java.util.List resolvers) { + resolvers.add(new org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver()); + } + } + + + @Autowired private MockMvc mockMvc; @@ -86,15 +96,18 @@ class PlaylistControllerTest { @Captor private ArgumentCaptor cursorRequestCaptor; - private Principal principal; + private MoplPrincipal principal; + private org.springframework.security.core.Authentication auth; private UUID playlistId; private UUID contentId; private PlaylistResponse playlistResponse; @BeforeEach void setUp() { - principal = Mockito.mock(Principal.class); - given(principal.getName()).willReturn("user@test.com"); + principal = Mockito.mock(MoplPrincipal.class); + auth = new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(principal, null, java.util.List.of(new org.springframework.security.core.authority.SimpleGrantedAuthority("ROLE_USER"))); + org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(auth); + given(principal.getId()).willReturn(java.util.UUID.fromString("11111111-1111-1111-1111-111111111111")); playlistId = UUID.randomUUID(); contentId = UUID.randomUUID(); @@ -107,10 +120,9 @@ void setUp() { @Test @DisplayName("단건 조회") - @WithMockUser void find() throws Exception { // given - given(playlistService.find(playlistId)).willReturn(playlistResponse); + given(playlistService.find(playlistId, java.util.UUID.fromString("11111111-1111-1111-1111-111111111111"))).willReturn(playlistResponse); // when & then mockMvc.perform(get("/api/playlists/{id}", playlistId)).andDo(print()) @@ -121,10 +133,9 @@ void find() throws Exception { @Test @DisplayName("단건 조회 - 실패 (플레이리스트 없음)") - @WithMockUser void find_fail_PlaylistNotFoundException() throws Exception { // given - given(playlistService.find(playlistId)) + given(playlistService.find(playlistId, java.util.UUID.fromString("11111111-1111-1111-1111-111111111111"))) .willThrow(new PlaylistNotFoundException(playlistId)); // when & then @@ -137,7 +148,6 @@ void find_fail_PlaylistNotFoundException() throws Exception { @Test @DisplayName("커서 기반 조회") - @WithMockUser void findCursor() throws Exception { // given CursorResponse cursorResponse = CursorResponse @@ -147,7 +157,7 @@ void findCursor() throws Exception { given(playlistMapper.toCommand(any(PlaylistCursorRequest.class))) .willReturn(command); - given(playlistService.findByCursor(command)).willReturn(cursorResponse); + given(playlistService.findByCursor(command, java.util.UUID.fromString("11111111-1111-1111-1111-111111111111"))).willReturn(cursorResponse); // when & then mockMvc.perform(get("/api/playlists").param("limit", "10") @@ -173,7 +183,6 @@ void findCursor() throws Exception { // sortBy 검증 "10, DESC, null, sortBy"}, nullValues = {"null"}) @DisplayName("커서 기반 조회 - 실패 (입력값 유효성 검증)") - @WithMockUser void findCursor_fail_validation(String limit, String sortDirection, String sortBy, String errorField) throws Exception { var requestBuilder = get("/api/playlists"); @@ -196,7 +205,6 @@ void findCursor_fail_validation(String limit, String sortDirection, String sortB @Test @DisplayName("커서 기반 조회 - 실패 (선택 파라미터 UUID 포맷 오류)") - @WithMockUser void findCursor_fail_invalid_uuid_format() throws Exception { mockMvc.perform(get("/api/playlists").param("limit", "10") .param("sortBy", "UPDATED_AT").param("sortDirection", "DESC") @@ -209,7 +217,6 @@ void findCursor_fail_invalid_uuid_format() throws Exception { @Test @DisplayName("커서 기반 조회 - 실패 (유효하지 않은 정렬 기준)") - @WithMockUser void findCursor_fail_PlaylistIncorrectSortByException() throws Exception { // given willThrow(new PlaylistIncorrectSortByException("INVALID_SORT")) @@ -227,7 +234,6 @@ void findCursor_fail_PlaylistIncorrectSortByException() throws Exception { @ParameterizedTest @CsvSource({"UPDATED_AT, not_a_date", "SUBSCRIBE_COUNT, not_a_number"}) @DisplayName("커서 기반 조회 - 실패 (정렬 기준과 커서 타입 불일치)") - @WithMockUser void findCursor_fail_PlaylistSortByMismatchException(String sortBy, String cursor) throws Exception { // given @@ -246,21 +252,20 @@ void findCursor_fail_PlaylistSortByMismatchException(String sortBy, String curso @Test @DisplayName("플레이리스트 생성") - @WithMockUser(username = "user@test.com") void create() throws Exception { // given PlaylistCreateRequest request = new PlaylistCreateRequest("New Playlist", "Desc"); - given(playlistService.create(eq("user@test.com"), any(PlaylistCreateRequest.class))) + given(playlistService.create(eq(java.util.UUID.fromString("11111111-1111-1111-1111-111111111111")), any(PlaylistCreateRequest.class))) .willReturn(playlistResponse); // when & then - mockMvc.perform(post("/api/playlists").with(csrf()).principal(principal) + mockMvc.perform(post("/api/playlists").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))).andDo(print()) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").value(playlistId.toString())); - verify(playlistService).create(eq("user@test.com"), createRequestCaptor.capture()); + verify(playlistService).create(eq(java.util.UUID.fromString("11111111-1111-1111-1111-111111111111")), createRequestCaptor.capture()); PlaylistCreateRequest captured = createRequestCaptor.getValue(); assertThat(captured.title()).isEqualTo("New Playlist"); assertThat(captured.description()).isEqualTo("Desc"); @@ -268,22 +273,21 @@ void create() throws Exception { @Test @DisplayName("플레이리스트 수정") - @WithMockUser(username = "user@test.com") void update() throws Exception { // given PlaylistUpdateRequest request = new PlaylistUpdateRequest("Updated Playlist", "Updated Desc"); - given(playlistService.update(eq(playlistId), eq("user@test.com"), + given(playlistService.update(eq(playlistId), eq(java.util.UUID.fromString("11111111-1111-1111-1111-111111111111")), any(PlaylistUpdateRequest.class))).willReturn(playlistResponse); // when & then mockMvc.perform(patch("/api/playlists/{id}", playlistId).with(csrf()) - .principal(principal).contentType(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))).andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(playlistId.toString())); - verify(playlistService).update(eq(playlistId), eq("user@test.com"), + verify(playlistService).update(eq(playlistId), eq(java.util.UUID.fromString("11111111-1111-1111-1111-111111111111")), updateRequestCaptor.capture()); PlaylistUpdateRequest captured = updateRequestCaptor.getValue(); assertThat(captured.title()).isEqualTo("Updated Playlist"); @@ -292,28 +296,26 @@ void update() throws Exception { @Test @DisplayName("플레이리스트 삭제") - @WithMockUser(username = "user@test.com") void deletePlaylist() throws Exception { // given - willDoNothing().given(playlistService).delete(playlistId, "user@test.com"); + willDoNothing().given(playlistService).delete(playlistId, java.util.UUID.fromString("11111111-1111-1111-1111-111111111111")); // when & then mockMvc.perform(delete("/api/playlists/{id}", playlistId).with(csrf()) - .principal(principal)).andDo(print()) + ).andDo(print()) .andExpect(status().isNoContent()); } @Test @DisplayName("플레이리스트 삭제 - 실패 (권한 없음)") - @WithMockUser(username = "user@test.com") void deletePlaylist_fail_PlaylistAccessDeniedException() throws Exception { // given - willThrow(new PlaylistAccessDeniedException(playlistId, "user@test.com")) - .given(playlistService).delete(playlistId, "user@test.com"); + willThrow(new PlaylistAccessDeniedException(playlistId, java.util.UUID.fromString("11111111-1111-1111-1111-111111111111"))) + .given(playlistService).delete(playlistId, java.util.UUID.fromString("11111111-1111-1111-1111-111111111111")); // when & then mockMvc.perform(delete("/api/playlists/{id}", playlistId).with(csrf()) - .principal(principal)).andDo(print()) + ).andDo(print()) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.exceptionType") .value("PlaylistAccessDeniedException")) @@ -322,29 +324,27 @@ void deletePlaylist_fail_PlaylistAccessDeniedException() throws Exception { @Test @DisplayName("플레이리스트 아이템 추가") - @WithMockUser(username = "user@test.com") void addContent() throws Exception { // given - willDoNothing().given(playlistService).addContent("user@test.com", playlistId, + willDoNothing().given(playlistService).addContent(java.util.UUID.fromString("11111111-1111-1111-1111-111111111111"), playlistId, contentId); // when & then mockMvc.perform(post("/api/playlists/{playlistId}/contents/{contentId}", playlistId, - contentId).with(csrf()).principal(principal)).andDo(print()) + contentId).with(csrf())).andDo(print()) .andExpect(status().isNoContent()); } @Test @DisplayName("플레이리스트 아이템 추가 - 실패 (컨텐츠 없음)") - @WithMockUser(username = "user@test.com") void addContent_fail_PlaylistContentNotFoundException() throws Exception { // given willThrow(new PlaylistContentNotFoundException(contentId)).given(playlistService) - .addContent("user@test.com", playlistId, contentId); + .addContent(java.util.UUID.fromString("11111111-1111-1111-1111-111111111111"), playlistId, contentId); // when & then mockMvc.perform(post("/api/playlists/{playlistId}/contents/{contentId}", playlistId, - contentId).with(csrf()).principal(principal)).andDo(print()) + contentId).with(csrf())).andDo(print()) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.exceptionType") .value("PlaylistContentNotFoundException")) @@ -353,30 +353,28 @@ void addContent_fail_PlaylistContentNotFoundException() throws Exception { @Test @DisplayName("플레이리스트 아이템 삭제") - @WithMockUser(username = "user@test.com") void removeContent() throws Exception { // given - willDoNothing().given(playlistService).removeContent("user@test.com", playlistId, + willDoNothing().given(playlistService).removeContent(java.util.UUID.fromString("11111111-1111-1111-1111-111111111111"), playlistId, contentId); // when & then mockMvc.perform(delete("/api/playlists/{playlistId}/contents/{contentId}", - playlistId, contentId).with(csrf()).principal(principal)) + playlistId, contentId).with(csrf())) .andDo(print()).andExpect(status().isNoContent()); } @Test @DisplayName("플레이리스트 아이템 삭제 - 실패 (아이템 없음)") - @WithMockUser(username = "user@test.com") void removeContent_fail_PlaylistItemNotFoundException() throws Exception { // given willThrow(new PlaylistItemNotFoundException(playlistId, contentId)) .given(playlistService) - .removeContent("user@test.com", playlistId, contentId); + .removeContent(java.util.UUID.fromString("11111111-1111-1111-1111-111111111111"), playlistId, contentId); // when & then mockMvc.perform(delete("/api/playlists/{playlistId}/contents/{contentId}", - playlistId, contentId).with(csrf()).principal(principal)) + playlistId, contentId).with(csrf())) .andDo(print()).andExpect(status().isBadRequest()) .andExpect(jsonPath("$.exceptionType") .value("PlaylistItemNotFoundException")) diff --git a/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistQueryRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistQueryRepositoryTest.java index 109dabce..834ca8eb 100644 --- a/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistQueryRepositoryTest.java +++ b/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistQueryRepositoryTest.java @@ -70,7 +70,7 @@ void findByIdWithContents() { // when Optional dtoOpt = - playlistQueryRepository.findByIdWithContents(playlist.getId()); + playlistQueryRepository.findByIdWithContents(playlist.getId(), user.getId()); ensureQueryCount(2); // 1. playlist fetch, 2. playlistItem + content fetch // then @@ -93,7 +93,7 @@ void findByCursor() { queryInspector.clear(); // when - List result = playlistQueryRepository.findByCursor(command); + List result = playlistQueryRepository.findByCursor(command, user.getId()); ensureQueryCount(2); // 1. playlist fetch, 2. playlistItem + content fetch // then diff --git a/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepositoryTest.java index 1d884f63..43ed2cdd 100644 --- a/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepositoryTest.java +++ b/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepositoryTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -39,15 +40,17 @@ void setUp() { @DisplayName("이메일과 아이디로 플레이리스트 소유 여부 확인") void existsByIdAndOwnerEmail() { // when - boolean exists = playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), user.getEmail()); + boolean exists = playlistRepository.existsByIdAndOwnerId(playlist.getId(), user.getId()); + + // then + assertThat(exists).isTrue(); ensureQueryCount(1); queryInspector.clear(); - boolean notExists = playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), "wrong@email.com"); + boolean notExists = playlistRepository.existsByIdAndOwnerId(playlist.getId(), UUID.randomUUID()); ensureQueryCount(1); // then - assertThat(exists).isTrue(); assertThat(notExists).isFalse(); } diff --git a/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java b/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java index 065c5d6a..6c95e491 100644 --- a/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java @@ -83,10 +83,10 @@ void setUp() { void create_success() { // given PlaylistCreateRequest request = new PlaylistCreateRequest("New Title", "New Description"); - given(userRepository.findByEmail(anyString())).willReturn(Optional.of(user)); + given(userRepository.findWithProfileImageById(user.getId())).willReturn(Optional.of(user)); // when - PlaylistResponse response = playlistService.create(user.getEmail(), request); + PlaylistResponse response = playlistService.create(user.getId(), request); // then assertThat(response).isNotNull(); @@ -101,10 +101,10 @@ void create_success() { void create_fail_userNotFound() { // given PlaylistCreateRequest request = new PlaylistCreateRequest("Title", "Desc"); - given(userRepository.findByEmail(anyString())).willReturn(Optional.empty()); + given(userRepository.findWithProfileImageById(org.mockito.ArgumentMatchers.any())).willReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> playlistService.create("wrong@email.com", request)) + assertThatThrownBy(() -> playlistService.create(UUID.randomUUID(), request)) .isInstanceOf(PlaylistUserNotFoundException.class); } @@ -112,12 +112,12 @@ void create_fail_userNotFound() { @DisplayName("플레이리스트 단건 조회 성공") void find_success() { // given - PlaylistContentsDto dto = new PlaylistContentsDto(playlist, Collections.emptyList()); - given(playlistRepository.findByIdWithContents(playlist.getId())) + PlaylistContentsDto dto = new PlaylistContentsDto(playlist, Collections.emptyList(), false); + given(playlistRepository.findByIdWithContents(playlist.getId(), user.getId())) .willReturn(Optional.of(dto)); // when - PlaylistResponse response = playlistService.find(playlist.getId()); + PlaylistResponse response = playlistService.find(playlist.getId(), user.getId()); // then assertThat(response).isNotNull(); @@ -129,10 +129,10 @@ void find_success() { void find_fail_notFound() { // given UUID id = UUID.randomUUID(); - given(playlistRepository.findByIdWithContents(id)).willReturn(Optional.empty()); + given(playlistRepository.findByIdWithContents(id, user.getId())).willReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> playlistService.find(id)) + assertThatThrownBy(() -> playlistService.find(id, user.getId())) .isInstanceOf(PlaylistNotFoundException.class); } @@ -141,13 +141,13 @@ void find_fail_notFound() { void update_success() { // given PlaylistUpdateRequest request = new PlaylistUpdateRequest("Updated Title", "Updated Desc"); - given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), user.getEmail())) + given(playlistRepository.existsByIdAndOwnerId(playlist.getId(), user.getId())) .willReturn(true); - given(playlistRepository.findById(playlist.getId())).willReturn(Optional.of(playlist)); + given(playlistRepository.findByIdWithContents(playlist.getId(), user.getId())).willReturn(Optional.of(new com.codeit.team5.mopl.playlist.dto.PlaylistContentsDto(playlist, java.util.Collections.emptyList(), false))); // when PlaylistResponse response = - playlistService.update(playlist.getId(), user.getEmail(), request); + playlistService.update(playlist.getId(), user.getId(), request); // then assertThat(response.title()).isEqualTo("Updated Title"); @@ -159,12 +159,12 @@ void update_success() { void update_fail_accessDenied() { // given PlaylistUpdateRequest request = new PlaylistUpdateRequest("Updated Title", "Updated Desc"); - given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), "other@email.com")) + given(playlistRepository.existsByIdAndOwnerId(org.mockito.ArgumentMatchers.eq(playlist.getId()), org.mockito.ArgumentMatchers.any(UUID.class))) .willReturn(false); // when & then assertThatThrownBy( - () -> playlistService.update(playlist.getId(), "other@email.com", request)) + () -> playlistService.update(playlist.getId(), UUID.randomUUID(), request)) .isInstanceOf(PlaylistAccessDeniedException.class); } @@ -172,11 +172,11 @@ void update_fail_accessDenied() { @DisplayName("플레이리스트 삭제 성공") void delete_success() { // given - given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), user.getEmail())) + given(playlistRepository.existsByIdAndOwnerId(playlist.getId(), user.getId())) .willReturn(true); // when - playlistService.delete(playlist.getId(), user.getEmail()); + playlistService.delete(playlist.getId(), user.getId()); // then verify(playlistRepository).deleteByIdDirectly(playlist.getId()); @@ -186,11 +186,11 @@ void delete_success() { @DisplayName("플레이리스트 삭제 실패 - 권한 없음") void delete_fail_accessDenied() { // given - given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), "other@email.com")) + given(playlistRepository.existsByIdAndOwnerId(org.mockito.ArgumentMatchers.eq(playlist.getId()), org.mockito.ArgumentMatchers.any(UUID.class))) .willReturn(false); // when & then - assertThatThrownBy(() -> playlistService.delete(playlist.getId(), "other@email.com")) + assertThatThrownBy(() -> playlistService.delete(playlist.getId(), UUID.randomUUID())) .isInstanceOf(PlaylistAccessDeniedException.class); } @@ -202,13 +202,13 @@ void addContent_success() { Content content = Content.createByAdmin(ContentType.MOVIE, "Content Title", "Desc"); ReflectionTestUtils.setField(content, "id", contentId); - given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), user.getEmail())) + given(playlistRepository.existsByIdAndOwnerId(playlist.getId(), user.getId())) .willReturn(true); given(contentRepository.existsById(contentId)).willReturn(true); given(contentRepository.getReferenceById(contentId)).willReturn(content); // when - playlistService.addContent(user.getEmail(), playlist.getId(), contentId); + playlistService.addContent(user.getId(), playlist.getId(), contentId); // then verify(playlistItemRepository).save(any(PlaylistItem.class)); @@ -219,14 +219,14 @@ void addContent_success() { void removeContent_success() { // given UUID contentId = UUID.randomUUID(); - given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), user.getEmail())) + given(playlistRepository.existsByIdAndOwnerId(playlist.getId(), user.getId())) .willReturn(true); given(contentRepository.existsById(contentId)).willReturn(true); given(playlistItemRepository.existsByPlaylistIdAndContentId(playlist.getId(), contentId)) .willReturn(true); // when - playlistService.removeContent(user.getEmail(), playlist.getId(), contentId); + playlistService.removeContent(user.getId(), playlist.getId(), contentId); // then verify(playlistItemRepository).deleteByPlaylistIdAndContentIdDirectly(playlist.getId(), @@ -238,11 +238,11 @@ void removeContent_success() { void addContent_fail_accessDenied() { // given UUID contentId = UUID.randomUUID(); - given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), "other@email.com")) + given(playlistRepository.existsByIdAndOwnerId(org.mockito.ArgumentMatchers.eq(playlist.getId()), org.mockito.ArgumentMatchers.any(UUID.class))) .willReturn(false); // when & then - assertThatThrownBy(() -> playlistService.addContent("other@email.com", playlist.getId(), contentId)) + assertThatThrownBy(() -> playlistService.addContent(UUID.randomUUID(), playlist.getId(), contentId)) .isInstanceOf(PlaylistAccessDeniedException.class); } @@ -251,12 +251,12 @@ void addContent_fail_accessDenied() { void addContent_fail_contentNotFound() { // given UUID contentId = UUID.randomUUID(); - given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), user.getEmail())) + given(playlistRepository.existsByIdAndOwnerId(playlist.getId(), user.getId())) .willReturn(true); given(contentRepository.existsById(contentId)).willReturn(false); // when & then - assertThatThrownBy(() -> playlistService.addContent(user.getEmail(), playlist.getId(), contentId)) + assertThatThrownBy(() -> playlistService.addContent(user.getId(), playlist.getId(), contentId)) .isInstanceOf(PlaylistContentNotFoundException.class); } @@ -265,11 +265,11 @@ void addContent_fail_contentNotFound() { void removeContent_fail_accessDenied() { // given UUID contentId = UUID.randomUUID(); - given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), "other@email.com")) + given(playlistRepository.existsByIdAndOwnerId(org.mockito.ArgumentMatchers.eq(playlist.getId()), org.mockito.ArgumentMatchers.any(UUID.class))) .willReturn(false); // when & then - assertThatThrownBy(() -> playlistService.removeContent("other@email.com", playlist.getId(), contentId)) + assertThatThrownBy(() -> playlistService.removeContent(UUID.randomUUID(), playlist.getId(), contentId)) .isInstanceOf(PlaylistAccessDeniedException.class); } @@ -278,12 +278,12 @@ void removeContent_fail_accessDenied() { void removeContent_fail_contentNotFound() { // given UUID contentId = UUID.randomUUID(); - given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), user.getEmail())) + given(playlistRepository.existsByIdAndOwnerId(playlist.getId(), user.getId())) .willReturn(true); given(contentRepository.existsById(contentId)).willReturn(false); // when & then - assertThatThrownBy(() -> playlistService.removeContent(user.getEmail(), playlist.getId(), contentId)) + assertThatThrownBy(() -> playlistService.removeContent(user.getId(), playlist.getId(), contentId)) .isInstanceOf(PlaylistContentNotFoundException.class); } @@ -292,14 +292,14 @@ void removeContent_fail_contentNotFound() { void removeContent_fail_playlistItemNotFound() { // given UUID contentId = UUID.randomUUID(); - given(playlistRepository.existsByIdAndOwnerEmail(playlist.getId(), user.getEmail())) + given(playlistRepository.existsByIdAndOwnerId(playlist.getId(), user.getId())) .willReturn(true); given(contentRepository.existsById(contentId)).willReturn(true); given(playlistItemRepository.existsByPlaylistIdAndContentId(playlist.getId(), contentId)) .willReturn(false); // when & then - assertThatThrownBy(() -> playlistService.removeContent(user.getEmail(), playlist.getId(), contentId)) + assertThatThrownBy(() -> playlistService.removeContent(user.getId(), playlist.getId(), contentId)) .isInstanceOf(PlaylistItemNotFoundException.class); } } From 2dd3f6ec1b548a4c6038d0cb76c2bb0fdced7f41 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 20:46:11 +0900 Subject: [PATCH 38/46] =?UTF-8?q?feat:=20=EC=BB=A4=EC=84=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=ED=94=8C=EB=A0=88=EC=9D=B4=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=97=B0=EC=86=8D=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 커서 기반 플레이리스트 조회 시, 여러 페이지에 걸쳐 연속적으로 데이터를 조회하는 시나리오에 대한 테스트 코드를 추가했습니다. - 이를 통해 페이징 로직의 정확성과 안정성을 검증합니다. --- .../PlaylistQueryRepositoryTest.java | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistQueryRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistQueryRepositoryTest.java index 834ca8eb..1c3eb5a5 100644 --- a/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistQueryRepositoryTest.java +++ b/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistQueryRepositoryTest.java @@ -93,7 +93,8 @@ void findByCursor() { queryInspector.clear(); // when - List result = playlistQueryRepository.findByCursor(command, user.getId()); + List result = + playlistQueryRepository.findByCursor(command, user.getId()); ensureQueryCount(2); // 1. playlist fetch, 2. playlistItem + content fetch // then @@ -101,6 +102,57 @@ void findByCursor() { assertThat(result.get(0).playlist().getId()).isEqualTo(playlist.getId()); } + @Test + @DisplayName("커서 기반 다건 연속 조회 (2 페이지 조회)") + void findByCursor_consecutive_pagination() { + // given + Playlist playlist2 = Playlist.of(user, "Test Playlist 2", "Description 2"); + playlistRepository.save(playlist2); + + Playlist playlist3 = Playlist.of(user, "Test Playlist 3", "Description 3"); + playlistRepository.save(playlist3); + + flush(); + clear(); + queryInspector.clear(); + + // 1st page query: limit 2 + PlaylistCursorCommand command1 = PlaylistCursorCommand.builder().limit(2) + .sortDirection(Sort.Direction.DESC).sortBy(PlaylistSortBy.UPDATED_AT).build(); + + // when 1 + // repository fetches limit + 1 items internally to check for next page + List result1 = + playlistQueryRepository.findByCursor(command1, user.getId()); + + // then 1 + assertThat(result1).hasSize(3); + + Playlist lastPlaylist1 = result1.get(1).playlist(); + + // 2nd page query: limit 2, with cursor from last element of page 1 + PlaylistCursorCommand command2 = PlaylistCursorCommand.builder().limit(2) + .cursor(lastPlaylist1.getUpdatedAt()).idAfter(lastPlaylist1.getId()) + .sortDirection(Sort.Direction.DESC).sortBy(PlaylistSortBy.UPDATED_AT).build(); + + // when 2 + List result2 = + playlistQueryRepository.findByCursor(command2, user.getId()); + + // then 2 + assertThat(result2).hasSize(1); + + // Combine results to check all 3 are returned exactly once + java.util.List allFetchedIds = new java.util.ArrayList<>(); + allFetchedIds.add(result1.get(0).playlist().getId()); + allFetchedIds.add(result1.get(1).playlist().getId()); + allFetchedIds.add(result2.get(0).playlist().getId()); + + assertThat(allFetchedIds).containsExactlyInAnyOrder(playlist.getId(), playlist2.getId(), + playlist3.getId()); + assertThat(result2.get(0).playlist().getId()).isEqualTo(playlist.getId()); + } + @Test @DisplayName("커서 커맨드 기반 카운트 조회") void countByCommand() { From 0264f1eb9a60365619780f5f32ff3bcbccfa7d27 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 20:46:38 +0900 Subject: [PATCH 39/46] =?UTF-8?q?test:=20=EC=95=8C=EB=A6=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=EA=B0=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReflectionTestUtils 대신 직접 createdAt 필드를 설정하는 헬퍼 메소드 사용 - 테스트 코드의 가독성과 유지보수성 향상 --- .../repository/NotificationRepositoryTest.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/codeit/team5/mopl/notification/repository/NotificationRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/notification/repository/NotificationRepositoryTest.java index afcebe54..7693b8b9 100644 --- a/src/test/java/com/codeit/team5/mopl/notification/repository/NotificationRepositoryTest.java +++ b/src/test/java/com/codeit/team5/mopl/notification/repository/NotificationRepositoryTest.java @@ -335,16 +335,15 @@ void findMissedNotifications_excludesRead() { Notification ref = notificationRepository.save(Notification.create( receiverId, NotificationType.FOLLOWED, "기준", null, NotificationLevel.INFO)); - ReflectionTestUtils.setField(ref,"createdAt", t0); - + setCreatedAt(ref.getId(), t0); Notification unread = notificationRepository.save(Notification.create( receiverId, NotificationType.FOLLOWED, "안읽음", null, NotificationLevel.INFO)); - ReflectionTestUtils.setField(unread,"createdAt", t1); + setCreatedAt(unread.getId(), t1); Notification read = notificationRepository.save(Notification.create( receiverId, NotificationType.FOLLOWED, "읽음", null, NotificationLevel.INFO)); - ReflectionTestUtils.setField(read,"createdAt", t1); + setCreatedAt(read.getId(), t1); read.markAsRead(); notificationRepository.save(read); From 8359a5585b4603da25ec416f87fee8e677e1872f Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 20:49:40 +0900 Subject: [PATCH 40/46] =?UTF-8?q?feat:=20QueryDSL=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20JaCoCo=20=EC=84=A4=EC=A0=95=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - QueryDSL 라이브러리 의존성을 `com.querydsl`에서 `io.github.openfeign.querydsl`로 변경했습니다. - `.env` 파일 로딩을 위한 `spring-dotenv` 라이브러리를 추가했습니다. - JaCoCo 테스트 리포트 생성 시 제외할 클래스 패턴을 정의하고, 리포트 자동 열림 기능을 추가했습니다. --- build.gradle | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 1fb3b8be..52e971f5 100644 --- a/build.gradle +++ b/build.gradle @@ -32,8 +32,8 @@ dependencies { implementation 'org.flywaydb:flyway-database-postgresql' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.17' implementation 'org.mapstruct:mapstruct:1.6.3' - implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' - annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' + implementation 'io.github.openfeign.querydsl:querydsl-jpa:7.1' + annotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:7.1:jpa' annotationProcessor 'jakarta.annotation:jakarta.annotation-api' annotationProcessor 'jakarta.persistence:jakarta.persistence-api' // AWS SDK (S3) @@ -71,6 +71,8 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + // .env + implementation 'me.paulschwarz:spring-dotenv:5.1.0' } tasks.named('test') { @@ -82,12 +84,53 @@ jacoco { toolVersion = '0.8.12' } +def jacocoExcludePatterns = [ + "**/*Application*", + "**/config/**", + "**/dto/**", + "**/entity/**", + "**/exception/**", + "**/event/**", + "**/mapper/**", + "**/constant/**", + "**/controller/api/**", + "**/Q*.class", + "**/*MapperImpl.class", + "**/*properties.class", + "**/*Decorator.class", + "**/MdcLoggingFilter.class", + "**/AuthUser.class", +] + +def openJacocoReport = false // true 로 설정 시 테스트 완료 후 리포트 자동 열림 + jacocoTestReport { dependsOn test reports { xml.required = true html.required = true } + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: jacocoExcludePatterns) + })) + } + doLast { + if (openJacocoReport) { + def reportFile = reports.html.entryPoint + if (reportFile.exists()) { + println "Opening JaCoCo Report: ${reportFile}" + def os = org.gradle.internal.os.OperatingSystem.current() + if (os.isWindows()) { + exec { commandLine 'cmd', '/c', "start \"\" \"${reportFile}\"" } + } else if (os.isMacOsX()) { + exec { commandLine 'open', reportFile } + } else { + exec { commandLine 'xdg-open', reportFile } + } + } + } + } } jacocoTestCoverageVerification { @@ -101,5 +144,10 @@ jacocoTestCoverageVerification { } } } + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: jacocoExcludePatterns) + })) + } } From 89668571f24b5767bb537404316d47de9de79a96 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 20:59:09 +0900 Subject: [PATCH 41/46] =?UTF-8?q?test:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 플레이리스트 단건 조회 시 콘텐츠 목록까지 함께 조회하는 경우에 대한 테스트를 추가했습니다. - 이를 통해 플레이리스트 조회 기능의 정확성과 안정성을 높였습니다. --- .../playlist/service/PlaylistServiceTest.java | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java b/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java index 6c95e491..4797cc64 100644 --- a/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java @@ -3,10 +3,10 @@ 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.BDDMockito.given; import static org.mockito.Mockito.verify; import java.util.Collections; +import java.util.List; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; @@ -124,6 +124,25 @@ void find_success() { assertThat(response.title()).isEqualTo(playlist.getTitle()); } + @Test + @DisplayName("플레이리스트 단건 조회 성공 - 콘텐츠 목록 포함") + void find_success_with_contents() { + // given + Content content = Content.createByAdmin(com.codeit.team5.mopl.content.entity.ContentType.MOVIE, "Movie Title", "Desc"); + PlaylistContentsDto dto = new PlaylistContentsDto(playlist, List.of(content), false); + given(playlistRepository.findByIdWithContents(playlist.getId(), user.getId())) + .willReturn(Optional.of(dto)); + + // when + PlaylistResponse response = playlistService.find(playlist.getId(), user.getId()); + + // then + assertThat(response).isNotNull(); + assertThat(response.title()).isEqualTo(playlist.getTitle()); + assertThat(response.contents()).hasSize(1); + assertThat(response.contents().get(0).title()).isEqualTo("Movie Title"); + } + @Test @DisplayName("플레이리스트 단건 조회 실패 - 존재하지 않음") void find_fail_notFound() { From 3af4daeb7fcaef4ec17d9629f9117f3ca0ee15f9 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 23:39:55 +0900 Subject: [PATCH 42/46] =?UTF-8?q?fix:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=BB=A8=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EC=8B=9C=20=EC=A4=91=EB=B3=B5=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - 플레이리스트에 컨텐츠를 추가할 때 이미 존재하는 컨텐츠인지 확인하는 로직을 추가했습니다. - 중복된 컨텐츠가 추가될 경우 `PlaylistItemAlreadyExistsException`을 발생시키도록 수정했습니다. - `PlaylistCreataionViolationException` 클래스가 삭제되어 해당 예외를 사용하지 않도록 변경했습니다. - `PlaylistCreateRequest`의 `title`과 `description` 필드에 `@NotBlank` 어노테이션을 적용하여 빈 문자열 입력을 방지하도록 수정했습니다. - Jacoco 테스트 리포트 생성 시 제외 패턴을 적용하도록 `build.gradle` 파일을 수정했습니다. --- build.gradle | 13 ++++++++----- .../dto/request/PlaylistCreateRequest.java | 6 +++--- .../PlaylistCreataionViolationException.java | 12 ------------ .../PlaylistItemAlreadyExistsException.java | 17 +++++++++++++++++ .../mopl/playlist/service/PlaylistService.java | 4 ++++ 5 files changed, 32 insertions(+), 20 deletions(-) delete mode 100644 src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistCreataionViolationException.java create mode 100644 src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistItemAlreadyExistsException.java diff --git a/build.gradle b/build.gradle index 52e971f5..60d31390 100644 --- a/build.gradle +++ b/build.gradle @@ -102,6 +102,14 @@ def jacocoExcludePatterns = [ "**/AuthUser.class", ] +tasks.withType(JacocoReportBase) { + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: jacocoExcludePatterns) + })) + } +} + def openJacocoReport = false // true 로 설정 시 테스트 완료 후 리포트 자동 열림 jacocoTestReport { @@ -144,10 +152,5 @@ jacocoTestCoverageVerification { } } } - afterEvaluate { - classDirectories.setFrom(files(classDirectories.files.collect { - fileTree(dir: it, exclude: jacocoExcludePatterns) - })) - } } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCreateRequest.java b/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCreateRequest.java index 0438b820..f3ab7a45 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCreateRequest.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCreateRequest.java @@ -1,8 +1,8 @@ package com.codeit.team5.mopl.playlist.dto.request; -import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotBlank; -public record PlaylistCreateRequest(@NotEmpty String title, - @NotEmpty String description) { +public record PlaylistCreateRequest(@NotBlank String title, + @NotBlank String description) { } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistCreataionViolationException.java b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistCreataionViolationException.java deleted file mode 100644 index 6f073f3d..00000000 --- a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistCreataionViolationException.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.codeit.team5.mopl.playlist.exception; - -import java.util.Map; -import org.springframework.http.HttpStatus; -import com.codeit.team5.mopl.global.exception.BusinessException; - -public class PlaylistCreataionViolationException extends BusinessException { - - public PlaylistCreataionViolationException(String message, Map details) { - super(HttpStatus.BAD_REQUEST, message, details); - } -} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistItemAlreadyExistsException.java b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistItemAlreadyExistsException.java new file mode 100644 index 00000000..176640e2 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistItemAlreadyExistsException.java @@ -0,0 +1,17 @@ +package com.codeit.team5.mopl.playlist.exception; + +import java.util.Map; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import com.codeit.team5.mopl.global.exception.BusinessException; + +/** + * PlaylistItemAlreadyExistsException + */ +public class PlaylistItemAlreadyExistsException extends BusinessException { + + public PlaylistItemAlreadyExistsException(UUID playlistId, UUID contentId) { + super(HttpStatus.BAD_REQUEST, "이미 플레이리스트에 추가된 컨텐츠입니다.", + Map.of("playlistId", playlistId, "contentId", contentId)); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java index f2fa8714..b17a9270 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java @@ -1,5 +1,6 @@ package com.codeit.team5.mopl.playlist.service; +import com.codeit.team5.mopl.playlist.exception.PlaylistItemAlreadyExistsException; import java.util.List; import java.util.UUID; import org.springframework.stereotype.Service; @@ -80,6 +81,9 @@ public void addContent(UUID userId, UUID playlistId, UUID contentId) { if (!contentRepository.existsById(contentId)) { throw new PlaylistContentNotFoundException(contentId); } + if (playlistItemRepository.existsByPlaylistIdAndContentId(playlistId, contentId)) { + throw new PlaylistItemAlreadyExistsException(playlistId, contentId); + } Content content = contentRepository.getReferenceById(contentId); PlaylistItem playlistItem = PlaylistItem.of(playlistId, content); playlistItemRepository.save(playlistItem); From 7cc90c0c0627c6b111e62c08c6167bc8fc2a7be3 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Fri, 3 Jul 2026 23:41:34 +0900 Subject: [PATCH 43/46] =?UTF-8?q?test:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - MockMvc 테스트에서 AuthenticationPrincipalArgumentResolver를 사용하기 위한 설정 추가 - UUID 관련 임포트 정리 및 코드 가독성 개선 - 테스트 코드 내에서 사용되는 UUID 리터럴을 상수로 분리하여 관리 - Mockito의 given, verify, willDoNothing 등의 메서드 호출 시 인자 매칭 방식 통일 --- .../controller/PlaylistControllerTest.java | 51 ++++++++++--------- .../SubscriptionRepositoryTest.java | 18 +++---- 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/src/test/java/com/codeit/team5/mopl/playlist/controller/PlaylistControllerTest.java b/src/test/java/com/codeit/team5/mopl/playlist/controller/PlaylistControllerTest.java index 885fe432..f183fec5 100644 --- a/src/test/java/com/codeit/team5/mopl/playlist/controller/PlaylistControllerTest.java +++ b/src/test/java/com/codeit/team5/mopl/playlist/controller/PlaylistControllerTest.java @@ -15,7 +15,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.codeit.team5.mopl.auth.security.details.MoplPrincipal; import java.time.Instant; import java.util.List; import java.util.UUID; @@ -30,16 +29,22 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.FilterType; import org.springframework.context.annotation.Import; import org.springframework.data.domain.Sort; import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import com.codeit.team5.mopl.TestGlobalExceptionHandlerConfig; import com.codeit.team5.mopl.auth.jwt.JwtAuthenticationFilter; +import com.codeit.team5.mopl.auth.security.details.MoplPrincipal; import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.global.exception.GlobalExceptionHandler; import com.codeit.team5.mopl.playlist.dto.PlaylistCursorCommand; @@ -65,11 +70,11 @@ @Import({GlobalExceptionHandler.class, TestGlobalExceptionHandlerConfig.class, PlaylistControllerTest.TestSecurityConfig.class}) class PlaylistControllerTest { - @org.springframework.boot.test.context.TestConfiguration - static class TestSecurityConfig implements org.springframework.web.servlet.config.annotation.WebMvcConfigurer { + @TestConfiguration + static class TestSecurityConfig implements WebMvcConfigurer { @Override - public void addArgumentResolvers(java.util.List resolvers) { - resolvers.add(new org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver()); + public void addArgumentResolvers(List resolvers) { + resolvers.add(new AuthenticationPrincipalArgumentResolver()); } } @@ -97,7 +102,7 @@ public void addArgumentResolvers(java.util.List cursorRequestCaptor; private MoplPrincipal principal; - private org.springframework.security.core.Authentication auth; + private Authentication auth; private UUID playlistId; private UUID contentId; private PlaylistResponse playlistResponse; @@ -106,8 +111,8 @@ public void addArgumentResolvers(java.util.List Date: Sat, 4 Jul 2026 15:44:30 +0900 Subject: [PATCH 44/46] =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BF=BC=EB=A6=AC=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `PlaylistCreateRequest`의 `title` 필드에 `@Size(max = 255)` 제약 조건을 추가하여 유효성 검사를 강화했습니다. - `PlaylistQueryRepositoryImpl`에서 `subscribedByMeSubquery`에 `IS_SUBSCRIBED` 별칭을 적용하여 쿼리 가독성을 높이고 중복 로직을 제거했습니다. - `PlaylistService.removeContent` 메서드에서 불필요한 `contentRepository.existsById` 호출을 제거하여 성능을 최적화했습니다. - `PlaylistQueryRepositoryTest`에 `subscribedByMe` 필드 검증 로직을 추가하여 플레이리스트 구독 여부 조회 기능의 정확성을 보장했습니다. --- .../dto/request/PlaylistCreateRequest.java | 3 +- .../query/PlaylistQueryRepositoryImpl.java | 17 +++++----- .../playlist/service/PlaylistService.java | 3 -- .../PlaylistQueryRepositoryTest.java | 33 +++++++++++++++++++ 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCreateRequest.java b/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCreateRequest.java index f3ab7a45..bfe15897 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCreateRequest.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCreateRequest.java @@ -1,8 +1,9 @@ package com.codeit.team5.mopl.playlist.dto.request; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; -public record PlaylistCreateRequest(@NotBlank String title, +public record PlaylistCreateRequest(@NotBlank @Size(max = 255) String title, @NotBlank String description) { } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java index 038982d0..60dd1894 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java @@ -22,9 +22,11 @@ import com.codeit.team5.mopl.playlist.entity.PlaylistItem; import com.querydsl.core.Tuple; import com.querydsl.core.types.Expression; +import com.querydsl.core.types.ExpressionUtils; import com.querydsl.core.types.Ops; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.BooleanPath; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQuery; @@ -37,6 +39,8 @@ public class PlaylistQueryRepositoryImpl implements PlaylistQueryRepository { private final JPAQueryFactory queryFactory; + private final BooleanPath IS_SUBSCRIBED = Expressions.booleanPath("isSubscribed"); + @Override public Optional findByIdWithContents(UUID id, UUID userId) { Tuple result = selectPlaylistWithSubscription(userId).where(playlist.id.eq(id)).fetchOne(); @@ -44,7 +48,7 @@ public Optional findByIdWithContents(UUID id, UUID userId) return Optional.empty(); } Playlist foundPlaylist = result.get(playlist); - boolean subscribedByMe = Boolean.TRUE.equals(result.get(subscribedByMeSubquery(userId))); + boolean subscribedByMe = Boolean.TRUE.equals(result.get(IS_SUBSCRIBED)); List items = fetchPlaylistItemsWithContents(List.of(id)); return Optional.of(new PlaylistContentsDto(foundPlaylist, items.stream().map(PlaylistItem::getContent).toList(), subscribedByMe)); @@ -67,9 +71,7 @@ public List findByCursor(PlaylistCursorCommand request, UUI Map> contentsByPlaylistId = items.stream().collect(Collectors.groupingBy(PlaylistItem::getPlaylistId, Collectors.mapping(PlaylistItem::getContent, Collectors.toList()))); - BooleanExpression subscribedByMeExpr = subscribedByMeSubquery(userId); - - return results.stream().map(t -> mapToDto(t, contentsByPlaylistId, subscribedByMeExpr)) + return results.stream().map(t -> mapToDto(t, contentsByPlaylistId)) .toList(); } @@ -141,12 +143,11 @@ private BooleanExpression keywordLikeQuery(PlaylistCursorCommand request) { .or(playlist.title.containsIgnoreCase(request.keywordLike())); } - private PlaylistContentsDto mapToDto(Tuple t, Map> contentsByPlaylistId, - BooleanExpression subscribedByMeExpr) { + private PlaylistContentsDto mapToDto(Tuple t, Map> contentsByPlaylistId) { Playlist p = t.get(playlist); return new PlaylistContentsDto(p, contentsByPlaylistId.getOrDefault(p.getId(), Collections.emptyList()), - Boolean.TRUE.equals(t.get(subscribedByMeExpr))); + Boolean.TRUE.equals(t.get(IS_SUBSCRIBED))); } private BooleanExpression subscribedByMeSubquery(UUID userId) { @@ -156,7 +157,7 @@ private BooleanExpression subscribedByMeSubquery(UUID userId) { } private JPAQuery selectPlaylistWithSubscription(UUID userId) { - return queryFactory.select(playlist, subscribedByMeSubquery(userId)).from(playlist) + return queryFactory.select(playlist, ExpressionUtils.as(subscribedByMeSubquery(userId), IS_SUBSCRIBED)).from(playlist) .leftJoin(playlist.owner, user).fetchJoin() .leftJoin(user.profileImage, binaryContent).fetchJoin(); } diff --git a/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java index b17a9270..2e5e1dec 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java @@ -92,9 +92,6 @@ public void addContent(UUID userId, UUID playlistId, UUID contentId) { @Transactional public void removeContent(UUID userId, UUID playlistId, UUID contentId) { validateOwner(playlistId, userId); - if (!contentRepository.existsById(contentId)) { - throw new PlaylistContentNotFoundException(contentId); - } if (!playlistItemRepository.existsByPlaylistIdAndContentId(playlistId, contentId)) { throw new PlaylistItemNotFoundException(playlistId, contentId); } diff --git a/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistQueryRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistQueryRepositoryTest.java index 1c3eb5a5..9ee87b8a 100644 --- a/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistQueryRepositoryTest.java +++ b/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistQueryRepositoryTest.java @@ -18,6 +18,8 @@ import com.codeit.team5.mopl.playlist.entity.Playlist; import com.codeit.team5.mopl.playlist.entity.PlaylistItem; import com.codeit.team5.mopl.playlist.repository.query.PlaylistQueryRepositoryImpl; +import com.codeit.team5.mopl.subscription.entity.Subscription; +import com.codeit.team5.mopl.subscription.repository.SubscriptionRepository; import com.codeit.team5.mopl.user.entity.User; import com.codeit.team5.mopl.user.repository.UserRepository; @@ -38,6 +40,9 @@ class PlaylistQueryRepositoryTest extends BaseRepositoryTest { @Autowired private ContentRepository contentRepository; + @Autowired + private SubscriptionRepository subscriptionRepository; + private User user; private Playlist playlist; @@ -79,6 +84,20 @@ void findByIdWithContents() { assertThat(dto.playlist().getId()).isEqualTo(playlist.getId()); assertThat(dto.contents()).hasSize(1); assertThat(dto.contents().get(0).getTitle()).isEqualTo("Movie Title"); + assertThat(dto.subscribedByMe()).isFalse(); + + // when (subscribe) + subscriptionRepository.save(Subscription.of(playlist, user)); + flush(); + clear(); + queryInspector.clear(); + + dtoOpt = playlistQueryRepository.findByIdWithContents(playlist.getId(), user.getId()); + ensureQueryCount(2); + + // then + assertThat(dtoOpt).isPresent(); + assertThat(dtoOpt.get().subscribedByMe()).isTrue(); } @Test @@ -100,6 +119,20 @@ void findByCursor() { // then assertThat(result).hasSize(1); assertThat(result.get(0).playlist().getId()).isEqualTo(playlist.getId()); + assertThat(result.get(0).subscribedByMe()).isFalse(); + + // when (subscribe) + subscriptionRepository.save(Subscription.of(playlist, user)); + flush(); + clear(); + queryInspector.clear(); + + result = playlistQueryRepository.findByCursor(command, user.getId()); + ensureQueryCount(2); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).subscribedByMe()).isTrue(); } @Test From 7618f18a9fffb43c03073ba90746c53802d8ae94 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Sat, 4 Jul 2026 16:17:51 +0900 Subject: [PATCH 45/46] =?UTF-8?q?refactor:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=BD=98=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 플레이리스트에서 콘텐츠를 삭제할 때, 콘텐츠 자체의 존재 여부를 확인하는 로직을 제거했습니다. - 플레이리스트에 해당 콘텐츠가 존재하는지만 확인하면 충분하므로, 불필요한 `contentRepository.existsById` 호출을 제거하여 로직을 간소화했습니다. --- .../NotificationEventListenerTest.java | 4 ++-- .../playlist/service/PlaylistServiceTest.java | 15 --------------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/test/java/com/codeit/team5/mopl/notification/eventlistener/NotificationEventListenerTest.java b/src/test/java/com/codeit/team5/mopl/notification/eventlistener/NotificationEventListenerTest.java index 739b6b3a..8f18d10b 100644 --- a/src/test/java/com/codeit/team5/mopl/notification/eventlistener/NotificationEventListenerTest.java +++ b/src/test/java/com/codeit/team5/mopl/notification/eventlistener/NotificationEventListenerTest.java @@ -9,11 +9,11 @@ import com.codeit.team5.mopl.follow.repository.FollowRepository; import com.codeit.team5.mopl.notification.event.DirectMessageSentEvent; import com.codeit.team5.mopl.watcher.event.WatchingSessionCreatedEvent; -import com.codeit.team5.mopl.playlist.entity.event.PlaylistSubscribedEvent; -import com.codeit.team5.mopl.playlist.entity.event.PlaylistUpdatedEvent; import com.codeit.team5.mopl.user.event.RoleChangedEvent; import com.codeit.team5.mopl.follow.event.UserFollowedEvent; import com.codeit.team5.mopl.notification.service.NotificationService; +import com.codeit.team5.mopl.playlist.event.PlaylistSubscribedEvent; +import com.codeit.team5.mopl.playlist.event.PlaylistUpdatedEvent; import java.util.List; import java.util.UUID; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java b/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java index 4797cc64..af41a02e 100644 --- a/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java @@ -240,7 +240,6 @@ void removeContent_success() { UUID contentId = UUID.randomUUID(); given(playlistRepository.existsByIdAndOwnerId(playlist.getId(), user.getId())) .willReturn(true); - given(contentRepository.existsById(contentId)).willReturn(true); given(playlistItemRepository.existsByPlaylistIdAndContentId(playlist.getId(), contentId)) .willReturn(true); @@ -292,19 +291,6 @@ void removeContent_fail_accessDenied() { .isInstanceOf(PlaylistAccessDeniedException.class); } - @Test - @DisplayName("콘텐츠 삭제 실패 - 콘텐츠를 찾을 수 없음") - void removeContent_fail_contentNotFound() { - // given - UUID contentId = UUID.randomUUID(); - given(playlistRepository.existsByIdAndOwnerId(playlist.getId(), user.getId())) - .willReturn(true); - given(contentRepository.existsById(contentId)).willReturn(false); - - // when & then - assertThatThrownBy(() -> playlistService.removeContent(user.getId(), playlist.getId(), contentId)) - .isInstanceOf(PlaylistContentNotFoundException.class); - } @Test @DisplayName("콘텐츠 삭제 실패 - 플레이리스트에 해당 콘텐츠가 없음") @@ -313,7 +299,6 @@ void removeContent_fail_playlistItemNotFound() { UUID contentId = UUID.randomUUID(); given(playlistRepository.existsByIdAndOwnerId(playlist.getId(), user.getId())) .willReturn(true); - given(contentRepository.existsById(contentId)).willReturn(true); given(playlistItemRepository.existsByPlaylistIdAndContentId(playlist.getId(), contentId)) .willReturn(false); From 9fbcde5c6336032cb5bb1e4d4b65150da5b24012 Mon Sep 17 00:00:00 2001 From: 8c8c8c8c8c8 <8c8c8c8c8c8@gmail.com> Date: Sat, 4 Jul 2026 16:29:38 +0900 Subject: [PATCH 46/46] =?UTF-8?q?refactor:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B4=80=EB=A0=A8=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 본문 --- - `com.codeit.team5.mopl.playlist.entity.event` 패키지를 `com.codeit.team5.mopl.playlist.event`로 변경했습니다. - `com.codeit.team5.mopl.playlist.entity.exception` 패키지를 `com.codeit.team5.mopl.playlist.exception`으로 변경했습니다. - 변경된 패키지 경로에 맞춰 관련 클래스의 import 문을 수정했습니다. - 이는 플레이리스트 관련 엔티티와 이벤트, 예외 클래스들을 좀 더 명확하게 분리하기 위한 구조 변경입니다. --- .../notification/eventlistener/NotificationEventListener.java | 4 ++-- .../playlist/{entity => }/event/PlaylistSubscribedEvent.java | 4 ++-- .../playlist/{entity => }/event/PlaylistUpdatedEvent.java | 4 ++-- .../{entity => }/exception/InvalidPlaylistNameException.java | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) rename src/main/java/com/codeit/team5/mopl/playlist/{entity => }/event/PlaylistSubscribedEvent.java (83%) rename src/main/java/com/codeit/team5/mopl/playlist/{entity => }/event/PlaylistUpdatedEvent.java (83%) rename src/main/java/com/codeit/team5/mopl/playlist/{entity => }/exception/InvalidPlaylistNameException.java (85%) diff --git a/src/main/java/com/codeit/team5/mopl/notification/eventlistener/NotificationEventListener.java b/src/main/java/com/codeit/team5/mopl/notification/eventlistener/NotificationEventListener.java index 36f3bf3f..415378f5 100644 --- a/src/main/java/com/codeit/team5/mopl/notification/eventlistener/NotificationEventListener.java +++ b/src/main/java/com/codeit/team5/mopl/notification/eventlistener/NotificationEventListener.java @@ -5,11 +5,11 @@ import com.codeit.team5.mopl.notification.entity.NotificationType; import com.codeit.team5.mopl.notification.event.DirectMessageSentEvent; import com.codeit.team5.mopl.watcher.event.WatchingSessionCreatedEvent; -import com.codeit.team5.mopl.playlist.entity.event.PlaylistSubscribedEvent; -import com.codeit.team5.mopl.playlist.entity.event.PlaylistUpdatedEvent; import com.codeit.team5.mopl.user.event.RoleChangedEvent; import com.codeit.team5.mopl.follow.event.UserFollowedEvent; import com.codeit.team5.mopl.notification.service.NotificationService; +import com.codeit.team5.mopl.playlist.event.PlaylistSubscribedEvent; +import com.codeit.team5.mopl.playlist.event.PlaylistUpdatedEvent; import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/codeit/team5/mopl/playlist/entity/event/PlaylistSubscribedEvent.java b/src/main/java/com/codeit/team5/mopl/playlist/event/PlaylistSubscribedEvent.java similarity index 83% rename from src/main/java/com/codeit/team5/mopl/playlist/entity/event/PlaylistSubscribedEvent.java rename to src/main/java/com/codeit/team5/mopl/playlist/event/PlaylistSubscribedEvent.java index c29656f7..fa9f0258 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/entity/event/PlaylistSubscribedEvent.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/event/PlaylistSubscribedEvent.java @@ -1,8 +1,8 @@ -package com.codeit.team5.mopl.playlist.entity.event; +package com.codeit.team5.mopl.playlist.event; import com.codeit.team5.mopl.notification.exception.InvalidNicknameException; import com.codeit.team5.mopl.notification.exception.InvalidReceiverIdException; -import com.codeit.team5.mopl.playlist.entity.exception.InvalidPlaylistNameException; +import com.codeit.team5.mopl.playlist.exception.InvalidPlaylistNameException; import java.util.UUID; import org.springframework.util.StringUtils; diff --git a/src/main/java/com/codeit/team5/mopl/playlist/entity/event/PlaylistUpdatedEvent.java b/src/main/java/com/codeit/team5/mopl/playlist/event/PlaylistUpdatedEvent.java similarity index 83% rename from src/main/java/com/codeit/team5/mopl/playlist/entity/event/PlaylistUpdatedEvent.java rename to src/main/java/com/codeit/team5/mopl/playlist/event/PlaylistUpdatedEvent.java index eaf8ba62..113330cf 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/entity/event/PlaylistUpdatedEvent.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/event/PlaylistUpdatedEvent.java @@ -1,8 +1,8 @@ -package com.codeit.team5.mopl.playlist.entity.event; +package com.codeit.team5.mopl.playlist.event; import com.codeit.team5.mopl.notification.exception.InvalidNicknameException; import com.codeit.team5.mopl.notification.exception.InvalidReceiverIdException; -import com.codeit.team5.mopl.playlist.entity.exception.InvalidPlaylistNameException; +import com.codeit.team5.mopl.playlist.exception.InvalidPlaylistNameException; import java.util.UUID; import org.springframework.util.StringUtils; diff --git a/src/main/java/com/codeit/team5/mopl/playlist/entity/exception/InvalidPlaylistNameException.java b/src/main/java/com/codeit/team5/mopl/playlist/exception/InvalidPlaylistNameException.java similarity index 85% rename from src/main/java/com/codeit/team5/mopl/playlist/entity/exception/InvalidPlaylistNameException.java rename to src/main/java/com/codeit/team5/mopl/playlist/exception/InvalidPlaylistNameException.java index 820e6c77..9f91617f 100644 --- a/src/main/java/com/codeit/team5/mopl/playlist/entity/exception/InvalidPlaylistNameException.java +++ b/src/main/java/com/codeit/team5/mopl/playlist/exception/InvalidPlaylistNameException.java @@ -1,4 +1,4 @@ -package com.codeit.team5.mopl.playlist.entity.exception; +package com.codeit.team5.mopl.playlist.exception; import com.codeit.team5.mopl.global.exception.BusinessException; import org.springframework.http.HttpStatus;