From 5809f4a8007f8abb2d0ca944450ef571eb2ce580 Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 10 Oct 2023 15:51:36 +0900 Subject: [PATCH 1/5] =?UTF-8?q?logout=EC=8B=9C=20refreshtoken=EC=9D=B4=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EB=90=98=EB=8A=94=20api=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#632)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 로그아웃 API 구현 * refactor: refreshtoken delete api 이름 변경 * refactor: dev에 localhost:3000 cors 추가 * refactor: 리뷰반영 deleteMapping -> patchMapping으로 변경 * test: doPring 제거 * test: 인스턴스 변수 띄어쓰기 추가 * test: repositoryTestConfig에 persistMember 추가 및 리팩토링 * test: given when then 에서 컨벤션 일관화 --- backend/baton/secret | 2 +- .../src/docs/asciidoc/OauthLogoutApi.adoc | 25 +++++++ backend/baton/src/docs/asciidoc/index.adoc | 1 + .../controller/OauthCommandController.java | 10 +++ .../RefreshTokenCommandRepository.java | 2 + .../command/service/OauthCommandService.java | 8 ++- .../baton/assure/common/AssuredSupport.java | 19 +++++ .../assure/oauth/OauthAssuredSupport.java | 18 +++++ .../assure/oauth/OauthDeleteAssuredTest.java | 67 +++++++++++++++++ .../baton/config/RepositoryTestConfig.java | 5 ++ .../document/oauth/OauthLogoutApiTest.java | 39 ++++++++++ .../oauth/github/GithubOauthApiTest.java | 4 +- .../oauth/token/RefreshTokenApiTest.java | 2 +- .../runner/read/RunnerReadByGuestApiTest.java | 2 - .../RefreshTokenCommandRepositoryTest.java | 38 +++++++--- .../OauthCommandServiceDeleteTest.java | 72 +++++++++++++++++++ .../OauthCommandServiceUpdateTest.java | 9 +++ .../domain/runnerpost/RunnerPostTest.java | 16 +++-- .../RunnerPostCommandServiceDeleteTest.java | 6 +- ...UpdateApplicantCancelationServiceTest.java | 4 +- .../service/SupporterCommandServiceTest.java | 2 +- 21 files changed, 320 insertions(+), 31 deletions(-) create mode 100644 backend/baton/src/docs/asciidoc/OauthLogoutApi.adoc create mode 100644 backend/baton/src/test/java/touch/baton/assure/oauth/OauthDeleteAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/oauth/OauthLogoutApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/oauth/command/service/OauthCommandServiceDeleteTest.java diff --git a/backend/baton/secret b/backend/baton/secret index 03b602f07..83ee50f98 160000 --- a/backend/baton/secret +++ b/backend/baton/secret @@ -1 +1 @@ -Subproject commit 03b602f076d9f43f2ecaf81abf55fb1d0874c34a +Subproject commit 83ee50f980a3b1f904ebcd79fb96dab3ed232bd4 diff --git a/backend/baton/src/docs/asciidoc/OauthLogoutApi.adoc b/backend/baton/src/docs/asciidoc/OauthLogoutApi.adoc new file mode 100644 index 000000000..2cf491c42 --- /dev/null +++ b/backend/baton/src/docs/asciidoc/OauthLogoutApi.adoc @@ -0,0 +1,25 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 3 +:sectlinks: +:operation-http-request-title: Example Request +:operation-http-response-title: Example Response + +==== *로그아웃 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/oauth-logout-api-test/logout/http-request.adoc[] + +===== *Http Request Headers* + +include::{snippets}/../../build/generated-snippets/oauth-logout-api-test/logout/request-headers.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/oauth-logout-api-test/logout/http-response.adoc[] diff --git a/backend/baton/src/docs/asciidoc/index.adoc b/backend/baton/src/docs/asciidoc/index.adoc index fc596cefa..de19ac59b 100644 --- a/backend/baton/src/docs/asciidoc/index.adoc +++ b/backend/baton/src/docs/asciidoc/index.adoc @@ -19,6 +19,7 @@ include::GithubBranchCreateApi.adoc[] == *[ 로그인 ]* include::GithubOauthApi.adoc[] +include::OauthLogoutApi.adoc[] include::RefreshTokenApi.adoc[] == *[ 프로필 ]* diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/command/controller/OauthCommandController.java b/backend/baton/src/main/java/touch/baton/domain/oauth/command/controller/OauthCommandController.java index 183b3dd3f..497888c99 100644 --- a/backend/baton/src/main/java/touch/baton/domain/oauth/command/controller/OauthCommandController.java +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/command/controller/OauthCommandController.java @@ -9,6 +9,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -16,11 +17,13 @@ import org.springframework.web.bind.annotation.RestController; import touch.baton.domain.common.exception.ClientErrorCode; import touch.baton.domain.common.exception.ClientRequestException; +import touch.baton.domain.member.command.Member; import touch.baton.domain.oauth.command.AuthorizationHeader; import touch.baton.domain.oauth.command.OauthType; import touch.baton.domain.oauth.command.service.OauthCommandService; import touch.baton.domain.oauth.command.token.RefreshToken; import touch.baton.domain.oauth.command.token.Tokens; +import touch.baton.domain.oauth.query.controller.resolver.AuthMemberPrincipal; import java.io.IOException; import java.time.Duration; @@ -93,4 +96,11 @@ private void setCookie(final HttpServletResponse response, final RefreshToken re .build(); response.addHeader("Set-Cookie", responseCookie.toString()); } + + @PatchMapping("/logout") + public ResponseEntity logout(@AuthMemberPrincipal final Member member) { + oauthCommandService.logout(member); + + return ResponseEntity.noContent().build(); + } } diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/command/repository/RefreshTokenCommandRepository.java b/backend/baton/src/main/java/touch/baton/domain/oauth/command/repository/RefreshTokenCommandRepository.java index d714fc49e..c973ffde4 100644 --- a/backend/baton/src/main/java/touch/baton/domain/oauth/command/repository/RefreshTokenCommandRepository.java +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/command/repository/RefreshTokenCommandRepository.java @@ -12,4 +12,6 @@ public interface RefreshTokenCommandRepository extends JpaRepository findByToken(final Token token); Optional findByMember(final Member member); + + void deleteByMember(final Member member); } diff --git a/backend/baton/src/main/java/touch/baton/domain/oauth/command/service/OauthCommandService.java b/backend/baton/src/main/java/touch/baton/domain/oauth/command/service/OauthCommandService.java index 283b84212..078649e85 100644 --- a/backend/baton/src/main/java/touch/baton/domain/oauth/command/service/OauthCommandService.java +++ b/backend/baton/src/main/java/touch/baton/domain/oauth/command/service/OauthCommandService.java @@ -57,7 +57,7 @@ public class OauthCommandService { public String readAuthCodeRedirect(final OauthType oauthType) { return authCodeRequestUrlProviderComposite.findRequestUrl(oauthType); } - + public Tokens login(final OauthType oauthType, final String code) { final OauthInformation oauthInformation = oauthInformationClientComposite.fetchInformation(oauthType, code); @@ -125,7 +125,7 @@ private Tokens createTokens(final SocialId socialId, final Member member) { refreshTokenCommandRepository.save(refreshToken); return new Tokens(accessToken, refreshToken); } - + public Tokens reissueAccessToken(final AuthorizationHeader authHeader, final String refreshToken) { final Claims claims = jwtDecoder.parseExpiredAuthorizationHeader(authHeader); final SocialId socialId = new SocialId(claims.get("socialId", String.class)); @@ -159,4 +159,8 @@ private AccessToken createAccessToken(final SocialId socialId) { ); return new AccessToken(jwtToken); } + + public void logout(final Member member) { + refreshTokenCommandRepository.deleteByMember(member); + } } diff --git a/backend/baton/src/test/java/touch/baton/assure/common/AssuredSupport.java b/backend/baton/src/test/java/touch/baton/assure/common/AssuredSupport.java index ef53e2a55..edcfe5ec3 100644 --- a/backend/baton/src/test/java/touch/baton/assure/common/AssuredSupport.java +++ b/backend/baton/src/test/java/touch/baton/assure/common/AssuredSupport.java @@ -144,6 +144,25 @@ public static ExtractableResponse get(final String uri, final String a .extract(); } + public static ExtractableResponse patch(final String uri) { + return RestAssured + .given().log().ifValidationFails() + .when().log().ifValidationFails() + .patch(uri) + .then().log().ifError() + .extract(); + } + + public static ExtractableResponse patch(final String uri, final String accessToken) { + return RestAssured + .given().log().ifValidationFails() + .auth().preemptive().oauth2(accessToken) + .when().log().ifValidationFails() + .patch(uri) + .then().log().ifError() + .extract(); + } + public static ExtractableResponse patch(final String uri, final String accessToken, final PathParams pathParams) { return RestAssured .given().log().ifValidationFails() diff --git a/backend/baton/src/test/java/touch/baton/assure/oauth/OauthAssuredSupport.java b/backend/baton/src/test/java/touch/baton/assure/oauth/OauthAssuredSupport.java index 366ce50a9..a04fc622f 100644 --- a/backend/baton/src/test/java/touch/baton/assure/oauth/OauthAssuredSupport.java +++ b/backend/baton/src/test/java/touch/baton/assure/oauth/OauthAssuredSupport.java @@ -91,6 +91,18 @@ public static class OauthClientRequestBuilder { return this; } + public OauthClientRequestBuilder 로그아웃을_요청한다(final AccessToken 액세스_토큰) { + response = AssuredSupport.patch("/api/v1/oauth/logout", 액세스_토큰.getValue()); + + return this; + } + + public OauthClientRequestBuilder 액세스_토큰_없이_로그아웃을_요청한다() { + response = AssuredSupport.patch("/api/v1/oauth/logout"); + + return this; + } + public OauthServerResponseBuilder 서버_응답() { return new OauthServerResponseBuilder(response); } @@ -153,5 +165,11 @@ public OauthServerResponseBuilder(final ExtractableResponse response) softly.assertThat(response.jsonPath().getString("errorCode")).isEqualTo(clientErrorCode.getErrorCode()); }); } + + public void 로그아웃이_성공한다() { + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value()); + }); + } } } diff --git a/backend/baton/src/test/java/touch/baton/assure/oauth/OauthDeleteAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/oauth/OauthDeleteAssuredTest.java new file mode 100644 index 000000000..adb556eef --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/oauth/OauthDeleteAssuredTest.java @@ -0,0 +1,67 @@ +package touch.baton.assure.oauth; + +import org.junit.jupiter.api.Test; +import touch.baton.config.AssuredTestConfig; +import touch.baton.config.infra.auth.oauth.authcode.FakeAuthCodes; +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.oauth.command.OauthType; +import touch.baton.domain.oauth.command.token.Tokens; +import touch.baton.fixture.domain.MemberFixture; + +@SuppressWarnings("NonAsciiCharacters") +class OauthDeleteAssuredTest extends AssuredTestConfig { + + @Test + void 로그아웃을_성공한다() { + // given + OauthAssuredSupport + .클라이언트_요청() + .소셜_로그인을_위한_리다이렉트_URL을_요청한다(OauthType.GITHUB) + + .서버_응답() + .소셜_로그인을_위한_리다이렉트_URL_요청_성공을_검증한다(); + + final Tokens 액세스_토큰과_리프레시_토큰 = OauthAssuredSupport + .클라이언트_요청() + .AuthCode를_통해_소셜_토큰을_발급_받은_후_사용자를_회원가입_한다(OauthType.GITHUB, FakeAuthCodes.ethanAuthCode()) + + .서버_응답() + .AuthCode를_통해_소셜_토큰_발급_및_사용자_회원가입에_성공한다() + .액세스_토큰과_리프레시_토큰을_반환한다(MemberFixture.createEthan()); + + // when, then + OauthAssuredSupport + .클라이언트_요청() + .로그아웃을_요청한다(액세스_토큰과_리프레시_토큰.accessToken()) + + .서버_응답() + .로그아웃이_성공한다(); + } + + @Test + void 액세스_토큰이_없이_로그아웃을_요청하면_실패한다() { + // given + OauthAssuredSupport + .클라이언트_요청() + .소셜_로그인을_위한_리다이렉트_URL을_요청한다(OauthType.GITHUB) + + .서버_응답() + .소셜_로그인을_위한_리다이렉트_URL_요청_성공을_검증한다(); + + final Tokens 액세스_토큰과_리프레시_토큰 = OauthAssuredSupport + .클라이언트_요청() + .AuthCode를_통해_소셜_토큰을_발급_받은_후_사용자를_회원가입_한다(OauthType.GITHUB, FakeAuthCodes.ethanAuthCode()) + + .서버_응답() + .AuthCode를_통해_소셜_토큰_발급_및_사용자_회원가입에_성공한다() + .액세스_토큰과_리프레시_토큰을_반환한다(MemberFixture.createEthan()); + + // when, then + OauthAssuredSupport + .클라이언트_요청() + .액세스_토큰_없이_로그아웃을_요청한다() + + .서버_응답() + .오류가_발생한다(ClientErrorCode.OAUTH_AUTHORIZATION_VALUE_IS_NULL); + } +} diff --git a/backend/baton/src/test/java/touch/baton/config/RepositoryTestConfig.java b/backend/baton/src/test/java/touch/baton/config/RepositoryTestConfig.java index 6a8570497..b651ce308 100644 --- a/backend/baton/src/test/java/touch/baton/config/RepositoryTestConfig.java +++ b/backend/baton/src/test/java/touch/baton/config/RepositoryTestConfig.java @@ -30,6 +30,11 @@ public abstract class RepositoryTestConfig { @Autowired protected EntityManager em; + protected Member persistMember(final Member member) { + em.persist(member); + return member; + } + protected Runner persistRunner(final Member member) { em.persist(member); final Runner runner = RunnerFixture.createRunner(member); diff --git a/backend/baton/src/test/java/touch/baton/document/oauth/OauthLogoutApiTest.java b/backend/baton/src/test/java/touch/baton/document/oauth/OauthLogoutApiTest.java new file mode 100644 index 000000000..a7bbb76c6 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/oauth/OauthLogoutApiTest.java @@ -0,0 +1,39 @@ +package touch.baton.document.oauth; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.member.command.Member; +import touch.baton.fixture.domain.MemberFixture; + +import java.util.Optional; + +import static org.apache.http.HttpHeaders.AUTHORIZATION; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class OauthLogoutApiTest extends RestdocsConfig { + + @DisplayName("로그아웃을 하면 리프레시 토큰이 삭제된다.") + @Test + void logout() throws Exception { + // given, when + final Member ethan = MemberFixture.createEthan(); + final String accessToken = getAccessTokenBySocialId(ethan.getSocialId().getValue()); + given(oauthMemberCommandRepository.findBySocialId(any())).willReturn(Optional.ofNullable(ethan)); + + // then + mockMvc.perform(patch("/api/v1/oauth/logout") + .header(AUTHORIZATION, "Bearer " + accessToken)) + .andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestHeaders( + headerWithName("Authorization").description("Access Token") + ) + )); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/oauth/github/GithubOauthApiTest.java b/backend/baton/src/test/java/touch/baton/document/oauth/github/GithubOauthApiTest.java index b29c006ca..9c733933f 100644 --- a/backend/baton/src/test/java/touch/baton/document/oauth/github/GithubOauthApiTest.java +++ b/backend/baton/src/test/java/touch/baton/document/oauth/github/GithubOauthApiTest.java @@ -35,7 +35,7 @@ class GithubOauthApiTest extends RestdocsConfig { @DisplayName("Github 소셜 로그인을 위한 AuthCode 를 받을 수 있도록 사용자를 redirect 한다.") @Test void github_redirect_auth_code() throws Exception { - // given & when + // given, when when(oauthCommandService.readAuthCodeRedirect(GITHUB)) .thenReturn("https://test-redirect-url.com"); @@ -58,7 +58,7 @@ void github_redirect_auth_code() throws Exception { @DisplayName("Github 소셜 로그인을 위해 AuthCode 를 받아 SocialToken 으로 교환하여 Github 프로필 정보를 찾아오고 미가입 사용자일 경우 자동으로 회원가입을 진행하고 JWT 로 변환하여 클라이언트에게 넘겨준다.") @Test void github_login() throws Exception { - // given & when + // given, when final RefreshToken refreshToken = RefreshToken.builder() .member(mock(Member.class)) .token(new Token("mock refresh token")) diff --git a/backend/baton/src/test/java/touch/baton/document/oauth/token/RefreshTokenApiTest.java b/backend/baton/src/test/java/touch/baton/document/oauth/token/RefreshTokenApiTest.java index 21ca8f553..ce0f7a6ce 100644 --- a/backend/baton/src/test/java/touch/baton/document/oauth/token/RefreshTokenApiTest.java +++ b/backend/baton/src/test/java/touch/baton/document/oauth/token/RefreshTokenApiTest.java @@ -32,7 +32,7 @@ class RefreshTokenApiTest extends RestdocsConfig { @DisplayName("만료된 jwt 토큰과 refresh token 으로 refresh 요청을 하면 새로운 토큰들이 반환된다.") @Test void refresh() throws Exception { - // given & when + // given, when final RefreshToken refreshToken = RefreshToken.builder() .token(new Token("refresh-token")) .member(MemberFixture.createEthan()) diff --git a/backend/baton/src/test/java/touch/baton/document/profile/runner/read/RunnerReadByGuestApiTest.java b/backend/baton/src/test/java/touch/baton/document/profile/runner/read/RunnerReadByGuestApiTest.java index e26033117..92ef066ce 100644 --- a/backend/baton/src/test/java/touch/baton/document/profile/runner/read/RunnerReadByGuestApiTest.java +++ b/backend/baton/src/test/java/touch/baton/document/profile/runner/read/RunnerReadByGuestApiTest.java @@ -27,7 +27,6 @@ import static org.springframework.restdocs.payload.JsonFieldType.STRING; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static touch.baton.fixture.vo.TagNameFixture.tagName; @@ -48,7 +47,6 @@ void readMyProfileByToken() throws Exception { // then mockMvc.perform(get("/api/v1/profile/runner/me") .header(AUTHORIZATION, "Bearer " + token)) - .andDo(print()) .andDo(restDocs.document( requestHeaders( headerWithName(AUTHORIZATION).description("Bearer JWT") diff --git a/backend/baton/src/test/java/touch/baton/domain/oauth/command/repository/RefreshTokenCommandRepositoryTest.java b/backend/baton/src/test/java/touch/baton/domain/oauth/command/repository/RefreshTokenCommandRepositoryTest.java index 47c23e32b..b66d7e744 100644 --- a/backend/baton/src/test/java/touch/baton/domain/oauth/command/repository/RefreshTokenCommandRepositoryTest.java +++ b/backend/baton/src/test/java/touch/baton/domain/oauth/command/repository/RefreshTokenCommandRepositoryTest.java @@ -15,6 +15,7 @@ import java.util.Optional; import static java.time.LocalDateTime.now; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.SoftAssertions.assertSoftly; import static touch.baton.fixture.vo.ExpireDateFixture.expireDate; import static touch.baton.fixture.vo.TokenFixture.token; @@ -32,10 +33,8 @@ class RefreshTokenCommandRepositoryTest extends RepositoryTestConfig { @Test void findByToken() { // given - final Member ethan = MemberFixture.createEthan(); - final Member ditoo = MemberFixture.createDitoo(); - em.persist(ethan); - em.persist(ditoo); + final Member ethan = persistMember(MemberFixture.createEthan()); + final Member ditoo = persistMember(MemberFixture.createDitoo()); final LocalDateTime expireDate = createExpireDate(now().plusDays(30)); @@ -57,17 +56,15 @@ void findByToken() { softly.assertThat(actual).isPresent(); softly.assertThat(actual.get().getToken()).isEqualTo(expected.getToken()); softly.assertThat(actual.get().getExpireDate()).isEqualTo(expected.getExpireDate()); - } ); + }); } @DisplayName("리프레시 토큰을 사용자로 찾을 수 있다.") @Test void findByMember() { // given - final Member owner = MemberFixture.createEthan(); - final Member notOwner = MemberFixture.createDitoo(); - em.persist(owner); - em.persist(notOwner); + final Member owner = persistMember(MemberFixture.createEthan()); + final Member notOwner = persistMember(MemberFixture.createDitoo()); final LocalDateTime expireDate = createExpireDate(now().plusDays(30)); @@ -87,6 +84,27 @@ void findByMember() { softly.assertThat(actual).isPresent(); softly.assertThat(actual.get().getToken()).isEqualTo(expected.getToken()); softly.assertThat(actual.get().getExpireDate()).isEqualTo(expected.getExpireDate()); - } ); + }); + } + + @DisplayName("사용자를 이용해 리프레시 토큰을 삭제할 수 있다.") + @Test + void logout() { + // given + final Member owner = persistMember(MemberFixture.createEthan()); + + final LocalDateTime expireDate = createExpireDate(now().plusDays(30)); + + final RefreshToken expected = RefreshTokenFixture.create(owner, token("ethan RefreshToken"), expireDate(expireDate)); + em.persist(expected); + + em.flush(); + em.clear(); + + // when + refreshTokenCommandRepository.deleteByMember(owner); + + // then + assertThat(refreshTokenCommandRepository.findByMember(owner)).isNotPresent(); } } diff --git a/backend/baton/src/test/java/touch/baton/domain/oauth/command/service/OauthCommandServiceDeleteTest.java b/backend/baton/src/test/java/touch/baton/domain/oauth/command/service/OauthCommandServiceDeleteTest.java new file mode 100644 index 000000000..7d5ebf654 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/oauth/command/service/OauthCommandServiceDeleteTest.java @@ -0,0 +1,72 @@ +package touch.baton.domain.oauth.command.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import touch.baton.domain.member.command.Member; +import touch.baton.domain.oauth.command.authcode.AuthCodeRequestUrlProviderComposite; +import touch.baton.domain.oauth.command.client.OauthInformationClientComposite; +import touch.baton.domain.oauth.command.repository.OauthMemberCommandRepository; +import touch.baton.domain.oauth.command.repository.OauthRunnerCommandRepository; +import touch.baton.domain.oauth.command.repository.OauthSupporterCommandRepository; +import touch.baton.domain.oauth.command.repository.RefreshTokenCommandRepository; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.infra.auth.jwt.JwtDecoder; +import touch.baton.infra.auth.jwt.JwtEncoder; + +import static org.mockito.BDDMockito.verify; +import static org.mockito.Mockito.only; + +@ExtendWith(MockitoExtension.class) +class OauthCommandServiceDeleteTest { + + private OauthCommandService oauthCommandService; + + @Mock + private AuthCodeRequestUrlProviderComposite authCodeRequestUrlProviderComposite; + + @Mock + private OauthInformationClientComposite oauthInformationClientComposite; + + @Mock + private OauthMemberCommandRepository oauthMemberCommandRepository; + + @Mock + private OauthRunnerCommandRepository oauthRunnerCommandRepository; + + @Mock + private OauthSupporterCommandRepository oauthSupporterCommandRepository; + + @Mock + private RefreshTokenCommandRepository refreshTokenCommandRepository; + + @Mock + private JwtEncoder jwtEncoder; + + @Mock + private JwtEncoder expiredJwtEncoder; + + @Mock + private JwtDecoder jwtDecoder; + + @BeforeEach + void setUp() { + oauthCommandService = new OauthCommandService(authCodeRequestUrlProviderComposite, oauthInformationClientComposite, oauthMemberCommandRepository, oauthRunnerCommandRepository, oauthSupporterCommandRepository, refreshTokenCommandRepository, jwtEncoder, jwtDecoder); + } + + @DisplayName("Member 로 RefreshToken 을 삭제할 수 있다.") + @Test + void success_logout() { + // given + final Member ethan = MemberFixture.createEthan(); + + // when + oauthCommandService.logout(ethan); + + // then + verify(refreshTokenCommandRepository, only()).deleteByMember(ethan); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/oauth/command/service/OauthCommandServiceUpdateTest.java b/backend/baton/src/test/java/touch/baton/domain/oauth/command/service/OauthCommandServiceUpdateTest.java index d9a7a8551..cdcb99b34 100644 --- a/backend/baton/src/test/java/touch/baton/domain/oauth/command/service/OauthCommandServiceUpdateTest.java +++ b/backend/baton/src/test/java/touch/baton/domain/oauth/command/service/OauthCommandServiceUpdateTest.java @@ -39,20 +39,29 @@ class OauthCommandServiceUpdateTest { private OauthCommandService oauthCommandService; + @Mock private AuthCodeRequestUrlProviderComposite authCodeRequestUrlProviderComposite; + @Mock private OauthInformationClientComposite oauthInformationClientComposite; + @Mock private OauthMemberCommandRepository oauthMemberCommandRepository; + @Mock private OauthRunnerCommandRepository oauthRunnerCommandRepository; + @Mock private OauthSupporterCommandRepository oauthSupporterCommandRepository; + @Mock private RefreshTokenCommandRepository refreshTokenCommandRepository; + private JwtEncoder jwtEncoder; + private JwtEncoder expiredJwtEncoder; + private JwtDecoder jwtDecoder; @BeforeEach diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/RunnerPostTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/RunnerPostTest.java index 50512627f..f92dbdb91 100644 --- a/backend/baton/src/test/java/touch/baton/domain/runnerpost/RunnerPostTest.java +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/RunnerPostTest.java @@ -40,7 +40,9 @@ import java.util.stream.Collectors; import java.util.stream.Stream; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.junit.jupiter.api.Assertions.assertAll; @@ -99,7 +101,7 @@ void addAllRunnerPostTags() { // when runnerPost.addAllRunnerPostTags(List.of(java, spring)); - List runnerPostTags = runnerPost.getRunnerPostTags().getRunnerPostTags(); + final List runnerPostTags = runnerPost.getRunnerPostTags().getRunnerPostTags(); final List actualTagNames = runnerPostTags.stream() .map(runnerPostTag -> runnerPostTag.getTag().getTagName().getValue()) .collect(Collectors.toList()); @@ -480,7 +482,7 @@ void fail_NOT_STARTED__to_IN_PROGRESS() { .runnerPostTags(new RunnerPostTags(new ArrayList<>())) .build(); - // when & then + // when, then assertThatThrownBy(() -> runnerPost.updateReviewStatus(ReviewStatus.IN_PROGRESS)) .isInstanceOf(RunnerPostDomainException.class); } @@ -504,7 +506,7 @@ void fail_NOT_STARTED__to_DONE() { .runnerPostTags(new RunnerPostTags(new ArrayList<>())) .build(); - // when & then + // when, then assertThatThrownBy(() -> runnerPost.updateReviewStatus(ReviewStatus.DONE)) .isInstanceOf(RunnerPostDomainException.class); } @@ -528,7 +530,7 @@ void fail_DONE_to_NOT_STARTED() { .runnerPostTags(new RunnerPostTags(new ArrayList<>())) .build(); - // when & then + // when, then assertThatThrownBy(() -> runnerPost.updateReviewStatus(ReviewStatus.NOT_STARTED)) .isInstanceOf(RunnerPostDomainException.class); } @@ -552,7 +554,7 @@ void fail_DONE_to_IN_PROGRESS() { .runnerPostTags(new RunnerPostTags(new ArrayList<>())) .build(); - // when & then + // when, then assertThatThrownBy(() -> runnerPost.updateReviewStatus(ReviewStatus.IN_PROGRESS)) .isInstanceOf(RunnerPostDomainException.class); } @@ -577,7 +579,7 @@ void fail_same_to_same(final ReviewStatus reviewStatus) { .runnerPostTags(new RunnerPostTags(new ArrayList<>())) .build(); - // when & then + // when, then assertThatThrownBy(() -> runnerPost.updateReviewStatus(reviewStatus)) .isInstanceOf(RunnerPostDomainException.class); } diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceDeleteTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceDeleteTest.java index 5c8b2ba7e..599e4086b 100644 --- a/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceDeleteTest.java +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceDeleteTest.java @@ -72,7 +72,7 @@ void fail_deleteByRunnerPostId_if_not_owner() { final Member memberRunnerPostNotOwner = memberCommandRepository.save(MemberFixture.createJudy()); final Runner runnerPostNotOwner = runnerQueryRepository.save(RunnerFixture.createRunner(memberRunnerPostNotOwner)); - // when & then + // when, then assertThatThrownBy(() -> runnerPostCommandService.deleteByRunnerPostId(runnerPost.getId(), runnerPostNotOwner)) .isInstanceOf(RunnerPostBusinessException.class); } @@ -92,7 +92,7 @@ void fail_deleteByRunnerPostId_if_reviewStatus_is_not_NOT_STARTED() { supporterRunnerPostQueryRepository.save(SupporterRunnerPostFixture.create(runnerPost, supporter)); runnerPost.assignSupporter(supporter); - // when & then + // when, then assertThatThrownBy(() -> runnerPostCommandService.deleteByRunnerPostId(runnerPost.getId(), runner)) .isInstanceOf(RunnerPostBusinessException.class); } @@ -111,7 +111,7 @@ void fail_deleteByRunnerPostId_if_applicant_is_exist() { final Supporter supporter = supporterQueryRepository.save(SupporterFixture.create(memberSupporter)); supporterRunnerPostQueryRepository.save(SupporterRunnerPostFixture.create(runnerPost, supporter)); - // when & then + // when, then assertThatThrownBy(() -> runnerPostCommandService.deleteByRunnerPostId(runnerPost.getId(), runner)) .isInstanceOf(RunnerPostBusinessException.class); } diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostUpdateApplicantCancelationServiceTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostUpdateApplicantCancelationServiceTest.java index fc9d27014..775f09f15 100644 --- a/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostUpdateApplicantCancelationServiceTest.java +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostUpdateApplicantCancelationServiceTest.java @@ -81,7 +81,7 @@ void fail_when_runnerPost_not_found() { supporterRunnerPostQueryRepository.save(supporterRunnerPost); runnerPostQueryRepository.delete(runnerPost); - // when & then + // when, then assertThatThrownBy(() -> runnerPostCommandService.deleteSupporterRunnerPost(applicantSupporter, runnerPost.getId())) .isInstanceOf(RunnerPostBusinessException.class); } @@ -103,7 +103,7 @@ void fail_when_runnerPost_reviewStatus_is_not_NOT_STARTED() { supporterRunnerPostQueryRepository.save(supporterRunnerPost); - // when & then + // when, then assertThatThrownBy(() -> runnerPostCommandService.deleteSupporterRunnerPost(applicantSupporter, runnerPost.getId())) .isInstanceOf(RunnerPostBusinessException.class); } diff --git a/backend/baton/src/test/java/touch/baton/domain/supporter/service/SupporterCommandServiceTest.java b/backend/baton/src/test/java/touch/baton/domain/supporter/service/SupporterCommandServiceTest.java index ba23c386a..6f97ea029 100644 --- a/backend/baton/src/test/java/touch/baton/domain/supporter/service/SupporterCommandServiceTest.java +++ b/backend/baton/src/test/java/touch/baton/domain/supporter/service/SupporterCommandServiceTest.java @@ -33,7 +33,7 @@ void updateSupporter() { final Supporter savedSupporter = supporterQueryRepository.save(SupporterFixture.create(savedMember)); final SupporterUpdateRequest request = new SupporterUpdateRequest("디투랜드", "두나무", "소개글입니다.", List.of("golang", "rust")); - // when & then + // when, then assertThatCode(() -> supporterCommandService.updateSupporter(savedSupporter, request)) .doesNotThrowAnyException(); } From d73a4c1d12b455616cb3d25aef2adb5e74f37c0a Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 10 Oct 2023 16:27:15 +0900 Subject: [PATCH 2/5] =?UTF-8?q?zero-downtime-deploy.sh=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#634)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 로그아웃 API 구현 * refactor: refreshtoken delete api 이름 변경 * refactor: dev에 localhost:3000 cors 추가 * refactor: 리뷰반영 deleteMapping -> patchMapping으로 변경 * test: doPring 제거 * test: 인스턴스 변수 띄어쓰기 추가 * test: repositoryTestConfig에 persistMember 추가 및 리팩토링 * test: given when then 에서 컨벤션 일관화 * refactor: zero-downtime-deploy 경로 변경 --- .github/workflows/dev-be-ci-cd-push.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dev-be-ci-cd-push.yml b/.github/workflows/dev-be-ci-cd-push.yml index 5f3ee1b01..62a461ff1 100644 --- a/.github/workflows/dev-be-ci-cd-push.yml +++ b/.github/workflows/dev-be-ci-cd-push.yml @@ -58,5 +58,5 @@ jobs: - name: Docker Compose run: | - ./zero-downtime-deploy.sh + /home/ubuntu/zero-downtime-deploy.sh sudo docker image prune -af From fa176d03d25e0587a2e9b2e26552eac6cf2ed11e Mon Sep 17 00:00:00 2001 From: "HyunSeo Park (Hyena)" Date: Tue, 10 Oct 2023 16:47:27 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=20=EB=B0=8F=20=EA=B5=AC=EB=8F=85,=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=A1=B0=ED=9A=8C,=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C,=20=EC=95=8C=EB=A6=BC=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8)=20(#625)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 알람 내 값객체 구현 (제목, 내용, 연관 식별자값, 읽기 여부, 알람 타입, 알람 메시지 모음) * feat: 알람 엔티티 구현 * feat: 알람 예외 클래스 구현 * feat: 알람 목록 조회 레포지터리 기능 구현 * feat: 알람 소프트 딜리트, 읽기 여부 true 업데이트 레포지터리 기능 구현 * feat: 알람 소프트 딜리트, 읽기 여부 true 업데이트 서비스 기능 구현 * feat: 알람 목록 조회 서비스 기능 구현 * feat: 알람 읽음 여부 기록 및 삭제 API 구현 * feat: 사용자 알람 목록 조회 API 구현 * feat: 러너 게시글 식별자값으로 러너 게시글과 서포터, 사용자 조회 레포지터리 기능 구현 * feat: 알람 이벤트 (러너 게시글 리뷰 완료, 러너 게시글 서포터 할당, 러너 게시글 서포터 지원) 리스너 구현 * feat: 이벤트 (러너 게시글 리뷰 완료, 러너 게시글 서포터 할당, 러너 게시글 서포터 지원) 발행 구현 * feat: 알람 생성 시간 초단위 삭제를 위한 BaseEntity 상속 클래스 구현 * refactor: 알람 이벤트 리스너 내부 메서드 리팩터링 * test: 로그인된 사용자 알람 목록 조회에서 알람 생성시간 테스트 수정 * test: 테스트용 알람 조회 레포지터리 구현 및 테스트용 회원 조회 레포지터리 수정 * test: 알람 읽음 여부 기록 업데이트 인수 테스트 * test: 러너 게시글 지원자 생성 인수 서포트 내부 사용하지 않는 검증문 삭제 * test: 알람 삭제 인수 테스트 * test: 로그인된 사용자 알람 목록 조회 인수 테스트 * chore: flyway 알람 테이블 생성, 알람 사용자(member) 외래키 제약조건 추가 * test: 알람 삭제, 업데이트 restdocs pathVariable 추가 * docs: 로그인된 사용자 알람 목록 조회, 알람 삭제, 알람 읽음 여부 업데이트 api 문서 추가 * style: 사용하지 않는 메서드 삭제 및 정렬 * test: 테스트 로그 삭제 * style: 사용하지 않는 메서드 삭제 * test: 실험용 테스트 코드 삭제 * docs: API 문서 Index 추가 * style: 알람(Alarm)을 알림(Notification)으로 수정 * test: willDoNothing()을 doNothing()으로 수정 * test: 이벤트 발행 카운트 테스트 수정 * refactor: IsRead 를 정적 팩터리 메서드를 이용해서 생성하도록 리팩터링 * feat: 알림 읽음 여부 수정 기능 구현 * feat: 알림 읽음 여부 수정 서비스 리팩터링 * refactor: 알림 이벤트 리스너 내부 NotificationText 를 문자열로 리팩터링 * feat: 알림 목록 조회 querydsl 기능 구현 * refactor: JpaRepository 조회 레포지터리를 Querydsl 레포지터리로 리팩터링 * test: 알림 Restdocs 테스트 수정 * test: 러너 게시글에 서포터 조인 조회 기능 테스트 수정 --- .../docs/asciidoc/NotificationDeleteApi.adoc | 29 +++ .../asciidoc/NotificationLoginReadApi.adoc | 29 +++ .../docs/asciidoc/NotificationUpdateApi.adoc | 29 +++ backend/baton/src/docs/asciidoc/index.adoc | 14 ++ .../domain/common/TruncatedBaseEntity.java | 22 ++ .../notification/command/Notification.java | 146 ++++++++++++ .../NotificationCommandController.java | 38 ++++ .../event/NotificationEventListener.java | 82 +++++++ .../NotificationCommandRepository.java | 7 + .../service/NotificationCommandService.java | 35 +++ .../notification/command/vo/IsRead.java | 35 +++ .../command/vo/NotificationMessage.java | 32 +++ .../command/vo/NotificationReferencedId.java | 30 +++ .../command/vo/NotificationTitle.java | 32 +++ .../command/vo/NotificationType.java | 6 + .../NotificationBusinessException.java | 10 + .../NotificationDomainException.java | 10 + .../NotificationRequestException.java | 11 + .../NotificationQueryController.java | 31 +++ .../response/NotificationResponse.java | 31 +++ .../response/NotificationResponses.java | 19 ++ .../NotificationQuerydslRepository.java | 27 +++ .../service/NotificationQueryService.java | 21 ++ .../event/RunnerPostApplySupporterEvent.java | 4 + .../event/RunnerPostAssignSupporterEvent.java | 4 + .../RunnerPostReviewStatusDoneEvent.java | 4 + .../service/RunnerPostCommandService.java | 15 +- .../repository/RunnerPostQueryRepository.java | 9 + ...V20231007_1__create_table_notification.sql | 13 ++ ...alter_table_notification_constraint_fk.sql | 3 + .../NotificationDeleteAssuredTest.java | 73 ++++++ .../NotificationUpdateAssuredTest.java | 73 ++++++ .../query/NotificationQueryAssuredTest.java | 91 ++++++++ .../command/NotificationDeleteSupport.java | 59 +++++ .../command/NotificationUpdateSupport.java | 59 +++++ .../query/NotificationQuerySupport.java | 60 +++++ .../TestMemberCommandRepository.java | 17 -- .../repository/TestMemberQueryRepository.java | 33 +++ .../TestNotificationCommandRepository.java | 6 + .../RunnerPostApplicantCreateSupport.java | 9 - .../touch/baton/config/AssuredTestConfig.java | 8 +- .../config/QueryDslRepositoryTestConfig.java | 6 + .../baton/config/RepositoryTestConfig.java | 14 ++ .../touch/baton/config/RestdocsConfig.java | 14 +- .../touch/baton/config/ServiceTestConfig.java | 12 + .../delete/NotificationDeleteApiTest.java | 57 +++++ ...ificationReadWithLoginedMemberApiTest.java | 83 +++++++ .../update/NotificationUpdateApiTest.java | 57 +++++ .../update/RunnerPostUpdateApiTest.java | 6 +- .../command/NotificationTest.java | 207 ++++++++++++++++++ .../event/NotificationEventListenerTest.java | 148 +++++++++++++ .../NotificationCommandRepositoryTest.java | 39 ++++ .../NotificationCommandServiceTest.java | 99 +++++++++ .../notification/command/vo/IsReadTest.java | 44 ++++ .../command/vo/NotificationMessageTest.java | 16 ++ .../vo/NotificationReferencedIdTest.java | 16 ++ .../command/vo/NotificationTitleTest.java | 16 ++ .../NotificationQuerydslRepositoryTest.java | 71 ++++++ .../service/NotificationQueryServiceTest.java | 77 +++++++ .../RunnerPostCommandServiceCreateTest.java | 3 +- .../RunnerPostCommandServiceDeleteTest.java | 3 +- .../RunnerPostCommandServiceEventTest.java | 119 ++++++++++ .../RunnerPostCommandServiceUpdateTest.java | 3 +- ...UpdateApplicantCancelationServiceTest.java | 5 +- .../RunnerPostQueryRepositoryTest.java | 28 +++ .../fixture/domain/NotificationFixture.java | 27 +++ .../vo/NotificationMessageFixture.java | 13 ++ .../vo/NotificationReferencedIdFixture.java | 13 ++ .../fixture/vo/NotificationTitleFixture.java | 13 ++ 69 files changed, 2437 insertions(+), 38 deletions(-) create mode 100644 backend/baton/src/docs/asciidoc/NotificationDeleteApi.adoc create mode 100644 backend/baton/src/docs/asciidoc/NotificationLoginReadApi.adoc create mode 100644 backend/baton/src/docs/asciidoc/NotificationUpdateApi.adoc create mode 100644 backend/baton/src/main/java/touch/baton/domain/common/TruncatedBaseEntity.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/command/Notification.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/command/controller/NotificationCommandController.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/command/event/NotificationEventListener.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/command/repository/NotificationCommandRepository.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/command/service/NotificationCommandService.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/command/vo/IsRead.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/command/vo/NotificationMessage.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/command/vo/NotificationReferencedId.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/command/vo/NotificationTitle.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/command/vo/NotificationType.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/exception/NotificationBusinessException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/exception/NotificationDomainException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/exception/NotificationRequestException.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/query/controller/NotificationQueryController.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/query/controller/response/NotificationResponse.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/query/controller/response/NotificationResponses.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/query/repository/NotificationQuerydslRepository.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/notification/query/service/NotificationQueryService.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/command/event/RunnerPostApplySupporterEvent.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/command/event/RunnerPostAssignSupporterEvent.java create mode 100644 backend/baton/src/main/java/touch/baton/domain/runnerpost/command/event/RunnerPostReviewStatusDoneEvent.java create mode 100644 backend/baton/src/main/resources/db/migration/V20231007_1__create_table_notification.sql create mode 100644 backend/baton/src/main/resources/db/migration/V20231007_2__alter_table_notification_constraint_fk.sql create mode 100644 backend/baton/src/test/java/touch/baton/assure/notification/command/NotificationDeleteAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/notification/command/NotificationUpdateAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/notification/query/NotificationQueryAssuredTest.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/notification/support/command/NotificationDeleteSupport.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/notification/support/command/NotificationUpdateSupport.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/notification/support/query/NotificationQuerySupport.java delete mode 100644 backend/baton/src/test/java/touch/baton/assure/repository/TestMemberCommandRepository.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/repository/TestMemberQueryRepository.java create mode 100644 backend/baton/src/test/java/touch/baton/assure/repository/TestNotificationCommandRepository.java create mode 100644 backend/baton/src/test/java/touch/baton/document/notification/delete/NotificationDeleteApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/notification/read/NotificationReadWithLoginedMemberApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/document/notification/update/NotificationUpdateApiTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/notification/command/NotificationTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/notification/command/event/NotificationEventListenerTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/notification/command/repository/NotificationCommandRepositoryTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/notification/command/service/NotificationCommandServiceTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/notification/command/vo/IsReadTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/notification/command/vo/NotificationMessageTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/notification/command/vo/NotificationReferencedIdTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/notification/command/vo/NotificationTitleTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/notification/query/repository/NotificationQuerydslRepositoryTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/notification/query/service/NotificationQueryServiceTest.java create mode 100644 backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceEventTest.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/domain/NotificationFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/NotificationMessageFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/NotificationReferencedIdFixture.java create mode 100644 backend/baton/src/test/java/touch/baton/fixture/vo/NotificationTitleFixture.java diff --git a/backend/baton/src/docs/asciidoc/NotificationDeleteApi.adoc b/backend/baton/src/docs/asciidoc/NotificationDeleteApi.adoc new file mode 100644 index 000000000..6510a9940 --- /dev/null +++ b/backend/baton/src/docs/asciidoc/NotificationDeleteApi.adoc @@ -0,0 +1,29 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 3 +:sectlinks: +:operation-http-request-title: Example Request +:operation-http-response-title: Example Response + +==== *알림 삭제 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/notification-delete-api-test/delete-notification-by-notification-id/http-request.adoc[] + +===== *Http Request Headers* + +include::{snippets}/../../build/generated-snippets/notification-delete-api-test/delete-notification-by-notification-id/request-headers.adoc[] + +===== *Http Request Path Parameters* + +include::{snippets}/../../build/generated-snippets/notification-delete-api-test/delete-notification-by-notification-id/path-parameters.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/notification-delete-api-test/delete-notification-by-notification-id/http-response.adoc[] diff --git a/backend/baton/src/docs/asciidoc/NotificationLoginReadApi.adoc b/backend/baton/src/docs/asciidoc/NotificationLoginReadApi.adoc new file mode 100644 index 000000000..6102b83fa --- /dev/null +++ b/backend/baton/src/docs/asciidoc/NotificationLoginReadApi.adoc @@ -0,0 +1,29 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 3 +:sectlinks: +:operation-http-request-title: Example Request +:operation-http-response-title: Example Response + +==== *로그인된 사용자 알림 목록 조회 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/notification-read-with-logined-member-api-test/read-notifications-by-member-id/http-request.adoc[] + +===== *Http Request Headers* + +include::{snippets}/../../build/generated-snippets/notification-read-with-logined-member-api-test/read-notifications-by-member-id/request-headers.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/notification-read-with-logined-member-api-test/read-notifications-by-member-id/http-response.adoc[] + +===== *Http Response Fields* + +include::{snippets}/../../build/generated-snippets/notification-read-with-logined-member-api-test/read-notifications-by-member-id/response-fields.adoc[] diff --git a/backend/baton/src/docs/asciidoc/NotificationUpdateApi.adoc b/backend/baton/src/docs/asciidoc/NotificationUpdateApi.adoc new file mode 100644 index 000000000..90226c48a --- /dev/null +++ b/backend/baton/src/docs/asciidoc/NotificationUpdateApi.adoc @@ -0,0 +1,29 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 3 +:sectlinks: +:operation-http-request-title: Example Request +:operation-http-response-title: Example Response + +==== *알림 읽음 여부 업데이트 API* + +===== *Http Request* + +include::{snippets}/../../build/generated-snippets/notification-update-api-test/update-notification-is-read-true-by-member/http-request.adoc[] + +===== *Http Request Headers* + +include::{snippets}/../../build/generated-snippets/notification-update-api-test/update-notification-is-read-true-by-member/request-headers.adoc[] + +===== *Http Request Path Parameters* + +include::{snippets}/../../build/generated-snippets/notification-update-api-test/update-notification-is-read-true-by-member/path-parameters.adoc[] + +===== *Http Response* + +include::{snippets}/../../build/generated-snippets/notification-update-api-test/update-notification-is-read-true-by-member/http-response.adoc[] diff --git a/backend/baton/src/docs/asciidoc/index.adoc b/backend/baton/src/docs/asciidoc/index.adoc index de19ac59b..ce5430092 100644 --- a/backend/baton/src/docs/asciidoc/index.adoc +++ b/backend/baton/src/docs/asciidoc/index.adoc @@ -64,3 +64,17 @@ include::RunnerPostUpdateApplicantCancelationApi.adoc[] === *러너 게시글 삭제* include::RunnerPostDeleteApi.adoc[] + +== *[ 알림 ]* + +=== *알림 조회* + +include::NotificationLoginReadApi.adoc[] + +=== *알림 수정* + +include::NotificationUpdateApi.adoc[] + +=== *알림 삭제* + +include::NotificationDeleteApi.adoc[] diff --git a/backend/baton/src/main/java/touch/baton/domain/common/TruncatedBaseEntity.java b/backend/baton/src/main/java/touch/baton/domain/common/TruncatedBaseEntity.java new file mode 100644 index 000000000..205b807ef --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/common/TruncatedBaseEntity.java @@ -0,0 +1,22 @@ +package touch.baton.domain.common; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + +public abstract class TruncatedBaseEntity extends BaseEntity { + + @Override + public LocalDateTime getCreatedAt() { + return super.getCreatedAt().truncatedTo(ChronoUnit.MINUTES); + } + + @Override + public LocalDateTime getDeletedAt() { + return super.getDeletedAt().truncatedTo(ChronoUnit.MINUTES); + } + + @Override + public LocalDateTime getUpdatedAt() { + return super.getUpdatedAt().truncatedTo(ChronoUnit.MINUTES); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/command/Notification.java b/backend/baton/src/main/java/touch/baton/domain/notification/command/Notification.java new file mode 100644 index 000000000..3528cad21 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/command/Notification.java @@ -0,0 +1,146 @@ +package touch.baton.domain.notification.command; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; +import touch.baton.domain.common.TruncatedBaseEntity; +import touch.baton.domain.member.command.Member; +import touch.baton.domain.notification.command.vo.IsRead; +import touch.baton.domain.notification.command.vo.NotificationMessage; +import touch.baton.domain.notification.command.vo.NotificationReferencedId; +import touch.baton.domain.notification.command.vo.NotificationTitle; +import touch.baton.domain.notification.command.vo.NotificationType; +import touch.baton.domain.notification.exception.NotificationDomainException; + +import java.util.Objects; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Where(clause = "deleted_at IS NULL") +@SQLDelete(sql = "UPDATE notification SET deleted_at = now() WHERE id = ?") +@Entity +public class Notification extends TruncatedBaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Embedded + private NotificationTitle notificationTitle; + + @Embedded + private NotificationMessage notificationMessage; + + @Enumerated(STRING) + @Column(nullable = false) + private NotificationType notificationType; + + @Embedded + private NotificationReferencedId notificationReferencedId; + + @Embedded + private IsRead isRead; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "member_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_notification_to_member")) + private Member member; + + @Builder + private Notification(final NotificationTitle notificationTitle, + final NotificationMessage notificationMessage, + final NotificationType notificationType, + final NotificationReferencedId notificationReferencedId, + final IsRead isRead, + final Member member + ) { + this(null, notificationTitle, notificationMessage, notificationType, notificationReferencedId, isRead, member); + } + + private Notification(final Long id, + final NotificationTitle notificationTitle, + final NotificationMessage notificationMessage, + final NotificationType notificationType, + final NotificationReferencedId notificationReferencedId, + final IsRead isRead, + final Member member + ) { + validateNotNull(notificationTitle, notificationMessage, notificationType, notificationReferencedId, isRead, member); + this.id = id; + this.notificationTitle = notificationTitle; + this.notificationMessage = notificationMessage; + this.notificationType = notificationType; + this.notificationReferencedId = notificationReferencedId; + this.isRead = isRead; + this.member = member; + } + + private void validateNotNull(final NotificationTitle notificationTitle, + final NotificationMessage notificationMessage, + final NotificationType notificationType, + final NotificationReferencedId notificationReferencedId, + final IsRead isRead, + final Member member + ) { + if (notificationTitle == null) { + throw new NotificationDomainException("NotificationTitle 의 notificationTitle 은 null 일 수 없습니다."); + } + if (notificationMessage == null) { + throw new NotificationDomainException("NotificationMessage 의 notificationMessage 는 null 일 수 없습니다."); + } + if (notificationType == null) { + throw new NotificationDomainException("NotificationType 의 notificationType 는 null 일 수 없습니다."); + } + if (notificationReferencedId == null) { + throw new NotificationDomainException("NotificationReferencedId 의 notificationReferencedId 은 null 일 수 없습니다."); + } + if (isRead == null) { + throw new NotificationDomainException("IsRead 의 isRead 는 null 일 수 없습니다."); + } + if (member == null) { + throw new NotificationDomainException("Member 의 member 는 null 일 수 없습니다."); + } + } + + public void markAsRead(final Member currentMember) { + if (!this.member.equals(currentMember)) { + throw new NotificationDomainException("Notification 의 주인(사용자)가 아니므로 알림의 읽은 여부를 수정할 수 없습니다."); + } + + this.isRead = IsRead.asRead(); + } + + public boolean isNotOwner(final Member currentMember) { + return !this.member.equals(currentMember); + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final Notification notification = (Notification) o; + return Objects.equals(id, notification.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/command/controller/NotificationCommandController.java b/backend/baton/src/main/java/touch/baton/domain/notification/command/controller/NotificationCommandController.java new file mode 100644 index 000000000..488cfc99d --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/command/controller/NotificationCommandController.java @@ -0,0 +1,38 @@ +package touch.baton.domain.notification.command.controller; + +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import touch.baton.domain.notification.command.service.NotificationCommandService; +import touch.baton.domain.member.command.Member; +import touch.baton.domain.oauth.query.controller.resolver.AuthMemberPrincipal; + +@RequiredArgsConstructor +@RequestMapping("/api/v1/notifications") +@RestController +public class NotificationCommandController { + + private final NotificationCommandService notificationCommandService; + + @PatchMapping("/{notificationId}") + public ResponseEntity updateNotificationIsReadTrueByNotificationId(@AuthMemberPrincipal final Member member, + @PathVariable final Long notificationId + ) { + notificationCommandService.updateNotificationIsReadTrueByMember(member, notificationId); + + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{notificationId}") + public ResponseEntity deleteNotificationByNotificationId(@AuthMemberPrincipal final Member member, + @PathVariable final Long notificationId + ) { + notificationCommandService.deleteNotificationByMember(member, notificationId); + + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/command/event/NotificationEventListener.java b/backend/baton/src/main/java/touch/baton/domain/notification/command/event/NotificationEventListener.java new file mode 100644 index 000000000..e7d27e42f --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/command/event/NotificationEventListener.java @@ -0,0 +1,82 @@ +package touch.baton.domain.notification.command.event; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; +import touch.baton.domain.member.command.Member; +import touch.baton.domain.notification.command.Notification; +import touch.baton.domain.notification.command.repository.NotificationCommandRepository; +import touch.baton.domain.notification.command.vo.IsRead; +import touch.baton.domain.notification.command.vo.NotificationMessage; +import touch.baton.domain.notification.command.vo.NotificationReferencedId; +import touch.baton.domain.notification.command.vo.NotificationTitle; +import touch.baton.domain.notification.command.vo.NotificationType; +import touch.baton.domain.notification.exception.NotificationBusinessException; +import touch.baton.domain.runnerpost.command.RunnerPost; +import touch.baton.domain.runnerpost.command.event.RunnerPostApplySupporterEvent; +import touch.baton.domain.runnerpost.command.event.RunnerPostAssignSupporterEvent; +import touch.baton.domain.runnerpost.command.event.RunnerPostReviewStatusDoneEvent; +import touch.baton.domain.runnerpost.query.repository.RunnerPostQueryRepository; + +import static org.springframework.transaction.annotation.Propagation.REQUIRES_NEW; +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +@RequiredArgsConstructor +@Component +public class NotificationEventListener { + + private final NotificationCommandRepository notificationCommandRepository; + private final RunnerPostQueryRepository runnerPostQueryRepository; + + @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(propagation = REQUIRES_NEW) + public void subscribeRunnerPostApplySupporterEvent(final RunnerPostApplySupporterEvent event) { + final RunnerPost foundRunnerPost = getRunnerPostWithRunnerOrThrowException(event.runnerPostId()); + + notificationCommandRepository.save( + createNotification("서포터의 제안이 왔습니다.", foundRunnerPost, foundRunnerPost.getRunner().getMember()) + ); + } + + private RunnerPost getRunnerPostWithRunnerOrThrowException(final Long runnerPostId) { + return runnerPostQueryRepository.joinMemberByRunnerPostId(runnerPostId) + .orElseThrow(() -> new NotificationBusinessException("러너 게시글 식별자값으로 러너 게시글과 러너(작성자)를 조회하던 도중에 오류가 발생하였습니다.")); + } + + private Notification createNotification(final String notificationTitle, final RunnerPost runnerPost, final Member targetMember) { + return Notification.builder() + .notificationTitle(new NotificationTitle(notificationTitle)) + .notificationMessage(new NotificationMessage(String.format("관련 게시글 - %s", runnerPost.getTitle().getValue()))) + .notificationType(NotificationType.RUNNER_POST) + .notificationReferencedId(new NotificationReferencedId(runnerPost.getId())) + .isRead(IsRead.asUnRead()) + .member(targetMember) + .build(); + } + + @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(propagation = REQUIRES_NEW) + public void subscribeRunnerPostReviewStatusDoneEvent(final RunnerPostReviewStatusDoneEvent event) { + final RunnerPost foundRunnerPost = getRunnerPostWithRunnerOrThrowException(event.runnerPostId()); + + notificationCommandRepository.save( + createNotification("코드 리뷰 상태가 완료로 변경되었습니다.", foundRunnerPost, foundRunnerPost.getRunner().getMember()) + ); + } + + @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(propagation = REQUIRES_NEW) + public void subscribeRunnerPostAssignSupporterEvent(final RunnerPostAssignSupporterEvent event) { + final RunnerPost foundRunnerPost = getRunnerPostWithSupporterOrThrowException(event.runnerPostId()); + + notificationCommandRepository.save( + createNotification("코드 리뷰 매칭이 완료되었습니다.", foundRunnerPost, foundRunnerPost.getSupporter().getMember()) + ); + } + + private RunnerPost getRunnerPostWithSupporterOrThrowException(final Long runnerPostId) { + return runnerPostQueryRepository.joinSupporterByRunnerPostId(runnerPostId) + .orElseThrow(() -> new NotificationBusinessException("러너 게시글 식별자값으로 러너 게시글과 서포터(지원자)를 조회하던 도중에 오류가 발생하였습니다.")); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/command/repository/NotificationCommandRepository.java b/backend/baton/src/main/java/touch/baton/domain/notification/command/repository/NotificationCommandRepository.java new file mode 100644 index 000000000..f0b32bc6e --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/command/repository/NotificationCommandRepository.java @@ -0,0 +1,7 @@ +package touch.baton.domain.notification.command.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import touch.baton.domain.notification.command.Notification; + +public interface NotificationCommandRepository extends JpaRepository { +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/command/service/NotificationCommandService.java b/backend/baton/src/main/java/touch/baton/domain/notification/command/service/NotificationCommandService.java new file mode 100644 index 000000000..c3551428c --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/command/service/NotificationCommandService.java @@ -0,0 +1,35 @@ +package touch.baton.domain.notification.command.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import touch.baton.domain.notification.command.Notification; +import touch.baton.domain.notification.command.repository.NotificationCommandRepository; +import touch.baton.domain.notification.exception.NotificationBusinessException; +import touch.baton.domain.member.command.Member; + +@RequiredArgsConstructor +@Transactional +@Service +public class NotificationCommandService { + + private final NotificationCommandRepository notificationCommandRepository; + + public void updateNotificationIsReadTrueByMember(final Member member, final Long notificationId) { + final Notification foundNotification = getNotificationByNotificationId(notificationId); + foundNotification.markAsRead(member); + } + + private Notification getNotificationByNotificationId(final Long notificationId) { + return notificationCommandRepository.findById(notificationId) + .orElseThrow(() -> new NotificationBusinessException("Notification 식별자값으로 알림을 조회할 수 없습니다.")); + } + + public void deleteNotificationByMember(final Member member, final Long notificationId) { + if (getNotificationByNotificationId(notificationId).isNotOwner(member)) { + throw new NotificationBusinessException("Notification 의 주인(사용자)가 아니므로 알림을 삭제할 수 없습니다."); + } + + notificationCommandRepository.deleteById(notificationId); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/command/vo/IsRead.java b/backend/baton/src/main/java/touch/baton/domain/notification/command/vo/IsRead.java new file mode 100644 index 000000000..20fe24a13 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/command/vo/IsRead.java @@ -0,0 +1,35 @@ +package touch.baton.domain.notification.command.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class IsRead { + + @ColumnDefault(value = "false") + @Column(name = "is_read", nullable = false) + private boolean value = false; + + private IsRead(final boolean value) { + this.value = value; + } + + public static IsRead asRead() { + return new IsRead(true); + } + + public static IsRead asUnRead() { + return new IsRead(false); + } + + public boolean getValue() { + return value; + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/command/vo/NotificationMessage.java b/backend/baton/src/main/java/touch/baton/domain/notification/command/vo/NotificationMessage.java new file mode 100644 index 000000000..6181bf570 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/command/vo/NotificationMessage.java @@ -0,0 +1,32 @@ +package touch.baton.domain.notification.command.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class NotificationMessage { + + @Column(name = "message", nullable = false) + private String value; + + public NotificationMessage(final String value) { + validateNotNull(value); + this.value = value; + } + + private void validateNotNull(final String value) { + if (Objects.isNull(value)) { + throw new IllegalArgumentException("NotificationMessage 객체 내부에 message 는 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/command/vo/NotificationReferencedId.java b/backend/baton/src/main/java/touch/baton/domain/notification/command/vo/NotificationReferencedId.java new file mode 100644 index 000000000..1892a01fe --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/command/vo/NotificationReferencedId.java @@ -0,0 +1,30 @@ +package touch.baton.domain.notification.command.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class NotificationReferencedId { + + @Column(name = "referenced_id", nullable = false) + private Long value; + + public NotificationReferencedId(final Long value) { + validateNotNull(value); + this.value = value; + } + + private void validateNotNull(final Long value) { + if (value == null) { + throw new IllegalArgumentException("NotificationReferencedId 객체 내부에 referencedId 은 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/command/vo/NotificationTitle.java b/backend/baton/src/main/java/touch/baton/domain/notification/command/vo/NotificationTitle.java new file mode 100644 index 000000000..b49bd80a6 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/command/vo/NotificationTitle.java @@ -0,0 +1,32 @@ +package touch.baton.domain.notification.command.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +import static lombok.AccessLevel.PROTECTED; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor(access = PROTECTED) +@Embeddable +public class NotificationTitle { + + @Column(name = "title", nullable = false) + private String value; + + public NotificationTitle(final String value) { + validateNotNull(value); + this.value = value; + } + + private void validateNotNull(final String value) { + if (Objects.isNull(value)) { + throw new IllegalArgumentException("NotificationTitle 객체 내부에 title 은 null 일 수 없습니다."); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/command/vo/NotificationType.java b/backend/baton/src/main/java/touch/baton/domain/notification/command/vo/NotificationType.java new file mode 100644 index 000000000..14a768acb --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/command/vo/NotificationType.java @@ -0,0 +1,6 @@ +package touch.baton.domain.notification.command.vo; + +public enum NotificationType { + + RUNNER_POST; +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/exception/NotificationBusinessException.java b/backend/baton/src/main/java/touch/baton/domain/notification/exception/NotificationBusinessException.java new file mode 100644 index 000000000..3e9a6af08 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/exception/NotificationBusinessException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.notification.exception; + +import touch.baton.domain.common.exception.BusinessException; + +public class NotificationBusinessException extends BusinessException { + + public NotificationBusinessException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/exception/NotificationDomainException.java b/backend/baton/src/main/java/touch/baton/domain/notification/exception/NotificationDomainException.java new file mode 100644 index 000000000..18d66ce9b --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/exception/NotificationDomainException.java @@ -0,0 +1,10 @@ +package touch.baton.domain.notification.exception; + +import touch.baton.domain.common.exception.DomainException; + +public class NotificationDomainException extends DomainException { + + public NotificationDomainException(final String message) { + super(message); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/exception/NotificationRequestException.java b/backend/baton/src/main/java/touch/baton/domain/notification/exception/NotificationRequestException.java new file mode 100644 index 000000000..16ac4d398 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/exception/NotificationRequestException.java @@ -0,0 +1,11 @@ +package touch.baton.domain.notification.exception; + +import touch.baton.domain.common.exception.ClientErrorCode; +import touch.baton.domain.common.exception.ClientRequestException; + +public class NotificationRequestException extends ClientRequestException { + + public NotificationRequestException(final ClientErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/query/controller/NotificationQueryController.java b/backend/baton/src/main/java/touch/baton/domain/notification/query/controller/NotificationQueryController.java new file mode 100644 index 000000000..fee64a9f7 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/query/controller/NotificationQueryController.java @@ -0,0 +1,31 @@ +package touch.baton.domain.notification.query.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import touch.baton.domain.member.command.Member; +import touch.baton.domain.notification.command.Notification; +import touch.baton.domain.notification.query.controller.response.NotificationResponses; +import touch.baton.domain.notification.query.service.NotificationQueryService; +import touch.baton.domain.oauth.query.controller.resolver.AuthMemberPrincipal; + +import java.util.List; + +@RequiredArgsConstructor +@RequestMapping("/api/v1/notifications") +@RestController +public class NotificationQueryController { + + private static final int READ_NOTIFICATION_DEFAULT_LIMIT = 10; + + private final NotificationQueryService notificationQueryService; + + @GetMapping + public ResponseEntity readNotificationsByMember(@AuthMemberPrincipal final Member member) { + final List foundNotifications = notificationQueryService.readNotificationsByMemberId(member.getId(), READ_NOTIFICATION_DEFAULT_LIMIT); + + return ResponseEntity.ok(NotificationResponses.SimpleNotifications.from(foundNotifications)); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/query/controller/response/NotificationResponse.java b/backend/baton/src/main/java/touch/baton/domain/notification/query/controller/response/NotificationResponse.java new file mode 100644 index 000000000..ebd6f789a --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/query/controller/response/NotificationResponse.java @@ -0,0 +1,31 @@ +package touch.baton.domain.notification.query.controller.response; + +import touch.baton.domain.notification.command.Notification; +import touch.baton.domain.notification.command.vo.NotificationType; + +import java.time.LocalDateTime; + +public record NotificationResponse() { + + public record Simple(Long notificationId, + String title, + String message, + NotificationType notificationType, + Long referencedId, + boolean isRead, + LocalDateTime createdAt + ) { + + public static Simple from(final Notification notification) { + return new NotificationResponse.Simple( + notification.getId(), + notification.getNotificationTitle().getValue(), + notification.getNotificationMessage().getValue(), + notification.getNotificationType(), + notification.getNotificationReferencedId().getValue(), + notification.getIsRead().getValue(), + notification.getCreatedAt() + ); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/query/controller/response/NotificationResponses.java b/backend/baton/src/main/java/touch/baton/domain/notification/query/controller/response/NotificationResponses.java new file mode 100644 index 000000000..d830165ef --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/query/controller/response/NotificationResponses.java @@ -0,0 +1,19 @@ +package touch.baton.domain.notification.query.controller.response; + +import touch.baton.domain.notification.command.Notification; + +import java.util.List; + +public record NotificationResponses() { + + public record SimpleNotifications(List data) { + + public static SimpleNotifications from(final List notifications) { + final List response = notifications.stream() + .map(NotificationResponse.Simple::from) + .toList(); + + return new SimpleNotifications(response); + } + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/query/repository/NotificationQuerydslRepository.java b/backend/baton/src/main/java/touch/baton/domain/notification/query/repository/NotificationQuerydslRepository.java new file mode 100644 index 000000000..d723ac8cf --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/query/repository/NotificationQuerydslRepository.java @@ -0,0 +1,27 @@ +package touch.baton.domain.notification.query.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import touch.baton.domain.notification.command.Notification; + +import java.util.List; + +import static touch.baton.domain.notification.command.QNotification.notification; + +@RequiredArgsConstructor +@Repository +public class NotificationQuerydslRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List findByMemberId(final Long memberId, + final int limit + ) { + return jpaQueryFactory.selectFrom(notification) + .where(notification.member.id.eq(memberId)) + .orderBy(notification.id.desc()) + .limit(limit) + .fetch(); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/notification/query/service/NotificationQueryService.java b/backend/baton/src/main/java/touch/baton/domain/notification/query/service/NotificationQueryService.java new file mode 100644 index 000000000..ece728ab8 --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/notification/query/service/NotificationQueryService.java @@ -0,0 +1,21 @@ +package touch.baton.domain.notification.query.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import touch.baton.domain.notification.command.Notification; +import touch.baton.domain.notification.query.repository.NotificationQuerydslRepository; + +import java.util.List; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class NotificationQueryService { + + private final NotificationQuerydslRepository notificationQuerydslRepository; + + public List readNotificationsByMemberId(final Long memberId, final int limit) { + return notificationQuerydslRepository.findByMemberId(memberId, limit); + } +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/command/event/RunnerPostApplySupporterEvent.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/command/event/RunnerPostApplySupporterEvent.java new file mode 100644 index 000000000..2262029fd --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/command/event/RunnerPostApplySupporterEvent.java @@ -0,0 +1,4 @@ +package touch.baton.domain.runnerpost.command.event; + +public record RunnerPostApplySupporterEvent(Long runnerPostId) { +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/command/event/RunnerPostAssignSupporterEvent.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/command/event/RunnerPostAssignSupporterEvent.java new file mode 100644 index 000000000..0f443233c --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/command/event/RunnerPostAssignSupporterEvent.java @@ -0,0 +1,4 @@ +package touch.baton.domain.runnerpost.command.event; + +public record RunnerPostAssignSupporterEvent(Long runnerPostId) { +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/command/event/RunnerPostReviewStatusDoneEvent.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/command/event/RunnerPostReviewStatusDoneEvent.java new file mode 100644 index 000000000..60b84a5ef --- /dev/null +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/command/event/RunnerPostReviewStatusDoneEvent.java @@ -0,0 +1,4 @@ +package touch.baton.domain.runnerpost.command.event; + +public record RunnerPostReviewStatusDoneEvent(Long runnerPostId) { +} diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandService.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandService.java index a093da6ea..10a7c7873 100644 --- a/backend/baton/src/main/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandService.java +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandService.java @@ -1,6 +1,7 @@ package touch.baton.domain.runnerpost.command.service; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import touch.baton.domain.common.vo.TagName; @@ -11,6 +12,9 @@ import touch.baton.domain.member.command.repository.SupporterRunnerPostCommandRepository; import touch.baton.domain.member.command.vo.Message; import touch.baton.domain.runnerpost.command.RunnerPost; +import touch.baton.domain.runnerpost.command.event.RunnerPostApplySupporterEvent; +import touch.baton.domain.runnerpost.command.event.RunnerPostAssignSupporterEvent; +import touch.baton.domain.runnerpost.command.event.RunnerPostReviewStatusDoneEvent; import touch.baton.domain.runnerpost.command.exception.RunnerPostBusinessException; import touch.baton.domain.runnerpost.command.repository.RunnerPostCommandRepository; import touch.baton.domain.runnerpost.command.service.dto.RunnerPostApplicantCreateRequest; @@ -33,6 +37,7 @@ public class RunnerPostCommandService { private final TagCommandRepository tagCommandRepository; private final SupporterCommandRepository supporterCommandRepository; private final SupporterRunnerPostCommandRepository supporterRunnerPostCommandRepository; + private final ApplicationEventPublisher eventPublisher; public Long createRunnerPost(final Runner runner, final RunnerPostCreateRequest request) { final RunnerPost runnerPost = toDomain(runner, request); @@ -113,7 +118,11 @@ public Long createRunnerPostApplicant(final Supporter supporter, .message(new Message(request.message())) .build(); - return supporterRunnerPostCommandRepository.save(runnerPostApplicant).getId(); + final Long savedApplicantId = supporterRunnerPostCommandRepository.save(runnerPostApplicant).getId(); + + eventPublisher.publishEvent(new RunnerPostApplySupporterEvent(foundRunnerPost.getId())); + + return savedApplicantId; } public void updateRunnerPostReviewStatusDone(final Long runnerPostId, final Supporter supporter) { @@ -129,6 +138,8 @@ public void updateRunnerPostReviewStatusDone(final Long runnerPostId, final Supp } foundRunnerPost.finishReview(); + + eventPublisher.publishEvent(new RunnerPostReviewStatusDoneEvent(foundRunnerPost.getId())); } public void deleteSupporterRunnerPost(final Supporter supporter, final Long runnerPostId) { @@ -157,6 +168,8 @@ public void updateRunnerPostAppliedSupporter(final Runner runner, } foundRunnerPost.assignSupporter(foundApplySupporter); + + eventPublisher.publishEvent(new RunnerPostAssignSupporterEvent(foundRunnerPost.getId())); } private boolean isApplySupporter(final Long runnerPostId, final Supporter foundSupporter) { diff --git a/backend/baton/src/main/java/touch/baton/domain/runnerpost/query/repository/RunnerPostQueryRepository.java b/backend/baton/src/main/java/touch/baton/domain/runnerpost/query/repository/RunnerPostQueryRepository.java index cbfb3b3c6..caf078fb6 100644 --- a/backend/baton/src/main/java/touch/baton/domain/runnerpost/query/repository/RunnerPostQueryRepository.java +++ b/backend/baton/src/main/java/touch/baton/domain/runnerpost/query/repository/RunnerPostQueryRepository.java @@ -20,6 +20,15 @@ public interface RunnerPostQueryRepository extends JpaRepository joinMemberByRunnerPostId(@Param("runnerPostId") final Long runnerPostId); + @Query(""" + select rp + from RunnerPost rp + join fetch Supporter s on s.id = rp.supporter.id + join fetch Member m on m.id = s.member.id + where rp.id = :runnerPostId + """) + Optional joinSupporterByRunnerPostId(@Param("runnerPostId") final Long runnerPostId); + List findByRunnerId(final Long runnerId); @Query(""" diff --git a/backend/baton/src/main/resources/db/migration/V20231007_1__create_table_notification.sql b/backend/baton/src/main/resources/db/migration/V20231007_1__create_table_notification.sql new file mode 100644 index 000000000..2519d24f1 --- /dev/null +++ b/backend/baton/src/main/resources/db/migration/V20231007_1__create_table_notification.sql @@ -0,0 +1,13 @@ +CREATE TABLE notification +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + message VARCHAR(255) NOT NULL, + notification_type VARCHAR(255) NOT NULL, + is_read bit default false NOT NULL, + referenced_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), +); diff --git a/backend/baton/src/main/resources/db/migration/V20231007_2__alter_table_notification_constraint_fk.sql b/backend/baton/src/main/resources/db/migration/V20231007_2__alter_table_notification_constraint_fk.sql new file mode 100644 index 000000000..eb7cf8c22 --- /dev/null +++ b/backend/baton/src/main/resources/db/migration/V20231007_2__alter_table_notification_constraint_fk.sql @@ -0,0 +1,3 @@ +ALTER TABLE notification + ADD CONSTRAINT fk_notification_to_member + FOREIGN KEY (member_id) REFERENCES member (id); diff --git a/backend/baton/src/test/java/touch/baton/assure/notification/command/NotificationDeleteAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/notification/command/NotificationDeleteAssuredTest.java new file mode 100644 index 000000000..76f47dc92 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/notification/command/NotificationDeleteAssuredTest.java @@ -0,0 +1,73 @@ +package touch.baton.assure.notification.command; + +import org.junit.jupiter.api.Test; +import touch.baton.assure.notification.support.command.NotificationDeleteSupport; +import touch.baton.assure.runnerpost.support.command.RunnerPostCreateSupport; +import touch.baton.config.AssuredTestConfig; +import touch.baton.config.infra.auth.oauth.authcode.FakeAuthCodes; +import touch.baton.domain.notification.command.Notification; +import touch.baton.domain.notification.command.vo.NotificationMessage; +import touch.baton.domain.notification.command.vo.NotificationReferencedId; +import touch.baton.domain.notification.command.vo.NotificationTitle; +import touch.baton.domain.notification.command.vo.NotificationType; +import touch.baton.domain.notification.command.vo.IsRead; +import touch.baton.domain.member.command.Member; +import touch.baton.domain.runnerpost.command.service.dto.RunnerPostCreateRequest; + +import java.time.LocalDateTime; +import java.util.List; + +@SuppressWarnings("NonAsciiCharacters") +class NotificationDeleteAssuredTest extends AssuredTestConfig { + + @Test + void 로그인한_사용자가_자신의_알림을_하나_삭제한다() { + // given + final String 헤나_액세스_토큰 = oauthLoginTestManager.소셜_회원가입을_진행한_후_액세스_토큰을_반환한다(FakeAuthCodes.hyenaAuthCode()); + + final RunnerPostCreateRequest 게시글_생성_요청 = new RunnerPostCreateRequest( + "테스트용 게시글 제목", + List.of("테스트용 태그1", "테스트용 태그2"), + "https://github.com/test", + LocalDateTime.now().plusDays(10), + "테스트용 구현 내용", + "테스트용 궁금한 내용", + "테스트용 남길 내용" + ); + + final Long 생성된_러너_게시글_식별자값 = RunnerPostCreateSupport + .클라이언트_요청() + .액세스_토큰으로_로그인한다(헤나_액세스_토큰) + .러너_게시글_등록_요청한다(게시글_생성_요청) + + .서버_응답() + .러너_게시글_생성_성공을_검증한다() + .생성한_러너_게시글의_식별자값을_반환한다(); + + // when & then + final Notification 저장된_알림 = 러너_게시글_작성자에게_알림을_저장한다(생성된_러너_게시글_식별자값); + + NotificationDeleteSupport + .클라이언트_요청() + .액세스_토큰으로_로그인한다(헤나_액세스_토큰) + .알림_삭제에_성공한다(저장된_알림.getId()) + + .서버_응답() + .알림_삭제_성공을_검증한다(); + } + + private Notification 러너_게시글_작성자에게_알림을_저장한다(final Long 생성된_러너_게시글_식별자값) { + final Member 헤나_사용자 = memberRepository.getAsRunnerByRunnerPostId(생성된_러너_게시글_식별자값); + + final Notification 저장되지_않은_알림 = Notification.builder() + .notificationTitle(new NotificationTitle("알림 테스트용 제목")) + .notificationMessage(new NotificationMessage("알림 테스트용 내용")) + .notificationType(NotificationType.RUNNER_POST) + .notificationReferencedId(new NotificationReferencedId(1L)) + .isRead(IsRead.asUnRead()) + .member(헤나_사용자) + .build(); + + return notificationCommandRepository.save(저장되지_않은_알림); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/notification/command/NotificationUpdateAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/notification/command/NotificationUpdateAssuredTest.java new file mode 100644 index 000000000..14be54de0 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/notification/command/NotificationUpdateAssuredTest.java @@ -0,0 +1,73 @@ +package touch.baton.assure.notification.command; + +import org.junit.jupiter.api.Test; +import touch.baton.assure.notification.support.command.NotificationUpdateSupport; +import touch.baton.assure.runnerpost.support.command.RunnerPostCreateSupport; +import touch.baton.config.AssuredTestConfig; +import touch.baton.config.infra.auth.oauth.authcode.FakeAuthCodes; +import touch.baton.domain.notification.command.Notification; +import touch.baton.domain.notification.command.vo.NotificationMessage; +import touch.baton.domain.notification.command.vo.NotificationReferencedId; +import touch.baton.domain.notification.command.vo.NotificationTitle; +import touch.baton.domain.notification.command.vo.NotificationType; +import touch.baton.domain.notification.command.vo.IsRead; +import touch.baton.domain.member.command.Member; +import touch.baton.domain.runnerpost.command.service.dto.RunnerPostCreateRequest; + +import java.time.LocalDateTime; +import java.util.List; + +@SuppressWarnings("NonAsciiCharacters") +class NotificationUpdateAssuredTest extends AssuredTestConfig { + + @Test + void 로그인한_사용자의_알림_읽기_여부_기록을_업데이트한다() { + // given + final String 헤나_액세스_토큰 = oauthLoginTestManager.소셜_회원가입을_진행한_후_액세스_토큰을_반환한다(FakeAuthCodes.hyenaAuthCode()); + + final RunnerPostCreateRequest 게시글_생성_요청 = new RunnerPostCreateRequest( + "테스트용 게시글 제목", + List.of("테스트용 태그1", "테스트용 태그2"), + "https://github.com/test", + LocalDateTime.now().plusDays(10), + "테스트용 구현 내용", + "테스트용 궁금한 내용", + "테스트용 남길 내용" + ); + + final Long 생성된_러너_게시글_식별자값 = RunnerPostCreateSupport + .클라이언트_요청() + .액세스_토큰으로_로그인한다(헤나_액세스_토큰) + .러너_게시글_등록_요청한다(게시글_생성_요청) + + .서버_응답() + .러너_게시글_생성_성공을_검증한다() + .생성한_러너_게시글의_식별자값을_반환한다(); + + // when & then + final Notification 저장된_알림 = 러너_게시글_작성자에게_알림을_저장한다(생성된_러너_게시글_식별자값); + + NotificationUpdateSupport + .클라이언트_요청() + .액세스_토큰으로_로그인한다(헤나_액세스_토큰) + .알림_읽음_여부_기록_업데이트를_요청한다(저장된_알림.getId()) + + .서버_응답() + .알림_읽음_여부_기록_업데이트_성공을_검증한다(); + } + + private Notification 러너_게시글_작성자에게_알림을_저장한다(final Long 생성된_러너_게시글_식별자값) { + final Member 헤나_사용자 = memberRepository.getAsRunnerByRunnerPostId(생성된_러너_게시글_식별자값); + + final Notification 저장되지_않은_알림 = Notification.builder() + .notificationTitle(new NotificationTitle("알림 테스트용 제목")) + .notificationMessage(new NotificationMessage("알림 테스트용 내용")) + .notificationType(NotificationType.RUNNER_POST) + .notificationReferencedId(new NotificationReferencedId(1L)) + .isRead(IsRead.asUnRead()) + .member(헤나_사용자) + .build(); + + return notificationCommandRepository.save(저장되지_않은_알림); + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/notification/query/NotificationQueryAssuredTest.java b/backend/baton/src/test/java/touch/baton/assure/notification/query/NotificationQueryAssuredTest.java new file mode 100644 index 000000000..e492d1a2d --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/notification/query/NotificationQueryAssuredTest.java @@ -0,0 +1,91 @@ +package touch.baton.assure.notification.query; + +import org.junit.jupiter.api.Test; +import touch.baton.assure.notification.support.query.NotificationQuerySupport; +import touch.baton.assure.runnerpost.support.command.RunnerPostCreateSupport; +import touch.baton.config.AssuredTestConfig; +import touch.baton.config.infra.auth.oauth.authcode.FakeAuthCodes; +import touch.baton.domain.notification.command.Notification; +import touch.baton.domain.notification.command.vo.NotificationMessage; +import touch.baton.domain.notification.command.vo.NotificationReferencedId; +import touch.baton.domain.notification.command.vo.NotificationTitle; +import touch.baton.domain.notification.command.vo.NotificationType; +import touch.baton.domain.notification.command.vo.IsRead; +import touch.baton.domain.member.command.Member; +import touch.baton.domain.runnerpost.command.service.dto.RunnerPostCreateRequest; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +@SuppressWarnings("NonAsciiCharacters") +class NotificationQueryAssuredTest extends AssuredTestConfig { + + @Test + void 로그인한_사용자의_알림_목록을_조회한다() { + // given + final String 헤나_액세스_토큰 = oauthLoginTestManager.소셜_회원가입을_진행한_후_액세스_토큰을_반환한다(FakeAuthCodes.hyenaAuthCode()); + + final RunnerPostCreateRequest 게시글_생성_요청 = new RunnerPostCreateRequest( + "테스트용 게시글 제목", + List.of("테스트용 태그1", "테스트용 태그2"), + "https://github.com/test", + LocalDateTime.now().plusDays(10), + "테스트용 구현 내용", + "테스트용 궁금한 내용", + "테스트용 남길 내용" + ); + + final Long 생성된_러너_게시글_식별자값 = RunnerPostCreateSupport + .클라이언트_요청() + .액세스_토큰으로_로그인한다(헤나_액세스_토큰) + .러너_게시글_등록_요청한다(게시글_생성_요청) + + .서버_응답() + .러너_게시글_생성_성공을_검증한다() + .생성한_러너_게시글의_식별자값을_반환한다(); + + // when & then + final List 예상된_알림_목록 = 러너_게시글_작성자에게_5개의_알림을_저장한다(생성된_러너_게시글_식별자값); + + NotificationQuerySupport + .클라이언트_요청() + .액세스_토큰으로_로그인한다(헤나_액세스_토큰) + .로그인한_사용자의_알림_목록을_조회한다() + + .서버_응답() + .로그인한_사용자의_알림_목록_조회_성공을_검증한다(예상된_알림_목록); + } + + private List 러너_게시글_작성자에게_5개의_알림을_저장한다(final Long 생성된_러너_게시글_식별자값) { + final List 예상된_알림_목록 = new ArrayList<>(); + for (int 저장될_알림_카운트_수 = 1; 저장될_알림_카운트_수 <= 5; 저장될_알림_카운트_수++) { + final Notification 저장된_알림 = 러너_게시글_작성자에게_알림을_저장한다(생성된_러너_게시글_식별자값); + 예상된_알림_목록.add(저장된_알림); + } + Collections.sort(예상된_알림_목록, 알림_식별자값을_기준_내림차순으로_정렬한다()); + + return 예상된_알림_목록; + } + + private Notification 러너_게시글_작성자에게_알림을_저장한다(final Long 생성된_러너_게시글_식별자값) { + final Member 헤나_사용자 = memberRepository.getAsRunnerByRunnerPostId(생성된_러너_게시글_식별자값); + + final Notification 저장되지_않은_알림 = Notification.builder() + .notificationTitle(new NotificationTitle("알림 테스트용 제목")) + .notificationMessage(new NotificationMessage("알림 테스트용 내용")) + .notificationType(NotificationType.RUNNER_POST) + .notificationReferencedId(new NotificationReferencedId(1L)) + .isRead(IsRead.asUnRead()) + .member(헤나_사용자) + .build(); + + return notificationCommandRepository.save(저장되지_않은_알림); + } + + private Comparator 알림_식별자값을_기준_내림차순으로_정렬한다() { + return (left, right) -> left.getId() < right.getId() ? 1 : -1; + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/notification/support/command/NotificationDeleteSupport.java b/backend/baton/src/test/java/touch/baton/assure/notification/support/command/NotificationDeleteSupport.java new file mode 100644 index 000000000..2cc3b03f6 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/notification/support/command/NotificationDeleteSupport.java @@ -0,0 +1,59 @@ +package touch.baton.assure.notification.support.command; + +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.springframework.http.HttpStatus; +import touch.baton.assure.common.AssuredSupport; +import touch.baton.assure.common.PathParams; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SuppressWarnings("NonAsciiCharacters") +public class NotificationDeleteSupport { + + private NotificationDeleteSupport() { + } + + public static NotificationDeleteBuilder 클라이언트_요청() { + return new NotificationDeleteBuilder(); + } + + public static class NotificationDeleteBuilder { + + private ExtractableResponse response; + + private String accessToken; + + public NotificationDeleteBuilder 액세스_토큰으로_로그인한다(final String 액세스_토큰) { + this.accessToken = 액세스_토큰; + return this; + } + + public NotificationDeleteBuilder 알림_삭제에_성공한다(final Long 알림_식별자값) { + response = AssuredSupport.delete("/api/v1/notifications/{notificationId}", + accessToken, + new PathParams(Map.of("notificationId", 알림_식별자값)) + ); + return this; + } + + public NotificationDeleteResponseBuilder 서버_응답() { + return new NotificationDeleteResponseBuilder(response); + } + } + + public static class NotificationDeleteResponseBuilder { + + private final ExtractableResponse response; + + public NotificationDeleteResponseBuilder(final ExtractableResponse response) { + this.response = response; + } + + public void 알림_삭제_성공을_검증한다() { + assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value()); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/notification/support/command/NotificationUpdateSupport.java b/backend/baton/src/test/java/touch/baton/assure/notification/support/command/NotificationUpdateSupport.java new file mode 100644 index 000000000..685668065 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/notification/support/command/NotificationUpdateSupport.java @@ -0,0 +1,59 @@ +package touch.baton.assure.notification.support.command; + +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.springframework.http.HttpStatus; +import touch.baton.assure.common.AssuredSupport; +import touch.baton.assure.common.PathParams; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SuppressWarnings("NonAsciiCharacters") +public class NotificationUpdateSupport { + + private NotificationUpdateSupport() { + } + + public static NotificationUpdateBuilder 클라이언트_요청() { + return new NotificationUpdateBuilder(); + } + + public static class NotificationUpdateBuilder { + + private ExtractableResponse response; + + private String accessToken; + + public NotificationUpdateBuilder 액세스_토큰으로_로그인한다(final String 액세스_토큰) { + this.accessToken = 액세스_토큰; + return this; + } + + public NotificationUpdateBuilder 알림_읽음_여부_기록_업데이트를_요청한다(final Long 알림_식별자값) { + response = AssuredSupport.patch("/api/v1/notifications/{notificationId}", + accessToken, + new PathParams(Map.of("notificationId", 알림_식별자값)) + ); + return this; + } + + public NotificationUpdateResponseBuilder 서버_응답() { + return new NotificationUpdateResponseBuilder(response); + } + } + + public static class NotificationUpdateResponseBuilder { + + private final ExtractableResponse response; + + public NotificationUpdateResponseBuilder(final ExtractableResponse response) { + this.response = response; + } + + public void 알림_읽음_여부_기록_업데이트_성공을_검증한다() { + assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value()); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/notification/support/query/NotificationQuerySupport.java b/backend/baton/src/test/java/touch/baton/assure/notification/support/query/NotificationQuerySupport.java new file mode 100644 index 000000000..bcb843af1 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/notification/support/query/NotificationQuerySupport.java @@ -0,0 +1,60 @@ +package touch.baton.assure.notification.support.query; + +import io.restassured.common.mapper.TypeRef; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import touch.baton.assure.common.AssuredSupport; +import touch.baton.domain.notification.command.Notification; +import touch.baton.domain.notification.query.controller.response.NotificationResponses; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SuppressWarnings("NonAsciiCharacters") +public class NotificationQuerySupport { + + private NotificationQuerySupport() { + } + + public static NotificationQueryBuilder 클라이언트_요청() { + return new NotificationQueryBuilder(); + } + + public static class NotificationQueryBuilder { + + private ExtractableResponse response; + + private String accessToken; + + public NotificationQueryBuilder 액세스_토큰으로_로그인한다(final String 액세스_토큰) { + this.accessToken = 액세스_토큰; + return this; + } + + public NotificationQueryBuilder 로그인한_사용자의_알림_목록을_조회한다() { + response = AssuredSupport.get("/api/v1/notifications", accessToken); + return this; + } + + public NotificationQueryResponseBuilder 서버_응답() { + return new NotificationQueryResponseBuilder(response); + } + } + + public static class NotificationQueryResponseBuilder { + + private final ExtractableResponse response; + + public NotificationQueryResponseBuilder(final ExtractableResponse response) { + this.response = response; + } + + public void 로그인한_사용자의_알림_목록_조회_성공을_검증한다(final List 알림_목록) { + final NotificationResponses.SimpleNotifications 조회된_알림_응답_목록 = this.response.as(new TypeRef<>() { + }); + + assertThat(조회된_알림_응답_목록).isEqualTo(NotificationResponses.SimpleNotifications.from(알림_목록)); + } + } +} diff --git a/backend/baton/src/test/java/touch/baton/assure/repository/TestMemberCommandRepository.java b/backend/baton/src/test/java/touch/baton/assure/repository/TestMemberCommandRepository.java deleted file mode 100644 index ab8a9331e..000000000 --- a/backend/baton/src/test/java/touch/baton/assure/repository/TestMemberCommandRepository.java +++ /dev/null @@ -1,17 +0,0 @@ -package touch.baton.assure.repository; - -import touch.baton.domain.member.command.Member; -import touch.baton.domain.member.command.repository.MemberCommandRepository; -import touch.baton.domain.member.command.vo.SocialId; - -import java.util.Optional; - -public interface TestMemberCommandRepository extends MemberCommandRepository { - - default Member getBySocialId(final SocialId socialId) { - return findBySocialId(socialId) - .orElseThrow(() -> new IllegalArgumentException("테스트에서 Runner 를 SocialId 로 조회할 수 없습니다.")); - }; - - Optional findBySocialId(final SocialId socialId); -} diff --git a/backend/baton/src/test/java/touch/baton/assure/repository/TestMemberQueryRepository.java b/backend/baton/src/test/java/touch/baton/assure/repository/TestMemberQueryRepository.java new file mode 100644 index 000000000..e7e0b8f70 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/repository/TestMemberQueryRepository.java @@ -0,0 +1,33 @@ +package touch.baton.assure.repository; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import touch.baton.domain.member.command.Member; +import touch.baton.domain.member.command.repository.MemberCommandRepository; +import touch.baton.domain.member.command.vo.SocialId; + +import java.util.Optional; + +public interface TestMemberQueryRepository extends MemberCommandRepository { + + default Member getBySocialId(final SocialId socialId) { + return findBySocialId(socialId) + .orElseThrow(() -> new IllegalArgumentException("테스트에서 Member 를 SocialId 로 조회할 수 없습니다.")); + }; + + Optional findBySocialId(final SocialId socialId); + + default Member getAsRunnerByRunnerPostId(final Long runnerPostId) { + return findAsRunnerByRunnerPostId(runnerPostId) + .orElseThrow(() -> new IllegalArgumentException("테스트에서 Member 를 runnerPostId로 러너 게시글의 작성자(Runner)로서 조회할 수 없습니다.")); + }; + + @Query(""" + select rp.runner.member + from RunnerPost rp + join fetch Runner r on r.id = rp.runner.id + join fetch Member m on m.id = r.member.id + where rp.id = :runnerPostId + """) + Optional findAsRunnerByRunnerPostId(@Param("runnerPostId") final Long runnerPostId); +} diff --git a/backend/baton/src/test/java/touch/baton/assure/repository/TestNotificationCommandRepository.java b/backend/baton/src/test/java/touch/baton/assure/repository/TestNotificationCommandRepository.java new file mode 100644 index 000000000..431040f04 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/assure/repository/TestNotificationCommandRepository.java @@ -0,0 +1,6 @@ +package touch.baton.assure.repository; + +import touch.baton.domain.notification.command.repository.NotificationCommandRepository; + +public interface TestNotificationCommandRepository extends NotificationCommandRepository { +} diff --git a/backend/baton/src/test/java/touch/baton/assure/runnerpost/support/command/applicant/RunnerPostApplicantCreateSupport.java b/backend/baton/src/test/java/touch/baton/assure/runnerpost/support/command/applicant/RunnerPostApplicantCreateSupport.java index a46985ed7..71c63cfbe 100644 --- a/backend/baton/src/test/java/touch/baton/assure/runnerpost/support/command/applicant/RunnerPostApplicantCreateSupport.java +++ b/backend/baton/src/test/java/touch/baton/assure/runnerpost/support/command/applicant/RunnerPostApplicantCreateSupport.java @@ -60,15 +60,6 @@ public RunnerPostApplicantCreateResponseBuilder(final ExtractableResponse { - softly.assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value()); - softly.assertThat(response.header(LOCATION)).startsWith("/api/v1/posts/runner/"); - }); - - return this; - } - public Long 생성한_러너_게시글의_식별자값을_반환한다() { final String savedRunnerPostId = this.response.header(LOCATION).replaceFirst("/api/v1/posts/runner/", ""); diff --git a/backend/baton/src/test/java/touch/baton/config/AssuredTestConfig.java b/backend/baton/src/test/java/touch/baton/config/AssuredTestConfig.java index d1aa8f335..2c7ee44b5 100644 --- a/backend/baton/src/test/java/touch/baton/config/AssuredTestConfig.java +++ b/backend/baton/src/test/java/touch/baton/config/AssuredTestConfig.java @@ -12,7 +12,8 @@ import org.springframework.test.context.TestExecutionListeners; import touch.baton.assure.common.JwtTestManager; import touch.baton.assure.common.OauthLoginTestManager; -import touch.baton.assure.repository.TestMemberCommandRepository; +import touch.baton.assure.repository.TestMemberQueryRepository; +import touch.baton.assure.repository.TestNotificationCommandRepository; import touch.baton.assure.repository.TestRefreshTokenRepository; import touch.baton.assure.repository.TestRunnerPostQueryRepository; import touch.baton.assure.repository.TestRunnerQueryRepository; @@ -31,7 +32,7 @@ public abstract class AssuredTestConfig { @Autowired - protected TestMemberCommandRepository memberRepository; + protected TestMemberQueryRepository memberRepository; @Autowired protected TestRunnerQueryRepository runnerRepository; @@ -48,6 +49,9 @@ public abstract class AssuredTestConfig { @Autowired protected TestTagQueryRepository tagRepository; + @Autowired + protected TestNotificationCommandRepository notificationCommandRepository; + @Autowired protected TestRefreshTokenRepository refreshTokenRepository; diff --git a/backend/baton/src/test/java/touch/baton/config/QueryDslRepositoryTestConfig.java b/backend/baton/src/test/java/touch/baton/config/QueryDslRepositoryTestConfig.java index d934d0c6d..d46c842b0 100644 --- a/backend/baton/src/test/java/touch/baton/config/QueryDslRepositoryTestConfig.java +++ b/backend/baton/src/test/java/touch/baton/config/QueryDslRepositoryTestConfig.java @@ -5,6 +5,7 @@ import jakarta.persistence.PersistenceContext; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; +import touch.baton.domain.notification.query.repository.NotificationQuerydslRepository; import touch.baton.domain.runnerpost.query.repository.RunnerPostPageRepository; @TestConfiguration @@ -22,4 +23,9 @@ public JPAQueryFactory jpaQueryFactory() { public RunnerPostPageRepository runnerPostPageRepository() { return new RunnerPostPageRepository(jpaQueryFactory()); } + + @Bean + public NotificationQuerydslRepository notificationQuerydslRepository() { + return new NotificationQuerydslRepository(jpaQueryFactory()); + } } diff --git a/backend/baton/src/test/java/touch/baton/config/RepositoryTestConfig.java b/backend/baton/src/test/java/touch/baton/config/RepositoryTestConfig.java index b651ce308..67728ef28 100644 --- a/backend/baton/src/test/java/touch/baton/config/RepositoryTestConfig.java +++ b/backend/baton/src/test/java/touch/baton/config/RepositoryTestConfig.java @@ -4,6 +4,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; +import touch.baton.domain.notification.command.Notification; +import touch.baton.domain.notification.command.vo.NotificationReferencedId; import touch.baton.domain.member.command.Member; import touch.baton.domain.member.command.Runner; import touch.baton.domain.member.command.Supporter; @@ -11,6 +13,7 @@ import touch.baton.domain.runnerpost.command.RunnerPost; import touch.baton.domain.tag.command.RunnerPostTag; import touch.baton.domain.tag.command.Tag; +import touch.baton.fixture.domain.NotificationFixture; import touch.baton.fixture.domain.RunnerFixture; import touch.baton.fixture.domain.RunnerPostFixture; import touch.baton.fixture.domain.RunnerPostTagFixture; @@ -61,6 +64,11 @@ protected SupporterRunnerPost persistApplicant(final Supporter supporter, final return applicant; } + protected void persistAssignSupporter(final Supporter supporter, final RunnerPost runnerPost) { + runnerPost.assignSupporter(supporter); + em.persist(runnerPost); + } + protected Tag persistTag(final String tagName) { final Tag tag = TagFixture.create(tagName(tagName)); em.persist(tag); @@ -72,4 +80,10 @@ protected RunnerPostTag persistRunnerPostTag(final RunnerPost runnerPost, final em.persist(runnerPostTag); return runnerPostTag; } + + protected Notification persistNotification(final Member targetMember, final NotificationReferencedId notificationReferencedId) { + final Notification notification = NotificationFixture.create(targetMember, notificationReferencedId); + em.persist(notification); + return notification; + } } diff --git a/backend/baton/src/test/java/touch/baton/config/RestdocsConfig.java b/backend/baton/src/test/java/touch/baton/config/RestdocsConfig.java index 3441acf8b..42ccfa96f 100644 --- a/backend/baton/src/test/java/touch/baton/config/RestdocsConfig.java +++ b/backend/baton/src/test/java/touch/baton/config/RestdocsConfig.java @@ -28,6 +28,10 @@ import touch.baton.domain.member.query.controller.SupporterQueryController; import touch.baton.domain.member.query.service.RunnerQueryService; import touch.baton.domain.member.query.service.SupporterQueryService; +import touch.baton.domain.notification.command.controller.NotificationCommandController; +import touch.baton.domain.notification.command.service.NotificationCommandService; +import touch.baton.domain.notification.query.controller.NotificationQueryController; +import touch.baton.domain.notification.query.service.NotificationQueryService; import touch.baton.domain.oauth.command.controller.OauthCommandController; import touch.baton.domain.oauth.command.repository.OauthMemberCommandRepository; import touch.baton.domain.oauth.command.repository.OauthRunnerCommandRepository; @@ -60,7 +64,9 @@ RunnerPostQueryController.class, TagQueryController.class, MemberBranchController.class, - OauthCommandController.class + OauthCommandController.class, + NotificationCommandController.class, + NotificationQueryController.class }) @Import({RestDocsResultConfig.class}) public abstract class RestdocsConfig { @@ -115,6 +121,12 @@ public abstract class RestdocsConfig { @MockBean protected TagQueryService tagQueryService; + @MockBean + protected NotificationQueryService notificationQueryService; + + @MockBean + protected NotificationCommandService notificationCommandService; + @BeforeEach void restdocsSetUp(final WebApplicationContext webApplicationContext) { this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) diff --git a/backend/baton/src/test/java/touch/baton/config/ServiceTestConfig.java b/backend/baton/src/test/java/touch/baton/config/ServiceTestConfig.java index 95a276d42..84bfaa8f0 100644 --- a/backend/baton/src/test/java/touch/baton/config/ServiceTestConfig.java +++ b/backend/baton/src/test/java/touch/baton/config/ServiceTestConfig.java @@ -1,6 +1,7 @@ package touch.baton.config; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import touch.baton.domain.feedback.command.repository.SupporterFeedbackCommandRepository; import touch.baton.domain.member.command.repository.MemberCommandRepository; import touch.baton.domain.member.command.repository.SupporterCommandRepository; @@ -8,6 +9,8 @@ import touch.baton.domain.member.query.repository.RunnerQueryRepository; import touch.baton.domain.member.query.repository.SupporterQueryRepository; import touch.baton.domain.member.query.repository.SupporterRunnerPostQueryRepository; +import touch.baton.domain.notification.command.repository.NotificationCommandRepository; +import touch.baton.domain.notification.query.repository.NotificationQuerydslRepository; import touch.baton.domain.runnerpost.command.repository.RunnerPostCommandRepository; import touch.baton.domain.runnerpost.query.repository.RunnerPostPageRepository; import touch.baton.domain.runnerpost.query.repository.RunnerPostQueryRepository; @@ -67,4 +70,13 @@ public abstract class ServiceTestConfig extends RepositoryTestConfig { @Autowired protected SupporterRunnerPostCommandRepository supporterRunnerPostCommandRepository; + + @Autowired + protected NotificationCommandRepository notificationCommandRepository; + + @Autowired + protected NotificationQuerydslRepository notificationQuerydslRepository; + + @Autowired + protected ApplicationEventPublisher publisher; } diff --git a/backend/baton/src/test/java/touch/baton/document/notification/delete/NotificationDeleteApiTest.java b/backend/baton/src/test/java/touch/baton/document/notification/delete/NotificationDeleteApiTest.java new file mode 100644 index 000000000..424ba978d --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/notification/delete/NotificationDeleteApiTest.java @@ -0,0 +1,57 @@ +package touch.baton.document.notification.delete; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.member.command.Member; +import touch.baton.domain.notification.command.Notification; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.NotificationFixture; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static touch.baton.fixture.vo.NotificationReferencedIdFixture.notificationReferencedId; + +class NotificationDeleteApiTest extends RestdocsConfig { + + @DisplayName("알림 삭제 API") + @Test + void deleteNotificationByNotificationId() throws Exception { + // given + final Member memberHyena = MemberFixture.createHyena(); + final String token = getAccessTokenBySocialId(memberHyena.getSocialId().getValue()); + final Notification notification = NotificationFixture.create(memberHyena, notificationReferencedId(1L)); + + // when + doNothing().when(notificationCommandService).deleteNotificationByMember(any(Member.class), anyLong()); + when(oauthMemberCommandRepository.findBySocialId(any())).thenReturn(Optional.ofNullable(memberHyena)); + + final Notification spyNotification = spy(notification); + when(spyNotification.getId()).thenReturn(1L); + + // then + mockMvc.perform(delete("/api/v1/notifications/{notificationId}", spyNotification.getId()) + .header(AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isNoContent()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("notificationId").description("알림 식별자값") + ), + requestHeaders( + headerWithName(AUTHORIZATION).description("Bearer JWT") + ) + )); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/notification/read/NotificationReadWithLoginedMemberApiTest.java b/backend/baton/src/test/java/touch/baton/document/notification/read/NotificationReadWithLoginedMemberApiTest.java new file mode 100644 index 000000000..5eaa9d2c0 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/notification/read/NotificationReadWithLoginedMemberApiTest.java @@ -0,0 +1,83 @@ +package touch.baton.document.notification.read; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.member.command.Member; +import touch.baton.domain.notification.command.Notification; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.NotificationFixture; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; + +import static org.apache.http.HttpHeaders.CONTENT_TYPE; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static touch.baton.fixture.vo.NotificationReferencedIdFixture.notificationReferencedId; + +class NotificationReadWithLoginedMemberApiTest extends RestdocsConfig { + + @DisplayName("로그인한 사용자 알림 목록 조회 API") + @Test + void readNotificationsByMemberId() throws Exception { + // given + final Member memberHyena = MemberFixture.createHyena(); + final String token = getAccessTokenBySocialId(memberHyena.getSocialId().getValue()); + final Notification notification = NotificationFixture.create(memberHyena, notificationReferencedId(1L)); + + final Member spyMember = spy(memberHyena); + when(spyMember.getId()).thenReturn(1L); + + final Notification spyNotification = spy(notification); + when(spyNotification.getId()).thenReturn(1L); + doReturn(LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES)) + .when(spyNotification) + .getCreatedAt(); + + // when + when(notificationQueryService.readNotificationsByMemberId(anyLong(), anyInt())).thenReturn(List.of(spyNotification)); + when(oauthMemberCommandRepository.findBySocialId(any())).thenReturn(Optional.of(spyMember)); + + // then + mockMvc.perform(get("/api/v1/notifications") + .header(AUTHORIZATION, "Bearer " + token) + .contentType(APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andDo(restDocs.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("Bearer JWT"), + headerWithName(CONTENT_TYPE).description(APPLICATION_JSON_VALUE) + ), + responseFields( + fieldWithPath("data.[].notificationId").type(NUMBER).description("알림 식별자값(id)"), + fieldWithPath("data.[].title").type(STRING).description("알림 제목"), + fieldWithPath("data.[].message").type(STRING).description("알림 내용"), + fieldWithPath("data.[].notificationType").type(STRING).description("알림 타입 (with referencedId)"), + fieldWithPath("data.[].referencedId").type(NUMBER).description("알림 연관된 식별자값"), + fieldWithPath("data.[].isRead").type(BOOLEAN).description("알림 읽음 여부"), + fieldWithPath("data.[].createdAt").type(STRING).description("알림 생성 시간") + )) + ); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/notification/update/NotificationUpdateApiTest.java b/backend/baton/src/test/java/touch/baton/document/notification/update/NotificationUpdateApiTest.java new file mode 100644 index 000000000..811b7f469 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/document/notification/update/NotificationUpdateApiTest.java @@ -0,0 +1,57 @@ +package touch.baton.document.notification.update; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.config.RestdocsConfig; +import touch.baton.domain.member.command.Member; +import touch.baton.domain.notification.command.Notification; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.NotificationFixture; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static touch.baton.fixture.vo.NotificationReferencedIdFixture.notificationReferencedId; + +class NotificationUpdateApiTest extends RestdocsConfig { + + @DisplayName("알림 읽기 여부 기록 API") + @Test + void updateNotificationIsReadTrueByMember() throws Exception { + // given + final Member memberHyena = MemberFixture.createHyena(); + final String token = getAccessTokenBySocialId(memberHyena.getSocialId().getValue()); + final Notification notification = NotificationFixture.create(memberHyena, notificationReferencedId(1L)); + + // when + doNothing().when(notificationCommandService).updateNotificationIsReadTrueByMember(any(Member.class), anyLong()); + when(oauthMemberCommandRepository.findBySocialId(any())).thenReturn(Optional.ofNullable(memberHyena)); + + final Notification spyNotification = spy(notification); + when(spyNotification.getId()).thenReturn(1L); + + // then + mockMvc.perform(patch("/api/v1/notifications/{notificationId}", spyNotification.getId()) + .header(AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isNoContent()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("notificationId").description("알림 식별자값") + ), + requestHeaders( + headerWithName(AUTHORIZATION).description("Bearer JWT") + ) + )); + } +} diff --git a/backend/baton/src/test/java/touch/baton/document/runnerpost/update/RunnerPostUpdateApiTest.java b/backend/baton/src/test/java/touch/baton/document/runnerpost/update/RunnerPostUpdateApiTest.java index b7d914f1e..515600216 100644 --- a/backend/baton/src/test/java/touch/baton/document/runnerpost/update/RunnerPostUpdateApiTest.java +++ b/backend/baton/src/test/java/touch/baton/document/runnerpost/update/RunnerPostUpdateApiTest.java @@ -16,7 +16,7 @@ import static org.apache.http.HttpHeaders.CONTENT_TYPE; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; import static org.springframework.http.HttpHeaders.AUTHORIZATION; import static org.springframework.http.HttpHeaders.LOCATION; @@ -44,7 +44,7 @@ void updateRunnerPostSupporter() throws Exception { final RunnerPostUpdateRequest.SelectSupporter request = new RunnerPostUpdateRequest.SelectSupporter(1L); // when - willDoNothing().given(runnerPostCommandService).updateRunnerPostAppliedSupporter(any(Runner.class), anyLong(), any(RunnerPostUpdateRequest.SelectSupporter.class)); + doNothing().when(runnerPostCommandService).updateRunnerPostAppliedSupporter(any(Runner.class), anyLong(), any(RunnerPostUpdateRequest.SelectSupporter.class)); when(oauthRunnerCommandRepository.joinByMemberSocialId(any())).thenReturn(Optional.ofNullable(ditooRunner)); // then @@ -72,7 +72,7 @@ void updateRunnerPostReviewStatusDone() throws Exception { final String accessToken = getAccessTokenBySocialId(ditooSocialId); // when - willDoNothing().given(runnerPostCommandService).updateRunnerPostReviewStatusDone(anyLong(), any(Supporter.class)); + doNothing().when(runnerPostCommandService).updateRunnerPostReviewStatusDone(anyLong(), any(Supporter.class)); when(oauthSupporterCommandRepository.joinByMemberSocialId(any())).thenReturn(Optional.ofNullable(supporter)); // then diff --git a/backend/baton/src/test/java/touch/baton/domain/notification/command/NotificationTest.java b/backend/baton/src/test/java/touch/baton/domain/notification/command/NotificationTest.java new file mode 100644 index 000000000..3b52e7a6a --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/notification/command/NotificationTest.java @@ -0,0 +1,207 @@ +package touch.baton.domain.notification.command; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import touch.baton.domain.member.command.Member; +import touch.baton.domain.member.command.vo.Company; +import touch.baton.domain.member.command.vo.GithubUrl; +import touch.baton.domain.member.command.vo.ImageUrl; +import touch.baton.domain.member.command.vo.MemberName; +import touch.baton.domain.member.command.vo.OauthId; +import touch.baton.domain.member.command.vo.SocialId; +import touch.baton.domain.notification.command.vo.IsRead; +import touch.baton.domain.notification.command.vo.NotificationMessage; +import touch.baton.domain.notification.command.vo.NotificationReferencedId; +import touch.baton.domain.notification.command.vo.NotificationTitle; +import touch.baton.domain.notification.command.vo.NotificationType; +import touch.baton.domain.notification.exception.NotificationDomainException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +class NotificationTest { + + private static final Member owner = Member.builder() + .memberName(new MemberName("사용자 테스트용 이름")) + .socialId(new SocialId("사용자 테스트용 소셜 아이디")) + .oauthId(new OauthId("사용자 테스트용 오어스 아이디")) + .githubUrl(new GithubUrl("https://github.com/사용자_테스트용_깃허브_주소")) + .company(new Company("사용자 테스트용 회사명")) + .imageUrl(new ImageUrl("https://사용자_테스트용_이미지_주소")) + .build(); + + private static final Member notOwner = Member.builder() + .memberName(new MemberName("사용자 테스트용 이름")) + .socialId(new SocialId("사용자 테스트용 소셜 아이디")) + .oauthId(new OauthId("사용자 테스트용 오어스 아이디")) + .githubUrl(new GithubUrl("https://github.com/사용자_테스트용_깃허브_주소")) + .company(new Company("사용자 테스트용 회사명")) + .imageUrl(new ImageUrl("https://사용자_테스트용_이미지_주소")) + .build(); + + @DisplayName("생성 테스트") + @Nested + class Create { + + @DisplayName("성공한다") + @Test + void success() { + assertThatCode(() -> Notification.builder() + .notificationTitle(new NotificationTitle("알림 테스트용 제목")) + .notificationMessage(new NotificationMessage("알림 테스트용 내용")) + .notificationType(NotificationType.RUNNER_POST) + .notificationReferencedId(new NotificationReferencedId(1L)) + .isRead(IsRead.asUnRead()) + .member(owner) + .build() + ).doesNotThrowAnyException(); + } + + @DisplayName("notificationTitle 에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_notificationTitle_is_null() { + assertThatThrownBy(() -> Notification.builder() + .notificationTitle(null) + .notificationMessage(new NotificationMessage("알림 테스트용 내용")) + .notificationType(NotificationType.RUNNER_POST) + .notificationReferencedId(new NotificationReferencedId(1L)) + .isRead(IsRead.asUnRead()) + .member(owner) + .build() + ).isInstanceOf(NotificationDomainException.class); + } + + @DisplayName("notificationMessage 에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_notificationMessage_is_null() { + assertThatThrownBy(() -> Notification.builder() + .notificationTitle(new NotificationTitle("알림 테스트용 제목")) + .notificationMessage(null) + .notificationType(NotificationType.RUNNER_POST) + .notificationReferencedId(new NotificationReferencedId(1L)) + .isRead(IsRead.asUnRead()) + .member(owner) + .build() + ).isInstanceOf(NotificationDomainException.class); + } + + @DisplayName("notificationType() 에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_notificationType_is_null() { + assertThatThrownBy(() -> Notification.builder() + .notificationTitle(new NotificationTitle("알림 테스트용 제목")) + .notificationMessage(new NotificationMessage("알림 테스트용 내용")) + .notificationType(null) + .notificationReferencedId(new NotificationReferencedId(1L)) + .isRead(IsRead.asUnRead()) + .member(owner) + .build() + ).isInstanceOf(NotificationDomainException.class); + } + + @DisplayName("notificationReferencedId() 에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_notificationReferencedId_is_null() { + assertThatThrownBy(() -> Notification.builder() + .notificationTitle(new NotificationTitle("알림 테스트용 제목")) + .notificationMessage(new NotificationMessage("알림 테스트용 내용")) + .notificationType(NotificationType.RUNNER_POST) + .notificationReferencedId(null) + .isRead(IsRead.asUnRead()) + .member(owner) + .build() + ).isInstanceOf(NotificationDomainException.class); + } + + @DisplayName("isRead 에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_isRead_is_null() { + assertThatThrownBy(() -> Notification.builder() + .notificationTitle(new NotificationTitle("알림 테스트용 제목")) + .notificationMessage(new NotificationMessage("알림 테스트용 내용")) + .notificationType(NotificationType.RUNNER_POST) + .notificationReferencedId(new NotificationReferencedId(1L)) + .isRead(null) + .member(owner) + .build() + ).isInstanceOf(NotificationDomainException.class); + } + + @DisplayName("member 에 null 이 들어갈 경우 예외가 발생한다.") + @Test + void fail_if_member_is_null() { + assertThatThrownBy(() -> Notification.builder() + .notificationTitle(new NotificationTitle("알림 테스트용 제목")) + .notificationMessage(new NotificationMessage("알림 테스트용 내용")) + .notificationType(NotificationType.RUNNER_POST) + .notificationReferencedId(new NotificationReferencedId(1L)) + .isRead(IsRead.asUnRead()) + .member(null) + .build() + ).isInstanceOf(NotificationDomainException.class); + } + } + + @DisplayName("알림 여부를 수정할 때 주인(Member) 일 경우 읽음 상태로 수정할 수 있다.") + @Test + void success_markAsRead() { + // given + final Notification notification = Notification.builder() + .notificationTitle(new NotificationTitle("알림 테스트용 제목")) + .notificationMessage(new NotificationMessage("알림 테스트용 내용")) + .notificationType(NotificationType.RUNNER_POST) + .notificationReferencedId(new NotificationReferencedId(1L)) + .isRead(IsRead.asUnRead()) + .member(owner) + .build(); + + // when + notification.markAsRead(owner); + + // then + final boolean actual = notification.getIsRead().getValue(); + + assertThat(actual).isTrue(); + } + + @DisplayName("알림 여부를 수정할 때 주인(Member) 이 아닐 경우 예외가 발생한다.") + @Test + void fail_markAsRead_when_member_isNotOwner() { + // given + final Notification notification = Notification.builder() + .notificationTitle(new NotificationTitle("알림 테스트용 제목")) + .notificationMessage(new NotificationMessage("알림 테스트용 내용")) + .notificationType(NotificationType.RUNNER_POST) + .notificationReferencedId(new NotificationReferencedId(1L)) + .isRead(IsRead.asUnRead()) + .member(owner) + .build(); + + // when & then + assertThatThrownBy(() -> notification.markAsRead(notOwner)) + .isInstanceOf(NotificationDomainException.class); + } + + @DisplayName("알림의 주인(Member) 이 아닌지 확인한다.") + @Test + void isNotOwner() { + // given + final Notification notification = Notification.builder() + .notificationTitle(new NotificationTitle("알림 테스트용 제목")) + .notificationMessage(new NotificationMessage("알림 테스트용 내용")) + .notificationType(NotificationType.RUNNER_POST) + .notificationReferencedId(new NotificationReferencedId(1L)) + .isRead(IsRead.asUnRead()) + .member(owner) + .build(); + + // when & then + assertSoftly(softly -> { + softly.assertThat(notification.isNotOwner(owner)).isFalse(); + softly.assertThat(notification.isNotOwner(notOwner)).isTrue(); + }); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/notification/command/event/NotificationEventListenerTest.java b/backend/baton/src/test/java/touch/baton/domain/notification/command/event/NotificationEventListenerTest.java new file mode 100644 index 000000000..d88091d78 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/notification/command/event/NotificationEventListenerTest.java @@ -0,0 +1,148 @@ +package touch.baton.domain.notification.command.event; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import touch.baton.config.RepositoryTestConfig; +import touch.baton.domain.member.command.Member; +import touch.baton.domain.member.command.Runner; +import touch.baton.domain.member.command.Supporter; +import touch.baton.domain.notification.command.Notification; +import touch.baton.domain.notification.command.repository.NotificationCommandRepository; +import touch.baton.domain.notification.command.vo.NotificationType; +import touch.baton.domain.notification.query.repository.NotificationQuerydslRepository; +import touch.baton.domain.runnerpost.command.RunnerPost; +import touch.baton.domain.runnerpost.command.event.RunnerPostApplySupporterEvent; +import touch.baton.domain.runnerpost.command.event.RunnerPostAssignSupporterEvent; +import touch.baton.domain.runnerpost.command.event.RunnerPostReviewStatusDoneEvent; +import touch.baton.domain.runnerpost.query.repository.RunnerPostQueryRepository; +import touch.baton.fixture.domain.MemberFixture; + +import java.util.List; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +class NotificationEventListenerTest extends RepositoryTestConfig { + + private NotificationEventListener notificationEventListener; + + @Autowired + private NotificationQuerydslRepository notificationQuerydslRepository; + + @BeforeEach + void setUp(@Autowired NotificationCommandRepository notificationCommandRepository, + @Autowired RunnerPostQueryRepository runnerPostQueryRepository + ) { + notificationEventListener = new NotificationEventListener(notificationCommandRepository, runnerPostQueryRepository); + } + + @DisplayName("러너 게시글에 서포터가 지원했다는 알림을 생성한다.") + @Test + void subscribeRunnerPostApplySupporterEvent() { + // given + final Runner targetRunner = persistRunner(MemberFixture.createHyena()); + final RunnerPost runnerPost = persistRunnerPost(targetRunner); + + // when + final RunnerPostApplySupporterEvent event = new RunnerPostApplySupporterEvent(runnerPost.getId()); + notificationEventListener.subscribeRunnerPostApplySupporterEvent(event); + + // then + final List actualNotifications = notificationQuerydslRepository.findByMemberId(targetRunner.getMember().getId(), 10); + + final String expectedNotificationTitle = "서포터의 제안이 왔습니다."; + final String expectedNotificationMessage = String.format("관련 게시글 - %s", runnerPost.getTitle().getValue()); + final NotificationType expectedNotificationType = NotificationType.RUNNER_POST; + final Long expectedReferencedId = runnerPost.getId(); + final boolean expectedIsRead = false; + final Member expectedMember = targetRunner.getMember(); + + assertSoftly(softly -> { + softly.assertThat(actualNotifications).hasSize(1); + final Notification actual = actualNotifications.get(0); + + softly.assertThat(actual.getId()).isPositive(); + softly.assertThat(actual.getNotificationTitle().getValue()).isEqualTo(expectedNotificationTitle); + softly.assertThat(actual.getNotificationMessage().getValue()).isEqualTo(expectedNotificationMessage); + softly.assertThat(actual.getNotificationType()).isEqualTo(expectedNotificationType); + softly.assertThat(actual.getNotificationReferencedId().getValue()).isEqualTo(expectedReferencedId); + softly.assertThat(actual.getIsRead().getValue()).isEqualTo(expectedIsRead); + softly.assertThat(actual.getMember()).isEqualTo(expectedMember); + }); + } + + @DisplayName("러너 게시글 리뷰 상태가 DONE 으로 업데이트 되었다는 알림을 생성한다.") + @Test + void subscribeRunnerPostReviewStatusDoneEvent() { + // given + final Runner targetRunner = persistRunner(MemberFixture.createHyena()); + final RunnerPost runnerPost = persistRunnerPost(targetRunner); + + // when + final RunnerPostReviewStatusDoneEvent event = new RunnerPostReviewStatusDoneEvent(runnerPost.getId()); + notificationEventListener.subscribeRunnerPostReviewStatusDoneEvent(event); + + // then + final List actualNotifications = notificationQuerydslRepository.findByMemberId(targetRunner.getMember().getId(), 10); + + final String expectedNotificationTitle = "코드 리뷰 상태가 완료로 변경되었습니다."; + final String expectedNotificationMessage = String.format("관련 게시글 - %s", runnerPost.getTitle().getValue()); + final NotificationType expectedNotificationType = NotificationType.RUNNER_POST; + final Long expectedReferencedId = runnerPost.getId(); + final boolean expectedIsRead = false; + final Member expectedMember = targetRunner.getMember(); + + assertSoftly(softly -> { + softly.assertThat(actualNotifications).hasSize(1); + final Notification actual = actualNotifications.get(0); + + softly.assertThat(actual.getId()).isPositive(); + softly.assertThat(actual.getNotificationTitle().getValue()).isEqualTo(expectedNotificationTitle); + softly.assertThat(actual.getNotificationMessage().getValue()).isEqualTo(expectedNotificationMessage); + softly.assertThat(actual.getNotificationType()).isEqualTo(expectedNotificationType); + softly.assertThat(actual.getNotificationReferencedId().getValue()).isEqualTo(expectedReferencedId); + softly.assertThat(actual.getIsRead().getValue()).isEqualTo(expectedIsRead); + softly.assertThat(actual.getMember()).isEqualTo(expectedMember); + }); + } + + @DisplayName("러너 게시글에 서포터를 할당했다는 알림을 생성한다.") + @Test + void subscribeRunnerPostAssignSupporterEvent() { + // given + final Runner runner = persistRunner(MemberFixture.createHyena()); + final RunnerPost runnerPost = persistRunnerPost(runner); + final Supporter targetSupporter = persistSupporter(MemberFixture.createEthan()); + + persistApplicant(targetSupporter, runnerPost); + persistAssignSupporter(targetSupporter, runnerPost); + + // when + final RunnerPostAssignSupporterEvent event = new RunnerPostAssignSupporterEvent(runnerPost.getId()); + notificationEventListener.subscribeRunnerPostAssignSupporterEvent(event); + + // then + final List actualNotifications = notificationQuerydslRepository.findByMemberId(targetSupporter.getMember().getId(), 10); + + final String expectedNotificationTitle = "코드 리뷰 매칭이 완료되었습니다."; + final String expectedNotificationMessage = String.format("관련 게시글 - %s", runnerPost.getTitle().getValue()); + final NotificationType expectedNotificationType = NotificationType.RUNNER_POST; + final Long expectedReferencedId = runnerPost.getId(); + final boolean expectedIsRead = false; + final Member expectedMember = targetSupporter.getMember(); + + assertSoftly(softly -> { + softly.assertThat(actualNotifications).hasSize(1); + final Notification actual = actualNotifications.get(0); + + softly.assertThat(actual.getId()).isPositive(); + softly.assertThat(actual.getNotificationTitle().getValue()).isEqualTo(expectedNotificationTitle); + softly.assertThat(actual.getNotificationMessage().getValue()).isEqualTo(expectedNotificationMessage); + softly.assertThat(actual.getNotificationType()).isEqualTo(expectedNotificationType); + softly.assertThat(actual.getNotificationReferencedId().getValue()).isEqualTo(expectedReferencedId); + softly.assertThat(actual.getIsRead().getValue()).isEqualTo(expectedIsRead); + softly.assertThat(actual.getMember()).isEqualTo(expectedMember); + }); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/notification/command/repository/NotificationCommandRepositoryTest.java b/backend/baton/src/test/java/touch/baton/domain/notification/command/repository/NotificationCommandRepositoryTest.java new file mode 100644 index 000000000..219bb5c91 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/notification/command/repository/NotificationCommandRepositoryTest.java @@ -0,0 +1,39 @@ +package touch.baton.domain.notification.command.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import touch.baton.config.RepositoryTestConfig; +import touch.baton.domain.member.command.Runner; +import touch.baton.domain.notification.command.Notification; +import touch.baton.domain.runnerpost.command.RunnerPost; +import touch.baton.fixture.domain.MemberFixture; + +import static org.assertj.core.api.Assertions.assertThat; +import static touch.baton.fixture.vo.NotificationReferencedIdFixture.notificationReferencedId; + +class NotificationCommandRepositoryTest extends RepositoryTestConfig { + + @Autowired + private NotificationCommandRepository notificationCommandRepository; + + @DisplayName("알림을 삭제할 경우 hard delete 가 아닌 soft delete 로 진행한다.") + @Test + void soft_delete() { + // given + final Runner runner = persistRunner(MemberFixture.createHyena()); + final RunnerPost runnerPost = persistRunnerPost(runner); + + final Notification notification = persistNotification(runner.getMember(), notificationReferencedId(runnerPost.getId())); + + // when + notificationCommandRepository.deleteById(notification.getId()); + + // then + final Notification foundNotification = (Notification) em.createNativeQuery("select * from notification where id = :id", Notification.class) + .setParameter("id", notification.getId()) + .getSingleResult(); + + assertThat(foundNotification.getDeletedAt()).isNotNull(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/notification/command/service/NotificationCommandServiceTest.java b/backend/baton/src/test/java/touch/baton/domain/notification/command/service/NotificationCommandServiceTest.java new file mode 100644 index 000000000..d4dc78fb3 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/notification/command/service/NotificationCommandServiceTest.java @@ -0,0 +1,99 @@ +package touch.baton.domain.notification.command.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.config.ServiceTestConfig; +import touch.baton.domain.notification.command.Notification; +import touch.baton.domain.notification.command.vo.NotificationReferencedId; +import touch.baton.domain.notification.exception.NotificationBusinessException; +import touch.baton.domain.member.command.Member; +import touch.baton.domain.notification.exception.NotificationDomainException; +import touch.baton.fixture.domain.NotificationFixture; +import touch.baton.fixture.domain.MemberFixture; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static touch.baton.fixture.vo.NotificationReferencedIdFixture.notificationReferencedId; + +class NotificationCommandServiceTest extends ServiceTestConfig { + + private NotificationCommandService notificationCommandService; + + @BeforeEach + void setUp() { + notificationCommandService = new NotificationCommandService(notificationCommandRepository); + } + + @DisplayName("알림 읽은 여부를 true 로 업데이트에 성공한다.") + @Test + void success_updateNotificationIsReadByMember() { + // given + final Member targetMember = memberCommandRepository.save(MemberFixture.createHyena()); + final NotificationReferencedId notificationReferencedId = notificationReferencedId(1L); + final Notification savedNotification = notificationCommandRepository.save(NotificationFixture.create(targetMember, notificationReferencedId)); + + // when + notificationCommandService.updateNotificationIsReadTrueByMember(targetMember, savedNotification.getId()); + + // then + final Optional maybeActual = notificationCommandRepository.findById(savedNotification.getId()); + + assertSoftly(softly -> { + softly.assertThat(maybeActual).isPresent(); + final Notification actual = maybeActual.get(); + + softly.assertThat(actual.getIsRead().getValue()).isEqualTo(true); + }); + } + + @DisplayName("알림 읽은 여부를 읽음 상태로 업데이트 할 때 알림의 주인(사용자)가 아닐 경우 예외가 발생한다.") + @Test + void fail_updateNotificationIsReadByMember_when_member_isNotOwner() { + // given + final Member targetMember = memberCommandRepository.save(MemberFixture.createHyena()); + final NotificationReferencedId notificationReferencedId = notificationReferencedId(1L); + final Notification savedNotification = notificationCommandRepository.save(NotificationFixture.create(targetMember, notificationReferencedId)); + + final Member notOwner = memberCommandRepository.save(MemberFixture.createEthan()); + + // when & then + assertThatThrownBy(() -> notificationCommandService.updateNotificationIsReadTrueByMember(notOwner, savedNotification.getId())) + .isInstanceOf(NotificationDomainException.class); + } + + @DisplayName("알림 삭제를 성공한다.") + @Test + void success_deleteNotificationByMember() { + // given + final Member targetMember = memberCommandRepository.save(MemberFixture.createHyena()); + final NotificationReferencedId notificationReferencedId = notificationReferencedId(1L); + final Notification savedNotification = notificationCommandRepository.save(NotificationFixture.create(targetMember, notificationReferencedId)); + + // when + notificationCommandService.deleteNotificationByMember(targetMember, savedNotification.getId()); + + // then + final Optional actual = notificationCommandRepository.findById(savedNotification.getId()); + + assertThat(actual).isEmpty(); + } + + @DisplayName("알림 삭제을 삭제할 때 알림의 주인(사용자)가 아닐 경우 예외가 발생한다.") + @Test + void fail_deleteNotificationByMember_when_member_isNotOwner() { + // given + final Member targetMember = memberCommandRepository.save(MemberFixture.createHyena()); + final NotificationReferencedId notificationReferencedId = notificationReferencedId(1L); + final Notification savedNotification = notificationCommandRepository.save(NotificationFixture.create(targetMember, notificationReferencedId)); + + final Member notOwner = memberCommandRepository.save(MemberFixture.createEthan()); + + // when & then + assertThatThrownBy(() -> notificationCommandService.deleteNotificationByMember(notOwner, savedNotification.getId())) + .isInstanceOf(NotificationBusinessException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/notification/command/vo/IsReadTest.java b/backend/baton/src/test/java/touch/baton/domain/notification/command/vo/IsReadTest.java new file mode 100644 index 000000000..57b2d2f19 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/notification/command/vo/IsReadTest.java @@ -0,0 +1,44 @@ +package touch.baton.domain.notification.command.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +class IsReadTest { + + @DisplayName("정적 팩터리 메서드로 읽음 여부 false 생성에 성공한다") + @Test + void success_create_isRead_false() { + assertThatCode(() -> IsRead.asUnRead()) + .doesNotThrowAnyException(); + } + + @DisplayName("정적 팩터리 메서드로 읽음 여부 false 를 생성 할 수 있다.") + @Test + void success_isRead_false() { + // given + final IsRead actual = IsRead.asUnRead(); + + // when & then + assertThat(actual.getValue()).isFalse(); + } + + @DisplayName("정적 팩터리 메서드로 읽음 여부 true 생성에 성공한다") + @Test + void success_create_isRead_true() { + assertThatCode(() -> IsRead.asRead()) + .doesNotThrowAnyException(); + } + + @DisplayName("정적 팩터리 메서드로 읽음 여부 true 를 생성 할 수 있다.") + @Test + void success_isRead_true() { + // given + final IsRead actual = IsRead.asRead(); + + // when & then + assertThat(actual.getValue()).isTrue(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/notification/command/vo/NotificationMessageTest.java b/backend/baton/src/test/java/touch/baton/domain/notification/command/vo/NotificationMessageTest.java new file mode 100644 index 000000000..4496c6989 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/notification/command/vo/NotificationMessageTest.java @@ -0,0 +1,16 @@ +package touch.baton.domain.notification.command.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class NotificationMessageTest { + + @DisplayName("value 가 null 이면 예외가 발생한다.") + @Test + void fail_if_value_is_null() { + assertThatThrownBy(() -> new NotificationMessage(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/notification/command/vo/NotificationReferencedIdTest.java b/backend/baton/src/test/java/touch/baton/domain/notification/command/vo/NotificationReferencedIdTest.java new file mode 100644 index 000000000..b79f2cf2a --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/notification/command/vo/NotificationReferencedIdTest.java @@ -0,0 +1,16 @@ +package touch.baton.domain.notification.command.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class NotificationReferencedIdTest { + + @DisplayName("value 가 null 이면 예외가 발생한다.") + @Test + void fail_if_value_is_null() { + assertThatThrownBy(() -> new NotificationReferencedId(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/notification/command/vo/NotificationTitleTest.java b/backend/baton/src/test/java/touch/baton/domain/notification/command/vo/NotificationTitleTest.java new file mode 100644 index 000000000..2a175f9b6 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/notification/command/vo/NotificationTitleTest.java @@ -0,0 +1,16 @@ +package touch.baton.domain.notification.command.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class NotificationTitleTest { + + @DisplayName("value 가 null 이면 예외가 발생한다.") + @Test + void fail_if_value_is_null() { + assertThatThrownBy(() -> new NotificationTitle(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/notification/query/repository/NotificationQuerydslRepositoryTest.java b/backend/baton/src/test/java/touch/baton/domain/notification/query/repository/NotificationQuerydslRepositoryTest.java new file mode 100644 index 000000000..22d09f7c5 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/notification/query/repository/NotificationQuerydslRepositoryTest.java @@ -0,0 +1,71 @@ +package touch.baton.domain.notification.query.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import touch.baton.config.RepositoryTestConfig; +import touch.baton.domain.member.command.Member; +import touch.baton.domain.notification.command.Notification; +import touch.baton.fixture.domain.MemberFixture; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static touch.baton.fixture.vo.NotificationReferencedIdFixture.notificationReferencedId; + +class NotificationQuerydslRepositoryTest extends RepositoryTestConfig { + + @Autowired + private NotificationQuerydslRepository notificationQuerydslRepository; + + @DisplayName("deleted_at 이 null 인 경우의 알림을 조회하기 위해서는 nativeQuery 를 사용한다.") + @Test + void success_findAll_deletedAt_isNull() { + // given + final Member targetMember = persistRunner(MemberFixture.createHyena()).getMember(); + + final Notification deletedAtIsNotNullNotification = persistNotification(targetMember, notificationReferencedId(1L)); + final Notification deletedAtIsNullNotification = persistNotification(targetMember, notificationReferencedId(2L)); + + // when + em.remove(deletedAtIsNullNotification); + final List actual = em.createNativeQuery("select * from notification", Notification.class).getResultList(); + + + // then + assertThat(actual).containsExactly(deletedAtIsNotNullNotification, deletedAtIsNullNotification); + } + + @DisplayName("deleted_at 이 null 이 아닌 경우 알림 목록을 사용자 식별자값을 이용해서 알림 식별자값 기준으로 내림차순 정렬하여 알림 목록을 조회한다") + @Test + void findByMemberIdOrderByIdDescLimit() { + // given + final Member targetMember = persistRunner(MemberFixture.createHyena()).getMember(); + + final List savedNotifications = new ArrayList<>(); + for (long referencedId = 1; referencedId <= 20; referencedId++) { + final Notification savedNotification = persistNotification(targetMember, notificationReferencedId(referencedId)); + savedNotifications.add(savedNotification); + } + savedNotifications.sort(orderByNotificationIdDesc()); + + // when + final int limit = 10; + final List actual = notificationQuerydslRepository.findByMemberId(targetMember.getId(), limit); + + // then + final List expected = savedNotifications.subList(0, limit); + + assertSoftly(softly -> { + softly.assertThat(actual).isSortedAccordingTo(orderByNotificationIdDesc()); + softly.assertThat(actual).containsExactlyElementsOf(expected); + }); + } + + private Comparator orderByNotificationIdDesc() { + return (left, right) -> left.getId() < right.getId() ? 1 : -1; + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/notification/query/service/NotificationQueryServiceTest.java b/backend/baton/src/test/java/touch/baton/domain/notification/query/service/NotificationQueryServiceTest.java new file mode 100644 index 000000000..1a0bbf726 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/notification/query/service/NotificationQueryServiceTest.java @@ -0,0 +1,77 @@ +package touch.baton.domain.notification.query.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import touch.baton.config.ServiceTestConfig; +import touch.baton.domain.notification.command.Notification; +import touch.baton.domain.member.command.Member; +import touch.baton.fixture.domain.MemberFixture; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static touch.baton.fixture.vo.NotificationReferencedIdFixture.notificationReferencedId; + +class NotificationQueryServiceTest extends ServiceTestConfig { + + private NotificationQueryService notificationQueryService; + + @BeforeEach + void setUp() { + notificationQueryService = new NotificationQueryService(notificationQuerydslRepository); + } + + @DisplayName("사용자 식별자값으로 알림 목록을 조회에 성공한다.") + @Test + void success_readNotificationsByMemberId() { + // given + final Member targetMember = memberCommandRepository.save(MemberFixture.createHyena()); + + + final List savedNotifications = new ArrayList<>(); + for (long referencedId = 1; referencedId <= 20; referencedId++) { + final Notification savedNotification = persistNotification(targetMember, notificationReferencedId(referencedId)); + savedNotifications.add(savedNotification); + } + savedNotifications.sort(orderByNotificationIdDesc()); + + // when + final int limit = 10; + final List actual = notificationQueryService.readNotificationsByMemberId(targetMember.getId(), limit); + + // then + final List expected = savedNotifications.subList(0, limit); + + assertSoftly(softly -> { + softly.assertThat(actual).isSortedAccordingTo(orderByNotificationIdDesc()); + softly.assertThat(actual).containsExactlyElementsOf(expected); + }); + } + + private Comparator orderByNotificationIdDesc() { + return (left, right) -> left.getId() < right.getId() ? 1 : -1; + } + + @DisplayName("사용자 식별자값으로 조회한 알림 목록이 비어있을 경우 빈 목록 반환한다.") + @Test + void success_readNotificationsByMemberId_when_Notifications_isEmpty() { + // given + final Member targetMember = memberCommandRepository.save(MemberFixture.createHyena()); + + // when + final int limit = 10; + final List actual = notificationQueryService.readNotificationsByMemberId(targetMember.getId(), limit); + + // then + final List expected = Collections.emptyList(); + + assertSoftly(softly -> { + softly.assertThat(actual).isSortedAccordingTo(orderByNotificationIdDesc()); + softly.assertThat(actual).containsExactlyElementsOf(expected); + }); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceCreateTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceCreateTest.java index 4f13b9981..e069c2963 100644 --- a/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceCreateTest.java +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceCreateTest.java @@ -54,7 +54,8 @@ void setUp() { runnerPostCommandRepository, tagCommandRepository, supporterCommandRepository, - supporterRunnerPostCommandRepository + supporterRunnerPostCommandRepository, + publisher ); } diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceDeleteTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceDeleteTest.java index 599e4086b..ea25d3e5b 100644 --- a/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceDeleteTest.java +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceDeleteTest.java @@ -31,7 +31,8 @@ void setUp() { runnerPostCommandRepository, tagCommandRepository, supporterCommandRepository, - supporterRunnerPostCommandRepository + supporterRunnerPostCommandRepository, + publisher ); } diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceEventTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceEventTest.java new file mode 100644 index 000000000..00042c60b --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceEventTest.java @@ -0,0 +1,119 @@ +package touch.baton.domain.runnerpost.command.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; +import touch.baton.config.ServiceTestConfig; +import touch.baton.domain.member.command.Member; +import touch.baton.domain.member.command.Runner; +import touch.baton.domain.member.command.Supporter; +import touch.baton.domain.runnerpost.command.RunnerPost; +import touch.baton.domain.runnerpost.command.event.RunnerPostApplySupporterEvent; +import touch.baton.domain.runnerpost.command.event.RunnerPostAssignSupporterEvent; +import touch.baton.domain.runnerpost.command.event.RunnerPostReviewStatusDoneEvent; +import touch.baton.domain.runnerpost.command.service.dto.RunnerPostApplicantCreateRequest; +import touch.baton.domain.runnerpost.command.service.dto.RunnerPostUpdateRequest; +import touch.baton.fixture.domain.MemberFixture; +import touch.baton.fixture.domain.RunnerFixture; +import touch.baton.fixture.domain.RunnerPostFixture; +import touch.baton.fixture.domain.SupporterFixture; + +import static java.time.LocalDateTime.now; +import static org.assertj.core.api.Assertions.assertThat; +import static touch.baton.fixture.vo.DeadlineFixture.deadline; + +@RecordApplicationEvents +class RunnerPostCommandServiceEventTest extends ServiceTestConfig { + + @Autowired + private ApplicationEvents applicationEvents; + + private RunnerPostCommandService runnerPostCommandService; + + @BeforeEach + void setUp() { + runnerPostCommandService = new RunnerPostCommandService( + runnerPostCommandRepository, + tagCommandRepository, + supporterCommandRepository, + supporterRunnerPostCommandRepository, + publisher + ); + } + + @DisplayName("서포터가 러너 게시글에 리뷰를 지원하면 이벤트가 발행된다.") + @Test + void success_supporter_apply_runnerPost() { + // given + final Member savedMemberDitoo = memberCommandRepository.save(MemberFixture.createDitoo()); + final Runner savedRunnerDitto = runnerQueryRepository.save(RunnerFixture.createRunner(savedMemberDitoo)); + + final Member savedMemberHyena = memberCommandRepository.save(MemberFixture.createHyena()); + final Supporter savedSupporterHyena = supporterCommandRepository.save(SupporterFixture.create(savedMemberHyena)); + + final RunnerPost savedRunnerPost = runnerPostCommandRepository.save(RunnerPostFixture.create(savedRunnerDitto, deadline(now().plusHours(100)))); + + // when + final RunnerPostApplicantCreateRequest request = new RunnerPostApplicantCreateRequest("안녕하세요. 서포터 헤나입니다."); + runnerPostCommandService.createRunnerPostApplicant(savedSupporterHyena, request, savedRunnerPost.getId()); + + // then + final long eventPublishedCount = applicationEvents.stream(RunnerPostApplySupporterEvent.class).count(); + + assertThat(eventPublishedCount).isOne(); + } + + @DisplayName("러너는 자신의 러너 게시글의 지원자 중 한 명을 서포터로서 확정하면 이벤트가 발행된다.") + @Test + void success_runner_assign_applicant_supporter() { + // given + final Member savedMemberDitoo = memberCommandRepository.save(MemberFixture.createDitoo()); + final Runner savedRunnerDitto = runnerQueryRepository.save(RunnerFixture.createRunner(savedMemberDitoo)); + + final Member savedMemberHyena = memberCommandRepository.save(MemberFixture.createHyena()); + final Supporter savedSupporterHyena = supporterCommandRepository.save(SupporterFixture.create(savedMemberHyena)); + + final RunnerPost savedRunnerPost = runnerPostCommandRepository.save(RunnerPostFixture.create(savedRunnerDitto, deadline(now().plusHours(100)))); + + final RunnerPostApplicantCreateRequest runnerPostApplicantCreateRequest = new RunnerPostApplicantCreateRequest("안녕하세요. 서포터 헤나입니다."); + runnerPostCommandService.createRunnerPostApplicant(savedSupporterHyena, runnerPostApplicantCreateRequest, savedRunnerPost.getId()); + + // when + final RunnerPostUpdateRequest.SelectSupporter runnerPostAssignSupporterRequest = new RunnerPostUpdateRequest.SelectSupporter(savedSupporterHyena.getId()); + runnerPostCommandService.updateRunnerPostAppliedSupporter(savedRunnerDitto, savedRunnerPost.getId(), runnerPostAssignSupporterRequest); + + // then + final long eventPublishedCount = applicationEvents.stream(RunnerPostAssignSupporterEvent.class).count(); + + assertThat(eventPublishedCount).isOne(); + } + + @DisplayName("서포터가 러너 게시글의 상태를 리뷰 완료로 변경할 경우 이벤트가 발행된다.") + @Test + void success_supporter_update_runnerPost_reviewStatus_done() { + final Member savedMemberDitoo = memberCommandRepository.save(MemberFixture.createDitoo()); + final Runner savedRunnerDitto = runnerQueryRepository.save(RunnerFixture.createRunner(savedMemberDitoo)); + + final Member savedMemberHyena = memberCommandRepository.save(MemberFixture.createHyena()); + final Supporter savedSupporterHyena = supporterCommandRepository.save(SupporterFixture.create(savedMemberHyena)); + + final RunnerPost savedRunnerPost = runnerPostCommandRepository.save(RunnerPostFixture.create(savedRunnerDitto, deadline(now().plusHours(100)))); + + final RunnerPostApplicantCreateRequest runnerPostApplicantCreateRequest = new RunnerPostApplicantCreateRequest("안녕하세요. 서포터 헤나입니다."); + runnerPostCommandService.createRunnerPostApplicant(savedSupporterHyena, runnerPostApplicantCreateRequest, savedRunnerPost.getId()); + + final RunnerPostUpdateRequest.SelectSupporter runnerPostAssignSupporterRequest = new RunnerPostUpdateRequest.SelectSupporter(savedSupporterHyena.getId()); + runnerPostCommandService.updateRunnerPostAppliedSupporter(savedRunnerDitto, savedRunnerPost.getId(), runnerPostAssignSupporterRequest); + + // when + runnerPostCommandService.updateRunnerPostReviewStatusDone(savedRunnerPost.getId(), savedSupporterHyena); + + // then + final long eventPublishedCount = applicationEvents.stream(RunnerPostReviewStatusDoneEvent.class).count(); + + assertThat(eventPublishedCount).isOne(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceUpdateTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceUpdateTest.java index 39187e7b6..4beaca789 100644 --- a/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceUpdateTest.java +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostCommandServiceUpdateTest.java @@ -45,7 +45,8 @@ void setUp() { runnerPostCommandRepository, tagCommandRepository, supporterCommandRepository, - supporterRunnerPostCommandRepository + supporterRunnerPostCommandRepository, + publisher ); final Member ehtanMember = memberCommandRepository.save(MemberFixture.createEthan()); diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostUpdateApplicantCancelationServiceTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostUpdateApplicantCancelationServiceTest.java index 775f09f15..4d50c0ec3 100644 --- a/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostUpdateApplicantCancelationServiceTest.java +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/command/service/RunnerPostUpdateApplicantCancelationServiceTest.java @@ -24,7 +24,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -public class RunnerPostUpdateApplicantCancelationServiceTest extends ServiceTestConfig { +class RunnerPostUpdateApplicantCancelationServiceTest extends ServiceTestConfig { private RunnerPostCommandService runnerPostCommandService; @@ -37,7 +37,8 @@ void setUp() { runnerPostCommandRepository, tagCommandRepository, supporterCommandRepository, - supporterRunnerPostCommandRepository + supporterRunnerPostCommandRepository, + publisher ); final Member applicantMember = memberCommandRepository.save(MemberFixture.createDitoo()); diff --git a/backend/baton/src/test/java/touch/baton/domain/runnerpost/query/repository/RunnerPostQueryRepositoryTest.java b/backend/baton/src/test/java/touch/baton/domain/runnerpost/query/repository/RunnerPostQueryRepositoryTest.java index a1c51ef65..e5c950559 100644 --- a/backend/baton/src/test/java/touch/baton/domain/runnerpost/query/repository/RunnerPostQueryRepositoryTest.java +++ b/backend/baton/src/test/java/touch/baton/domain/runnerpost/query/repository/RunnerPostQueryRepositoryTest.java @@ -12,8 +12,10 @@ import java.util.ArrayList; import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; class RunnerPostQueryRepositoryTest extends RepositoryTestConfig { @@ -122,4 +124,30 @@ void findApplicantCountMappingByRunnerPostIds() { assertThat(actual).isEqualTo(expected); } + + @DisplayName("러너 게시글 식별자값으로 러너 게시글과 서포터, 사용자를 조인하여 조회한다.") + @Test + void joinSupporterByRunnerPostId() { + // given + final Runner hyenaRunner = persistRunner(MemberFixture.createHyena()); + final Supporter ditooSupporter = persistSupporter(MemberFixture.createDitoo()); + final RunnerPost runnerPost = persistRunnerPost(hyenaRunner); + persistApplicant(ditooSupporter, runnerPost); + runnerPost.assignSupporter(ditooSupporter); + + em.flush(); + em.close(); + + // when + final Optional maybeActual = runnerPostQueryRepository.joinSupporterByRunnerPostId(runnerPost.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(maybeActual).isPresent(); + final RunnerPost actual = maybeActual.get(); + + softly.assertThat(actual.getSupporter()).isEqualTo(ditooSupporter); + softly.assertThat(actual).isEqualTo(runnerPost); + }); + } } diff --git a/backend/baton/src/test/java/touch/baton/fixture/domain/NotificationFixture.java b/backend/baton/src/test/java/touch/baton/fixture/domain/NotificationFixture.java new file mode 100644 index 000000000..8b0c84fa5 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/domain/NotificationFixture.java @@ -0,0 +1,27 @@ +package touch.baton.fixture.domain; + +import touch.baton.domain.member.command.Member; +import touch.baton.domain.notification.command.Notification; +import touch.baton.domain.notification.command.vo.IsRead; +import touch.baton.domain.notification.command.vo.NotificationReferencedId; + +import static touch.baton.domain.notification.command.vo.NotificationType.RUNNER_POST; +import static touch.baton.fixture.vo.NotificationMessageFixture.notificationMessage; +import static touch.baton.fixture.vo.NotificationTitleFixture.notificationTitle; + +public abstract class NotificationFixture { + + private NotificationFixture() { + } + + public static Notification create(final Member targetMember, final NotificationReferencedId notificationReferencedId) { + return Notification.builder() + .notificationTitle(notificationTitle("테스트용 알림 제목")) + .notificationMessage(notificationMessage("테스트용 알림 내용")) + .notificationType(RUNNER_POST) + .notificationReferencedId(notificationReferencedId) + .isRead(IsRead.asUnRead()) + .member(targetMember) + .build(); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/NotificationMessageFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/NotificationMessageFixture.java new file mode 100644 index 000000000..688ada0a2 --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/NotificationMessageFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.notification.command.vo.NotificationMessage; + +public abstract class NotificationMessageFixture { + + private NotificationMessageFixture() { + } + + public static NotificationMessage notificationMessage(final String value) { + return new NotificationMessage(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/NotificationReferencedIdFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/NotificationReferencedIdFixture.java new file mode 100644 index 000000000..56b0d803d --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/NotificationReferencedIdFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.notification.command.vo.NotificationReferencedId; + +public abstract class NotificationReferencedIdFixture { + + private NotificationReferencedIdFixture() { + } + + public static NotificationReferencedId notificationReferencedId(final Long value) { + return new NotificationReferencedId(value); + } +} diff --git a/backend/baton/src/test/java/touch/baton/fixture/vo/NotificationTitleFixture.java b/backend/baton/src/test/java/touch/baton/fixture/vo/NotificationTitleFixture.java new file mode 100644 index 000000000..a7d04ec8c --- /dev/null +++ b/backend/baton/src/test/java/touch/baton/fixture/vo/NotificationTitleFixture.java @@ -0,0 +1,13 @@ +package touch.baton.fixture.vo; + +import touch.baton.domain.notification.command.vo.NotificationTitle; + +public abstract class NotificationTitleFixture { + + private NotificationTitleFixture() { + } + + public static NotificationTitle notificationTitle(final String value) { + return new NotificationTitle(value); + } +} From 4229e3bd62b4ab3c8d586629c7990a739d54b393 Mon Sep 17 00:00:00 2001 From: Jeonghoon Park <39729721+shb03323@users.noreply.github.com> Date: Tue, 10 Oct 2023 18:16:32 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=ED=94=84=EB=A1=9C=EB=8D=95=EC=85=98=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=AC=B4=EC=A4=91=EB=8B=A8=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20(#640)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: cicd 스크립트 수정 * refactor: cicd 스크립트 수정 --- .github/workflows/deploy-be-ci-cd-push.yml | 8 ++------ backend/baton/secret | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy-be-ci-cd-push.yml b/.github/workflows/deploy-be-ci-cd-push.yml index c30277920..01e58b0a7 100644 --- a/.github/workflows/deploy-be-ci-cd-push.yml +++ b/.github/workflows/deploy-be-ci-cd-push.yml @@ -53,13 +53,9 @@ jobs: - name: Pull Latest Docker Image run: | sudo docker login --username ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} --password ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} - if sudo docker inspect spring-baton &>/dev/null; then - sudo docker stop spring-baton - sudo docker rm -f spring-baton - sudo docker image prune -af - fi sudo docker pull 2023batondeploy/2023-baton-deploy:latest - name: Docker Compose run: | - sudo docker run --name spring-baton -v /home/ubuntu/logs:/app/logs -p 8080:8080 -e TZ=Asia/Seoul 2023batondeploy/2023-baton-deploy:latest 1>> build.log 2>> error.log & + /home/ubuntu/zero-downtime-deploy.sh + sudo docker image prune -af diff --git a/backend/baton/secret b/backend/baton/secret index 83ee50f98..ca9b453e7 160000 --- a/backend/baton/secret +++ b/backend/baton/secret @@ -1 +1 @@ -Subproject commit 83ee50f980a3b1f904ebcd79fb96dab3ed232bd4 +Subproject commit ca9b453e7e8e730dc6fb2ecf4431d6aa48cc2a5e From 053be7358f696844f94f37b7afd826f03e55d7a1 Mon Sep 17 00:00:00 2001 From: "HyunSeo Park (Hyena)" Date: Tue, 10 Oct 2023 19:15:59 +0900 Subject: [PATCH 5/5] =?UTF-8?q?flyway=20=EC=95=8C=EB=A6=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#644)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chore: flyway 알림 테이블 생성 내 콤마 삭제 --- .../db/migration/V20231007_1__create_table_notification.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/baton/src/main/resources/db/migration/V20231007_1__create_table_notification.sql b/backend/baton/src/main/resources/db/migration/V20231007_1__create_table_notification.sql index 2519d24f1..1015c6727 100644 --- a/backend/baton/src/main/resources/db/migration/V20231007_1__create_table_notification.sql +++ b/backend/baton/src/main/resources/db/migration/V20231007_1__create_table_notification.sql @@ -9,5 +9,5 @@ CREATE TABLE notification member_id BIGINT NOT NULL, created_at DATETIME(6) NOT NULL, updated_at DATETIME(6) NOT NULL, - deleted_at DATETIME(6), + deleted_at DATETIME(6) );