diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 6e932a159..43b773757 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -5,6 +5,9 @@ import org.springframework.http.HttpStatus; import static com.example.solidconnection.application.service.ApplicationSubmissionService.APPLICATION_UPDATE_COUNT_LIMIT; +import static com.example.solidconnection.news.service.NewsCommandService.MAX_DESCRIPTION_LENGTH; +import static com.example.solidconnection.news.service.NewsCommandService.MAX_TITLE_LENGTH; +import static com.example.solidconnection.news.service.NewsCommandService.MAX_URL_LENGTH; import static com.example.solidconnection.siteuser.service.MyPageService.MIN_DAYS_BETWEEN_NICKNAME_CHANGES; @Getter @@ -42,6 +45,7 @@ public enum ErrorCode { COUNTRY_NOT_FOUND_BY_KOREAN_NAME(HttpStatus.NOT_FOUND.value(), "이름에 해당하는 국가를 찾을 수 없습니다."), GPA_SCORE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 학점입니다."), LANGUAGE_TEST_SCORE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 어학성적입니다."), + NEWS_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 소식지입니다."), // auth USER_ALREADY_SIGN_OUT(HttpStatus.UNAUTHORIZED.value(), "로그아웃 되었습니다."), @@ -96,6 +100,13 @@ public enum ErrorCode { USER_DO_NOT_HAVE_GPA(HttpStatus.BAD_REQUEST.value(), "해당 유저의 학점을 찾을 수 없음"), REJECTED_REASON_REQUIRED(HttpStatus.BAD_REQUEST.value(), "거절 사유가 필요합니다."), + // news + NEWS_TITLE_TOO_LONG(HttpStatus.BAD_REQUEST.value(), "소식지 제목은 " + MAX_TITLE_LENGTH + "자 이하여야 합니다."), + NEWS_TITLE_EMPTY(HttpStatus.BAD_REQUEST.value(), "소식지 제목은 빈 값일 수 없습니다."), + NEWS_DESCRIPTION_TOO_LONG(HttpStatus.BAD_REQUEST.value(), "소식지 설명은 " + MAX_DESCRIPTION_LENGTH + "자 이하여야 합니다."), + NEWS_URL_INVALID(HttpStatus.BAD_REQUEST.value(), "올바른 URL 형식이 아닙니다."), + NEWS_URL_TOO_LONG(HttpStatus.BAD_REQUEST.value(), "소식지 URL은 " + MAX_URL_LENGTH + "자 이하여야 합니다."), + // general JSON_PARSING_FAILED(HttpStatus.BAD_REQUEST.value(), "JSON 파싱을 할 수 없습니다."), JWT_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "JWT 토큰을 처리할 수 없습니다."), diff --git a/src/main/java/com/example/solidconnection/news/controller/NewsController.java b/src/main/java/com/example/solidconnection/news/controller/NewsController.java new file mode 100644 index 000000000..72d254a62 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/controller/NewsController.java @@ -0,0 +1,83 @@ +package com.example.solidconnection.news.controller; + +import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.news.dto.NewsCreateRequest; +import com.example.solidconnection.news.dto.NewsCommandResponse; +import com.example.solidconnection.news.dto.NewsFindResponse; +import com.example.solidconnection.news.dto.NewsResponse; +import com.example.solidconnection.news.dto.NewsUpdateRequest; +import com.example.solidconnection.news.service.NewsCommandService; +import com.example.solidconnection.news.service.NewsQueryService; +import com.example.solidconnection.security.annotation.RequireAdminAccess; +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/news") +public class NewsController { + + private final NewsQueryService newsQueryService; + private final NewsCommandService newsCommandService; + + // todo: 추후 검색 조건 및 Slice 적용 + @GetMapping + public ResponseEntity searchNews() { + NewsResponse newsResponse = newsQueryService.searchNews(); + return ResponseEntity.ok(newsResponse); + } + + @GetMapping(value = "/{news_id}") + public ResponseEntity findNewsById( + @AuthorizedUser SiteUser siteUser, + @PathVariable("news_id") Long newsId + ) { + NewsFindResponse newsFindResponse = newsQueryService.findNewsById(newsId); + return ResponseEntity.ok(newsFindResponse); + } + + @RequireAdminAccess + @PostMapping + public ResponseEntity createNews( + @AuthorizedUser SiteUser siteUser, + @Valid @RequestPart("newsCreateRequest") NewsCreateRequest newsCreateRequest, + @RequestParam(value = "file") MultipartFile imageFile + ) { + NewsCommandResponse newsCommandResponse = newsCommandService.createNews(newsCreateRequest, imageFile); + return ResponseEntity.ok(newsCommandResponse); + } + + @RequireAdminAccess + @PatchMapping(value = "/{news_id}") + public ResponseEntity updateNews( + @AuthorizedUser SiteUser siteUser, + @PathVariable("news_id") Long newsId, + @Valid @RequestPart(value = "newsUpdateRequest") NewsUpdateRequest newsUpdateRequest, + @RequestParam(value = "file", required = false) MultipartFile imageFile + ) { + NewsCommandResponse newsCommandResponse = newsCommandService.updateNews(newsId, newsUpdateRequest, imageFile); + return ResponseEntity.ok(newsCommandResponse); + } + + @RequireAdminAccess + @DeleteMapping(value = "/{news_id}") + public ResponseEntity deleteNewsById( + @AuthorizedUser SiteUser siteUser, + @PathVariable("news_id") Long newsId + ) { + NewsCommandResponse newsCommandResponse = newsCommandService.deleteNewsById(newsId); + return ResponseEntity.ok(newsCommandResponse); + } +} diff --git a/src/main/java/com/example/solidconnection/news/domain/News.java b/src/main/java/com/example/solidconnection/news/domain/News.java index 6a3bbdf1f..f7e2b9d8c 100644 --- a/src/main/java/com/example/solidconnection/news/domain/News.java +++ b/src/main/java/com/example/solidconnection/news/domain/News.java @@ -29,4 +29,31 @@ public class News extends BaseEntity { @Column(length = 500) private String url; + + public News( + String title, + String description, + String thumbnailUrl, + String url) { + this.title = title; + this.description = description; + this.thumbnailUrl = thumbnailUrl; + this.url = url; + } + + public void updateTitle(String title) { + this.title = title; + } + + public void updateDescription(String description) { + this.description = description; + } + + public void updateUrl(String url) { + this.url = url; + } + + public void updateThumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } } diff --git a/src/main/java/com/example/solidconnection/news/dto/NewsCommandResponse.java b/src/main/java/com/example/solidconnection/news/dto/NewsCommandResponse.java new file mode 100644 index 000000000..fc0b8daf3 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsCommandResponse.java @@ -0,0 +1,13 @@ +package com.example.solidconnection.news.dto; + +import com.example.solidconnection.news.domain.News; + +public record NewsCommandResponse( + long id +) { + public static NewsCommandResponse from(News news) { + return new NewsCommandResponse( + news.getId() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/news/dto/NewsCreateRequest.java b/src/main/java/com/example/solidconnection/news/dto/NewsCreateRequest.java new file mode 100644 index 000000000..3aeccf441 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsCreateRequest.java @@ -0,0 +1,28 @@ +package com.example.solidconnection.news.dto; + +import com.example.solidconnection.news.domain.News; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record NewsCreateRequest( + @NotBlank(message = "소식지 제목을 입력해주세요.") + @Size(max = 255, message = "소식지 제목은 최대 255자 이하여야 합니다.") + String title, + + @NotBlank(message = "소식지 설명을 입력해주세요.") + @Size(max = 255, message = "소식지 설명은 최대 255자 이하여야 합니다.") + String description, + + @NotBlank(message = "소식지 URL을 입력해주세요.") + @Size(max = 500, message = "소식지 URL은 최대 500자 이하여야 합니다.") + String url +) { + public News toEntity(String thumbnailUrl) { + return new News( + title, + description, + thumbnailUrl, + url + ); + } +} diff --git a/src/main/java/com/example/solidconnection/news/dto/NewsFindResponse.java b/src/main/java/com/example/solidconnection/news/dto/NewsFindResponse.java new file mode 100644 index 000000000..a657f6a82 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsFindResponse.java @@ -0,0 +1,27 @@ +package com.example.solidconnection.news.dto; + +import com.example.solidconnection.news.domain.News; + +import java.time.ZonedDateTime; + +public record NewsFindResponse( + long id, + String title, + String description, + String thumbnailUrl, + String url, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { + public static NewsFindResponse from(News news) { + return new NewsFindResponse( + news.getId(), + news.getTitle(), + news.getDescription(), + news.getThumbnailUrl(), + news.getUrl(), + news.getCreatedAt(), + news.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/news/dto/NewsItemResponse.java b/src/main/java/com/example/solidconnection/news/dto/NewsItemResponse.java new file mode 100644 index 000000000..d73306455 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsItemResponse.java @@ -0,0 +1,25 @@ +package com.example.solidconnection.news.dto; + +import com.example.solidconnection.news.domain.News; + +import java.time.ZonedDateTime; + +public record NewsItemResponse( + long id, + String title, + String thumbnailUrl, + String url, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { + public static NewsItemResponse from(News news) { + return new NewsItemResponse( + news.getId(), + news.getTitle(), + news.getThumbnailUrl(), + news.getUrl(), + news.getCreatedAt(), + news.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/news/dto/NewsResponse.java b/src/main/java/com/example/solidconnection/news/dto/NewsResponse.java new file mode 100644 index 000000000..4e51ff80e --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsResponse.java @@ -0,0 +1,11 @@ +package com.example.solidconnection.news.dto; + +import java.util.List; + +public record NewsResponse( + List newsItemsResponseList +) { + public static NewsResponse from(List newsItemsResponseList) { + return new NewsResponse(newsItemsResponseList); + } +} diff --git a/src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java b/src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java new file mode 100644 index 000000000..cd9c8b473 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java @@ -0,0 +1,16 @@ +package com.example.solidconnection.news.dto; + +import jakarta.validation.constraints.Size; + +public record NewsUpdateRequest( + + @Size(max = 255, message = "소식지 제목은 최대 255자 이하여야 합니다.") + String title, + + @Size(max = 255, message = "소식지 설명은 최대 255자 이하여야 합니다.") + String description, + + @Size(max = 500, message = "소식지 URL은 최대 500자 이하여야 합니다.") + String url +) { +} diff --git a/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java b/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java new file mode 100644 index 000000000..f3ee7556c --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java @@ -0,0 +1,11 @@ +package com.example.solidconnection.news.repository; + +import com.example.solidconnection.news.domain.News; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface NewsRepository extends JpaRepository { + + List findAllByOrderByUpdatedAtDesc(); +} diff --git a/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java new file mode 100644 index 000000000..62e4ac563 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java @@ -0,0 +1,101 @@ +package com.example.solidconnection.news.service; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.news.domain.News; +import com.example.solidconnection.news.dto.NewsCreateRequest; +import com.example.solidconnection.news.dto.NewsCommandResponse; +import com.example.solidconnection.news.dto.NewsUpdateRequest; +import com.example.solidconnection.news.repository.NewsRepository; +import com.example.solidconnection.s3.domain.ImgType; +import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; +import com.example.solidconnection.s3.service.S3Service; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import static com.example.solidconnection.common.exception.ErrorCode.NEWS_DESCRIPTION_TOO_LONG; +import static com.example.solidconnection.common.exception.ErrorCode.NEWS_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.NEWS_TITLE_EMPTY; +import static com.example.solidconnection.common.exception.ErrorCode.NEWS_TITLE_TOO_LONG; +import static com.example.solidconnection.common.exception.ErrorCode.NEWS_URL_INVALID; +import static com.example.solidconnection.common.exception.ErrorCode.NEWS_URL_TOO_LONG; + +@Service +@RequiredArgsConstructor +public class NewsCommandService { + + public static final int MAX_TITLE_LENGTH = 255; + public static final int MAX_DESCRIPTION_LENGTH = 255; + public static final int MAX_URL_LENGTH = 500; + + private final S3Service s3Service; + private final NewsRepository newsRepository; + + @Transactional + public NewsCommandResponse createNews(NewsCreateRequest newsCreateRequest, MultipartFile imageFile) { + UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, ImgType.NEWS); + News news = newsCreateRequest.toEntity(uploadedFile.fileUrl()); + News savedNews = newsRepository.save(news); + return NewsCommandResponse.from(savedNews); + } + + @Transactional + public NewsCommandResponse updateNews(Long newsId, NewsUpdateRequest newsUpdateRequest, MultipartFile imageFile) { + News news = newsRepository.findById(newsId) + .orElseThrow(() -> new CustomException(NEWS_NOT_FOUND)); + if (newsUpdateRequest.title() != null) { + validateTitle(newsUpdateRequest.title()); + news.updateTitle(newsUpdateRequest.title()); + } + if (newsUpdateRequest.description() != null) { + validateDescription(newsUpdateRequest.description()); + news.updateDescription(newsUpdateRequest.description()); + } + if (newsUpdateRequest.url() != null) { + validateUrl(newsUpdateRequest.url()); + news.updateUrl(newsUpdateRequest.url()); + } + if (imageFile != null) { + UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, ImgType.NEWS); + s3Service.deletePostImage(news.getThumbnailUrl()); + String thumbnailImageUrl = uploadedFile.fileUrl(); + news.updateThumbnailUrl(thumbnailImageUrl); + } + News savedNews = newsRepository.save(news); + return NewsCommandResponse.from(savedNews); + } + + @Transactional + public NewsCommandResponse deleteNewsById(Long newsId) { + News news = newsRepository.findById(newsId) + .orElseThrow(() -> new CustomException(NEWS_NOT_FOUND)); + newsRepository.deleteById(newsId); + s3Service.deletePostImage(news.getThumbnailUrl()); + return NewsCommandResponse.from(news); + } + + private void validateTitle(String title) { + if (title.trim().isEmpty()) { + throw new CustomException(NEWS_TITLE_EMPTY); + } + if (title.length() > MAX_TITLE_LENGTH) { + throw new CustomException(NEWS_TITLE_TOO_LONG); + } + } + + private void validateDescription(String description) { + if (description.length() > MAX_DESCRIPTION_LENGTH) { + throw new CustomException(NEWS_DESCRIPTION_TOO_LONG); + } + } + + private void validateUrl(String url) { + if (!url.matches("^https?://.*")) { + throw new CustomException(NEWS_URL_INVALID); + } + if (url.length() > MAX_URL_LENGTH) { + throw new CustomException(NEWS_URL_TOO_LONG); + } + } +} diff --git a/src/main/java/com/example/solidconnection/news/service/NewsQueryService.java b/src/main/java/com/example/solidconnection/news/service/NewsQueryService.java new file mode 100644 index 000000000..89b80aff1 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/service/NewsQueryService.java @@ -0,0 +1,38 @@ +package com.example.solidconnection.news.service; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.news.domain.News; +import com.example.solidconnection.news.dto.NewsFindResponse; +import com.example.solidconnection.news.dto.NewsItemResponse; +import com.example.solidconnection.news.dto.NewsResponse; +import com.example.solidconnection.news.repository.NewsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.example.solidconnection.common.exception.ErrorCode.NEWS_NOT_FOUND; + +@Service +@RequiredArgsConstructor +public class NewsQueryService { + + private final NewsRepository newsRepository; + + @Transactional(readOnly = true) + public NewsResponse searchNews() { + List newsList = newsRepository.findAllByOrderByUpdatedAtDesc(); + List newsItemsResponseList = newsList.stream() + .map(NewsItemResponse::from) + .toList(); + return NewsResponse.from(newsItemsResponseList); + } + + @Transactional(readOnly = true) + public NewsFindResponse findNewsById(Long newsId) { + News news = newsRepository.findById(newsId) + .orElseThrow(() -> new CustomException(NEWS_NOT_FOUND)); + return NewsFindResponse.from(news); + } +} diff --git a/src/main/java/com/example/solidconnection/s3/domain/ImgType.java b/src/main/java/com/example/solidconnection/s3/domain/ImgType.java index df881fe4b..7efedb1a5 100644 --- a/src/main/java/com/example/solidconnection/s3/domain/ImgType.java +++ b/src/main/java/com/example/solidconnection/s3/domain/ImgType.java @@ -4,7 +4,7 @@ @Getter public enum ImgType { - PROFILE("profile"), GPA("gpa"), LANGUAGE_TEST("language"), COMMUNITY("community"); + PROFILE("profile"), GPA("gpa"), LANGUAGE_TEST("language"), COMMUNITY("community"), NEWS("news"); private final String type; diff --git a/src/test/java/com/example/solidconnection/news/fixture/NewsFixture.java b/src/test/java/com/example/solidconnection/news/fixture/NewsFixture.java new file mode 100644 index 000000000..49c6c8cfe --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/fixture/NewsFixture.java @@ -0,0 +1,21 @@ +package com.example.solidconnection.news.fixture; + +import com.example.solidconnection.news.domain.News; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class NewsFixture { + + private final NewsFixtureBuilder newsFixtureBuilder; + + public News 소식지() { + return newsFixtureBuilder + .title("소식지 제목") + .description("소식지 설명") + .thumbnailUrl("news/5a02ba2f-38f5-4ae9-9a24-53d624a18233") + .url("https://youtu.be/test") + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/news/fixture/NewsFixtureBuilder.java b/src/test/java/com/example/solidconnection/news/fixture/NewsFixtureBuilder.java new file mode 100644 index 000000000..a80d04b11 --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/fixture/NewsFixtureBuilder.java @@ -0,0 +1,47 @@ +package com.example.solidconnection.news.fixture; + +import com.example.solidconnection.news.domain.News; +import com.example.solidconnection.news.repository.NewsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class NewsFixtureBuilder { + + private final NewsRepository newsRepository; + + private String title; + private String description; + private String thumbnailUrl; + private String url; + + public NewsFixtureBuilder title(String title) { + this.title = title; + return this; + } + + public NewsFixtureBuilder description(String description) { + this.description = description; + return this; + } + + public NewsFixtureBuilder thumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + return this; + } + + public NewsFixtureBuilder url(String url) { + this.url = url; + return this; + } + + public News create() { + News news = new News( + title, + description, + thumbnailUrl, + url); + return newsRepository.save(news); + } +} diff --git a/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java new file mode 100644 index 000000000..ec9d2d47f --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java @@ -0,0 +1,192 @@ +package com.example.solidconnection.news.service; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.news.domain.News; +import com.example.solidconnection.news.dto.NewsCreateRequest; +import com.example.solidconnection.news.dto.NewsCommandResponse; +import com.example.solidconnection.news.dto.NewsUpdateRequest; +import com.example.solidconnection.news.fixture.NewsFixture; +import com.example.solidconnection.news.repository.NewsRepository; +import com.example.solidconnection.s3.domain.ImgType; +import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; +import com.example.solidconnection.s3.service.S3Service; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import static com.example.solidconnection.common.exception.ErrorCode.NEWS_TITLE_EMPTY; +import static com.example.solidconnection.common.exception.ErrorCode.NEWS_URL_INVALID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@TestContainerSpringBootTest +@DisplayName("소식지 생성/수정/삭제 서비스 테스트") +class NewsCommandServiceTest { + + @Autowired + private NewsCommandService newsCommandService; + + @MockBean + private S3Service s3Service; + + @Autowired + private NewsRepository newsRepository; + + @Autowired + private NewsFixture newsFixture; + + @Nested + class 소식지_생성_테스트 { + + @Test + void 소식지를_성공적으로_생성한다() { + // given + NewsCreateRequest request = createNewsCreateRequest(); + MultipartFile imageFile = createImageFile(); + String expectedImageUrl = "news/5a02ba2f-38f5-4ae9-9a24-53d624a18233"; + given(s3Service.uploadFile(any(), eq(ImgType.NEWS))) + .willReturn(new UploadedFileUrlResponse(expectedImageUrl)); + + // when + NewsCommandResponse response = newsCommandService.createNews(request, imageFile); + + // then + News savedNews = newsRepository.findById(response.id()).orElseThrow(); + assertThat(response.id()).isEqualTo(savedNews.getId()); + } + } + + @Nested + class 소식지_수정_테스트 { + + private News originNews; + + @BeforeEach + void setUp() { + originNews = newsFixture.소식지(); + } + + @Test + void 소식지를_성공적으로_수정한다() { + // given + String expectedTitle = "제목 수정"; + String expectedDescription = "설명 수정"; + String expectedUrl = "https://youtu.be/test-edit"; + MultipartFile expectedFile = createImageFile(); + String expectedNewImageUrl = "news/5a02ba2f-38f5-4ae9-9a24-53d624a18233-edit"; + given(s3Service.uploadFile(any(), eq(ImgType.NEWS))) + .willReturn(new UploadedFileUrlResponse(expectedNewImageUrl)); + NewsUpdateRequest request = createNewsUpdateRequest(expectedTitle, expectedDescription, expectedUrl); + + // when + NewsCommandResponse response = newsCommandService.updateNews(originNews.getId(), request, expectedFile + ); + + // then + News savedNews = newsRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(savedNews.getTitle()).isEqualTo(expectedTitle), + () -> assertThat(savedNews.getDescription()).isEqualTo(expectedDescription), + () -> assertThat(savedNews.getThumbnailUrl()).isEqualTo(expectedNewImageUrl), + () -> assertThat(savedNews.getUrl()).isEqualTo(expectedUrl) + ); + } + + @Test + void 소식지_제목만_수정한다() { + // given + String expectedTitle = "제목 수정"; + String originalDescription = originNews.getDescription(); + String originalUrl = originNews.getUrl(); + String originalThumbnailUrl = originNews.getThumbnailUrl(); + NewsUpdateRequest request = createNewsUpdateRequest(expectedTitle, null, null); + + // when + NewsCommandResponse response = newsCommandService.updateNews(originNews.getId(), request, null); + + // then + News savedNews = newsRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(savedNews.getTitle()).isEqualTo(expectedTitle), + () -> assertThat(savedNews.getDescription()).isEqualTo(originalDescription), + () -> assertThat(savedNews.getUrl()).isEqualTo(originalUrl), + () -> assertThat(savedNews.getThumbnailUrl()).isEqualTo(originalThumbnailUrl) + ); + } + + @Test + void 빈_제목으로_수정시_예외가_발생한다() { + // given + NewsUpdateRequest request = createNewsUpdateRequest(" ", null, null); + + // when & then + assertThatCode(() -> newsCommandService.updateNews( + originNews.getId(), + request, + null)) + .isInstanceOf(CustomException.class) + .hasMessage(NEWS_TITLE_EMPTY.getMessage()); + } + + @Test + void 잘못된_URL_형식으로_수정시_예외가_발생한다() { + // given + NewsUpdateRequest request = createNewsUpdateRequest(null, null, "invalid-url"); + + // when & then + assertThatCode(() -> newsCommandService.updateNews( + originNews.getId(), + request, + null)) + .isInstanceOf(CustomException.class) + .hasMessage(NEWS_URL_INVALID.getMessage()); + } + } + + @Nested + class 소식지_삭제_테스트 { + + @Test + void 소식지를_성공적으로_삭제한다() { + // given + News originNews = newsFixture.소식지(); + String expectedImageUrl = originNews.getThumbnailUrl(); + + // when + NewsCommandResponse response = newsCommandService.deleteNewsById(originNews.getId()); + + // then + assertThat(response.id()).isEqualTo(originNews.getId()); + assertThat(newsRepository.findById(originNews.getId())).isEmpty(); + then(s3Service).should().deletePostImage(expectedImageUrl); + } + } + + private NewsCreateRequest createNewsCreateRequest() { + return new NewsCreateRequest("제목", "설명", "https://youtu.be/test"); + } + + private NewsUpdateRequest createNewsUpdateRequest(String title, String description, String url) { + return new NewsUpdateRequest(title, description, url); + } + + private MockMultipartFile createImageFile() { + return new MockMultipartFile( + "image", + "test.jpg", + "image/jpeg", + "test image content".getBytes() + ); + } +} diff --git a/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java b/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java new file mode 100644 index 000000000..999458aa7 --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java @@ -0,0 +1,58 @@ +package com.example.solidconnection.news.service; + +import com.example.solidconnection.news.domain.News; +import com.example.solidconnection.news.dto.NewsFindResponse; +import com.example.solidconnection.news.dto.NewsItemResponse; +import com.example.solidconnection.news.dto.NewsResponse; +import com.example.solidconnection.news.fixture.NewsFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.Comparator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@TestContainerSpringBootTest +@DisplayName("소식지 조회 서비스 테스트") +class NewsQueryServiceTest { + + @Autowired + private NewsQueryService newsQueryService; + + @Autowired + private NewsFixture newsFixture; + + @Test + void 소식지_목록을_성공적으로_조회한다() { + // given + News news1 = newsFixture.소식지(); + News news2 = newsFixture.소식지(); + News news3 = newsFixture.소식지(); + List newsList = List.of(news1, news2, news3); + + // when + NewsResponse response = newsQueryService.searchNews(); + + // then + assertThat(response.newsItemsResponseList()).hasSize(newsList.size()); + assertThat(response.newsItemsResponseList()) + .extracting(NewsItemResponse::updatedAt) + .isSortedAccordingTo(Comparator.reverseOrder()); + } + + @Test + void 소식지를_성공적으로_조회한다() { + // given + News news = newsFixture.소식지(); + + // when + NewsFindResponse response = newsQueryService.findNewsById(news.getId()); + + // then + assertThat(response.id()).isEqualTo(news.getId()); + assertThat(response.description()).isEqualTo(news.getDescription()); + } +}