diff --git a/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java b/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java index 228c436ba..e79b4f8e3 100644 --- a/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java +++ b/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java @@ -6,7 +6,8 @@ import com.example.solidconnection.application.service.ApplicationQueryService; import com.example.solidconnection.application.service.ApplicationSubmissionService; import com.example.solidconnection.common.resolver.AuthorizedUser; -import com.example.solidconnection.security.annotation.RequireAdminAccess; +import com.example.solidconnection.security.annotation.RequireRoleAccess; +import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -39,7 +40,7 @@ public ResponseEntity apply( .body(applicationSubmissionResponse); } - @RequireAdminAccess + @RequireRoleAccess(roles = {Role.ADMIN}) @GetMapping public ResponseEntity getApplicants( @AuthorizedUser SiteUser siteUser, 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 00e600201..4f5a331bb 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -42,6 +42,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 +97,10 @@ public enum ErrorCode { USER_DO_NOT_HAVE_GPA(HttpStatus.BAD_REQUEST.value(), "해당 유저의 학점을 찾을 수 없음"), REJECTED_REASON_REQUIRED(HttpStatus.BAD_REQUEST.value(), "거절 사유가 필요합니다."), + // news + INVALID_NEWS_ACCESS(HttpStatus.BAD_REQUEST.value(), "자신의 소식지만 제어할 수 있습니다."), + + // database DATA_INTEGRITY_VIOLATION(HttpStatus.CONFLICT.value(), "데이터베이스 무결성 제약조건 위반이 발생했습니다."), diff --git a/src/main/java/com/example/solidconnection/news/config/NewsProperties.java b/src/main/java/com/example/solidconnection/news/config/NewsProperties.java new file mode 100644 index 000000000..ecd7c96b8 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/config/NewsProperties.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.news.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "news") +public record NewsProperties( + String defaultThumbnailUrl +) { +} 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..51b739f8c --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/controller/NewsController.java @@ -0,0 +1,80 @@ +package com.example.solidconnection.news.controller; + +import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.news.dto.NewsCommandResponse; +import com.example.solidconnection.news.dto.NewsCreateRequest; +import com.example.solidconnection.news.dto.NewsListResponse; +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.RequireRoleAccess; +import com.example.solidconnection.siteuser.domain.Role; +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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +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 findNewsBySiteUserId( + @RequestParam(value = "site-user-id") Long siteUserId + ) { + NewsListResponse newsListResponse = newsQueryService.findNewsBySiteUserId(siteUserId); + return ResponseEntity.ok(newsListResponse); + } + + @RequireRoleAccess(roles = {Role.ADMIN, Role.MENTOR}) + @PostMapping + public ResponseEntity createNews( + @AuthorizedUser SiteUser siteUser, + @Valid @RequestPart("newsCreateRequest") NewsCreateRequest newsCreateRequest, + @RequestParam(value = "file", required = false) MultipartFile imageFile + ) { + NewsCommandResponse newsCommandResponse = newsCommandService.createNews(siteUser.getId(), newsCreateRequest, imageFile); + return ResponseEntity.ok(newsCommandResponse); + } + + @RequireRoleAccess(roles = {Role.ADMIN, Role.MENTOR}) + @PutMapping("/{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( + siteUser.getId(), + newsId, + newsUpdateRequest, + imageFile); + return ResponseEntity.ok(newsCommandResponse); + } + + @RequireRoleAccess(roles = {Role.ADMIN, Role.MENTOR}) + @DeleteMapping("/{news-id}") + public ResponseEntity deleteNewsById( + @AuthorizedUser SiteUser siteUser, + @PathVariable("news-id") Long newsId + ) { + NewsCommandResponse newsCommandResponse = newsCommandService.deleteNewsById(siteUser, 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..5443f65aa 100644 --- a/src/main/java/com/example/solidconnection/news/domain/News.java +++ b/src/main/java/com/example/solidconnection/news/domain/News.java @@ -6,13 +6,16 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; -@Getter @Entity -@NoArgsConstructor +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) @EqualsAndHashCode public class News extends BaseEntity { @@ -29,4 +32,29 @@ public class News extends BaseEntity { @Column(length = 500) private String url; + + private long siteUserId; + + public News( + String title, + String description, + String thumbnailUrl, + String url, + long siteUserId) { + this.title = title; + this.description = description; + this.thumbnailUrl = thumbnailUrl; + this.url = url; + this.siteUserId = siteUserId; + } + + public void updateNews(String title, String description, String url) { + this.title = title; + this.description = description; + 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..660611537 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsCreateRequest.java @@ -0,0 +1,31 @@ +package com.example.solidconnection.news.dto; + +import com.example.solidconnection.news.domain.News; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import org.hibernate.validator.constraints.URL; + +public record NewsCreateRequest( + @NotBlank(message = "소식지 제목을 입력해주세요.") + @Size(max = 20, message = "소식지 제목은 20자 이하여야 합니다.") + String title, + + @NotBlank(message = "소식지 내용을 입력해주세요.") + @Size(max = 30, message = "소식지 내용은 30자 이하여야 합니다.") + String description, + + @NotBlank(message = "소식지 URL을 입력해주세요.") + @Size(max = 500, message = "소식지 URL은 500자 이하여야 합니다.") + @URL(message = "올바른 URL 형식이 아닙니다.") + String url +) { + public News toEntity(String thumbnailUrl, long siteUserId) { + return new News( + title, + description, + thumbnailUrl, + url, + siteUserId + ); + } +} diff --git a/src/main/java/com/example/solidconnection/news/dto/NewsListResponse.java b/src/main/java/com/example/solidconnection/news/dto/NewsListResponse.java new file mode 100644 index 000000000..b501b3810 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsListResponse.java @@ -0,0 +1,11 @@ +package com.example.solidconnection.news.dto; + +import java.util.List; + +public record NewsListResponse( + List newsResponseList +) { + public static NewsListResponse from(List newsResponseList) { + return new NewsListResponse(newsResponseList); + } +} 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..b39daffce --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsResponse.java @@ -0,0 +1,25 @@ +package com.example.solidconnection.news.dto; + +import com.example.solidconnection.news.domain.News; + +import java.time.ZonedDateTime; + +public record NewsResponse( + long id, + String title, + String description, + String thumbnailUrl, + String url, + ZonedDateTime updatedAt +) { + public static NewsResponse from(News news) { + return new NewsResponse( + news.getId(), + news.getTitle(), + news.getDescription(), + news.getThumbnailUrl(), + news.getUrl(), + news.getUpdatedAt() + ); + } +} 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..9d09001bd --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java @@ -0,0 +1,23 @@ +package com.example.solidconnection.news.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import org.hibernate.validator.constraints.URL; + +public record NewsUpdateRequest( + @NotBlank(message = "소식지 제목을 입력해주세요.") + @Size(max = 20, message = "소식지 제목은 20자 이하여야 합니다.") + String title, + + @NotBlank(message = "소식지 내용을 입력해주세요.") + @Size(max = 30, message = "소식지 내용은 30자 이하여야 합니다.") + String description, + + @NotBlank(message = "소식지 URL을 입력해주세요.") + @Size(max = 500, message = "소식지 URL은 500자 이하여야 합니다.") + @URL(message = "올바른 URL 형식이 아닙니다.") + String url, + + Boolean resetToDefaultImage +) { +} 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..3171bfcf8 --- /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 findAllBySiteUserIdOrderByUpdatedAtDesc(long siteUserId); +} 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..a9d8c74a4 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java @@ -0,0 +1,103 @@ +package com.example.solidconnection.news.service; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.news.config.NewsProperties; +import com.example.solidconnection.news.domain.News; +import com.example.solidconnection.news.dto.NewsCommandResponse; +import com.example.solidconnection.news.dto.NewsCreateRequest; +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 com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.siteuser.domain.SiteUser; +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.INVALID_NEWS_ACCESS; +import static com.example.solidconnection.common.exception.ErrorCode.NEWS_NOT_FOUND; + +@Service +@RequiredArgsConstructor +public class NewsCommandService { + + private final S3Service s3Service; + private final NewsProperties newsProperties; + private final NewsRepository newsRepository; + + @Transactional + public NewsCommandResponse createNews(long siteUserId,NewsCreateRequest newsCreateRequest, MultipartFile imageFile) { + String thumbnailUrl = getImageUrl(imageFile); + News news = newsCreateRequest.toEntity(thumbnailUrl, siteUserId); + News savedNews = newsRepository.save(news); + return NewsCommandResponse.from(savedNews); + } + + private String getImageUrl(MultipartFile imageFile) { + if (imageFile != null && !imageFile.isEmpty()) { + UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, ImgType.NEWS); + return uploadedFile.fileUrl(); + } + return newsProperties.defaultThumbnailUrl(); + } + + @Transactional + public NewsCommandResponse updateNews( + long siteUserId, + Long newsId, + NewsUpdateRequest newsUpdateRequest, + MultipartFile imageFile) { + News news = newsRepository.findById(newsId) + .orElseThrow(() -> new CustomException(NEWS_NOT_FOUND)); + validateOwnership(news, siteUserId); + news.updateNews(newsUpdateRequest.title(), newsUpdateRequest.description(), newsUpdateRequest.url()); + updateThumbnail(news, imageFile, newsUpdateRequest.resetToDefaultImage()); + News savedNews = newsRepository.save(news); + return NewsCommandResponse.from(savedNews); + } + + private void validateOwnership(News news, long siteUserId) { + if (news.getSiteUserId() != siteUserId) { + throw new CustomException(INVALID_NEWS_ACCESS); + } + } + + private void updateThumbnail(News news, MultipartFile imageFile, Boolean resetToDefaultImage) { + if (Boolean.TRUE.equals(resetToDefaultImage)) { + deleteCustomImage(news.getThumbnailUrl()); + news.updateThumbnailUrl(newsProperties.defaultThumbnailUrl()); + } + else if (imageFile != null && !imageFile.isEmpty()) { + UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, ImgType.NEWS); + deleteCustomImage(news.getThumbnailUrl()); + news.updateThumbnailUrl(uploadedFile.fileUrl()); + } + } + + @Transactional + public NewsCommandResponse deleteNewsById(SiteUser siteUser, Long newsId) { + News news = newsRepository.findById(newsId) + .orElseThrow(() -> new CustomException(NEWS_NOT_FOUND)); + validatePermission(siteUser, news); + deleteCustomImage(news.getThumbnailUrl()); + newsRepository.delete(news); + return NewsCommandResponse.from(news); + } + + private void validatePermission(SiteUser currentUser, News news) { + boolean isOwner = news.getSiteUserId() == currentUser.getId(); + boolean isAdmin = currentUser.getRole().equals(Role.ADMIN); + if (!isOwner && !isAdmin) { + throw new CustomException(INVALID_NEWS_ACCESS); + } + } + + private void deleteCustomImage(String imageUrl) { + if (!newsProperties.defaultThumbnailUrl().equals(imageUrl)) { + s3Service.deletePostImage(imageUrl); + } + } +} 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..3b04e3b86 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/service/NewsQueryService.java @@ -0,0 +1,27 @@ +package com.example.solidconnection.news.service; + +import com.example.solidconnection.news.domain.News; +import com.example.solidconnection.news.dto.NewsResponse; +import com.example.solidconnection.news.dto.NewsListResponse; +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; + +@Service +@RequiredArgsConstructor +public class NewsQueryService { + + private final NewsRepository newsRepository; + + @Transactional(readOnly = true) + public NewsListResponse findNewsBySiteUserId(long siteUserId) { + List newsList = newsRepository.findAllBySiteUserIdOrderByUpdatedAtDesc(siteUserId); + List newsResponseList = newsList.stream() + .map(NewsResponse::from) + .toList(); + return NewsListResponse.from(newsResponseList); + } +} 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/main/java/com/example/solidconnection/security/annotation/RequireAdminAccess.java b/src/main/java/com/example/solidconnection/security/annotation/RequireRoleAccess.java similarity index 71% rename from src/main/java/com/example/solidconnection/security/annotation/RequireAdminAccess.java rename to src/main/java/com/example/solidconnection/security/annotation/RequireRoleAccess.java index 682d5bdf8..aecef342d 100644 --- a/src/main/java/com/example/solidconnection/security/annotation/RequireAdminAccess.java +++ b/src/main/java/com/example/solidconnection/security/annotation/RequireRoleAccess.java @@ -1,5 +1,7 @@ package com.example.solidconnection.security.annotation; +import com.example.solidconnection.siteuser.domain.Role; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -7,5 +9,6 @@ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) -public @interface RequireAdminAccess { +public @interface RequireRoleAccess { + Role[] roles(); } diff --git a/src/main/java/com/example/solidconnection/security/aspect/AdminAuthorizationAspect.java b/src/main/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspect.java similarity index 55% rename from src/main/java/com/example/solidconnection/security/aspect/AdminAuthorizationAspect.java rename to src/main/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspect.java index 5ebba881f..b1b1f4223 100644 --- a/src/main/java/com/example/solidconnection/security/aspect/AdminAuthorizationAspect.java +++ b/src/main/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspect.java @@ -1,7 +1,8 @@ package com.example.solidconnection.security.aspect; import com.example.solidconnection.common.exception.CustomException; -import com.example.solidconnection.security.annotation.RequireAdminAccess; +import com.example.solidconnection.security.annotation.RequireRoleAccess; +import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; import lombok.RequiredArgsConstructor; import org.aspectj.lang.ProceedingJoinPoint; @@ -9,17 +10,18 @@ import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; +import java.util.Arrays; + import static com.example.solidconnection.common.exception.ErrorCode.ACCESS_DENIED; -import static com.example.solidconnection.siteuser.domain.Role.ADMIN; @Aspect @Component @RequiredArgsConstructor -public class AdminAuthorizationAspect { +public class RoleAuthorizationAspect { - @Around("@annotation(requireAdminAccess)") - public Object checkAdminAccess(ProceedingJoinPoint joinPoint, - RequireAdminAccess requireAdminAccess) throws Throwable { + // todo: 추후 siteUserId로 파라미터 변경 시 수정 필요 + @Around("@annotation(requireRoleAccess)") + public Object checkRoleAccess(ProceedingJoinPoint joinPoint, RequireRoleAccess requireRoleAccess) throws Throwable { SiteUser siteUser = null; for (Object arg : joinPoint.getArgs()) { if (arg instanceof SiteUser) { @@ -27,7 +29,12 @@ public Object checkAdminAccess(ProceedingJoinPoint joinPoint, break; } } - if (siteUser == null || !ADMIN.equals(siteUser.getRole())) { + if (siteUser == null) { + throw new CustomException(ACCESS_DENIED); + } + Role[] allowedRoles = requireRoleAccess.roles(); + boolean hasAccess = Arrays.asList(allowedRoles).contains(siteUser.getRole()); + if (!hasAccess) { throw new CustomException(ACCESS_DENIED); } return joinPoint.proceed(); diff --git a/src/main/resources/db/migration/V20__add_site_user_id_fk_to_news.sql b/src/main/resources/db/migration/V20__add_site_user_id_fk_to_news.sql new file mode 100644 index 000000000..84be3d84a --- /dev/null +++ b/src/main/resources/db/migration/V20__add_site_user_id_fk_to_news.sql @@ -0,0 +1,4 @@ +ALTER TABLE news + ADD COLUMN site_user_id BIGINT NOT NULL; +ALTER TABLE news + ADD CONSTRAINT fk_news_site_user_id FOREIGN KEY (site_user_id) REFERENCES site_user (id); diff --git a/src/main/resources/secret b/src/main/resources/secret index 84002e866..be52e6ce9 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit 84002e86670d380219f580c6605fb7c66ed7d977 +Subproject commit be52e6ce9ca3d2c6eb51442108328b00a539510b 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..44091a51b --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/fixture/NewsFixture.java @@ -0,0 +1,32 @@ +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 소식지(long siteUserId) { + return newsFixtureBuilder + .title("소식지 제목") + .description("소식지 설명") + .thumbnailUrl("news/5a02ba2f-38f5-4ae9-9a24-53d624a18233") + .url("https://youtu.be/test") + .siteUserId(siteUserId) + .create(); + } + + public News 소식지(long siteUserId, String thumbnailUrl) { + return newsFixtureBuilder + .title("소식지 제목") + .description("소식지 설명") + .thumbnailUrl(thumbnailUrl) + .url("https://youtu.be/test") + .siteUserId(siteUserId) + .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..5da97d93f --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/fixture/NewsFixtureBuilder.java @@ -0,0 +1,54 @@ +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; + private long siteUserId; + + 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 NewsFixtureBuilder siteUserId(long siteUserId) { + this.siteUserId = siteUserId; + return this; + } + + public News create() { + News news = new News( + title, + description, + thumbnailUrl, + url, + siteUserId); + 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..681c2a959 --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java @@ -0,0 +1,315 @@ +package com.example.solidconnection.news.service; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.news.config.NewsProperties; +import com.example.solidconnection.news.domain.News; +import com.example.solidconnection.news.dto.NewsCommandResponse; +import com.example.solidconnection.news.dto.NewsCreateRequest; +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.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +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.INVALID_NEWS_ACCESS; +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.never; +import static org.mockito.BDDMockito.then; + +@TestContainerSpringBootTest +@DisplayName("소식지 생성/수정/삭제 서비스 테스트") +class NewsCommandServiceTest { + + @Autowired + private NewsCommandService newsCommandService; + + @Autowired + private NewsProperties newsProperties; + + @MockBean + private S3Service s3Service; + + @Autowired + private NewsRepository newsRepository; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private NewsFixture newsFixture; + + private SiteUser user; + + @BeforeEach + void setUp() { + user = siteUserFixture.멘토(1, "mentor"); + } + + @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(user.getId(), request, imageFile); + + // then + News savedNews = newsRepository.findById(response.id()).orElseThrow(); + assertThat(response.id()).isEqualTo(savedNews.getId()); + } + } + + private NewsCreateRequest createNewsCreateRequest() { + return new NewsCreateRequest("제목", "설명", "https://youtu.be/test"); + } + + @Nested + class 소식지_수정_테스트 { + + private static final String CUSTOM_IMAGE_URL = "news/custom-image-url"; + + private News originNews; + + @Nested + class 기본_필드_수정_테스트 { + + @BeforeEach + void setUp() { + originNews = newsFixture.소식지(user.getId()); + } + + @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, + null); + + // when + NewsCommandResponse response = newsCommandService.updateNews( + user.getId(), + 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 + SiteUser anotherUser = siteUserFixture.멘토(2, "anotherMentor"); + NewsUpdateRequest request = createNewsUpdateRequest( + "제목 수정", + null, + null, + null); + + // when & then + assertThatCode(() -> newsCommandService.updateNews( + anotherUser.getId(), + originNews.getId(), + request, + null)) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_NEWS_ACCESS.getMessage()); + } + } + + @Nested + class 커스텀_이미지_관련_수정_테스트 { + + @BeforeEach + void setUp() { + originNews = newsFixture.소식지(user.getId(), CUSTOM_IMAGE_URL); + } + + @Test + void 기본_이미지로_변경_요청시_기존_커스텀_이미지를_삭제하고_기본_이미지로_변경한다() { + // given + NewsUpdateRequest request = createNewsUpdateRequest( + null, + null, + null, + true); + + // when + NewsCommandResponse response = newsCommandService.updateNews( + user.getId(), + originNews.getId(), + request, + null); + + // then + News savedNews = newsRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(savedNews.getThumbnailUrl()).isEqualTo(newsProperties.defaultThumbnailUrl()), + () -> then(s3Service).should().deletePostImage(CUSTOM_IMAGE_URL), + () -> then(s3Service).should(never()).uploadFile(null, ImgType.NEWS) + ); + } + + @Test + void 새_이미지_업로드시_기존_커스텀_이미지를_삭제하고_새_이미지로_변경한다() { + // given + MultipartFile newImageFile = createImageFile(); + String newImageUrl = "news/new-image-url"; + given(s3Service.uploadFile(newImageFile, ImgType.NEWS)) + .willReturn(new UploadedFileUrlResponse(newImageUrl)); + NewsUpdateRequest request = createNewsUpdateRequest( + null, + null, + null, + null); + + // when + NewsCommandResponse response = newsCommandService.updateNews( + user.getId(), + originNews.getId(), + request, + newImageFile); + + // then + News savedNews = newsRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(savedNews.getThumbnailUrl()).isEqualTo(newImageUrl), + () -> then(s3Service).should().deletePostImage(CUSTOM_IMAGE_URL), + () -> then(s3Service).should().uploadFile(any(), any()) + ); + } + } + + @Nested + class 기본_이미지_관련_수정_테스트 { + + @BeforeEach + void setUp() { + originNews = newsFixture.소식지(user.getId(), newsProperties.defaultThumbnailUrl()); + } + + @Test + void 기본_이미지에서_기본_이미지로_변경_요청시_삭제_호출되지_않는다() { + // given + NewsUpdateRequest request = createNewsUpdateRequest( + null, + null, + null, + true); + + // when + newsCommandService.updateNews( + user.getId(), + originNews.getId(), + request, + null); + + // then + News savedNews = newsRepository.findById(originNews.getId()).orElseThrow(); + assertAll( + () -> assertThat(savedNews.getThumbnailUrl()).isEqualTo(newsProperties.defaultThumbnailUrl()), + () -> then(s3Service).should(never()).deletePostImage(newsProperties.defaultThumbnailUrl()), + () -> then(s3Service).should(never()).uploadFile(null, ImgType.NEWS) + ); + } + + @Test + void 기본_이미지에서_새_이미지_업로드시_삭제_호출되지_않고_새_이미지로_변경한다() { + // given + MultipartFile newImageFile = createImageFile(); + String newImageUrl = "news/new-image-url"; + given(s3Service.uploadFile(newImageFile, ImgType.NEWS)) + .willReturn(new UploadedFileUrlResponse(newImageUrl)); + NewsUpdateRequest request = createNewsUpdateRequest(null, null, null, null); + + // when + newsCommandService.updateNews( + user.getId(), + originNews.getId(), + request, + newImageFile); + + // then + News savedNews = newsRepository.findById(originNews.getId()).orElseThrow(); + assertAll( + () -> assertThat(savedNews.getThumbnailUrl()).isEqualTo(newImageUrl), + () -> then(s3Service).should(never()).deletePostImage(newsProperties.defaultThumbnailUrl()), + () -> then(s3Service).should().uploadFile(any(), any()) + ); + } + } + } + + private NewsUpdateRequest createNewsUpdateRequest(String title, String description, String url, Boolean resetToDefaultImage) { + return new NewsUpdateRequest(title, description, url, resetToDefaultImage); + } + + private MockMultipartFile createImageFile() { + return new MockMultipartFile( + "image", + "test.jpg", + "image/jpeg", + "test image content".getBytes() + ); + } + + @Nested + class 소식지_삭제_테스트 { + + @Test + void 소식지를_성공적으로_삭제한다() { + // given + News originNews = newsFixture.소식지(user.getId()); + String expectedImageUrl = originNews.getThumbnailUrl(); + + // when + NewsCommandResponse response = newsCommandService.deleteNewsById(user, originNews.getId()); + + // then + assertAll( + () -> assertThat(response.id()).isEqualTo(originNews.getId()), + () -> assertThat(newsRepository.findById(originNews.getId())).isEmpty(), + () -> then(s3Service).should().deletePostImage(expectedImageUrl) + ); + } + } +} 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..73926a6dc --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java @@ -0,0 +1,54 @@ +package com.example.solidconnection.news.service; + +import com.example.solidconnection.news.domain.News; +import com.example.solidconnection.news.dto.NewsResponse; +import com.example.solidconnection.news.dto.NewsListResponse; +import com.example.solidconnection.news.fixture.NewsFixture; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +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; +import static org.junit.jupiter.api.Assertions.assertAll; + +@TestContainerSpringBootTest +@DisplayName("소식지 조회 서비스 테스트") +class NewsQueryServiceTest { + + @Autowired + private NewsQueryService newsQueryService; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private NewsFixture newsFixture; + + @Test + void 특정_사용자의_소식지_목록을_성공적으로_조회한다() { + // given + SiteUser user1 = siteUserFixture.멘토(1, "mentor1"); + SiteUser user2 = siteUserFixture.멘토(2, "mentor2"); + News news1 = newsFixture.소식지(user1.getId()); + News news2 = newsFixture.소식지(user1.getId()); + newsFixture.소식지(user2.getId()); + List newsList = List.of(news1, news2); + + // when + NewsListResponse response = newsQueryService.findNewsBySiteUserId(user1.getId()); + + // then + assertAll( + () -> assertThat(response.newsResponseList()).hasSize(newsList.size()), + () -> assertThat(response.newsResponseList()) + .extracting(NewsResponse::updatedAt) + .isSortedAccordingTo(Comparator.reverseOrder()) + ); + } +} diff --git a/src/test/java/com/example/solidconnection/security/aspect/AdminAuthorizationAspectTest.java b/src/test/java/com/example/solidconnection/security/aspect/AdminAuthorizationAspectTest.java deleted file mode 100644 index 343bbbc30..000000000 --- a/src/test/java/com/example/solidconnection/security/aspect/AdminAuthorizationAspectTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.example.solidconnection.security.aspect; - -import com.example.solidconnection.common.exception.CustomException; -import com.example.solidconnection.security.annotation.RequireAdminAccess; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.fixture.SiteUserFixture; -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 org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.stereotype.Component; - -import static com.example.solidconnection.common.exception.ErrorCode.ACCESS_DENIED; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; - -@TestContainerSpringBootTest -@DisplayName("어드민 권한 검사 Aspect 테스트") -class AdminAuthorizationAspectTest { - - @Autowired - private TestService testService; - - @Autowired - private SiteUserFixture siteUserFixture; - - @Test - void 어드민_사용자는_어드민_전용_메소드에_접근할_수_있다() { - // given - SiteUser admin = siteUserFixture.관리자(); - - // when - boolean response = testService.adminOnlyMethod(admin); - - // then - assertThat(response).isTrue(); - } - - @Test - void 일반_사용자가_어드민_전용_메소드에_접근하면_예외_응답을_반환한다() { - // given - SiteUser user = siteUserFixture.사용자(); - - // when & then - assertThatCode(() -> testService.adminOnlyMethod(user)) - .isInstanceOf(CustomException.class) - .hasMessage(ACCESS_DENIED.getMessage()); - } - - @Test - void 어드민_어노테이션이_없는_메소드는_모두_접근_가능하다() { - // given - SiteUser user = siteUserFixture.사용자(); - SiteUser admin = siteUserFixture.관리자(); - - // when - boolean menteeResponse = testService.publicMethod(user); - boolean adminResponse = testService.publicMethod(admin); - - // then - assertThat(menteeResponse).isTrue(); - assertThat(adminResponse).isTrue(); - } - - @TestConfiguration - static class TestConfig { - - @Bean - public TestService testService() { - return new TestService(); - } - } - - @Component - static class TestService { - - @RequireAdminAccess - public boolean adminOnlyMethod(SiteUser siteUser) { - return true; - } - - public boolean publicMethod(SiteUser siteUser) { - return true; - } - } -} diff --git a/src/test/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspectTest.java b/src/test/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspectTest.java new file mode 100644 index 000000000..a3a1333e8 --- /dev/null +++ b/src/test/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspectTest.java @@ -0,0 +1,99 @@ +package com.example.solidconnection.security.aspect; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.security.annotation.RequireRoleAccess; +import com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +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 org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Component; + +import static com.example.solidconnection.common.exception.ErrorCode.ACCESS_DENIED; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +@TestContainerSpringBootTest +@DisplayName("권한 검사 Aspect 테스트") +class RoleAuthorizationAspectTest { + + @Autowired + private TestService testService; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Test + void 요구하는_역할을_가진_사용자는_메서드를_정상적으로_호출할_수_있다() { + // given + SiteUser admin = siteUserFixture.관리자(); + SiteUser mentor = siteUserFixture.멘토(1, "mentor"); + + // when & then + assertAll( + () -> assertThatCode(() -> testService.adminOnlyMethod(admin)) + .doesNotThrowAnyException(), + () -> assertThatCode(() -> testService.mentorOrAdminMethod(mentor)) + .doesNotThrowAnyException() + ); + } + + @Test + void 요구하는_역할이_없는_사용자가_메서드를_호출하면_예외가_발생한다() { + // given + SiteUser user = siteUserFixture.사용자(); + + // when & then + assertThatCode(() -> testService.mentorOrAdminMethod(user)) + .isInstanceOf(CustomException.class) + .hasMessage(ACCESS_DENIED.getMessage()); + } + + @Test + void 역할을_요구하지_않는_메서드는_누구나_호출할_수_있다() { + // given + SiteUser admin = siteUserFixture.관리자(); + SiteUser mentor = siteUserFixture.멘토(1, "mentor"); + SiteUser user = siteUserFixture.사용자(); + + // when & then + assertAll( + () -> assertThatCode(() -> testService.publicMethod(admin)) + .doesNotThrowAnyException(), + () -> assertThatCode(() -> testService.publicMethod(mentor)) + .doesNotThrowAnyException(), + () -> assertThatCode(() -> testService.publicMethod(user)) + .doesNotThrowAnyException() + ); + } + + @TestConfiguration + static class TestConfig { + @Bean + public TestService testService() { + return new TestService(); + } + } + + @Component + static class TestService { + + @RequireRoleAccess(roles = {Role.ADMIN}) + public boolean adminOnlyMethod(SiteUser siteUser) { + return true; + } + + @RequireRoleAccess(roles = {Role.ADMIN, Role.MENTOR}) + public boolean mentorOrAdminMethod(SiteUser siteUser) { + return true; + } + + public boolean publicMethod(SiteUser siteUser) { + return true; + } + } +} diff --git a/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java b/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java index 664a727c1..9c2eb12bc 100644 --- a/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java @@ -56,6 +56,17 @@ public class SiteUserFixture { .create(); } + public SiteUser 멘토(int index, String nickname) { + return siteUserFixtureBuilder.siteUser() + .email("mentor" + index + "@example.com") + .authType(AuthType.EMAIL) + .nickname(nickname) + .profileImageUrl("profileImageUrl") + .role(Role.MENTOR) + .password("mentor123") + .create(); + } + public SiteUser 관리자() { return siteUserFixtureBuilder.siteUser() .email("admin@example.com") diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 7c6f83171..83fe6e8cf 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -71,3 +71,5 @@ jwt: cors: allowed-origins: - "http://localhost:8080" +news: + default-thumbnail-url: "default-thumbnail-url"