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 diff --git a/build.gradle b/build.gradle index 1fb3b8be..60d31390 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,61 @@ 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", +] + +tasks.withType(JacocoReportBase) { + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: jacocoExcludePatterns) + })) + } +} + +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 { 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; + } } 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..eca31f1b --- /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.WARN, + nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE, + injectionStrategy = InjectionStrategy.CONSTRUCTOR) +public interface GlobalMapperConfig { + +} 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/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 new file mode 100644 index 00000000..02e77401 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/controller/PlaylistController.java @@ -0,0 +1,94 @@ +package com.codeit.team5.mopl.playlist.controller; + +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; +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.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; +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") +@RequiredArgsConstructor +public class PlaylistController implements PlaylistControllerApi { + + private final PlaylistService service; + private final PlaylistMapper mapper; + + @Override + @GetMapping("/{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), userId)); + } + + @Override + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity create(@AuthenticationPrincipal MoplPrincipal principal, + @RequestBody @Valid PlaylistCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(service.create(principal.getId(), request)); + } + + @Override + @PatchMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity update(@AuthenticationPrincipal MoplPrincipal principal, + @PathVariable UUID id, @RequestBody PlaylistUpdateRequest request) { + return ResponseEntity.status(HttpStatus.OK) + .body(service.update(id, principal.getId(), request)); + } + + @Override + @DeleteMapping("/{id}") + 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(@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(@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 new file mode 100644 index 00000000..21d167d5 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/controller/api/PlaylistControllerApi.java @@ -0,0 +1,49 @@ +package com.codeit.team5.mopl.playlist.controller.api; + +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(@Parameter(hidden = true) MoplPrincipal principal, + @PathVariable UUID id); + + @Operation(summary = "플레이리스트 목록 조회 (커서 페이지네이션)") + ResponseEntity> findCursor( + @Parameter(hidden = true) MoplPrincipal principal, + @ModelAttribute PlaylistCursorRequest request); + + @Operation(summary = "플레이리스트 생성", description = "생성한 플레이리스트는 API 요청자 본인의 플레이리스트로 생성됩니다.") + ResponseEntity create(@Parameter(hidden = true) MoplPrincipal principal, + @RequestBody PlaylistCreateRequest request); + + @Operation(summary = "플레이리스트 수정", description = "플레이리스트 소유자만 수정할 수 있습니다.") + ResponseEntity update(@Parameter(hidden = true) MoplPrincipal principal, + @PathVariable UUID id, @RequestBody PlaylistUpdateRequest request); + + @Operation(summary = "플레이리스트 삭제", description = "플레이리스트 소유자만 삭제할 수 있습니다.") + ResponseEntity delete(@Parameter(hidden = true) MoplPrincipal principal, + @PathVariable UUID playlistId); + + @Operation(summary = "플레이리스트에 콘텐츠 추가", description = "플레이리스트 소유자만 콘텐츠를 추가할 수 있습니다.") + ResponseEntity addContent(@Parameter(hidden = true) MoplPrincipal principal, + @PathVariable UUID playlistId, @PathVariable UUID contentId); + + @Operation(summary = "플레이리스트에서 콘텐츠 삭제", description = "플레이리스트 소유자만 콘텐츠를 삭제할 수 있습니다.") + 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 new file mode 100644 index 00000000..087ab087 --- /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, boolean subscribedByMe) { +} 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/PlaylistCreateRequest.java b/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCreateRequest.java new file mode 100644 index 00000000..bfe15897 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCreateRequest.java @@ -0,0 +1,9 @@ +package com.codeit.team5.mopl.playlist.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PlaylistCreateRequest(@NotBlank @Size(max = 255) String title, + @NotBlank String description) { + +} 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..e477f12b --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/dto/request/PlaylistCursorRequest.java @@ -0,0 +1,18 @@ +package com.codeit.team5.mopl.playlist.dto.request; + +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; + +public record PlaylistCursorRequest(String keywordLike, + UUID ownerIdEqual, + UUID subscriberIdEqual, + String cursor, + UUID idAfter, + @NotNull @Positive @Max(50) Integer limit, + @NotNull Sort.Direction sortDirection, + @NotNull String sortBy) { + +} 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..1b712d68 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/dto/response/PlaylistResponse.java @@ -0,0 +1,17 @@ +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, + UserSummary owner, + Instant updatedAt, + String title, + String description, + Integer subscriberCount, + boolean subscribedByMe, + List contents) { +} 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..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,52 +2,67 @@ import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; import com.codeit.team5.mopl.user.entity.User; -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.Setter; -import org.hibernate.annotations.ColumnDefault; -import org.hibernate.annotations.OnDelete; -import org.hibernate.annotations.OnDeleteAction; +import lombok.NoArgsConstructor; +import org.springframework.util.StringUtils; @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) { + 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(); + } + + 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); + } } 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..1cc65986 --- /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", nullable = false) + private UUID playlistId; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id", nullable = false) + 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); + } +} 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; 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..c798dbe2 --- /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, UUID userId) { + super(HttpStatus.FORBIDDEN, "플레이리스트 접근 권한이 없습니다.", + Map.of("playlistId", id, "userId", userId)); + } +} 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/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/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/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/exception/PlaylistNotFoundException.java b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistNotFoundException.java new file mode 100644 index 00000000..1728c473 --- /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.NOT_FOUND, "플레이리스트를 찾을 수 없습니다.", + Map.of("playlistId", id)); + } +} 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/exception/PlaylistUserNotFoundException.java b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistUserNotFoundException.java new file mode 100644 index 00000000..6200e81d --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/exception/PlaylistUserNotFoundException.java @@ -0,0 +1,15 @@ +package com.codeit.team5.mopl.playlist.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import java.util.Map; +import org.springframework.http.HttpStatus; + +import java.util.UUID; + +public class PlaylistUserNotFoundException extends BusinessException { + + public PlaylistUserNotFoundException(UUID userId) { + super(HttpStatus.NOT_FOUND, "유저를 찾을 수 없습니다.", + Map.of("userId", userId)); + } +} 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..b36aa7c4 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/mapper/PlaylistMapper.java @@ -0,0 +1,67 @@ +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; +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 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); + + @Mapping(target = "sortBy", source = "sortBy", qualifiedByName = "toSortBy") + @Mapping(target = "cursor", source = "request", 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); + } + + 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(); + 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/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); +} 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..5879a620 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepository.java @@ -0,0 +1,25 @@ +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.Playlist; +import com.codeit.team5.mopl.playlist.repository.query.PlaylistQueryRepository; + +public interface PlaylistRepository extends JpaRepository, PlaylistQueryRepository { + + 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 new file mode 100644 index 00000000..f22f0304 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepository.java @@ -0,0 +1,16 @@ +package com.codeit.team5.mopl.playlist.repository.query; + +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 { + + Optional findByIdWithContents(UUID id, UUID userId); + + 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 new file mode 100644 index 00000000..60dd1894 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/repository/query/PlaylistQueryRepositoryImpl.java @@ -0,0 +1,180 @@ +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.content.entity.QContentStats.contentStats; +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.subscription.entity.QSubscription.subscription; +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.content.entity.Content; +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.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; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +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(); + if (result == null) { + return Optional.empty(); + } + Playlist foundPlaylist = result.get(playlist); + 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)); + } + + @Override + 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 (results.isEmpty()) { + return Collections.emptyList(); + } + + 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()))); + return results.stream().map(t -> mapToDto(t, contentsByPlaylistId)) + .toList(); + } + + @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 List fetchPlaylistItemsWithContents(List playlistIds) { + 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(); + } + + 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(); + + 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) { + if (request.subscriberIdEqual() == null) { + return null; + } + 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) { + 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())); + } + + 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(IS_SUBSCRIBED))); + } + + 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, ExpressionUtils.as(subscribedByMeSubquery(userId), IS_SUBSCRIBED)).from(playlist) + .leftJoin(playlist.owner, user).fetchJoin() + .leftJoin(user.profileImage, binaryContent).fetchJoin(); + } + + @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 new file mode 100644 index 00000000..2e5e1dec --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/playlist/service/PlaylistService.java @@ -0,0 +1,113 @@ +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; +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; +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; +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.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; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +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(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, UUID userId) { + return mapper.toDto(findById(id, userId)); + } + + @Transactional + 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(dto); + } + + @Transactional + public void delete(UUID id, UUID userId) { + validateOwner(id, userId); + repository.deleteByIdDirectly(id); + } + + 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); + return mapper.toCursorResponse(data, dto, hasNext, totalCount); + } + + @Transactional + public void addContent(UUID userId, UUID playlistId, UUID contentId) { + validateOwner(playlistId, userId); + 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); + } + + @Transactional + public void removeContent(UUID userId, UUID playlistId, UUID contentId) { + validateOwner(playlistId, userId); + if (!playlistItemRepository.existsByPlaylistIdAndContentId(playlistId, contentId)) { + throw new PlaylistItemNotFoundException(playlistId, contentId); + } + playlistItemRepository.deleteByPlaylistIdAndContentIdDirectly(playlistId, contentId); + } + + private void validateOwner(UUID id, UUID userId) { + if (repository.existsByIdAndOwnerId(id, userId)) { + return; + } + throw new PlaylistAccessDeniedException(id, userId); + } + + private PlaylistContentsDto findById(UUID id, UUID userId) { + PlaylistContentsDto dto = repository.findByIdWithContents(id, userId) + .orElseThrow(() -> new PlaylistNotFoundException(id)); + return dto; + } +} 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..bc582924 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/subscription/controller/SubscriptionController.java @@ -0,0 +1,34 @@ +package com.codeit.team5.mopl.subscription.controller; + +import java.security.Principal; +import java.util.UUID; +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; +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 implements SubscriptionControllerApi { + + 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(); + } + + @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/controller/api/SubscriptionControllerApi.java b/src/main/java/com/codeit/team5/mopl/subscription/controller/api/SubscriptionControllerApi.java new file mode 100644 index 00000000..217800cd --- /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); +} 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..32cc7290 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/subscription/entity/Subscription.java @@ -0,0 +1,42 @@ +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.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; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Immutable +@Entity +@Table(name = "playlist_subscriptions") +public class Subscription extends BaseEntity { + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "playlist_id") + private Playlist playlist; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "subscriber_id") + 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/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 new file mode 100644 index 00000000..992b25f6 --- /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/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)); + } +} 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..7aaec1b1 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/subscription/repository/SubscriptionRepository.java @@ -0,0 +1,16 @@ +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; +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 new file mode 100644 index 00000000..29838f80 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/subscription/service/SubscriptionService.java @@ -0,0 +1,51 @@ +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.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; +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)); + if (repository.existsBySubscriberEmailAndPlaylistId(email, playlistId)) { + throw new SubscriptionAlreadyExistsException(email, playlistId); + } + 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); + } +} 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); +} 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..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; @@ -13,6 +14,7 @@ public interface UserRepository extends JpaRepository, UserQueryRepository { boolean existsByEmail(String email); + @EntityGraph(attributePaths = {"profileImage"}) Optional findByEmail(String email); // refresh Token 재발급/저장을 직렬화하기 위함 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 { 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"; 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/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); 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..f183fec5 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/playlist/controller/PlaylistControllerTest.java @@ -0,0 +1,389 @@ +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.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.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.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; +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, PlaylistControllerTest.TestSecurityConfig.class}) +class PlaylistControllerTest { + + @TestConfiguration + static class TestSecurityConfig implements WebMvcConfigurer { + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new AuthenticationPrincipalArgumentResolver()); + } + } + + + + @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 MoplPrincipal principal; + private Authentication auth; + private UUID playlistId; + private UUID contentId; + private PlaylistResponse playlistResponse; + + @BeforeEach + void setUp() { + 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"))); + SecurityContextHolder.getContext().setAuthentication(auth); + given(principal.getId()).willReturn(UUID.fromString("11111111-1111-1111-1111-111111111111")); + + 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("단건 조회") + void find() throws Exception { + // given + given(playlistService.find(playlistId, UUID.fromString("11111111-1111-1111-1111-111111111111"))).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("단건 조회 - 실패 (플레이리스트 없음)") + void find_fail_PlaylistNotFoundException() throws Exception { + // given + given(playlistService.find(playlistId, UUID.fromString("11111111-1111-1111-1111-111111111111"))) + .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("커서 기반 조회") + 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, UUID.fromString("11111111-1111-1111-1111-111111111111"))).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("커서 기반 조회 - 실패 (입력값 유효성 검증)") + 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 포맷 오류)") + 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("커서 기반 조회 - 실패 (유효하지 않은 정렬 기준)") + 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("커서 기반 조회 - 실패 (정렬 기준과 커서 타입 불일치)") + 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("플레이리스트 생성") + void create() throws Exception { + // given + PlaylistCreateRequest request = new PlaylistCreateRequest("New Playlist", "Desc"); + given(playlistService.create(eq(UUID.fromString("11111111-1111-1111-1111-111111111111")), any(PlaylistCreateRequest.class))) + .willReturn(playlistResponse); + + // when & then + 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(UUID.fromString("11111111-1111-1111-1111-111111111111")), createRequestCaptor.capture()); + PlaylistCreateRequest captured = createRequestCaptor.getValue(); + assertThat(captured.title()).isEqualTo("New Playlist"); + assertThat(captured.description()).isEqualTo("Desc"); + } + + @Test + @DisplayName("플레이리스트 수정") + void update() throws Exception { + // given + PlaylistUpdateRequest request = + new PlaylistUpdateRequest("Updated Playlist", "Updated Desc"); + given(playlistService.update(eq(playlistId), eq(UUID.fromString("11111111-1111-1111-1111-111111111111")), + any(PlaylistUpdateRequest.class))).willReturn(playlistResponse); + + // when & then + mockMvc.perform(patch("/api/playlists/{id}", playlistId).with(csrf()) + .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(UUID.fromString("11111111-1111-1111-1111-111111111111")), + updateRequestCaptor.capture()); + PlaylistUpdateRequest captured = updateRequestCaptor.getValue(); + assertThat(captured.title()).isEqualTo("Updated Playlist"); + assertThat(captured.description()).isEqualTo("Updated Desc"); + } + + @Test + @DisplayName("플레이리스트 삭제") + void deletePlaylist() throws Exception { + // given + willDoNothing().given(playlistService).delete(playlistId, UUID.fromString("11111111-1111-1111-1111-111111111111")); + + // when & then + mockMvc.perform(delete("/api/playlists/{id}", playlistId).with(csrf()) + ).andDo(print()) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("플레이리스트 삭제 - 실패 (권한 없음)") + void deletePlaylist_fail_PlaylistAccessDeniedException() throws Exception { + // given + willThrow(new PlaylistAccessDeniedException(playlistId, UUID.fromString("11111111-1111-1111-1111-111111111111"))) + .given(playlistService).delete(playlistId, UUID.fromString("11111111-1111-1111-1111-111111111111")); + + // when & then + mockMvc.perform(delete("/api/playlists/{id}", playlistId).with(csrf()) + ).andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.exceptionType") + .value("PlaylistAccessDeniedException")) + .andExpect(jsonPath("$.message").value("플레이리스트 접근 권한이 없습니다.")); + } + + @Test + @DisplayName("플레이리스트 아이템 추가") + void addContent() throws Exception { + // given + willDoNothing().given(playlistService).addContent(UUID.fromString("11111111-1111-1111-1111-111111111111"), playlistId, + contentId); + + // when & then + mockMvc.perform(post("/api/playlists/{playlistId}/contents/{contentId}", playlistId, + contentId).with(csrf())).andDo(print()) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("플레이리스트 아이템 추가 - 실패 (컨텐츠 없음)") + void addContent_fail_PlaylistContentNotFoundException() throws Exception { + // given + willThrow(new PlaylistContentNotFoundException(contentId)).given(playlistService) + .addContent(UUID.fromString("11111111-1111-1111-1111-111111111111"), playlistId, contentId); + + // when & then + mockMvc.perform(post("/api/playlists/{playlistId}/contents/{contentId}", playlistId, + contentId).with(csrf())).andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType") + .value("PlaylistContentNotFoundException")) + .andExpect(jsonPath("$.message").value("컨텐츠를 찾을 수 없습니다.")); + } + + @Test + @DisplayName("플레이리스트 아이템 삭제") + void removeContent() throws Exception { + // given + willDoNothing().given(playlistService).removeContent(UUID.fromString("11111111-1111-1111-1111-111111111111"), playlistId, + contentId); + + // when & then + mockMvc.perform(delete("/api/playlists/{playlistId}/contents/{contentId}", + playlistId, contentId).with(csrf())) + .andDo(print()).andExpect(status().isNoContent()); + } + + @Test + @DisplayName("플레이리스트 아이템 삭제 - 실패 (아이템 없음)") + void removeContent_fail_PlaylistItemNotFoundException() throws Exception { + // given + willThrow(new PlaylistItemNotFoundException(playlistId, contentId)) + .given(playlistService) + .removeContent(UUID.fromString("11111111-1111-1111-1111-111111111111"), playlistId, contentId); + + // when & then + mockMvc.perform(delete("/api/playlists/{playlistId}/contents/{contentId}", + playlistId, contentId).with(csrf())) + .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/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..9ee87b8a --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistQueryRepositoryTest.java @@ -0,0 +1,206 @@ +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.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; + +class PlaylistQueryRepositoryTest extends BaseRepositoryTest { + + @Autowired + private PlaylistRepository playlistRepository; + + @Autowired + private PlaylistQueryRepositoryImpl playlistQueryRepository; + + @Autowired + private PlaylistItemRepository playlistItemRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ContentRepository contentRepository; + + @Autowired + private SubscriptionRepository subscriptionRepository; + + 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(), user.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"); + 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 + @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, user.getId()); + ensureQueryCount(2); // 1. playlist fetch, 2. playlistItem + content fetch + + // 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 + @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() { + // 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..43ed2cdd --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/playlist/repository/PlaylistRepositoryTest.java @@ -0,0 +1,70 @@ +package com.codeit.team5.mopl.playlist.repository; + +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; +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.existsByIdAndOwnerId(playlist.getId(), user.getId()); + + // then + assertThat(exists).isTrue(); + ensureQueryCount(1); + + queryInspector.clear(); + boolean notExists = playlistRepository.existsByIdAndOwnerId(playlist.getId(), UUID.randomUUID()); + ensureQueryCount(1); + + // then + 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..af41a02e --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/playlist/service/PlaylistServiceTest.java @@ -0,0 +1,309 @@ +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.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; +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.findWithProfileImageById(user.getId())).willReturn(Optional.of(user)); + + // when + PlaylistResponse response = playlistService.create(user.getId(), request); + + // then + assertThat(response).isNotNull(); + assertThat(response.title()).isEqualTo("New Title"); + assertThat(response.description()).isEqualTo("New Description"); + + verify(playlistRepository).save(any(Playlist.class)); + } + + @Test + @DisplayName("플레이리스트 생성 실패 - 유저를 찾을 수 없음") + void create_fail_userNotFound() { + // given + PlaylistCreateRequest request = new PlaylistCreateRequest("Title", "Desc"); + given(userRepository.findWithProfileImageById(org.mockito.ArgumentMatchers.any())).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> playlistService.create(UUID.randomUUID(), request)) + .isInstanceOf(PlaylistUserNotFoundException.class); + } + + @Test + @DisplayName("플레이리스트 단건 조회 성공") + void find_success() { + // given + 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(), user.getId()); + + // then + assertThat(response).isNotNull(); + 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() { + // given + UUID id = UUID.randomUUID(); + given(playlistRepository.findByIdWithContents(id, user.getId())).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> playlistService.find(id, user.getId())) + .isInstanceOf(PlaylistNotFoundException.class); + } + + @Test + @DisplayName("플레이리스트 수정 성공") + void update_success() { + // given + PlaylistUpdateRequest request = new PlaylistUpdateRequest("Updated Title", "Updated Desc"); + given(playlistRepository.existsByIdAndOwnerId(playlist.getId(), user.getId())) + .willReturn(true); + 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.getId(), 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.existsByIdAndOwnerId(org.mockito.ArgumentMatchers.eq(playlist.getId()), org.mockito.ArgumentMatchers.any(UUID.class))) + .willReturn(false); + + // when & then + assertThatThrownBy( + () -> playlistService.update(playlist.getId(), UUID.randomUUID(), request)) + .isInstanceOf(PlaylistAccessDeniedException.class); + } + + @Test + @DisplayName("플레이리스트 삭제 성공") + void delete_success() { + // given + given(playlistRepository.existsByIdAndOwnerId(playlist.getId(), user.getId())) + .willReturn(true); + + // when + playlistService.delete(playlist.getId(), user.getId()); + + // then + verify(playlistRepository).deleteByIdDirectly(playlist.getId()); + } + + @Test + @DisplayName("플레이리스트 삭제 실패 - 권한 없음") + void delete_fail_accessDenied() { + // given + given(playlistRepository.existsByIdAndOwnerId(org.mockito.ArgumentMatchers.eq(playlist.getId()), org.mockito.ArgumentMatchers.any(UUID.class))) + .willReturn(false); + + // when & then + assertThatThrownBy(() -> playlistService.delete(playlist.getId(), UUID.randomUUID())) + .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.existsByIdAndOwnerId(playlist.getId(), user.getId())) + .willReturn(true); + given(contentRepository.existsById(contentId)).willReturn(true); + given(contentRepository.getReferenceById(contentId)).willReturn(content); + + // when + playlistService.addContent(user.getId(), playlist.getId(), contentId); + + // then + verify(playlistItemRepository).save(any(PlaylistItem.class)); + } + + @Test + @DisplayName("콘텐츠 삭제 성공") + void removeContent_success() { + // given + UUID contentId = UUID.randomUUID(); + given(playlistRepository.existsByIdAndOwnerId(playlist.getId(), user.getId())) + .willReturn(true); + given(playlistItemRepository.existsByPlaylistIdAndContentId(playlist.getId(), contentId)) + .willReturn(true); + + // when + playlistService.removeContent(user.getId(), playlist.getId(), contentId); + + // then + verify(playlistItemRepository).deleteByPlaylistIdAndContentIdDirectly(playlist.getId(), + contentId); + } + + @Test + @DisplayName("콘텐츠 추가 실패 - 권한 없음") + void addContent_fail_accessDenied() { + // given + UUID contentId = UUID.randomUUID(); + given(playlistRepository.existsByIdAndOwnerId(org.mockito.ArgumentMatchers.eq(playlist.getId()), org.mockito.ArgumentMatchers.any(UUID.class))) + .willReturn(false); + + // when & then + assertThatThrownBy(() -> playlistService.addContent(UUID.randomUUID(), playlist.getId(), contentId)) + .isInstanceOf(PlaylistAccessDeniedException.class); + } + + @Test + @DisplayName("콘텐츠 추가 실패 - 콘텐츠를 찾을 수 없음") + void addContent_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.addContent(user.getId(), playlist.getId(), contentId)) + .isInstanceOf(PlaylistContentNotFoundException.class); + } + + @Test + @DisplayName("콘텐츠 삭제 실패 - 권한 없음") + void removeContent_fail_accessDenied() { + // given + UUID contentId = UUID.randomUUID(); + given(playlistRepository.existsByIdAndOwnerId(org.mockito.ArgumentMatchers.eq(playlist.getId()), org.mockito.ArgumentMatchers.any(UUID.class))) + .willReturn(false); + + // when & then + assertThatThrownBy(() -> playlistService.removeContent(UUID.randomUUID(), playlist.getId(), contentId)) + .isInstanceOf(PlaylistAccessDeniedException.class); + } + + + @Test + @DisplayName("콘텐츠 삭제 실패 - 플레이리스트에 해당 콘텐츠가 없음") + void removeContent_fail_playlistItemNotFound() { + // given + UUID contentId = UUID.randomUUID(); + given(playlistRepository.existsByIdAndOwnerId(playlist.getId(), user.getId())) + .willReturn(true); + given(playlistItemRepository.existsByPlaylistIdAndContentId(playlist.getId(), contentId)) + .willReturn(false); + + // when & then + assertThatThrownBy(() -> playlistService.removeContent(user.getId(), playlist.getId(), contentId)) + .isInstanceOf(PlaylistItemNotFoundException.class); + } +} 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..45d10450 --- /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 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); + } +}