diff --git a/build.gradle b/build.gradle index 21ce9789..5df959ba 100644 --- a/build.gradle +++ b/build.gradle @@ -28,7 +28,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-database-postgresql' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.17' implementation 'org.mapstruct:mapstruct:1.6.3' // Spring security implementation 'org.springframework.boot:spring-boot-starter-security' diff --git a/src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java b/src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java new file mode 100644 index 00000000..dcacec44 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java @@ -0,0 +1,37 @@ +package com.codeit.team5.mopl.content.controller; + +import com.codeit.team5.mopl.content.controller.api.ContentApi; +import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; +import com.codeit.team5.mopl.content.dto.response.ContentResponse; +import com.codeit.team5.mopl.content.service.ContentService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/contents") +@RequiredArgsConstructor +@Slf4j +public class ContentController implements ContentApi { + + private final ContentService contentService; + + @Override + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity postContent( + @Valid @RequestPart("request") ContentCreateRequest request, + @RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail + ) { + log.info("Content Create request: POST /api/contents"); + ContentResponse response = contentService.create(request, thumbnail); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/controller/api/ContentApi.java b/src/main/java/com/codeit/team5/mopl/content/controller/api/ContentApi.java new file mode 100644 index 00000000..fa59c6ce --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/controller/api/ContentApi.java @@ -0,0 +1,53 @@ +package com.codeit.team5.mopl.content.controller.api; + +import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; +import com.codeit.team5.mopl.content.dto.response.ContentResponse; +import com.codeit.team5.mopl.global.dto.ErrorResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Encoding; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "콘텐츠 관리", description = "콘텐츠 관련 API") +public interface ContentApi { + + @Schema(name = "ContentCreateMultipartRequest") + class MultipartRequest { + @Schema(implementation = ContentCreateRequest.class) + public ContentCreateRequest request; + + @Schema(type = "string", format = "binary", description = "썸네일 이미지") + public MultipartFile thumbnail; + } + + @Operation(summary = "[어드민] 콘텐츠 생성", description = "새로운 콘텐츠를 등록합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "성공", + content = @Content(schema = @Schema(implementation = ContentResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "403", description = "권한 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @RequestBody(content = @Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + schema = @Schema(implementation = MultipartRequest.class), + encoding = @Encoding(name = "request", contentType = MediaType.APPLICATION_JSON_VALUE) + )) + ResponseEntity postContent( + @Parameter(hidden = true) ContentCreateRequest request, + @Parameter(hidden = true) MultipartFile thumbnail + ); +} \ No newline at end of file diff --git a/src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCreateRequest.java b/src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCreateRequest.java new file mode 100644 index 00000000..09e8c4ed --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCreateRequest.java @@ -0,0 +1,24 @@ +package com.codeit.team5.mopl.content.dto.request; + +import com.codeit.team5.mopl.content.entity.ContentType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record ContentCreateRequest( + @NotNull(message = "콘텐츠 타입은 필수입니다.") + ContentType type, + + @NotBlank(message = "제목은 필수입니다.") + @Size(max = 500, message = "제목은 500자를 초과할 수 없습니다.") + String title, + + String description, + + @NotNull(message = "콘텐츠 태그는 필수 입니다.") + @NotEmpty(message = "콘텐츠 태그 목록은 비어있을 수 없습니다.") + List<@NotBlank(message = "태그는 공백일 수 없습니다.") String> tags +) { +} diff --git a/src/main/java/com/codeit/team5/mopl/content/dto/response/ContentResponse.java b/src/main/java/com/codeit/team5/mopl/content/dto/response/ContentResponse.java new file mode 100644 index 00000000..e560a406 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/dto/response/ContentResponse.java @@ -0,0 +1,18 @@ +package com.codeit.team5.mopl.content.dto.response; + +import com.codeit.team5.mopl.content.entity.ContentType; +import java.util.List; +import java.util.UUID; + +public record ContentResponse( + UUID id, + ContentType type, + String title, + String description, + String thumbnailUrl, + List tags, + double averageRating, + int reviewCount, + long watcherCount +) { +} diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java index fb8939f9..5a79a747 100644 --- a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java +++ b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java @@ -1,38 +1,51 @@ package com.codeit.team5.mopl.content.entity; +import com.codeit.team5.mopl.content.exception.InvalidContentSourceException; import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; +import com.codeit.team5.mopl.tag.entity.Tag; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; +import jakarta.persistence.UniqueConstraint; import java.time.Instant; -import java.util.Map; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import lombok.AccessLevel; import lombok.Getter; -import lombok.Setter; +import lombok.NoArgsConstructor; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; @Getter -@Setter @Entity -@Table(name = "contents") +@Table( + name = "contents", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_contents_source_external_id", + columnNames = {"source", "external_id"} + ) + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Content extends BaseUpdatableEntity { - @Size(max = 20) - @NotNull - @Column(name = "type", nullable = false, length = 20) - private String type; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ContentType type; - @Size(max = 500) - @NotNull - @Column(name = "title", nullable = false, length = 500) + @Column(nullable = false, length = 500) private String title; - @Column(name = "description", length = Integer.MAX_VALUE) + @Column(columnDefinition = "TEXT") private String description; - @Size(max = 512) @Column(name = "thumbnail_url", length = 512) private String thumbnailUrl; @@ -40,20 +53,64 @@ public class Content extends BaseUpdatableEntity { private Instant releasedAt; @JdbcTypeCode(SqlTypes.JSON) - @Column(name = "metadata") - private Map metadata; + @Column(columnDefinition = "JSONB") + private String metadata; - @Size(max = 20) - @NotNull - @Column(name = "source", nullable = false, length = 20) - private String source; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ContentSource source; - @Size(max = 100) - @NotNull @Column(name = "external_id", nullable = false, length = 100) private String externalId; - @NotNull - @Column(name = "updated_at", nullable = false) - private Instant updatedAt; + @OneToMany(mappedBy = "content", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private List contentTags = new ArrayList<>(); + + public static Content createByAdmin(ContentType type, String title, String description) { + Content content = new Content(); + content.type = type; + content.title = title; + content.description = description; + content.source = ContentSource.ADMIN; + content.externalId = UUID.randomUUID().toString(); + return content; + } + + public static Content createByExternalSource(ContentType type, String title, String description, + ContentSource source, String externalId, Instant releasedAt, String metadata) { + if (source == null || source == ContentSource.ADMIN) { + throw new InvalidContentSourceException("외부 소스 생성에 ADMIN 소스는 사용할 수 없습니다."); + } + if (externalId == null || externalId.isBlank()) { + throw new InvalidContentSourceException("외부 소스 콘텐츠는 externalId가 필수입니다."); + } + Content content = new Content(); + content.type = type; + content.title = title; + content.description = description; + content.source = source; + content.externalId = externalId; + content.releasedAt = releasedAt; + content.metadata = metadata; + return content; + } + + public void addTag(Tag tag) { + if (tag == null) { + return; + } + + boolean alreadyExists = this.contentTags.stream() + .anyMatch(ct -> { + Tag existing = ct.getTag(); + if (existing.getId() != null && tag.getId() != null) { + return existing.getId().equals(tag.getId()); + } + return existing.getName().equals(tag.getName()); + }); + + if (!alreadyExists) { + contentTags.add(ContentTag.create(this, tag)); + } + } } diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/ContentSource.java b/src/main/java/com/codeit/team5/mopl/content/entity/ContentSource.java new file mode 100644 index 00000000..81f931c2 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/entity/ContentSource.java @@ -0,0 +1,7 @@ +package com.codeit.team5.mopl.content.entity; + +public enum ContentSource { + TMDB, + SPORTS_DB, + ADMIN +} diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/ContentStats.java b/src/main/java/com/codeit/team5/mopl/content/entity/ContentStats.java new file mode 100644 index 00000000..0ea24d58 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/entity/ContentStats.java @@ -0,0 +1,58 @@ +package com.codeit.team5.mopl.content.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "content_stats") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ContentStats { + + @Id + private UUID contentId; + + @MapsId + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "content_id") + private Content content; + + @Column(nullable = false) + private int reviewCount; + + @Column(nullable = false) + private double ratingSum; + + @Column(nullable = false) + private long watcherCount; + + @Column(nullable = false) + private Instant updatedAt; + + @PreUpdate + protected void onUpdate() { + this.updatedAt = Instant.now(); + } + + public static ContentStats create(Content content) { + ContentStats stats = new ContentStats(); + stats.content = content; + stats.reviewCount = 0; + stats.ratingSum = 0.0; + stats.watcherCount = 0; + stats.updatedAt = Instant.now(); + return stats; + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java b/src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java new file mode 100644 index 00000000..9c517f36 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java @@ -0,0 +1,60 @@ +package com.codeit.team5.mopl.content.entity; + +import com.codeit.team5.mopl.tag.entity.Tag; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; +import jakarta.persistence.Table; +import java.io.Serializable; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "content_tags") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ContentTag { + + @EmbeddedId + private ContentTagId id; + + @MapsId("contentId") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "content_id") + private Content content; + + @MapsId("tagId") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "tag_id") + private Tag tag; + + public static ContentTag create(Content content, Tag tag) { + ContentTag contentTag = new ContentTag(); + contentTag.id = new ContentTagId(); + contentTag.content = content; + contentTag.tag = tag; + return contentTag; + } + + @Embeddable + @Getter + @EqualsAndHashCode + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class ContentTagId implements Serializable { + + private UUID contentId; + private UUID tagId; + + public ContentTagId(UUID contentId, UUID tagId) { + this.contentId = contentId; + this.tagId = tagId; + } + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/ContentType.java b/src/main/java/com/codeit/team5/mopl/content/entity/ContentType.java new file mode 100644 index 00000000..12c5782e --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/entity/ContentType.java @@ -0,0 +1,32 @@ +package com.codeit.team5.mopl.content.entity; + +import com.codeit.team5.mopl.content.exception.InvalidContentTypeException; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum ContentType { + MOVIE("movie"), + TV_SERIES("tvSeries"), + SPORT("sport"); + + private final String response; + + ContentType(String response) { + this.response = response; + } + + @JsonValue + public String getResponse() { + return response; + } + + @JsonCreator + public static ContentType from(String value) { + for (ContentType type : values()) { + if (type.response.equals(value)) { + return type; + } + } + throw new InvalidContentTypeException(value); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/exception/ContentException.java b/src/main/java/com/codeit/team5/mopl/content/exception/ContentException.java new file mode 100644 index 00000000..2a40ff53 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/exception/ContentException.java @@ -0,0 +1,15 @@ +package com.codeit.team5.mopl.content.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import com.codeit.team5.mopl.global.exception.ErrorCode; + +public class ContentException extends BusinessException { + + public ContentException(ErrorCode errorCode) { + super(errorCode); + } + + public ContentException(ErrorCode errorCode, String detailMessage) { + super(errorCode, detailMessage); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/exception/ContentNotFoundException.java b/src/main/java/com/codeit/team5/mopl/content/exception/ContentNotFoundException.java new file mode 100644 index 00000000..db88b08b --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/exception/ContentNotFoundException.java @@ -0,0 +1,10 @@ +package com.codeit.team5.mopl.content.exception; + +import com.codeit.team5.mopl.global.exception.ErrorCode; + +public class ContentNotFoundException extends ContentException { + + public ContentNotFoundException() { + super(ErrorCode.CONTENT_NOT_FOUND); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentSourceException.java b/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentSourceException.java new file mode 100644 index 00000000..199f3b5a --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentSourceException.java @@ -0,0 +1,10 @@ +package com.codeit.team5.mopl.content.exception; + +import com.codeit.team5.mopl.global.exception.ErrorCode; + +public class InvalidContentSourceException extends ContentException { + + public InvalidContentSourceException(String detailMessage) { + super(ErrorCode.INVALID_CONTENT_SOURCE, detailMessage); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentTypeException.java b/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentTypeException.java new file mode 100644 index 00000000..d751c603 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentTypeException.java @@ -0,0 +1,10 @@ +package com.codeit.team5.mopl.content.exception; + +import com.codeit.team5.mopl.global.exception.ErrorCode; + +public class InvalidContentTypeException extends ContentException { + + public InvalidContentTypeException(String value) { + super(ErrorCode.INVALID_INPUT, "유효하지 않은 콘텐츠 타입: " + value); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java new file mode 100644 index 00000000..eec4d3c2 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java @@ -0,0 +1,32 @@ +package com.codeit.team5.mopl.content.mapper; + +import com.codeit.team5.mopl.content.dto.response.ContentResponse; +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentStats; +import com.codeit.team5.mopl.content.entity.ContentTag; +import java.util.Collections; +import java.util.List; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface ContentMapper { + + @Mapping(target = "tags", source = "contentTags") + @Mapping(target = "averageRating", source = "stats") + @Mapping(target = "reviewCount", source = "stats.reviewCount") + @Mapping(target = "watcherCount", source = "stats.watcherCount") + ContentResponse toDto(Content content, List contentTags, ContentStats stats); + + default List toTagNames(List contentTags) { + if (contentTags == null) return Collections.emptyList(); + return contentTags.stream() + .map(ct -> ct.getTag().getName()) + .toList(); + } + + default double toAverageRating(ContentStats stats) { + if (stats == null || stats.getReviewCount() == 0) return 0.0; + return stats.getRatingSum() / stats.getReviewCount(); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java b/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java index 7dd60d08..9b70a238 100644 --- a/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java +++ b/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java @@ -5,5 +5,4 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface ContentRepository extends JpaRepository { - } diff --git a/src/main/java/com/codeit/team5/mopl/content/repository/ContentStatsRepository.java b/src/main/java/com/codeit/team5/mopl/content/repository/ContentStatsRepository.java new file mode 100644 index 00000000..014b5185 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/repository/ContentStatsRepository.java @@ -0,0 +1,8 @@ +package com.codeit.team5.mopl.content.repository; + +import com.codeit.team5.mopl.content.entity.ContentStats; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ContentStatsRepository extends JpaRepository { +} diff --git a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java new file mode 100644 index 00000000..afc42a7b --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java @@ -0,0 +1,74 @@ +package com.codeit.team5.mopl.content.service; + +import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; +import com.codeit.team5.mopl.content.dto.response.ContentResponse; +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentStats; +import com.codeit.team5.mopl.content.exception.ContentException; +import com.codeit.team5.mopl.content.mapper.ContentMapper; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.codeit.team5.mopl.content.repository.ContentStatsRepository; +import com.codeit.team5.mopl.global.exception.ErrorCode; +import com.codeit.team5.mopl.tag.entity.Tag; +import com.codeit.team5.mopl.tag.repository.TagRepository; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class ContentService { + + private final ContentRepository contentRepository; + private final ContentStatsRepository contentStatsRepository; + private final TagRepository tagRepository; + private final ContentMapper contentMapper; + + @Transactional + public ContentResponse create(ContentCreateRequest request, MultipartFile thumbnail) { + Content content = contentRepository.save(Content.createByAdmin( + request.type(), + request.title(), + request.description() + )); + + // todo 썸네일 스토리지 업로드 후 URL 반영 + + List tagNames = request.tags().stream() + .map(String::trim) + .filter(tag -> !tag.isEmpty()) + .map(String::toLowerCase) + .distinct() + .toList(); + + if (tagNames.isEmpty()) { + throw new ContentException(ErrorCode.INVALID_INPUT, "정규화 후 유효한 태그가 없습니다."); + } + + Map existingTags = tagRepository.findByNameIn(tagNames).stream() + .collect(Collectors.toMap(Tag::getName, Function.identity())); + + List newTags = tagNames.stream() + .filter(name -> !existingTags.containsKey(name)) + .map(Tag::create) + .toList(); + + if (!newTags.isEmpty()) { + tagRepository.saveAll(newTags).forEach(tag -> existingTags.put(tag.getName(), tag)); + } + + tagNames.forEach(name -> content.addTag(existingTags.get(name))); + + ContentStats stats = contentStatsRepository.save(ContentStats.create(content)); + + return contentMapper.toDto(content, content.getContentTags(), stats); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/global/exception/ErrorCode.java b/src/main/java/com/codeit/team5/mopl/global/exception/ErrorCode.java index a800a881..5f449b90 100644 --- a/src/main/java/com/codeit/team5/mopl/global/exception/ErrorCode.java +++ b/src/main/java/com/codeit/team5/mopl/global/exception/ErrorCode.java @@ -28,6 +28,10 @@ public enum ErrorCode { // Content CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "콘텐츠를 찾을 수 없습니다."), + INVALID_CONTENT_SOURCE(HttpStatus.BAD_REQUEST, "유효하지 않은 콘텐츠 소스입니다."), + + // Tag + INVALID_TAG_NAME(HttpStatus.BAD_REQUEST, "유효하지 않은 태그 이름입니다."), // Review REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다."), diff --git a/src/main/java/com/codeit/team5/mopl/tag/entity/Tag.java b/src/main/java/com/codeit/team5/mopl/tag/entity/Tag.java new file mode 100644 index 00000000..6ef041a3 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/tag/entity/Tag.java @@ -0,0 +1,39 @@ +package com.codeit.team5.mopl.tag.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import com.codeit.team5.mopl.tag.exception.InvalidTagNameException; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "tags") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Tag { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, unique = true, length = 50) + private String name; + + public static Tag create(String name) { + if (name == null || name.isBlank()) { + throw new InvalidTagNameException("태그 이름은 필수입니다."); + } + if (name.length() > 50) { + throw new InvalidTagNameException("태그 이름은 50자를 초과할 수 없습니다."); + } + Tag tag = new Tag(); + tag.name = name; + return tag; + } +} diff --git a/src/main/java/com/codeit/team5/mopl/tag/exception/InvalidTagNameException.java b/src/main/java/com/codeit/team5/mopl/tag/exception/InvalidTagNameException.java new file mode 100644 index 00000000..3d8a13bf --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/tag/exception/InvalidTagNameException.java @@ -0,0 +1,10 @@ +package com.codeit.team5.mopl.tag.exception; + +import com.codeit.team5.mopl.global.exception.ErrorCode; + +public class InvalidTagNameException extends TagException { + + public InvalidTagNameException(String detailMessage) { + super(ErrorCode.INVALID_TAG_NAME, detailMessage); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/tag/exception/TagException.java b/src/main/java/com/codeit/team5/mopl/tag/exception/TagException.java new file mode 100644 index 00000000..a313bcf6 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/tag/exception/TagException.java @@ -0,0 +1,15 @@ +package com.codeit.team5.mopl.tag.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import com.codeit.team5.mopl.global.exception.ErrorCode; + +public class TagException extends BusinessException { + + public TagException(ErrorCode errorCode) { + super(errorCode); + } + + public TagException(ErrorCode errorCode, String detailMessage) { + super(errorCode, detailMessage); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/tag/repository/TagRepository.java b/src/main/java/com/codeit/team5/mopl/tag/repository/TagRepository.java new file mode 100644 index 00000000..d7376680 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/tag/repository/TagRepository.java @@ -0,0 +1,14 @@ +package com.codeit.team5.mopl.tag.repository; + +import com.codeit.team5.mopl.tag.entity.Tag; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TagRepository extends JpaRepository { + + Optional findByName(String name); + + List findByNameIn(List names); +} diff --git a/src/main/resources/db/migration/V3__modify_watcher_count_type.sql b/src/main/resources/db/migration/V3__modify_watcher_count_type.sql new file mode 100644 index 00000000..ab131756 --- /dev/null +++ b/src/main/resources/db/migration/V3__modify_watcher_count_type.sql @@ -0,0 +1,2 @@ +ALTER TABLE content_stats +ALTER COLUMN watcher_count TYPE BIGINT; \ No newline at end of file diff --git a/src/main/resources/db/migration/V4__add_admin_content_source.sql b/src/main/resources/db/migration/V4__add_admin_content_source.sql new file mode 100644 index 00000000..7a28e929 --- /dev/null +++ b/src/main/resources/db/migration/V4__add_admin_content_source.sql @@ -0,0 +1,5 @@ +ALTER TABLE contents + DROP CONSTRAINT ck_contents_source; + +ALTER TABLE contents + ADD CONSTRAINT ck_contents_source CHECK (source IN ('TMDB', 'SPORTS_DB', 'ADMIN')); diff --git a/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java b/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java new file mode 100644 index 00000000..ae47dd36 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java @@ -0,0 +1,259 @@ +package com.codeit.team5.mopl.content.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.codeit.team5.mopl.config.SecurityConfig; +import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; +import com.codeit.team5.mopl.content.dto.response.ContentResponse; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.service.ContentService; +import com.codeit.team5.mopl.global.exception.GlobalExceptionHandler; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.multipart.MultipartFile; + +@WebMvcTest(ContentController.class) +@Import({GlobalExceptionHandler.class, SecurityConfig.class}) +class ContentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ContentService contentService; + + @Captor + private ArgumentCaptor requestCaptor; + + @Test + @DisplayName("정상적인 콘텐츠 생성 요청이면 생성된 콘텐츠와 201 응답을 반환한다") + void postContent_success() throws Exception { + // Given + ContentCreateRequest request = new ContentCreateRequest( + ContentType.MOVIE, + "테스트 영화", + "테스트 설명", + List.of("액션", "드라마") + ); + ContentResponse response = new ContentResponse( + UUID.fromString("11111111-1111-1111-1111-111111111111"), + ContentType.MOVIE, + "테스트 영화", + "테스트 설명", + null, + List.of("액션", "드라마"), + 0.0, 0, 0 + ); + + given(contentService.create(any(ContentCreateRequest.class), any())).willReturn(response); + + MockMultipartFile requestPart = new MockMultipartFile( + "request", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + MockMultipartFile thumbnailPart = new MockMultipartFile( + "thumbnail", + "test.jpg", + MediaType.IMAGE_JPEG_VALUE, + new byte[]{1, 2, 3} + ); + + // When & Then + mockMvc.perform(multipart("/api/contents") + .file(requestPart) + .file(thumbnailPart)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(response.id().toString())) + .andExpect(jsonPath("$.type").value("movie")) + .andExpect(jsonPath("$.title").value("테스트 영화")) + .andExpect(jsonPath("$.description").value("테스트 설명")) + .andExpect(jsonPath("$.tags[0]").value("액션")) + .andExpect(jsonPath("$.tags[1]").value("드라마")) + .andExpect(jsonPath("$.averageRating").value(0.0)) + .andExpect(jsonPath("$.reviewCount").value(0)) + .andExpect(jsonPath("$.watcherCount").value(0)); + + verify(contentService).create(requestCaptor.capture(), any(MultipartFile.class)); + ContentCreateRequest captured = requestCaptor.getValue(); + assertThat(captured.type()).isEqualTo(ContentType.MOVIE); + assertThat(captured.title()).isEqualTo("테스트 영화"); + assertThat(captured.description()).isEqualTo("테스트 설명"); + assertThat(captured.tags()).containsExactly("액션", "드라마"); + } + + //todo 현재는 썸네일 처리 로직이 없기에 썸네일이 있는 테스트 코드와 다르지 않음 + @Test + @DisplayName("thumbnail 없이 콘텐츠 생성 요청이면 201 응답을 반환한다") + void postContent_withoutThumbnail_success() throws Exception { + // Given + ContentCreateRequest request = new ContentCreateRequest( + ContentType.TV_SERIES, + "테스트 드라마", + "테스트 설명", + List.of("로맨스") + ); + ContentResponse response = new ContentResponse( + UUID.fromString("22222222-2222-2222-2222-222222222222"), + ContentType.TV_SERIES, + "테스트 드라마", + "테스트 설명", + null, + List.of("로맨스"), + 0.0, 0, 0 + ); + + given(contentService.create(any(ContentCreateRequest.class), isNull())).willReturn(response); + + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + // When & Then + mockMvc.perform(multipart("/api/contents") + .file(requestPart)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(response.id().toString())) + .andExpect(jsonPath("$.type").value("tvSeries")) + .andExpect(jsonPath("$.title").value("테스트 드라마")) + .andExpect(jsonPath("$.description").value("테스트 설명")) + .andExpect(jsonPath("$.tags[0]").value("로맨스")) + .andExpect(jsonPath("$.averageRating").value(0.0)) + .andExpect(jsonPath("$.reviewCount").value(0)) + .andExpect(jsonPath("$.watcherCount").value(0)); + verify(contentService).create(requestCaptor.capture(), isNull()); + ContentCreateRequest captured = requestCaptor.getValue(); + assertThat(captured.type()).isEqualTo(ContentType.TV_SERIES); + assertThat(captured.title()).isEqualTo("테스트 드라마"); + assertThat(captured.description()).isEqualTo("테스트 설명"); + assertThat(captured.tags()).containsExactly("로맨스"); + } + + @Test + @DisplayName("콘텐츠 타입이 누락되면 400 검증 실패 응답을 반환한다") + void postContent_missingType_returnsBadRequest() throws Exception { + // Given + String requestJson = """ + { + "title": "테스트 영화", + "tags": ["액션"] + } + """; + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, requestJson.getBytes() + ); + + // When & Then + mockMvc.perform(multipart("/api/contents") + .file(requestPart)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionName").value("INVALID_INPUT")) + .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) + .andExpect(jsonPath("$.details.type[0]").value("콘텐츠 타입은 필수입니다.")); + + verify(contentService, never()).create(any(), any()); + } + + @Test + @DisplayName("제목이 공백이면 400 검증 실패 응답을 반환한다") + void postContent_blankTitle_returnsBadRequest() throws Exception { + // Given + ContentCreateRequest request = new ContentCreateRequest( + ContentType.MOVIE, + " ", + null, + List.of("액션") + ); + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + // When & Then + mockMvc.perform(multipart("/api/contents") + .file(requestPart)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionName").value("INVALID_INPUT")) + .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) + .andExpect(jsonPath("$.details.title[0]").value("제목은 필수입니다.")); + + verify(contentService, never()).create(any(), any()); + } + + @Test + @DisplayName("태그 목록이 비어있으면 400 검증 실패 응답을 반환한다") + void postContent_emptyTags_returnsBadRequest() throws Exception { + // Given + ContentCreateRequest request = new ContentCreateRequest( + ContentType.MOVIE, + "테스트 영화", + null, + List.of() + ); + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + // When & Then + mockMvc.perform(multipart("/api/contents") + .file(requestPart)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionName").value("INVALID_INPUT")) + .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) + .andExpect(jsonPath("$.details.tags[0]").value("콘텐츠 태그 목록은 비어있을 수 없습니다.")); + + verify(contentService, never()).create(any(), any()); + } + + @Test + @DisplayName("예상하지 못한 서비스 예외가 발생하면 500 오류 응답을 반환한다") + void postContent_unexpectedException_returnsInternalServerError() throws Exception { + // Given + ContentCreateRequest request = new ContentCreateRequest( + ContentType.MOVIE, + "테스트 영화", + null, + List.of("액션") + ); + given(contentService.create(any(ContentCreateRequest.class), any())) + .willThrow(new IllegalStateException("unexpected")); + + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + // When & Then + mockMvc.perform(multipart("/api/contents") + .file(requestPart)) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.exceptionName").value("INTERNAL_SERVER_ERROR")) + .andExpect(jsonPath("$.message").value("서버 내부 오류가 발생했습니다.")); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java b/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java new file mode 100644 index 00000000..2a472e4b --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java @@ -0,0 +1,259 @@ +package com.codeit.team5.mopl.content.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.AdditionalAnswers.returnsFirstArg; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; +import com.codeit.team5.mopl.content.dto.response.ContentResponse; +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentStats; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.exception.ContentException; +import com.codeit.team5.mopl.content.mapper.ContentMapper; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.codeit.team5.mopl.content.repository.ContentStatsRepository; +import com.codeit.team5.mopl.tag.entity.Tag; +import com.codeit.team5.mopl.tag.repository.TagRepository; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@ExtendWith(MockitoExtension.class) +class ContentServiceTest { + + @Mock + private ContentRepository contentRepository; + + @Mock + private ContentStatsRepository contentStatsRepository; + + @Mock + private TagRepository tagRepository; + + @Mock + private ContentMapper contentMapper; + + @InjectMocks + private ContentService contentService; + + @Test + @DisplayName("콘텐츠 생성에 성공한다") + void create_success() { + // Given + ContentCreateRequest request = new ContentCreateRequest( + ContentType.MOVIE, + "테스트 영화", + "테스트 설명", + List.of("액션", "드라마") + ); + MultipartFile thumbnail = new MockMultipartFile("thumbnail", "test.jpg", "image/jpeg", new byte[]{1, 2, 3}); + + Tag actionTag = Tag.create("액션"); + Tag dramaTag = Tag.create("드라마"); + ContentResponse expectedResponse = new ContentResponse( + UUID.randomUUID(), + ContentType.MOVIE, + "테스트 영화", + "테스트 설명", + null, + List.of("액션", "드라마"), + 0.0, 0, 0 + ); + + when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); + when(tagRepository.findByNameIn(List.of("액션", "드라마"))).thenReturn(List.of(actionTag)); + when(tagRepository.saveAll(anyList())).thenReturn(List.of(dramaTag)); + when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); + when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(expectedResponse); + + // When + ContentResponse result = contentService.create(request, thumbnail); + + // Then + assertThat(result).isSameAs(expectedResponse); + + ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(Content.class); + verify(contentRepository).save(contentCaptor.capture()); + Content savedContent = contentCaptor.getValue(); + assertThat(savedContent.getType()).isEqualTo(ContentType.MOVIE); + assertThat(savedContent.getTitle()).isEqualTo("테스트 영화"); + assertThat(savedContent.getDescription()).isEqualTo("테스트 설명"); + + verify(tagRepository).findByNameIn(List.of("액션", "드라마")); + ArgumentCaptor> saveAllCaptor = ArgumentCaptor.forClass(List.class); + verify(tagRepository).saveAll(saveAllCaptor.capture()); + assertThat(saveAllCaptor.getValue()).hasSize(1); + verify(contentStatsRepository).save(any(ContentStats.class)); + } + + @Test + @DisplayName("thumbnail이 null이어도 콘텐츠 생성에 성공한다") + void create_withoutThumbnail_success() { + // Given + ContentCreateRequest request = new ContentCreateRequest( + ContentType.TV_SERIES, + "테스트 드라마", + null, + List.of("로맨스") + ); + Tag romanceTag = Tag.create("로맨스"); + ContentResponse expectedResponse = new ContentResponse( + UUID.randomUUID(), + ContentType.TV_SERIES, + "테스트 드라마", + null, + null, + List.of("로맨스"), + 0.0, 0, 0 + ); + + when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); + when(tagRepository.findByNameIn(List.of("로맨스"))).thenReturn(List.of(romanceTag)); + when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); + when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(expectedResponse); + + // When + ContentResponse result = contentService.create(request, null); + + // Then + assertThat(result).isSameAs(expectedResponse); + verify(contentRepository).save(any(Content.class)); + verify(contentStatsRepository).save(any(ContentStats.class)); + } + + @Test + @DisplayName("이미 존재하는 태그는 새로 저장하지 않는다") + void create_existingTag_doesNotSaveNewTag() { + // Given + ContentCreateRequest request = new ContentCreateRequest( + ContentType.MOVIE, + "테스트 영화", + null, + List.of("액션") + ); + Tag existingTag = Tag.create("액션"); + + when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); + when(tagRepository.findByNameIn(List.of("액션"))).thenReturn(List.of(existingTag)); + when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); + when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(null); + + // When + contentService.create(request, null); + + // Then + verify(tagRepository, never()).saveAll(anyList()); + } + + @Test + @DisplayName("존재하지 않는 태그는 새로 저장한다") + void create_newTag_savesTag() { + // Given + ContentCreateRequest request = new ContentCreateRequest( + ContentType.MOVIE, + "테스트 영화", + null, + List.of("새태그1", "새태그2") + ); + + when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); + when(tagRepository.findByNameIn(anyList())).thenReturn(List.of()); + when(tagRepository.saveAll(anyList())).then(returnsFirstArg()); + when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); + when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(null); + + // When + contentService.create(request, null); + + // Then + ArgumentCaptor> saveAllCaptor = ArgumentCaptor.forClass(List.class); + verify(tagRepository).saveAll(saveAllCaptor.capture()); + assertThat(saveAllCaptor.getValue()).hasSize(2); + } + + @Test + @DisplayName("중복된 태그가 요청에 포함되어도 한 번만 저장한다") + void create_duplicateTags_savesOnce() { + // Given + ContentCreateRequest request = new ContentCreateRequest( + ContentType.MOVIE, + "테스트 영화", + null, + List.of("액션", "액션") + ); + + when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); + when(tagRepository.findByNameIn(List.of("액션"))).thenReturn(List.of()); + when(tagRepository.saveAll(anyList())).then(returnsFirstArg()); + when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); + when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(null); + + // When + contentService.create(request, null); + + // Then + ArgumentCaptor> saveAllCaptor = ArgumentCaptor.forClass(List.class); + verify(tagRepository).saveAll(saveAllCaptor.capture()); + assertThat(saveAllCaptor.getValue()).hasSize(1); + } + + @Test + @DisplayName("정규화 후 유효한 태그가 없으면 예외를 던진다") + void create_allBlankTags_throwsException() { + // Given + ContentCreateRequest request = new ContentCreateRequest( + ContentType.MOVIE, + "테스트 영화", + null, + List.of(" ", " ") + ); + + when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); + + // When & Then + assertThatThrownBy(() -> contentService.create(request, null)) + .isInstanceOf(ContentException.class); + + verify(tagRepository, never()).findByNameIn(anyList()); + verify(tagRepository, never()).saveAll(anyList()); + } + + @Test + @DisplayName("태그는 앞뒤 공백 제거 및 소문자로 정규화되어 저장된다") + void create_tagsNormalized() { + // Given + ContentCreateRequest request = new ContentCreateRequest( + ContentType.MOVIE, + "테스트 영화", + null, + List.of(" Action ", "DRAMA") + ); + + when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); + when(tagRepository.findByNameIn(List.of("action", "drama"))).thenReturn(List.of()); + when(tagRepository.saveAll(anyList())).then(returnsFirstArg()); + when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); + when(contentMapper.toDto(any(Content.class), any(), any(ContentStats.class))).thenReturn(null); + + // When + contentService.create(request, null); + + // Then + verify(tagRepository).findByNameIn(List.of("action", "drama")); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/watcher/repository/WatchingSessionRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/watcher/repository/WatchingSessionRepositoryTest.java index 7830fe02..dda330e8 100644 --- a/src/test/java/com/codeit/team5/mopl/watcher/repository/WatchingSessionRepositoryTest.java +++ b/src/test/java/com/codeit/team5/mopl/watcher/repository/WatchingSessionRepositoryTest.java @@ -3,10 +3,11 @@ import static org.assertj.core.api.Assertions.assertThat; import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentSource; +import com.codeit.team5.mopl.content.entity.ContentType; import com.codeit.team5.mopl.global.support.base.BaseRepositoryTest; import com.codeit.team5.mopl.user.entity.User; import com.codeit.team5.mopl.watcher.entity.WatchingSession; -import java.time.Instant; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; @@ -213,12 +214,15 @@ private User createUser(String email) { } private Content createContent() { - Content content = new Content(); - content.setType("MOVIE"); - content.setTitle("test title"); - content.setSource("TMDB"); - content.setExternalId("ext-id-123"); - content.setUpdatedAt(Instant.now()); + Content content = Content.createByExternalSource( + ContentType.MOVIE, + "test title", + null, + ContentSource.TMDB, + "ext-id-123", + null, + null + ); return persistAndFlush(content); } diff --git a/src/test/java/com/codeit/team5/mopl/watcher/service/WatchingSessionServiceTest.java b/src/test/java/com/codeit/team5/mopl/watcher/service/WatchingSessionServiceTest.java index 75999d0a..6b7c15d1 100644 --- a/src/test/java/com/codeit/team5/mopl/watcher/service/WatchingSessionServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/watcher/service/WatchingSessionServiceTest.java @@ -9,6 +9,8 @@ import static org.mockito.Mockito.when; import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentSource; +import com.codeit.team5.mopl.content.entity.ContentType; import com.codeit.team5.mopl.content.repository.ContentRepository; import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.user.entity.User; @@ -258,7 +260,15 @@ private User createDummyUser(UUID id) { } private Content createDummyContent(UUID id) { - Content content = new Content(); + Content content = Content.createByExternalSource( + ContentType.MOVIE, + "test title", + null, + ContentSource.TMDB, + "ext-id-123", + null, + null + ); ReflectionTestUtils.setField(content, "id", id); return content; }