From 583f7e713a624e6e1bad6fb71074985a51dbf823 Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Sun, 29 Jun 2025 12:42:40 +0900 Subject: [PATCH 01/23] =?UTF-8?q?feat:=20News=EC=97=90=20siteUserId=20FK?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/solidconnection/news/domain/News.java | 2 ++ .../db/migration/V20__add_site_user_id_fk_to_news.sql | 4 ++++ 2 files changed, 6 insertions(+) create mode 100644 src/main/resources/db/migration/V20__add_site_user_id_fk_to_news.sql 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..e2f9310b5 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,6 @@ public class News extends BaseEntity { @Column(length = 500) private String url; + + private long siteUserId; } 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); From aef798740b0d6728975a9b63e5c8838fc7312a6a Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Sun, 29 Jun 2025 13:02:19 +0900 Subject: [PATCH 02/23] =?UTF-8?q?refactor:=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EC=97=AD?= =?UTF-8?q?=ED=95=A0=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=9C=A0?= =?UTF-8?q?=EC=97=B0=ED=95=98=EA=B2=8C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 어드민 전용 어노테이션을 다중 역할 지원하도록 확장 - @RequireRoleAccess 어노테이션으로 여러 역할 조합 가능하게 변경 --- .../controller/ApplicationController.java | 5 +- ...dminAccess.java => RequireRoleAccess.java} | 5 +- ...pect.java => RoleAuthorizationAspect.java} | 25 ++- .../aspect/AdminAuthorizationAspectTest.java | 88 --------- .../aspect/RoleAuthorizationAspectTest.java | 173 ++++++++++++++++++ .../siteuser/fixture/SiteUserFixture.java | 11 ++ 6 files changed, 209 insertions(+), 98 deletions(-) rename src/main/java/com/example/solidconnection/security/annotation/{RequireAdminAccess.java => RequireRoleAccess.java} (67%) rename src/main/java/com/example/solidconnection/security/aspect/{AdminAuthorizationAspect.java => RoleAuthorizationAspect.java} (51%) delete mode 100644 src/test/java/com/example/solidconnection/security/aspect/AdminAuthorizationAspectTest.java create mode 100644 src/test/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspectTest.java 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/security/annotation/RequireAdminAccess.java b/src/main/java/com/example/solidconnection/security/annotation/RequireRoleAccess.java similarity index 67% 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..348c743eb 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() default {Role.ADMIN}; } 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 51% 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..0dd66e1fb 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,16 @@ public Object checkAdminAccess(ProceedingJoinPoint joinPoint, break; } } - if (siteUser == null || !ADMIN.equals(siteUser.getRole())) { + + if (siteUser == null) { + throw new CustomException(ACCESS_DENIED); + } + + final SiteUser finalSiteUser = siteUser; + Role[] allowedRoles = requireRoleAccess.roles(); + boolean hasAccess = Arrays.stream(allowedRoles) + .anyMatch(role -> role.equals(finalSiteUser.getRole())); + if (!hasAccess) { throw new CustomException(ACCESS_DENIED); } return joinPoint.proceed(); 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..45c35da2c --- /dev/null +++ b/src/test/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspectTest.java @@ -0,0 +1,173 @@ +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.Nested; +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 RoleAuthorizationAspectTest { + + @Autowired + private TestService testService; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Nested + class 어드민_권한_테스트 { + + @Test + void 어드민은_어드민_전용_메소드에_접근할_수_있다() { + // given + SiteUser admin = siteUserFixture.관리자(); + + // when + boolean response = testService.adminOnlyMethod(admin); + + // then + assertThat(response).isTrue(); + } + + @Test + void 어드민은_멘토_또는_어드민_메소드에_접근할_수_있다() { + // given + SiteUser admin = siteUserFixture.관리자(); + + // when + boolean response = testService.mentorOrAdminMethod(admin); + + // then + assertThat(response).isTrue(); + } + + @Test + void 어드민은_권한_제한이_없는_메소드에_접근할_수_있다() { + // given + SiteUser admin = siteUserFixture.관리자(); + + // when + boolean response = testService.publicMethod(admin); + + // then + assertThat(response).isTrue(); + } + } + + @Nested + class 멘토_권한_테스트 { + + @Test + void 멘토가_어드민_전용_메소드에_접근하면_예외가_발생한다() { + // given + SiteUser mentor = siteUserFixture.멘토(); + + // when & then + assertThatCode(() -> testService.adminOnlyMethod(mentor)) + .isInstanceOf(CustomException.class) + .hasMessage(ACCESS_DENIED.getMessage()); + } + + @Test + void 멘토는_멘토_또는_어드민_메소드에_접근할_수_있다() { + // given + SiteUser mentor = siteUserFixture.멘토(); + + // when + boolean response = testService.mentorOrAdminMethod(mentor); + + // then + assertThat(response).isTrue(); + } + + @Test + void 멘토는_권한_제한이_없는_메소드에_접근할_수_있다() { + // given + SiteUser mentor = siteUserFixture.멘토(); + + // when + boolean response = testService.publicMethod(mentor); + + // then + assertThat(response).isTrue(); + } + } + + @Nested + class 일반_사용자_권한_테스트 { + + @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.사용자(); + + // when & then + assertThatCode(() -> testService.mentorOrAdminMethod(user)) + .isInstanceOf(CustomException.class) + .hasMessage(ACCESS_DENIED.getMessage()); + } + + @Test + void 일반_사용자는_권한_제한이_없는_메소드에_접근할_수_있다() { + // given + SiteUser user = siteUserFixture.사용자(); + + // when + boolean response = testService.publicMethod(user); + + // then + assertThat(response).isTrue(); + } + } + + @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..5027be46f 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 멘토() { + return siteUserFixtureBuilder.siteUser() + .email("mentor@example.com") + .authType(AuthType.EMAIL) + .nickname("멘토") + .profileImageUrl("profileImageUrl") + .role(Role.MENTOR) + .password("mentor123") + .create(); + } + public SiteUser 관리자() { return siteUserFixtureBuilder.siteUser() .email("admin@example.com") From 5563ef964107de95b359d59158baabcedbefd661 Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Sun, 29 Jun 2025 13:53:25 +0900 Subject: [PATCH 03/23] =?UTF-8?q?feat:=20=EC=86=8C=EC=8B=9D=EC=A7=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/controller/NewsController.java | 37 ++++++++++++++++++ .../solidconnection/news/domain/News.java | 20 +++++++++- .../news/dto/NewsCommandResponse.java | 13 +++++++ .../news/dto/NewsCreateRequest.java | 33 ++++++++++++++++ .../news/repository/NewsRepository.java | 7 ++++ .../news/service/NewsCommandService.java | 38 +++++++++++++++++++ .../solidconnection/s3/domain/ImgType.java | 2 +- 7 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/news/controller/NewsController.java create mode 100644 src/main/java/com/example/solidconnection/news/dto/NewsCommandResponse.java create mode 100644 src/main/java/com/example/solidconnection/news/dto/NewsCreateRequest.java create mode 100644 src/main/java/com/example/solidconnection/news/repository/NewsRepository.java create mode 100644 src/main/java/com/example/solidconnection/news/service/NewsCommandService.java 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..fcbae41aa --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/controller/NewsController.java @@ -0,0 +1,37 @@ +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.service.NewsCommandService; +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.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 NewsCommandService newsCommandService; + + @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); + } +} 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 e2f9310b5..8754c2e81 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 { @@ -31,4 +34,17 @@ public class News extends BaseEntity { 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; + } } 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..ebfe1742a --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsCreateRequest.java @@ -0,0 +1,33 @@ +package com.example.solidconnection.news.dto; + +import com.example.solidconnection.news.domain.News; +import com.fasterxml.jackson.annotation.JsonProperty; +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, + + @JsonProperty("contentPreview") + @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/repository/NewsRepository.java b/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java new file mode 100644 index 000000000..0e00b7e19 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.news.repository; + +import com.example.solidconnection.news.domain.News; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NewsRepository extends JpaRepository { +} 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..fd9230ca0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java @@ -0,0 +1,38 @@ +package com.example.solidconnection.news.service; + +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.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; + +@Service +@RequiredArgsConstructor +public class NewsCommandService { + + private final S3Service s3Service; + 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(); + } + // todo: default 이미지 URL을 설정하는 로직 필요 + return "https://default-logo.png"; + } +} 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; From e002fccd43dc11f40730ea03e3d7e44c9ac93803 Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Sun, 29 Jun 2025 13:59:55 +0900 Subject: [PATCH 04/23] =?UTF-8?q?test:=20=EC=86=8C=EC=8B=9D=EC=A7=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EC=86=8C=EC=8B=9D=EC=A7=80=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20fixture=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/fixture/NewsFixture.java | 22 +++++ .../news/fixture/NewsFixtureBuilder.java | 54 ++++++++++++ .../news/service/NewsCommandServiceTest.java | 83 +++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 src/test/java/com/example/solidconnection/news/fixture/NewsFixture.java create mode 100644 src/test/java/com/example/solidconnection/news/fixture/NewsFixtureBuilder.java create mode 100644 src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java 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..c4a1bcf4f --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/fixture/NewsFixture.java @@ -0,0 +1,22 @@ +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(); + } +} 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..d67de7442 --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java @@ -0,0 +1,83 @@ +package com.example.solidconnection.news.service; + +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.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 org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@TestContainerSpringBootTest +@DisplayName("소식지 생성/수정/삭제 서비스 테스트") +public class NewsCommandServiceTest { + + @Autowired + private NewsCommandService newsCommandService; + + @MockBean + private S3Service s3Service; + + @Autowired + private NewsRepository newsRepository; + + @Autowired + private SiteUserFixture siteUserFixture; + + private SiteUser user; + + @BeforeEach + void setUp() { + user = siteUserFixture.멘토(); + } + + @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"); + } + + private MockMultipartFile createImageFile() { + return new MockMultipartFile( + "image", + "test.jpg", + "image/jpeg", + "test image content".getBytes() + ); + } +} From a968baf5221f57518b0836d1b04191ab6a756b70 Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Sun, 29 Jun 2025 19:32:01 +0900 Subject: [PATCH 05/23] =?UTF-8?q?feat:=20=EC=86=8C=EC=8B=9D=EC=A7=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20api=20=EC=B6=94=EA=B0=80=20-=20default=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20URL=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=96=B4=EB=96=BB=EA=B2=8C=20=ED=95=A0=EC=A7=80=20=EB=85=BC?= =?UTF-8?q?=EC=9D=98=20=ED=95=84=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/ErrorCode.java | 5 + .../news/controller/NewsController.java | 19 ++ .../solidconnection/news/domain/News.java | 16 ++ .../news/dto/NewsUpdateRequest.java | 21 ++ .../news/service/NewsCommandService.java | 66 ++++- .../news/fixture/NewsFixture.java | 10 + .../news/service/NewsCommandServiceTest.java | 252 +++++++++++++++++- .../aspect/RoleAuthorizationAspectTest.java | 6 +- .../siteuser/fixture/SiteUserFixture.java | 6 +- 9 files changed, 392 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java 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/controller/NewsController.java b/src/main/java/com/example/solidconnection/news/controller/NewsController.java index fcbae41aa..47dce1560 100644 --- a/src/main/java/com/example/solidconnection/news/controller/NewsController.java +++ b/src/main/java/com/example/solidconnection/news/controller/NewsController.java @@ -3,6 +3,7 @@ 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.NewsUpdateRequest; import com.example.solidconnection.news.service.NewsCommandService; import com.example.solidconnection.security.annotation.RequireRoleAccess; import com.example.solidconnection.siteuser.domain.Role; @@ -10,6 +11,8 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +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; @@ -34,4 +37,20 @@ public ResponseEntity createNews( NewsCommandResponse newsCommandResponse = newsCommandService.createNews(siteUser.getId(), newsCreateRequest, imageFile); return ResponseEntity.ok(newsCommandResponse); } + + @RequireRoleAccess(roles = {Role.ADMIN, Role.MENTOR}) + @PatchMapping("/{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); + } } 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 8754c2e81..cc12ea5da 100644 --- a/src/main/java/com/example/solidconnection/news/domain/News.java +++ b/src/main/java/com/example/solidconnection/news/domain/News.java @@ -47,4 +47,20 @@ public News( this.url = url; this.siteUserId = siteUserId; } + + 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/NewsUpdateRequest.java b/src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java new file mode 100644 index 000000000..56f43ec3b --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java @@ -0,0 +1,21 @@ +package com.example.solidconnection.news.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Size; +import org.hibernate.validator.constraints.URL; + +public record NewsUpdateRequest( + @Size(max = 20, message = "소식지 제목은 20자 이하여야 합니다.") + String title, + + @JsonProperty("contentPreview") + @Size(max = 30, message = "소식지 내용은 30자 이하여야 합니다.") + String description, + + @Size(max = 500, message = "소식지 URL은 500자 이하여야 합니다.") + @URL(message = "올바른 URL 형식이 아닙니다.") + String url, + + Boolean resetToDefaultImage +) { +} diff --git a/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java index fd9230ca0..d97f22063 100644 --- a/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java +++ b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java @@ -1,8 +1,10 @@ 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.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; @@ -12,10 +14,16 @@ 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 { + // todo: default 이미지 URL을 설정하는 로직 필요 + private static final String DEFAULT_IMAGE_URL = "news/default-logo.png"; + private final S3Service s3Service; private final NewsRepository newsRepository; @@ -27,12 +35,66 @@ public NewsCommandResponse createNews(Long siteUserId,NewsCreateRequest newsCrea return NewsCommandResponse.from(savedNews); } + @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); + updateNews(news, newsUpdateRequest); + updateThumbnail(news, imageFile, newsUpdateRequest.resetToDefaultImage()); + 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(); } - // todo: default 이미지 URL을 설정하는 로직 필요 - return "https://default-logo.png"; + return DEFAULT_IMAGE_URL; + } + + private void validateOwnership(News news, Long siteUserId) { + if (news.getSiteUserId() != siteUserId) { + throw new CustomException(INVALID_NEWS_ACCESS); + } + } + + private void updateNews(News news, NewsUpdateRequest request) { + if (hasValue(request.title())) { + news.updateTitle(request.title()); + } + if (hasValue(request.description())) { + news.updateDescription(request.description()); + } + if (hasValue(request.url())) { + news.updateUrl(request.url()); + } + } + + private void updateThumbnail(News news, MultipartFile imageFile, Boolean resetToDefaultImage) { + if (Boolean.TRUE.equals(resetToDefaultImage)) { + deleteCustomImage(news.getThumbnailUrl()); + news.updateThumbnailUrl(DEFAULT_IMAGE_URL); + } + else if (imageFile != null && !imageFile.isEmpty()) { + UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, ImgType.NEWS); + deleteCustomImage(news.getThumbnailUrl()); + news.updateThumbnailUrl(uploadedFile.fileUrl()); + } + } + + private void deleteCustomImage(String imageUrl) { + if (!DEFAULT_IMAGE_URL.equals(imageUrl)) { + s3Service.deletePostImage(imageUrl); + } + } + + private boolean hasValue(String value) { + return value != null && !value.trim().isEmpty(); } } diff --git a/src/test/java/com/example/solidconnection/news/fixture/NewsFixture.java b/src/test/java/com/example/solidconnection/news/fixture/NewsFixture.java index c4a1bcf4f..44091a51b 100644 --- a/src/test/java/com/example/solidconnection/news/fixture/NewsFixture.java +++ b/src/test/java/com/example/solidconnection/news/fixture/NewsFixture.java @@ -19,4 +19,14 @@ public class NewsFixture { .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/service/NewsCommandServiceTest.java b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java index d67de7442..05a91d698 100644 --- a/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java +++ b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java @@ -1,8 +1,11 @@ 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.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; @@ -19,10 +22,15 @@ 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.then; +import static org.mockito.BDDMockito.never; @TestContainerSpringBootTest @DisplayName("소식지 생성/수정/삭제 서비스 테스트") @@ -40,11 +48,14 @@ public class NewsCommandServiceTest { @Autowired private SiteUserFixture siteUserFixture; + @Autowired + private NewsFixture newsFixture; + private SiteUser user; @BeforeEach void setUp() { - user = siteUserFixture.멘토(); + user = siteUserFixture.멘토(1, "mentor"); } @Nested @@ -68,10 +79,249 @@ class 소식지_생성_테스트 { } } + @Nested + class 소식지_수정_테스트 { + + private final String CUSTOM_IMAGE_URL = "news/custom-image-url"; + private final String DEFAULT_IMAGE_URL = "news/default-logo.png"; + + 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 + String expectedTitle = "제목 수정"; + String originalDescription = originNews.getDescription(); + String originalUrl = originNews.getUrl(); + String originalThumbnailUrl = originNews.getThumbnailUrl(); + NewsUpdateRequest request = createNewsUpdateRequest( + expectedTitle, + null, + null, + null); + + // when + NewsCommandResponse response = newsCommandService.updateNews( + user.getId(), + 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 + NewsCommandResponse response = newsCommandService.updateNews( + user.getId(), + originNews.getId(), + request, + null); + + // then + News savedNews = newsRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(savedNews.getTitle()).isEqualTo(originNews.getTitle()), + () -> assertThat(savedNews.getDescription()).isEqualTo(originNews.getDescription()) + ); + } + + @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(); + assertThat(savedNews.getThumbnailUrl()).isEqualTo(DEFAULT_IMAGE_URL); + 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(); + 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(), DEFAULT_IMAGE_URL); + } + + @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(); + assertThat(savedNews.getThumbnailUrl()).isEqualTo(DEFAULT_IMAGE_URL); + then(s3Service).should(never()).deletePostImage(DEFAULT_IMAGE_URL); + then(s3Service).should(never()).uploadFile(any(), any()); + } + + @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(); + assertThat(savedNews.getThumbnailUrl()).isEqualTo(newImageUrl); + then(s3Service).should(never()).deletePostImage(DEFAULT_IMAGE_URL); + then(s3Service).should().uploadFile(any(), any()); + } + } + } + private NewsCreateRequest createNewsCreateRequest() { return new NewsCreateRequest("제목", "설명", "https://youtu.be/test"); } + 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", diff --git a/src/test/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspectTest.java b/src/test/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspectTest.java index 45c35da2c..c164872c3 100644 --- a/src/test/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspectTest.java +++ b/src/test/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspectTest.java @@ -74,7 +74,7 @@ class 멘토_권한_테스트 { @Test void 멘토가_어드민_전용_메소드에_접근하면_예외가_발생한다() { // given - SiteUser mentor = siteUserFixture.멘토(); + SiteUser mentor = siteUserFixture.멘토(1, "mentor"); // when & then assertThatCode(() -> testService.adminOnlyMethod(mentor)) @@ -85,7 +85,7 @@ class 멘토_권한_테스트 { @Test void 멘토는_멘토_또는_어드민_메소드에_접근할_수_있다() { // given - SiteUser mentor = siteUserFixture.멘토(); + SiteUser mentor = siteUserFixture.멘토(1, "mentor"); // when boolean response = testService.mentorOrAdminMethod(mentor); @@ -97,7 +97,7 @@ class 멘토_권한_테스트 { @Test void 멘토는_권한_제한이_없는_메소드에_접근할_수_있다() { // given - SiteUser mentor = siteUserFixture.멘토(); + SiteUser mentor = siteUserFixture.멘토(1, "mentor"); // when boolean response = testService.publicMethod(mentor); 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 5027be46f..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,11 +56,11 @@ public class SiteUserFixture { .create(); } - public SiteUser 멘토() { + public SiteUser 멘토(int index, String nickname) { return siteUserFixtureBuilder.siteUser() - .email("mentor@example.com") + .email("mentor" + index + "@example.com") .authType(AuthType.EMAIL) - .nickname("멘토") + .nickname(nickname) .profileImageUrl("profileImageUrl") .role(Role.MENTOR) .password("mentor123") From 9b246fb9fcf73e7c180810be43999364e962efa7 Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Sun, 29 Jun 2025 19:42:36 +0900 Subject: [PATCH 06/23] =?UTF-8?q?feat:=20=EC=86=8C=EC=8B=9D=EC=A7=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20api=20=EC=B6=94=EA=B0=80=20-=20=EC=96=B4?= =?UTF-8?q?=EB=93=9C=EB=AF=BC=EC=9D=80=20=EA=B7=B8=EB=83=A5=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B0=80=EB=8A=A5=ED=95=9C=EC=A7=80=20=EB=85=BC?= =?UTF-8?q?=EC=9D=98=20=ED=95=84=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/controller/NewsController.java | 11 ++++++++++ .../news/service/NewsCommandService.java | 20 +++++++++++++++++++ .../news/service/NewsCommandServiceTest.java | 19 ++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/src/main/java/com/example/solidconnection/news/controller/NewsController.java b/src/main/java/com/example/solidconnection/news/controller/NewsController.java index 47dce1560..075d3a808 100644 --- a/src/main/java/com/example/solidconnection/news/controller/NewsController.java +++ b/src/main/java/com/example/solidconnection/news/controller/NewsController.java @@ -11,6 +11,7 @@ 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.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -53,4 +54,14 @@ public ResponseEntity updateNews( 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/service/NewsCommandService.java b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java index d97f22063..e8c0bc1bc 100644 --- a/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java +++ b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java @@ -9,6 +9,8 @@ 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; @@ -50,6 +52,16 @@ public NewsCommandResponse updateNews( return NewsCommandResponse.from(savedNews); } + @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 String getImageUrl(MultipartFile imageFile) { if (imageFile != null && !imageFile.isEmpty()) { UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, ImgType.NEWS); @@ -64,6 +76,14 @@ private void validateOwnership(News news, Long siteUserId) { } } + 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 updateNews(News news, NewsUpdateRequest request) { if (hasValue(request.title())) { news.updateTitle(request.title()); diff --git a/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java index 05a91d698..7ea51a696 100644 --- a/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java +++ b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java @@ -314,6 +314,25 @@ void setUp() { } } + @Nested + class 소식지_삭제_테스트 { + + @Test + void 소식지를_성공적으로_삭제한다() { + // given + News originNews = newsFixture.소식지(user.getId()); + String expectedImageUrl = originNews.getThumbnailUrl(); + + // when + NewsCommandResponse response = newsCommandService.deleteNewsById(user, 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"); } From 1e8461f7e490d94746c97ddb62cc45f4694d095b Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Sun, 29 Jun 2025 20:06:10 +0900 Subject: [PATCH 07/23] =?UTF-8?q?feat:=20=ED=8A=B9=EC=A0=95=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=EC=9D=98=20=EC=86=8C=EC=8B=9D=EC=A7=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20api=20=EC=B6=94=EA=B0=80=20-?= =?UTF-8?q?=20=EC=B6=94=ED=9B=84=20Slice=20=EC=A0=81=EC=9A=A9=20=ED=95=84?= =?UTF-8?q?=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/controller/NewsController.java | 13 +++++ .../news/dto/NewsItemResponse.java | 29 +++++++++++ .../news/dto/NewsResponse.java | 11 ++++ .../news/repository/NewsRepository.java | 3 ++ .../news/service/NewsQueryService.java | 27 ++++++++++ .../news/service/NewsQueryServiceTest.java | 51 +++++++++++++++++++ 6 files changed, 134 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/news/dto/NewsItemResponse.java create mode 100644 src/main/java/com/example/solidconnection/news/dto/NewsResponse.java create mode 100644 src/main/java/com/example/solidconnection/news/service/NewsQueryService.java create mode 100644 src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java diff --git a/src/main/java/com/example/solidconnection/news/controller/NewsController.java b/src/main/java/com/example/solidconnection/news/controller/NewsController.java index 075d3a808..6541e1ac5 100644 --- a/src/main/java/com/example/solidconnection/news/controller/NewsController.java +++ b/src/main/java/com/example/solidconnection/news/controller/NewsController.java @@ -3,8 +3,10 @@ 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.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.RequireRoleAccess; import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; @@ -12,6 +14,7 @@ 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; @@ -26,8 +29,18 @@ @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 + ) { + NewsResponse newsResponse = newsQueryService.findNewsBySiteUserId(siteUserId); + return ResponseEntity.ok(newsResponse); + } + @RequireRoleAccess(roles = {Role.ADMIN, Role.MENTOR}) @PostMapping public ResponseEntity createNews( 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..c78b358da --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/NewsItemResponse.java @@ -0,0 +1,29 @@ +package com.example.solidconnection.news.dto; + +import com.example.solidconnection.news.domain.News; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.ZonedDateTime; + +public record NewsItemResponse( + long id, + String title, + + @JsonProperty("contentPreview") + String description, + + String thumbnailUrl, + String url, + ZonedDateTime updatedAt +) { + public static NewsItemResponse from(News news) { + return new NewsItemResponse( + news.getId(), + news.getTitle(), + news.getDescription(), + news.getThumbnailUrl(), + news.getUrl(), + 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/repository/NewsRepository.java b/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java index 0e00b7e19..8d7029013 100644 --- a/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java +++ b/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java @@ -3,5 +3,8 @@ 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/NewsQueryService.java b/src/main/java/com/example/solidconnection/news/service/NewsQueryService.java new file mode 100644 index 000000000..f4740769b --- /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.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; + +@Service +@RequiredArgsConstructor +public class NewsQueryService { + + private final NewsRepository newsRepository; + + @Transactional(readOnly = true) + public NewsResponse findNewsBySiteUserId(Long siteUserId) { + List newsList = newsRepository.findAllBySiteUserIdOrderByUpdatedAtDesc(siteUserId); + List newsItemsResponseList = newsList.stream() + .map(NewsItemResponse::from) + .toList(); + return NewsResponse.from(newsItemsResponseList); + } +} 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..9c88c0b59 --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java @@ -0,0 +1,51 @@ +package com.example.solidconnection.news.service; + +import com.example.solidconnection.news.domain.News; +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.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; + +@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 + NewsResponse response = newsQueryService.findNewsBySiteUserId(user1.getId()); + + // then + assertThat(response.newsItemsResponseList()).hasSize(newsList.size()); + assertThat(response.newsItemsResponseList()) + .extracting(NewsItemResponse::updatedAt) + .isSortedAccordingTo(Comparator.reverseOrder()); + } +} From 4eaf083acec05ae8b092a808bd32b4462939d01e Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Sun, 29 Jun 2025 20:17:29 +0900 Subject: [PATCH 08/23] =?UTF-8?q?style:=20news=5Fid=EC=97=90=EC=84=9C=20ne?= =?UTF-8?q?ws-id=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solidconnection/news/controller/NewsController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/solidconnection/news/controller/NewsController.java b/src/main/java/com/example/solidconnection/news/controller/NewsController.java index 6541e1ac5..39cdad430 100644 --- a/src/main/java/com/example/solidconnection/news/controller/NewsController.java +++ b/src/main/java/com/example/solidconnection/news/controller/NewsController.java @@ -69,10 +69,10 @@ public ResponseEntity updateNews( } @RequireRoleAccess(roles = {Role.ADMIN, Role.MENTOR}) - @DeleteMapping("/{news_id}") + @DeleteMapping("/{news-id}") public ResponseEntity deleteNewsById( @AuthorizedUser SiteUser siteUser, - @PathVariable("news_id") Long newsId + @PathVariable("news-id") Long newsId ) { NewsCommandResponse newsCommandResponse = newsCommandService.deleteNewsById(siteUser, newsId); return ResponseEntity.ok(newsCommandResponse); From 37a0bb95ad556f76b2489cb5e71dd4412b9b3e45 Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Sun, 29 Jun 2025 20:18:53 +0900 Subject: [PATCH 09/23] =?UTF-8?q?style:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=EC=97=90=EC=84=9C=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20public=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solidconnection/news/service/NewsCommandServiceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java index 7ea51a696..80111bc5f 100644 --- a/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java +++ b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java @@ -34,7 +34,7 @@ @TestContainerSpringBootTest @DisplayName("소식지 생성/수정/삭제 서비스 테스트") -public class NewsCommandServiceTest { +class NewsCommandServiceTest { @Autowired private NewsCommandService newsCommandService; From 627c6910523b4c542f28d00be2bedece25cea3d8 Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Sun, 29 Jun 2025 20:29:23 +0900 Subject: [PATCH 10/23] =?UTF-8?q?refactor:=20@JsonProperty=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/solidconnection/news/dto/NewsCreateRequest.java | 2 -- .../example/solidconnection/news/dto/NewsItemResponse.java | 4 ---- .../example/solidconnection/news/dto/NewsUpdateRequest.java | 2 -- 3 files changed, 8 deletions(-) diff --git a/src/main/java/com/example/solidconnection/news/dto/NewsCreateRequest.java b/src/main/java/com/example/solidconnection/news/dto/NewsCreateRequest.java index ebfe1742a..660611537 100644 --- a/src/main/java/com/example/solidconnection/news/dto/NewsCreateRequest.java +++ b/src/main/java/com/example/solidconnection/news/dto/NewsCreateRequest.java @@ -1,7 +1,6 @@ package com.example.solidconnection.news.dto; import com.example.solidconnection.news.domain.News; -import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import org.hibernate.validator.constraints.URL; @@ -11,7 +10,6 @@ public record NewsCreateRequest( @Size(max = 20, message = "소식지 제목은 20자 이하여야 합니다.") String title, - @JsonProperty("contentPreview") @NotBlank(message = "소식지 내용을 입력해주세요.") @Size(max = 30, message = "소식지 내용은 30자 이하여야 합니다.") String description, diff --git a/src/main/java/com/example/solidconnection/news/dto/NewsItemResponse.java b/src/main/java/com/example/solidconnection/news/dto/NewsItemResponse.java index c78b358da..0346a615f 100644 --- a/src/main/java/com/example/solidconnection/news/dto/NewsItemResponse.java +++ b/src/main/java/com/example/solidconnection/news/dto/NewsItemResponse.java @@ -1,17 +1,13 @@ package com.example.solidconnection.news.dto; import com.example.solidconnection.news.domain.News; -import com.fasterxml.jackson.annotation.JsonProperty; import java.time.ZonedDateTime; public record NewsItemResponse( long id, String title, - - @JsonProperty("contentPreview") String description, - String thumbnailUrl, String url, ZonedDateTime updatedAt diff --git a/src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java b/src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java index 56f43ec3b..8620fbe77 100644 --- a/src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java +++ b/src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java @@ -1,6 +1,5 @@ package com.example.solidconnection.news.dto; -import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.Size; import org.hibernate.validator.constraints.URL; @@ -8,7 +7,6 @@ public record NewsUpdateRequest( @Size(max = 20, message = "소식지 제목은 20자 이하여야 합니다.") String title, - @JsonProperty("contentPreview") @Size(max = 30, message = "소식지 내용은 30자 이하여야 합니다.") String description, From 7d928040f4479fa962432ec93033def0c6fb3a7b Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:20:52 +0900 Subject: [PATCH 11/23] =?UTF-8?q?refactor:=20defaultThumbnailUrl=20applica?= =?UTF-8?q?tion-varaible.yml=EC=97=90=EC=84=9C=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A4=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/config/NewsProperties.java | 9 +++++++++ .../news/service/NewsCommandService.java | 11 +++++------ src/main/resources/secret | 2 +- .../news/service/NewsCommandServiceTest.java | 19 +++++++++++-------- src/test/resources/application.yml | 2 ++ 5 files changed, 28 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/news/config/NewsProperties.java 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/service/NewsCommandService.java b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java index e8c0bc1bc..798299ebc 100644 --- a/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java +++ b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java @@ -1,6 +1,7 @@ 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; @@ -23,10 +24,8 @@ @RequiredArgsConstructor public class NewsCommandService { - // todo: default 이미지 URL을 설정하는 로직 필요 - private static final String DEFAULT_IMAGE_URL = "news/default-logo.png"; - private final S3Service s3Service; + private final NewsProperties newsProperties; private final NewsRepository newsRepository; @Transactional @@ -67,7 +66,7 @@ private String getImageUrl(MultipartFile imageFile) { UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, ImgType.NEWS); return uploadedFile.fileUrl(); } - return DEFAULT_IMAGE_URL; + return newsProperties.defaultThumbnailUrl(); } private void validateOwnership(News news, Long siteUserId) { @@ -99,7 +98,7 @@ private void updateNews(News news, NewsUpdateRequest request) { private void updateThumbnail(News news, MultipartFile imageFile, Boolean resetToDefaultImage) { if (Boolean.TRUE.equals(resetToDefaultImage)) { deleteCustomImage(news.getThumbnailUrl()); - news.updateThumbnailUrl(DEFAULT_IMAGE_URL); + news.updateThumbnailUrl(newsProperties.defaultThumbnailUrl()); } else if (imageFile != null && !imageFile.isEmpty()) { UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, ImgType.NEWS); @@ -109,7 +108,7 @@ else if (imageFile != null && !imageFile.isEmpty()) { } private void deleteCustomImage(String imageUrl) { - if (!DEFAULT_IMAGE_URL.equals(imageUrl)) { + if (!newsProperties.defaultThumbnailUrl().equals(imageUrl)) { s3Service.deletePostImage(imageUrl); } } diff --git a/src/main/resources/secret b/src/main/resources/secret index 84002e866..bfa5e203b 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit 84002e86670d380219f580c6605fb7c66ed7d977 +Subproject commit bfa5e203bb72ea70ce4eaeb10f4e5d8779f9b4df diff --git a/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java index 80111bc5f..16343616f 100644 --- a/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java +++ b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java @@ -1,6 +1,7 @@ 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; @@ -29,8 +30,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.then; import static org.mockito.BDDMockito.never; +import static org.mockito.BDDMockito.then; @TestContainerSpringBootTest @DisplayName("소식지 생성/수정/삭제 서비스 테스트") @@ -39,6 +40,9 @@ class NewsCommandServiceTest { @Autowired private NewsCommandService newsCommandService; + @Autowired + private NewsProperties newsProperties; + @MockBean private S3Service s3Service; @@ -82,8 +86,7 @@ class 소식지_생성_테스트 { @Nested class 소식지_수정_테스트 { - private final String CUSTOM_IMAGE_URL = "news/custom-image-url"; - private final String DEFAULT_IMAGE_URL = "news/default-logo.png"; + private static final String CUSTOM_IMAGE_URL = "news/custom-image-url"; private News originNews; @@ -225,7 +228,7 @@ void setUp() { // then News savedNews = newsRepository.findById(response.id()).orElseThrow(); - assertThat(savedNews.getThumbnailUrl()).isEqualTo(DEFAULT_IMAGE_URL); + assertThat(savedNews.getThumbnailUrl()).isEqualTo(newsProperties.defaultThumbnailUrl()); then(s3Service).should().deletePostImage(CUSTOM_IMAGE_URL); then(s3Service).should(never()).uploadFile(null, ImgType.NEWS); } @@ -263,7 +266,7 @@ class 기본_이미지_관련_수정_테스트 { @BeforeEach void setUp() { - originNews = newsFixture.소식지(user.getId(), DEFAULT_IMAGE_URL); + originNews = newsFixture.소식지(user.getId(), newsProperties.defaultThumbnailUrl()); } @Test @@ -284,8 +287,8 @@ void setUp() { // then News savedNews = newsRepository.findById(originNews.getId()).orElseThrow(); - assertThat(savedNews.getThumbnailUrl()).isEqualTo(DEFAULT_IMAGE_URL); - then(s3Service).should(never()).deletePostImage(DEFAULT_IMAGE_URL); + assertThat(savedNews.getThumbnailUrl()).isEqualTo(newsProperties.defaultThumbnailUrl()); + then(s3Service).should(never()).deletePostImage(newsProperties.defaultThumbnailUrl()); then(s3Service).should(never()).uploadFile(any(), any()); } @@ -308,7 +311,7 @@ void setUp() { // then News savedNews = newsRepository.findById(originNews.getId()).orElseThrow(); assertThat(savedNews.getThumbnailUrl()).isEqualTo(newImageUrl); - then(s3Service).should(never()).deletePostImage(DEFAULT_IMAGE_URL); + then(s3Service).should(never()).deletePostImage(newsProperties.defaultThumbnailUrl()); then(s3Service).should().uploadFile(any(), any()); } } 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" From df0253adce89b2e3ee0f380c3b26df3840732d8d Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:26:01 +0900 Subject: [PATCH 12/23] =?UTF-8?q?refactor:=20RequireRoleAccess=20=EC=96=B4?= =?UTF-8?q?=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20roles=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B0=92=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solidconnection/security/annotation/RequireRoleAccess.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/example/solidconnection/security/annotation/RequireRoleAccess.java b/src/main/java/com/example/solidconnection/security/annotation/RequireRoleAccess.java index 348c743eb..aecef342d 100644 --- a/src/main/java/com/example/solidconnection/security/annotation/RequireRoleAccess.java +++ b/src/main/java/com/example/solidconnection/security/annotation/RequireRoleAccess.java @@ -10,5 +10,5 @@ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRoleAccess { - Role[] roles() default {Role.ADMIN}; + Role[] roles(); } From 954ef78e308178f43c0195c91ae01d1f4ba82efe Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:53:35 +0900 Subject: [PATCH 13/23] =?UTF-8?q?refactor:=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EB=A1=9C=EC=A7=81=20contains=EB=A1=9C=20?= =?UTF-8?q?=EB=8B=A8=EC=88=9C=ED=99=94=ED=95=98=EC=97=AC=20final=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/aspect/RoleAuthorizationAspect.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/main/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspect.java b/src/main/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspect.java index 0dd66e1fb..b1b1f4223 100644 --- a/src/main/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspect.java +++ b/src/main/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspect.java @@ -29,15 +29,11 @@ public Object checkRoleAccess(ProceedingJoinPoint joinPoint, RequireRoleAccess r break; } } - if (siteUser == null) { throw new CustomException(ACCESS_DENIED); } - - final SiteUser finalSiteUser = siteUser; Role[] allowedRoles = requireRoleAccess.roles(); - boolean hasAccess = Arrays.stream(allowedRoles) - .anyMatch(role -> role.equals(finalSiteUser.getRole())); + boolean hasAccess = Arrays.asList(allowedRoles).contains(siteUser.getRole()); if (!hasAccess) { throw new CustomException(ACCESS_DENIED); } From 8214db7305769fcb128968e7e2ccdaa5c79369a8 Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:00:07 +0900 Subject: [PATCH 14/23] =?UTF-8?q?test:=20=EA=B6=8C=ED=95=9C=EB=B3=84=20?= =?UTF-8?q?=EB=82=98=EC=97=B4=EC=8B=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20=EC=9A=94=EA=B5=AC=20=EC=97=AD=ED=95=A0?= =?UTF-8?q?=20=EC=9C=A0=EB=AC=B4=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=95=B5?= =?UTF-8?q?=EC=8B=AC=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=EC=A4=91?= =?UTF-8?q?=EC=8B=AC=EC=9C=BC=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aspect/RoleAuthorizationAspectTest.java | 150 +++++------------- 1 file changed, 38 insertions(+), 112 deletions(-) diff --git a/src/test/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspectTest.java b/src/test/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspectTest.java index c164872c3..a3a1333e8 100644 --- a/src/test/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspectTest.java +++ b/src/test/java/com/example/solidconnection/security/aspect/RoleAuthorizationAspectTest.java @@ -7,7 +7,6 @@ import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; 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.context.TestConfiguration; @@ -15,8 +14,8 @@ 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; +import static org.junit.jupiter.api.Assertions.assertAll; @TestContainerSpringBootTest @DisplayName("권한 검사 Aspect 테스트") @@ -28,121 +27,48 @@ class RoleAuthorizationAspectTest { @Autowired private SiteUserFixture siteUserFixture; - @Nested - class 어드민_권한_테스트 { - - @Test - void 어드민은_어드민_전용_메소드에_접근할_수_있다() { - // given - SiteUser admin = siteUserFixture.관리자(); - - // when - boolean response = testService.adminOnlyMethod(admin); - - // then - assertThat(response).isTrue(); - } - - @Test - void 어드민은_멘토_또는_어드민_메소드에_접근할_수_있다() { - // given - SiteUser admin = siteUserFixture.관리자(); - - // when - boolean response = testService.mentorOrAdminMethod(admin); - - // then - assertThat(response).isTrue(); - } - - @Test - void 어드민은_권한_제한이_없는_메소드에_접근할_수_있다() { - // given - SiteUser admin = siteUserFixture.관리자(); - - // when - boolean response = testService.publicMethod(admin); - - // then - assertThat(response).isTrue(); - } + @Test + void 요구하는_역할을_가진_사용자는_메서드를_정상적으로_호출할_수_있다() { + // given + SiteUser admin = siteUserFixture.관리자(); + SiteUser mentor = siteUserFixture.멘토(1, "mentor"); + + // when & then + assertAll( + () -> assertThatCode(() -> testService.adminOnlyMethod(admin)) + .doesNotThrowAnyException(), + () -> assertThatCode(() -> testService.mentorOrAdminMethod(mentor)) + .doesNotThrowAnyException() + ); } - @Nested - class 멘토_권한_테스트 { - - @Test - void 멘토가_어드민_전용_메소드에_접근하면_예외가_발생한다() { - // given - SiteUser mentor = siteUserFixture.멘토(1, "mentor"); - - // when & then - assertThatCode(() -> testService.adminOnlyMethod(mentor)) - .isInstanceOf(CustomException.class) - .hasMessage(ACCESS_DENIED.getMessage()); - } - - @Test - void 멘토는_멘토_또는_어드민_메소드에_접근할_수_있다() { - // given - SiteUser mentor = siteUserFixture.멘토(1, "mentor"); + @Test + void 요구하는_역할이_없는_사용자가_메서드를_호출하면_예외가_발생한다() { + // given + SiteUser user = siteUserFixture.사용자(); - // when - boolean response = testService.mentorOrAdminMethod(mentor); - - // then - assertThat(response).isTrue(); - } - - @Test - void 멘토는_권한_제한이_없는_메소드에_접근할_수_있다() { - // given - SiteUser mentor = siteUserFixture.멘토(1, "mentor"); - - // when - boolean response = testService.publicMethod(mentor); - - // then - assertThat(response).isTrue(); - } + // when & then + assertThatCode(() -> testService.mentorOrAdminMethod(user)) + .isInstanceOf(CustomException.class) + .hasMessage(ACCESS_DENIED.getMessage()); } - @Nested - class 일반_사용자_권한_테스트 { - - @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.사용자(); - - // when & then - assertThatCode(() -> testService.mentorOrAdminMethod(user)) - .isInstanceOf(CustomException.class) - .hasMessage(ACCESS_DENIED.getMessage()); - } - - @Test - void 일반_사용자는_권한_제한이_없는_메소드에_접근할_수_있다() { - // given - SiteUser user = siteUserFixture.사용자(); - - // when - boolean response = testService.publicMethod(user); - - // then - assertThat(response).isTrue(); - } + @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 From 18a91212b129048efebe4e15ee2e577d788dd45b Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:19:42 +0900 Subject: [PATCH 15/23] =?UTF-8?q?refactor:=20=EC=86=8C=EC=8B=9D=EC=A7=80?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20PATCH=20->=20PUT=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/controller/NewsController.java | 4 +- .../solidconnection/news/domain/News.java | 8 +-- .../news/dto/NewsUpdateRequest.java | 4 ++ .../news/service/NewsCommandService.java | 18 +------ .../news/service/NewsCommandServiceTest.java | 50 ------------------- 5 files changed, 8 insertions(+), 76 deletions(-) diff --git a/src/main/java/com/example/solidconnection/news/controller/NewsController.java b/src/main/java/com/example/solidconnection/news/controller/NewsController.java index 39cdad430..9445c0fac 100644 --- a/src/main/java/com/example/solidconnection/news/controller/NewsController.java +++ b/src/main/java/com/example/solidconnection/news/controller/NewsController.java @@ -15,9 +15,9 @@ 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.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; @@ -53,7 +53,7 @@ public ResponseEntity createNews( } @RequireRoleAccess(roles = {Role.ADMIN, Role.MENTOR}) - @PatchMapping("/{news-id}") + @PutMapping("/{news-id}") public ResponseEntity updateNews( @AuthorizedUser SiteUser siteUser, @PathVariable("news-id") Long newsId, 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 cc12ea5da..5443f65aa 100644 --- a/src/main/java/com/example/solidconnection/news/domain/News.java +++ b/src/main/java/com/example/solidconnection/news/domain/News.java @@ -48,15 +48,9 @@ public News( this.siteUserId = siteUserId; } - public void updateTitle(String title) { + public void updateNews(String title, String description, String url) { this.title = title; - } - - public void updateDescription(String description) { this.description = description; - } - - public void updateUrl(String url) { this.url = url; } diff --git a/src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java b/src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java index 8620fbe77..9d09001bd 100644 --- a/src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java +++ b/src/main/java/com/example/solidconnection/news/dto/NewsUpdateRequest.java @@ -1,15 +1,19 @@ 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, diff --git a/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java index 798299ebc..f68b8003e 100644 --- a/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java +++ b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java @@ -45,7 +45,7 @@ public NewsCommandResponse updateNews( News news = newsRepository.findById(newsId) .orElseThrow(() -> new CustomException(NEWS_NOT_FOUND)); validateOwnership(news, siteUserId); - updateNews(news, newsUpdateRequest); + news.updateNews(newsUpdateRequest.title(), newsUpdateRequest.description(), newsUpdateRequest.url()); updateThumbnail(news, imageFile, newsUpdateRequest.resetToDefaultImage()); News savedNews = newsRepository.save(news); return NewsCommandResponse.from(savedNews); @@ -83,18 +83,6 @@ private void validatePermission(SiteUser currentUser, News news) { } } - private void updateNews(News news, NewsUpdateRequest request) { - if (hasValue(request.title())) { - news.updateTitle(request.title()); - } - if (hasValue(request.description())) { - news.updateDescription(request.description()); - } - if (hasValue(request.url())) { - news.updateUrl(request.url()); - } - } - private void updateThumbnail(News news, MultipartFile imageFile, Boolean resetToDefaultImage) { if (Boolean.TRUE.equals(resetToDefaultImage)) { deleteCustomImage(news.getThumbnailUrl()); @@ -112,8 +100,4 @@ private void deleteCustomImage(String imageUrl) { s3Service.deletePostImage(imageUrl); } } - - private boolean hasValue(String value) { - return value != null && !value.trim().isEmpty(); - } } diff --git a/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java index 16343616f..4f0f243ee 100644 --- a/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java +++ b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java @@ -131,56 +131,6 @@ void setUp() { ); } - @Test - void 소식지_제목만_수정한다() { - // given - String expectedTitle = "제목 수정"; - String originalDescription = originNews.getDescription(); - String originalUrl = originNews.getUrl(); - String originalThumbnailUrl = originNews.getThumbnailUrl(); - NewsUpdateRequest request = createNewsUpdateRequest( - expectedTitle, - null, - null, - null); - - // when - NewsCommandResponse response = newsCommandService.updateNews( - user.getId(), - 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 - NewsCommandResponse response = newsCommandService.updateNews( - user.getId(), - originNews.getId(), - request, - null); - - // then - News savedNews = newsRepository.findById(response.id()).orElseThrow(); - assertAll( - () -> assertThat(savedNews.getTitle()).isEqualTo(originNews.getTitle()), - () -> assertThat(savedNews.getDescription()).isEqualTo(originNews.getDescription()) - ); - } - @Test void 다른_사용자의_소식지를_수정하면_예외_응답을_반환한다() { // given From 389f3eeef62813a4918a39a29216a6a60c705f09 Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:24:45 +0900 Subject: [PATCH 16/23] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=EB=AA=85=EC=9D=84=20=EB=A9=98?= =?UTF-8?q?=ED=86=A0=20=E2=86=92=20=EC=82=AC=EC=9A=A9=EC=9E=90=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=BC=EB=B0=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solidconnection/news/service/NewsQueryServiceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java b/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java index 9c88c0b59..a2a9ca95d 100644 --- a/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java +++ b/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java @@ -30,7 +30,7 @@ class NewsQueryServiceTest { private NewsFixture newsFixture; @Test - void 특정_멘토의_소식지_목록을_성공적으로_조회한다() { + void 특정_사용자의_소식지_목록을_성공적으로_조회한다() { // given SiteUser user1 = siteUserFixture.멘토(1, "mentor1"); SiteUser user2 = siteUserFixture.멘토(2, "mentor2"); From 36be2abb7432910f02a7b801e8baddf6151f8313 Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:26:09 +0900 Subject: [PATCH 17/23] =?UTF-8?q?style:=20NewsRepository=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=84=A0=EC=96=B8=20=EA=B0=9C=ED=96=89=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20Long=20=E2=86=92=20long=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solidconnection/news/repository/NewsRepository.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java b/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java index 8d7029013..3171bfcf8 100644 --- a/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java +++ b/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java @@ -6,5 +6,6 @@ import java.util.List; public interface NewsRepository extends JpaRepository { - List findAllBySiteUserIdOrderByUpdatedAtDesc(Long siteUserId); + + List findAllBySiteUserIdOrderByUpdatedAtDesc(long siteUserId); } From b275edec266cc7ade4b7dfbf626df24f2bc18942 Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:31:44 +0900 Subject: [PATCH 18/23] =?UTF-8?q?refactor:=20=EB=8B=A8=EC=9D=BC/=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=9D=91=EB=8B=B5=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/controller/NewsController.java | 8 +++--- .../news/dto/NewsItemResponse.java | 25 ------------------- .../news/dto/NewsListResponse.java | 11 ++++++++ .../news/dto/NewsResponse.java | 22 +++++++++++++--- .../news/service/NewsQueryService.java | 10 ++++---- .../news/service/NewsQueryServiceTest.java | 10 ++++---- 6 files changed, 43 insertions(+), 43 deletions(-) delete mode 100644 src/main/java/com/example/solidconnection/news/dto/NewsItemResponse.java create mode 100644 src/main/java/com/example/solidconnection/news/dto/NewsListResponse.java diff --git a/src/main/java/com/example/solidconnection/news/controller/NewsController.java b/src/main/java/com/example/solidconnection/news/controller/NewsController.java index 9445c0fac..51b739f8c 100644 --- a/src/main/java/com/example/solidconnection/news/controller/NewsController.java +++ b/src/main/java/com/example/solidconnection/news/controller/NewsController.java @@ -3,7 +3,7 @@ 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.NewsResponse; +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; @@ -34,11 +34,11 @@ public class NewsController { // todo: 추후 Slice 적용 @GetMapping - public ResponseEntity findNewsBySiteUserId( + public ResponseEntity findNewsBySiteUserId( @RequestParam(value = "site-user-id") Long siteUserId ) { - NewsResponse newsResponse = newsQueryService.findNewsBySiteUserId(siteUserId); - return ResponseEntity.ok(newsResponse); + NewsListResponse newsListResponse = newsQueryService.findNewsBySiteUserId(siteUserId); + return ResponseEntity.ok(newsListResponse); } @RequireRoleAccess(roles = {Role.ADMIN, Role.MENTOR}) diff --git a/src/main/java/com/example/solidconnection/news/dto/NewsItemResponse.java b/src/main/java/com/example/solidconnection/news/dto/NewsItemResponse.java deleted file mode 100644 index 0346a615f..000000000 --- a/src/main/java/com/example/solidconnection/news/dto/NewsItemResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -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 description, - String thumbnailUrl, - String url, - ZonedDateTime updatedAt -) { - public static NewsItemResponse from(News news) { - return new NewsItemResponse( - news.getId(), - news.getTitle(), - news.getDescription(), - news.getThumbnailUrl(), - news.getUrl(), - news.getUpdatedAt() - ); - } -} 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 index 4e51ff80e..b39daffce 100644 --- a/src/main/java/com/example/solidconnection/news/dto/NewsResponse.java +++ b/src/main/java/com/example/solidconnection/news/dto/NewsResponse.java @@ -1,11 +1,25 @@ package com.example.solidconnection.news.dto; -import java.util.List; +import com.example.solidconnection.news.domain.News; + +import java.time.ZonedDateTime; public record NewsResponse( - List newsItemsResponseList + long id, + String title, + String description, + String thumbnailUrl, + String url, + ZonedDateTime updatedAt ) { - public static NewsResponse from(List newsItemsResponseList) { - return new NewsResponse(newsItemsResponseList); + 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/service/NewsQueryService.java b/src/main/java/com/example/solidconnection/news/service/NewsQueryService.java index f4740769b..33d444cfe 100644 --- a/src/main/java/com/example/solidconnection/news/service/NewsQueryService.java +++ b/src/main/java/com/example/solidconnection/news/service/NewsQueryService.java @@ -1,8 +1,8 @@ package com.example.solidconnection.news.service; import com.example.solidconnection.news.domain.News; -import com.example.solidconnection.news.dto.NewsItemResponse; 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; @@ -17,11 +17,11 @@ public class NewsQueryService { private final NewsRepository newsRepository; @Transactional(readOnly = true) - public NewsResponse findNewsBySiteUserId(Long siteUserId) { + public NewsListResponse findNewsBySiteUserId(Long siteUserId) { List newsList = newsRepository.findAllBySiteUserIdOrderByUpdatedAtDesc(siteUserId); - List newsItemsResponseList = newsList.stream() - .map(NewsItemResponse::from) + List newsResponseList = newsList.stream() + .map(NewsResponse::from) .toList(); - return NewsResponse.from(newsItemsResponseList); + return NewsListResponse.from(newsResponseList); } } diff --git a/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java b/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java index a2a9ca95d..b03d42f1f 100644 --- a/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java +++ b/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java @@ -1,8 +1,8 @@ package com.example.solidconnection.news.service; import com.example.solidconnection.news.domain.News; -import com.example.solidconnection.news.dto.NewsItemResponse; 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; @@ -40,12 +40,12 @@ class NewsQueryServiceTest { List newsList = List.of(news1, news2); // when - NewsResponse response = newsQueryService.findNewsBySiteUserId(user1.getId()); + NewsListResponse response = newsQueryService.findNewsBySiteUserId(user1.getId()); // then - assertThat(response.newsItemsResponseList()).hasSize(newsList.size()); - assertThat(response.newsItemsResponseList()) - .extracting(NewsItemResponse::updatedAt) + assertThat(response.newsResponseList()).hasSize(newsList.size()); + assertThat(response.newsResponseList()) + .extracting(NewsResponse::updatedAt) .isSortedAccordingTo(Comparator.reverseOrder()); } } From a425050e32d46ba23973a5c01fe10f75dc0f0631 Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Mon, 30 Jun 2025 20:38:43 +0900 Subject: [PATCH 19/23] =?UTF-8?q?chore:=20=EC=84=9C=EB=B8=8C=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=BB=A4=EB=B0=8B=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/secret | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/secret b/src/main/resources/secret index bfa5e203b..985a180d6 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit bfa5e203bb72ea70ce4eaeb10f4e5d8779f9b4df +Subproject commit 985a180d6f0f65db873c13d5bea0c445fb6b2fc1 From cb778948a7964ecee95ccd366152ab964d818100 Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Tue, 1 Jul 2025 23:30:39 +0900 Subject: [PATCH 20/23] =?UTF-8?q?refactor:=20siteUserId=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=9D=84=20Long=EC=97=90=EC=84=9C=20long=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solidconnection/news/service/NewsCommandService.java | 6 +++--- .../solidconnection/news/service/NewsQueryService.java | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java index f68b8003e..fc4106b3f 100644 --- a/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java +++ b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java @@ -29,7 +29,7 @@ public class NewsCommandService { private final NewsRepository newsRepository; @Transactional - public NewsCommandResponse createNews(Long siteUserId,NewsCreateRequest newsCreateRequest, MultipartFile imageFile) { + public NewsCommandResponse createNews(long siteUserId,NewsCreateRequest newsCreateRequest, MultipartFile imageFile) { String thumbnailUrl = getImageUrl(imageFile); News news = newsCreateRequest.toEntity(thumbnailUrl, siteUserId); News savedNews = newsRepository.save(news); @@ -38,7 +38,7 @@ public NewsCommandResponse createNews(Long siteUserId,NewsCreateRequest newsCrea @Transactional public NewsCommandResponse updateNews( - Long siteUserId, + long siteUserId, Long newsId, NewsUpdateRequest newsUpdateRequest, MultipartFile imageFile) { @@ -69,7 +69,7 @@ private String getImageUrl(MultipartFile imageFile) { return newsProperties.defaultThumbnailUrl(); } - private void validateOwnership(News news, Long siteUserId) { + private void validateOwnership(News news, long siteUserId) { if (news.getSiteUserId() != siteUserId) { throw new CustomException(INVALID_NEWS_ACCESS); } diff --git a/src/main/java/com/example/solidconnection/news/service/NewsQueryService.java b/src/main/java/com/example/solidconnection/news/service/NewsQueryService.java index 33d444cfe..3b04e3b86 100644 --- a/src/main/java/com/example/solidconnection/news/service/NewsQueryService.java +++ b/src/main/java/com/example/solidconnection/news/service/NewsQueryService.java @@ -17,7 +17,7 @@ public class NewsQueryService { private final NewsRepository newsRepository; @Transactional(readOnly = true) - public NewsListResponse findNewsBySiteUserId(Long siteUserId) { + public NewsListResponse findNewsBySiteUserId(long siteUserId) { List newsList = newsRepository.findAllBySiteUserIdOrderByUpdatedAtDesc(siteUserId); List newsResponseList = newsList.stream() .map(NewsResponse::from) From cef3ff539d29913d9aaa9b76ec513afd0a5085c1 Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Tue, 1 Jul 2025 23:36:24 +0900 Subject: [PATCH 21/23] =?UTF-8?q?test:=20assertAll=EB=A1=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B7=B8=EB=A3=B9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/service/NewsCommandServiceTest.java | 40 ++++++++++++------- .../news/service/NewsQueryServiceTest.java | 9 +++-- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java index 4f0f243ee..c3f3192d7 100644 --- a/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java +++ b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java @@ -178,9 +178,11 @@ void setUp() { // then News savedNews = newsRepository.findById(response.id()).orElseThrow(); - assertThat(savedNews.getThumbnailUrl()).isEqualTo(newsProperties.defaultThumbnailUrl()); - then(s3Service).should().deletePostImage(CUSTOM_IMAGE_URL); - then(s3Service).should(never()).uploadFile(null, ImgType.NEWS); + assertAll( + () -> assertThat(savedNews.getThumbnailUrl()).isEqualTo(newsProperties.defaultThumbnailUrl()), + () -> then(s3Service).should().deletePostImage(CUSTOM_IMAGE_URL), + () -> then(s3Service).should(never()).uploadFile(null, ImgType.NEWS) + ); } @Test @@ -205,9 +207,11 @@ void setUp() { // then News savedNews = newsRepository.findById(response.id()).orElseThrow(); - assertThat(savedNews.getThumbnailUrl()).isEqualTo(newImageUrl); - then(s3Service).should().deletePostImage(CUSTOM_IMAGE_URL); - then(s3Service).should().uploadFile(any(), any()); + assertAll( + () -> assertThat(savedNews.getThumbnailUrl()).isEqualTo(newImageUrl), + () -> then(s3Service).should().deletePostImage(CUSTOM_IMAGE_URL), + () -> then(s3Service).should().uploadFile(any(), any()) + ); } } @@ -237,9 +241,11 @@ void setUp() { // then News savedNews = newsRepository.findById(originNews.getId()).orElseThrow(); - assertThat(savedNews.getThumbnailUrl()).isEqualTo(newsProperties.defaultThumbnailUrl()); - then(s3Service).should(never()).deletePostImage(newsProperties.defaultThumbnailUrl()); - then(s3Service).should(never()).uploadFile(any(), any()); + assertAll( + () -> assertThat(savedNews.getThumbnailUrl()).isEqualTo(newsProperties.defaultThumbnailUrl()), + () -> then(s3Service).should(never()).deletePostImage(newsProperties.defaultThumbnailUrl()), + () -> then(s3Service).should(never()).uploadFile(null, ImgType.NEWS) + ); } @Test @@ -260,9 +266,11 @@ void setUp() { // then News savedNews = newsRepository.findById(originNews.getId()).orElseThrow(); - assertThat(savedNews.getThumbnailUrl()).isEqualTo(newImageUrl); - then(s3Service).should(never()).deletePostImage(newsProperties.defaultThumbnailUrl()); - then(s3Service).should().uploadFile(any(), any()); + assertAll( + () -> assertThat(savedNews.getThumbnailUrl()).isEqualTo(newImageUrl), + () -> then(s3Service).should(never()).deletePostImage(newsProperties.defaultThumbnailUrl()), + () -> then(s3Service).should().uploadFile(any(), any()) + ); } } } @@ -280,9 +288,11 @@ class 소식지_삭제_테스트 { NewsCommandResponse response = newsCommandService.deleteNewsById(user, originNews.getId()); // then - assertThat(response.id()).isEqualTo(originNews.getId()); - assertThat(newsRepository.findById(originNews.getId())).isEmpty(); - then(s3Service).should().deletePostImage(expectedImageUrl); + 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 index b03d42f1f..73926a6dc 100644 --- a/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java +++ b/src/test/java/com/example/solidconnection/news/service/NewsQueryServiceTest.java @@ -15,6 +15,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; @TestContainerSpringBootTest @DisplayName("소식지 조회 서비스 테스트") @@ -43,9 +44,11 @@ class NewsQueryServiceTest { NewsListResponse response = newsQueryService.findNewsBySiteUserId(user1.getId()); // then - assertThat(response.newsResponseList()).hasSize(newsList.size()); - assertThat(response.newsResponseList()) + assertAll( + () -> assertThat(response.newsResponseList()).hasSize(newsList.size()), + () -> assertThat(response.newsResponseList()) .extracting(NewsResponse::updatedAt) - .isSortedAccordingTo(Comparator.reverseOrder()); + .isSortedAccordingTo(Comparator.reverseOrder()) + ); } } From a170b6b5cf08d91f1e394aae23a82a862f3600de Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Wed, 2 Jul 2025 00:44:19 +0900 Subject: [PATCH 22/23] =?UTF-8?q?refactor:=20private=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=9C=84=EC=B9=98=EB=A5=BC=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EB=B6=80=20=EC=95=84=EB=9E=98=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/service/NewsCommandService.java | 52 +++++++++---------- .../news/service/NewsCommandServiceTest.java | 34 ++++++------ 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java index fc4106b3f..a9d8c74a4 100644 --- a/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java +++ b/src/main/java/com/example/solidconnection/news/service/NewsCommandService.java @@ -36,6 +36,14 @@ public NewsCommandResponse createNews(long siteUserId,NewsCreateRequest newsCrea 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, @@ -51,38 +59,12 @@ public NewsCommandResponse updateNews( return NewsCommandResponse.from(savedNews); } - @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 String getImageUrl(MultipartFile imageFile) { - if (imageFile != null && !imageFile.isEmpty()) { - UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, ImgType.NEWS); - return uploadedFile.fileUrl(); - } - return newsProperties.defaultThumbnailUrl(); - } - private void validateOwnership(News news, long siteUserId) { if (news.getSiteUserId() != siteUserId) { throw new CustomException(INVALID_NEWS_ACCESS); } } - 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 updateThumbnail(News news, MultipartFile imageFile, Boolean resetToDefaultImage) { if (Boolean.TRUE.equals(resetToDefaultImage)) { deleteCustomImage(news.getThumbnailUrl()); @@ -95,6 +77,24 @@ else if (imageFile != null && !imageFile.isEmpty()) { } } + @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/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java index c3f3192d7..681c2a959 100644 --- a/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java +++ b/src/test/java/com/example/solidconnection/news/service/NewsCommandServiceTest.java @@ -83,6 +83,10 @@ class 소식지_생성_테스트 { } } + private NewsCreateRequest createNewsCreateRequest() { + return new NewsCreateRequest("제목", "설명", "https://youtu.be/test"); + } + @Nested class 소식지_수정_테스트 { @@ -275,6 +279,19 @@ void setUp() { } } + 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 소식지_삭제_테스트 { @@ -295,21 +312,4 @@ class 소식지_삭제_테스트 { ); } } - - private NewsCreateRequest createNewsCreateRequest() { - return new NewsCreateRequest("제목", "설명", "https://youtu.be/test"); - } - - 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() - ); - } } From 5eb35a7ad99d1bc84346d3c96f287a137776c8c2 Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Wed, 2 Jul 2025 10:11:09 +0900 Subject: [PATCH 23/23] =?UTF-8?q?chore:=20=EC=84=9C=EB=B8=8C=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=BB=A4=EB=B0=8B=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/secret | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/secret b/src/main/resources/secret index 985a180d6..be52e6ce9 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit 985a180d6f0f65db873c13d5bea0c445fb6b2fc1 +Subproject commit be52e6ce9ca3d2c6eb51442108328b00a539510b