diff --git a/.github/workflows/backend-sonarqube.yml b/.github/workflows/backend-sonarqube.yml new file mode 100644 index 00000000..aeec42e7 --- /dev/null +++ b/.github/workflows/backend-sonarqube.yml @@ -0,0 +1,51 @@ +name: 줍줍 백엔드 SonarQube 정적 분석 +on: + push: + branches: + - main + - release/* + - develop + paths: 'backend/**' + pull_request: + branches: + - main + - release/* + - develop + paths: 'backend/**' + +defaults: + run: + working-directory: backend + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: 리포지토리를 가져옵니다 + uses: actions/checkout@v3 + with: + fetch-depth: 0 + token: ${{ secrets.SUBMODULE_TOKEN }} + submodules: recursive + + - name: JDK 11을 설치합니다 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + + - name: TimeZone을 Asia/Seoul로 설정합니다 + uses: zcong1993/setup-timezone@master + with: + timezone: Asia/Seoul + + - name: Gradle 명령 실행을 위한 권한을 부여합니다 + run: chmod +x gradlew + + - name: 정적 분석 결과를 SonarQube 서버로 전송합니다 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + run: ./gradlew build sonarqube --info diff --git a/.github/workflows/frontend-lighthouse-ci.yml b/.github/workflows/frontend-lighthouse-ci.yml new file mode 100644 index 00000000..af43e7ad --- /dev/null +++ b/.github/workflows/frontend-lighthouse-ci.yml @@ -0,0 +1,107 @@ +name: 줍줍 프론트엔드 Google Lighthouse 성능 측정 자동화 +on: + pull_request: + branches: + - main + - release/* + - develop + paths: "frontend/**" + +jobs: + lhci: + name: Lighthouse + runs-on: ubuntu-latest + env: + working-directory: ./frontend + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: Use Node.js 16.x + uses: actions/setup-node@v3 + with: + node-version: 16 + - name: npm install, build + run: | + npm install + npm run build + working-directory: ${{ env.working-directory }} + - name: run Lighthouse CI + run: | + npm install -g @lhci/cli@0.8.x + lhci autorun + working-directory: ${{ env.working-directory }} + env: + LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} + - name: Format lighthouse score + id: format_lighthouse_score + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + + const fs = require('fs'); + const results = JSON.parse(fs.readFileSync("./frontend/lhci_reports/manifest.json")); + let comments = ""; + results.forEach((result) => { + const { summary, jsonPath } = result; + const details = JSON.parse(fs.readFileSync(jsonPath)); + const { audits } = details; + + const formatResult = (res) => Math.round(res * 100); + Object.keys(summary).forEach( + (key) => (summary[key] = formatResult(summary[key])) + ); + + const score = (res) => (res >= 90 ? "🟢" : res >= 50 ? "🟠" : "🔴"); + + const comment = [ + `⚡️ 줍줍 Lighthouse 성능 측정 결과`, + `| Category | Score |`, + `| --- | --- |`, + `| ${score(summary.performance)} Performance | ${summary.performance} |`, + `| ${score(summary.accessibility)} Accessibility | ${summary.accessibility} |`, + `| ${score(summary["best-practices"])} Best-Practices | ${summary["best-practices"]} |`, + `| ${score(summary.seo)} SEO | ${summary.seo} |`, + `| ${score(summary.pwa)} PWA | ${summary.pwa} |` + ].join("\n"); + + const detail = [ + `| Category | Score |`, + `| --- | --- |`, + `| ${score( + audits["first-contentful-paint"].score * 100 + )} First Contentful Paint | ${ + audits["first-contentful-paint"].displayValue + } |`, + `| ${score( + audits["largest-contentful-paint"].score * 100 + )} Largest Contentful Paint | ${ + audits["largest-contentful-paint"].displayValue + } |`, + `| ${score( + audits["first-meaningful-paint"].score * 100 + )} First Meaningful Paint | ${ + audits["first-meaningful-paint"].displayValue + } |`, + `| ${score( + audits["speed-index"].score * 100 + )} Speed Index | ${ + audits["speed-index"].displayValue + } |`, + `| ${score( + audits["total-blocking-time"].score * 100 + )} Total Blocking Time | ${ + audits["total-blocking-time"].displayValue + } |`, + ].join("\n"); + + comments += comment + "\n" +"\n"+ detail + "\n"; + }); + core.setOutput('comments', comments) + + - name: comment PR + uses: marocchino/sticky-pull-request-comment@v2 + with: + message: | + ${{ steps.format_lighthouse_score.outputs.comments}} diff --git a/backend/build.gradle b/backend/build.gradle index 44e9d175..c3956e5f 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -4,6 +4,8 @@ plugins { id 'java' id "com.ewerk.gradle.plugins.querydsl" version "1.0.10" id "org.asciidoctor.jvm.convert" version "3.3.2" + id "org.sonarqube" version "3.4.0.2513" + id "jacoco" } group = 'com.pickpick' @@ -23,6 +25,7 @@ repositories { } dependencies { + implementation 'org.springframework.boot:spring-boot-configuration-processor' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -34,6 +37,7 @@ dependencies { compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" runtimeOnly 'com.h2database:h2' runtimeOnly 'mysql:mysql-connector-java' @@ -43,6 +47,8 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' testImplementation 'io.rest-assured:rest-assured:4.4.0' + testImplementation 'org.mockito:mockito-inline' + testImplementation 'org.mockito:mockito-core' asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor' } @@ -54,6 +60,7 @@ ext { test { outputs.dir snippetsDir useJUnitPlatform() + finalizedBy 'jacocoTestReport' } asciidoctor { @@ -91,13 +98,32 @@ tasks.named('test') { } def querydslDir = "$buildDir/generated/querydsl" + querydsl { jpa = true querydslSourcesDir = querydslDir } + sourceSets { main.java.srcDir querydslDir } + compileQuerydsl { options.annotationProcessorPath = configurations.querydsl } + +sonarqube { + properties { + property "sonar.projectKey", "woowacourse-teams_2022-pickpick_AYKprLeNXDQxKhlck1fc" + } +} + +jacoco { + toolVersion = '0.8.8' +} + +jacocoTestReport { + reports { + xml.enabled true + } +} diff --git a/backend/src/docs/asciidoc/index.adoc b/backend/src/docs/asciidoc/index.adoc index c1352fe6..ebb322e7 100644 --- a/backend/src/docs/asciidoc/index.adoc +++ b/backend/src/docs/asciidoc/index.adoc @@ -60,3 +60,39 @@ operation::channel-subscription-controller-test/update-order-of-subscribed-chann 자세한 예시는 https://github.com/woowacourse-teams/2022-pickpick/wiki/메시지-조회-API-사용법에서 확인 operation::message-controller-test/find-all-message-with-condition[snippets='http-request,request-headers,request-parameters,http-response,response-fields'] + +== 북마크 API + +=== 북마크 조회 API + +operation::bookmark-controller-test/find[snippets='http-request,request-headers,http-response,response-fields'] + +=== 북마크 추가 API + +operation::bookmark-controller-test/save[snippets='http-request,request-headers,request-fields,http-response'] + +=== 북마크 삭제 API + +operation::bookmark-controller-test/delete[snippets='http-request,request-headers,request-parameters,http-response'] + +== 리마인더 API + +=== 리마인더 목록 조회 API + +operation::reminder-controller-test/find[snippets='http-request,request-headers,request-parameters,http-response,response-fields'] + +=== 리마인더 단건 조회 API + +operation::reminder-controller-test/find-one[snippets='http-request,request-headers,request-parameters,http-response,response-fields'] + +=== 리마인더 추가 API + +operation::reminder-controller-test/save[snippets='http-request,request-headers,request-fields,http-response'] + +=== 리마인더 삭제 API + +operation::reminder-controller-test/delete[snippets='http-request,request-headers,request-parameters,http-response'] + +=== 리마인더 수정 API + +operation::reminder-controller-test/update[snippets='http-request,request-headers,request-fields,http-response'] diff --git a/backend/src/main/java/com/pickpick/PickpickApplication.java b/backend/src/main/java/com/pickpick/PickpickApplication.java index 63d9c040..20bd6f50 100644 --- a/backend/src/main/java/com/pickpick/PickpickApplication.java +++ b/backend/src/main/java/com/pickpick/PickpickApplication.java @@ -2,7 +2,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling +@ConfigurationPropertiesScan @SpringBootApplication public class PickpickApplication { diff --git a/backend/src/main/java/com/pickpick/auth/application/AuthService.java b/backend/src/main/java/com/pickpick/auth/application/AuthService.java index 26e18f56..04cd9aa3 100644 --- a/backend/src/main/java/com/pickpick/auth/application/AuthService.java +++ b/backend/src/main/java/com/pickpick/auth/application/AuthService.java @@ -2,8 +2,9 @@ import com.pickpick.auth.support.JwtTokenProvider; import com.pickpick.auth.ui.dto.LoginResponse; -import com.pickpick.exception.MemberNotFoundException; -import com.pickpick.exception.SlackClientException; +import com.pickpick.config.SlackProperties; +import com.pickpick.exception.SlackApiCallException; +import com.pickpick.exception.member.MemberNotFoundException; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; import com.slack.api.methods.MethodsClient; @@ -11,36 +12,32 @@ import com.slack.api.methods.request.oauth.OAuthV2AccessRequest; import com.slack.api.methods.request.users.UsersIdentityRequest; import java.io.IOException; +import javax.transaction.Transactional; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @Slf4j @Service public class AuthService { - private final String clientId; - private final String clientSecret; - private final String redirectUrl; - private final MemberRepository members; private final MethodsClient slackClient; private final JwtTokenProvider jwtTokenProvider; + private final SlackProperties slackProperties; - public AuthService(@Value("${slack.client-id}") final String clientId, - @Value("${slack.client-secret}") final String clientSecret, - @Value("${slack.redirect-url}") final String redirectUrl, - final MemberRepository members, - final MethodsClient slackClient, - final JwtTokenProvider jwtTokenProvider) { - this.clientId = clientId; - this.clientSecret = clientSecret; - this.redirectUrl = redirectUrl; + public AuthService(final MemberRepository members, final MethodsClient slackClient, + final JwtTokenProvider jwtTokenProvider, final SlackProperties slackProperties) { this.members = members; this.slackClient = slackClient; this.jwtTokenProvider = jwtTokenProvider; + this.slackProperties = slackProperties; + } + + public void verifyToken(final String token) { + jwtTokenProvider.validateToken(token); } + @Transactional public LoginResponse login(final String code) { try { String token = requestSlackToken(code); @@ -57,15 +54,15 @@ public LoginResponse login(final String code) { .firstLogin(isFirstLogin) .build(); } catch (IOException | SlackApiException e) { - throw new SlackClientException(e); + throw new SlackApiCallException(e); } } private String requestSlackToken(final String code) throws IOException, SlackApiException { OAuthV2AccessRequest request = OAuthV2AccessRequest.builder() - .clientId(clientId) - .clientSecret(clientSecret) - .redirectUri(redirectUrl) + .clientId(slackProperties.getClientId()) + .clientSecret(slackProperties.getClientSecret()) + .redirectUri(slackProperties.getRedirectUrl()) .code(code) .build(); @@ -83,8 +80,4 @@ private String requestMemberSlackId(final String token) throws IOException, Slac .getUser() .getId(); } - - public void verifyToken(final String token) { - jwtTokenProvider.validateToken(token); - } } diff --git a/backend/src/main/java/com/pickpick/auth/support/JwtTokenProvider.java b/backend/src/main/java/com/pickpick/auth/support/JwtTokenProvider.java index 57d47e00..785e8016 100644 --- a/backend/src/main/java/com/pickpick/auth/support/JwtTokenProvider.java +++ b/backend/src/main/java/com/pickpick/auth/support/JwtTokenProvider.java @@ -1,6 +1,7 @@ package com.pickpick.auth.support; -import com.pickpick.exception.InvalidTokenException; +import com.pickpick.exception.auth.ExpiredTokenException; +import com.pickpick.exception.auth.InvalidTokenException; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jws; @@ -41,9 +42,9 @@ public void validateToken(final String token) { try { parseClaims(token); } catch (ExpiredJwtException e) { - throw new InvalidTokenException("만료된 토큰입니다."); + throw new ExpiredTokenException(token); } catch (Exception e) { - throw new InvalidTokenException("유효하지 않은 토큰입니다."); + throw new InvalidTokenException(token); } } diff --git a/backend/src/main/java/com/pickpick/auth/ui/AuthController.java b/backend/src/main/java/com/pickpick/auth/ui/AuthController.java index 1e35ef1c..c768ab5d 100644 --- a/backend/src/main/java/com/pickpick/auth/ui/AuthController.java +++ b/backend/src/main/java/com/pickpick/auth/ui/AuthController.java @@ -20,14 +20,14 @@ public AuthController(final AuthService authService) { this.authService = authService; } - @GetMapping("/slack-login") - public LoginResponse login(@RequestParam @NotEmpty final String code) { - return authService.login(code); - } - @GetMapping("/certification") public void verifyToken(final HttpServletRequest request) { String token = AuthorizationExtractor.extract(request); authService.verifyToken(token); } + + @GetMapping("/slack-login") + public LoginResponse login(@RequestParam @NotEmpty final String code) { + return authService.login(code); + } } diff --git a/backend/src/main/java/com/pickpick/channel/application/ChannelService.java b/backend/src/main/java/com/pickpick/channel/application/ChannelService.java index 991f7ed1..3fb45f20 100644 --- a/backend/src/main/java/com/pickpick/channel/application/ChannelService.java +++ b/backend/src/main/java/com/pickpick/channel/application/ChannelService.java @@ -1,14 +1,40 @@ package com.pickpick.channel.application; +import com.pickpick.channel.domain.Channel; import com.pickpick.channel.domain.ChannelRepository; +import com.pickpick.exception.SlackApiCallException; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.model.Conversation; +import java.io.IOException; import org.springframework.stereotype.Service; @Service public class ChannelService { private final ChannelRepository channels; + private final MethodsClient slackClient; - public ChannelService(final ChannelRepository channels) { + public ChannelService(final ChannelRepository channels, final MethodsClient slackClient) { this.channels = channels; + this.slackClient = slackClient; + } + + public Channel createChannel(final String channelSlackId) { + try { + Conversation conversation = slackClient.conversationsInfo( + request -> request.channel(channelSlackId) + ).getChannel(); + + Channel channel = toChannel(conversation); + + return channels.save(channel); + } catch (IOException | SlackApiException e) { + throw new SlackApiCallException(e); + } + } + + private Channel toChannel(final Conversation channel) { + return new Channel(channel.getId(), channel.getName()); } } diff --git a/backend/src/main/java/com/pickpick/channel/application/ChannelSubscriptionService.java b/backend/src/main/java/com/pickpick/channel/application/ChannelSubscriptionService.java index 28a82cfd..6cceffcb 100644 --- a/backend/src/main/java/com/pickpick/channel/application/ChannelSubscriptionService.java +++ b/backend/src/main/java/com/pickpick/channel/application/ChannelSubscriptionService.java @@ -7,11 +7,11 @@ import com.pickpick.channel.ui.dto.ChannelOrderRequest; import com.pickpick.channel.ui.dto.ChannelResponse; import com.pickpick.channel.ui.dto.ChannelSubscriptionRequest; -import com.pickpick.exception.ChannelNotFoundException; -import com.pickpick.exception.MemberNotFoundException; -import com.pickpick.exception.SubscriptionDuplicateException; -import com.pickpick.exception.SubscriptionNotExistException; -import com.pickpick.exception.SubscriptionOrderDuplicateException; +import com.pickpick.exception.channel.ChannelNotFoundException; +import com.pickpick.exception.channel.SubscriptionDuplicateException; +import com.pickpick.exception.channel.SubscriptionNotExistException; +import com.pickpick.exception.channel.SubscriptionOrderDuplicateException; +import com.pickpick.exception.member.MemberNotFoundException; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; import java.util.List; @@ -111,7 +111,7 @@ private void validateRequest(final List subscribedChannels, throw new SubscriptionNotExistException("멤버가 구독한 적 없는 채널의 순서를 변경할 수 없습니다."); } - if (isEverySubscribedChannelNotContain(subscribedChannels, orderRequests)) { + if (isEverySubscriptionExceptionNotIncluded(subscribedChannels, orderRequests)) { throw new SubscriptionNotExistException("멤버의 모든 구독 채널 아이디가 포함되지 않았습니다."); } } @@ -123,7 +123,6 @@ private boolean isDuplicatedViewOrder(final List orderReque .count(); } - private boolean isUnsubscribedChannelOfMember(final List subscribedChannels, final List orderRequests) { List subscribedChannelIds = subscribedChannels.stream() @@ -134,8 +133,8 @@ private boolean isUnsubscribedChannelOfMember(final List su .allMatch(it -> subscribedChannelIds.contains(it.getId())); } - private boolean isEverySubscribedChannelNotContain(final List subscribedChannels, - final List orderRequests) { + private boolean isEverySubscriptionExceptionNotIncluded(final List subscribedChannels, + final List orderRequests) { List requestChannelId = orderRequests.stream() .map(ChannelOrderRequest::getId) .collect(Collectors.toList()); diff --git a/backend/src/main/java/com/pickpick/channel/domain/Channel.java b/backend/src/main/java/com/pickpick/channel/domain/Channel.java index e73328bd..f5076b57 100644 --- a/backend/src/main/java/com/pickpick/channel/domain/Channel.java +++ b/backend/src/main/java/com/pickpick/channel/domain/Channel.java @@ -1,6 +1,6 @@ package com.pickpick.channel.domain; -import com.pickpick.exception.SlackBadRequestException; +import com.pickpick.exception.channel.ChannelInvalidNameException; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; @@ -40,7 +40,7 @@ public void changeName(final String name) { private void validateName(final String name) { if (!StringUtils.hasText(name)) { - throw new SlackBadRequestException(String.format("채널명 변경 - 채널 이름이 유효하지 않습니다. %s", name)); + throw new ChannelInvalidNameException(name); } } } diff --git a/backend/src/main/java/com/pickpick/channel/domain/ChannelRepository.java b/backend/src/main/java/com/pickpick/channel/domain/ChannelRepository.java index b5fa8dcf..d4048b88 100644 --- a/backend/src/main/java/com/pickpick/channel/domain/ChannelRepository.java +++ b/backend/src/main/java/com/pickpick/channel/domain/ChannelRepository.java @@ -6,7 +6,7 @@ public interface ChannelRepository extends Repository { - void save(Channel channel); + Channel save(Channel channel); List findAll(); diff --git a/backend/src/main/java/com/pickpick/channel/domain/ChannelSubscription.java b/backend/src/main/java/com/pickpick/channel/domain/ChannelSubscription.java index a07d8b5b..217656e1 100644 --- a/backend/src/main/java/com/pickpick/channel/domain/ChannelSubscription.java +++ b/backend/src/main/java/com/pickpick/channel/domain/ChannelSubscription.java @@ -1,6 +1,6 @@ package com.pickpick.channel.domain; -import com.pickpick.exception.SubscriptionOrderMinException; +import com.pickpick.exception.channel.SubscriptionInvalidOrderException; import com.pickpick.member.domain.Member; import javax.persistence.Column; import javax.persistence.Entity; @@ -18,6 +18,8 @@ @Entity public class ChannelSubscription { + private static final int MIN_ORDER = 1; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -48,8 +50,8 @@ public void changeOrder(int order) { } private void validateOrder(final int order) { - if (order < 1) { - throw new SubscriptionOrderMinException(); + if (order < MIN_ORDER) { + throw new SubscriptionInvalidOrderException(order); } } diff --git a/backend/src/main/java/com/pickpick/channel/domain/ChannelSubscriptionRepository.java b/backend/src/main/java/com/pickpick/channel/domain/ChannelSubscriptionRepository.java index da33c37e..a1902f49 100644 --- a/backend/src/main/java/com/pickpick/channel/domain/ChannelSubscriptionRepository.java +++ b/backend/src/main/java/com/pickpick/channel/domain/ChannelSubscriptionRepository.java @@ -7,7 +7,7 @@ public interface ChannelSubscriptionRepository extends Repository { - void save(ChannelSubscription channelSubscription); + ChannelSubscription save(ChannelSubscription channelSubscription); void saveAll(Iterable channelSubscriptions); diff --git a/backend/src/main/java/com/pickpick/config/ControllerAdvice.java b/backend/src/main/java/com/pickpick/config/ControllerAdvice.java index 67322dc1..a7cc19aa 100644 --- a/backend/src/main/java/com/pickpick/config/ControllerAdvice.java +++ b/backend/src/main/java/com/pickpick/config/ControllerAdvice.java @@ -1,5 +1,6 @@ package com.pickpick.config; +import com.pickpick.config.dto.ErrorResponse; import com.pickpick.exception.BadRequestException; import com.pickpick.exception.NotFoundException; import lombok.extern.slf4j.Slf4j; @@ -14,14 +15,17 @@ public class ControllerAdvice { @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler - public void handleBadRequestException(final BadRequestException e) { + public ErrorResponse handleBadRequestException(final BadRequestException e) { log.error("예외 발생: ", e); + return new ErrorResponse(e.getErrorCode(), e.getClientMessage()); + } @ResponseStatus(HttpStatus.NOT_FOUND) @ExceptionHandler - public void handleNotFoundException(final NotFoundException e) { + public ErrorResponse handleNotFoundException(final NotFoundException e) { log.error("예외 발생: ", e); + return new ErrorResponse(e.getErrorCode(), e.getClientMessage()); } @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) diff --git a/backend/src/main/java/com/pickpick/config/CorsConfig.java b/backend/src/main/java/com/pickpick/config/CorsConfig.java index e9710e1e..2402bc75 100644 --- a/backend/src/main/java/com/pickpick/config/CorsConfig.java +++ b/backend/src/main/java/com/pickpick/config/CorsConfig.java @@ -1,10 +1,12 @@ package com.pickpick.config; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.http.HttpHeaders; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +@Profile({"local", "dev"}) @Configuration public class CorsConfig implements WebMvcConfigurer { public static final String ALLOWED_METHOD_NAMES = "GET,HEAD,POST,PUT,DELETE,TRACE,OPTIONS,PATCH"; diff --git a/backend/src/main/java/com/pickpick/config/SlackConfig.java b/backend/src/main/java/com/pickpick/config/SlackConfig.java index 42755967..77e67973 100644 --- a/backend/src/main/java/com/pickpick/config/SlackConfig.java +++ b/backend/src/main/java/com/pickpick/config/SlackConfig.java @@ -2,18 +2,22 @@ import com.slack.api.Slack; import com.slack.api.methods.MethodsClient; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class SlackConfig { - @Value("${slack.bot-token}") - private String token; + private final SlackProperties slackProperties; + + public SlackConfig(final SlackProperties slackProperties) { + this.slackProperties = slackProperties; + } @Bean public MethodsClient methodsClient() { - return Slack.getInstance().methods(token); + String botToken = slackProperties.getBotToken(); + + return Slack.getInstance().methods(botToken); } } diff --git a/backend/src/main/java/com/pickpick/config/SlackProperties.java b/backend/src/main/java/com/pickpick/config/SlackProperties.java new file mode 100644 index 00000000..a23bb48b --- /dev/null +++ b/backend/src/main/java/com/pickpick/config/SlackProperties.java @@ -0,0 +1,32 @@ +package com.pickpick.config; + +import javax.validation.constraints.NotBlank; +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConstructorBinding; + +@Getter +@ConstructorBinding +@ConfigurationProperties(prefix = "slack") +public class SlackProperties { + + @NotBlank + private final String botToken; + + @NotBlank + private final String clientId; + + @NotBlank + private final String clientSecret; + + @NotBlank + private final String redirectUrl; + + public SlackProperties(final String botToken, final String clientId, final String clientSecret, + final String redirectUrl) { + this.botToken = botToken; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectUrl = redirectUrl; + } +} diff --git a/backend/src/main/java/com/pickpick/config/TimeZoneConfig.java b/backend/src/main/java/com/pickpick/config/TimeConfig.java similarity index 61% rename from backend/src/main/java/com/pickpick/config/TimeZoneConfig.java rename to backend/src/main/java/com/pickpick/config/TimeConfig.java index 13148015..525ec882 100644 --- a/backend/src/main/java/com/pickpick/config/TimeZoneConfig.java +++ b/backend/src/main/java/com/pickpick/config/TimeConfig.java @@ -1,14 +1,21 @@ package com.pickpick.config; +import java.time.Clock; import java.util.TimeZone; import javax.annotation.PostConstruct; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration -public class TimeZoneConfig { +public class TimeConfig { @PostConstruct public void setTimeZone() { TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); } + + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } } diff --git a/backend/src/main/java/com/pickpick/config/dto/ErrorResponse.java b/backend/src/main/java/com/pickpick/config/dto/ErrorResponse.java new file mode 100644 index 00000000..4f947072 --- /dev/null +++ b/backend/src/main/java/com/pickpick/config/dto/ErrorResponse.java @@ -0,0 +1,18 @@ +package com.pickpick.config.dto; + +import lombok.Getter; + +@Getter +public class ErrorResponse { + + private String code; + private String message; + + private ErrorResponse() { + } + + public ErrorResponse(final String code, final String message) { + this.code = code; + this.message = message; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/BadRequestException.java b/backend/src/main/java/com/pickpick/exception/BadRequestException.java index 05d657e9..08272a15 100644 --- a/backend/src/main/java/com/pickpick/exception/BadRequestException.java +++ b/backend/src/main/java/com/pickpick/exception/BadRequestException.java @@ -2,7 +2,18 @@ public class BadRequestException extends RuntimeException { + private static final String ERROR_CODE = "BAD_REQUEST"; + private static final String CLIENT_MESSAGE = "요청 값이 잘못되었습니다."; + public BadRequestException(final String message) { super(message); } + + public String getErrorCode() { + return ERROR_CODE; + } + + public String getClientMessage() { + return CLIENT_MESSAGE; + } } diff --git a/backend/src/main/java/com/pickpick/exception/BookmarkDeleteFailureException.java b/backend/src/main/java/com/pickpick/exception/BookmarkDeleteFailureException.java deleted file mode 100644 index 7645c870..00000000 --- a/backend/src/main/java/com/pickpick/exception/BookmarkDeleteFailureException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.pickpick.exception; - -public class BookmarkDeleteFailureException extends BadRequestException { - - private static final String DEFAULT_MESSAGE = "해당 북마크를 삭제할 수 없습니다"; - - public BookmarkDeleteFailureException(final Long id, final Long memberId) { - super(String.format("%s -> bookmark id: %d, member id: %d", DEFAULT_MESSAGE, id, memberId)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/BookmarkNotFoundException.java b/backend/src/main/java/com/pickpick/exception/BookmarkNotFoundException.java deleted file mode 100644 index 0b092986..00000000 --- a/backend/src/main/java/com/pickpick/exception/BookmarkNotFoundException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.pickpick.exception; - -public class BookmarkNotFoundException extends NotFoundException { - - private static final String DEFAULT_MESSAGE = "북마크를 찾지 못했습니다"; - - public BookmarkNotFoundException(final Long id) { - super(String.format("%s -> bookmark id: %d", DEFAULT_MESSAGE, id)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/ChannelNotFoundException.java b/backend/src/main/java/com/pickpick/exception/ChannelNotFoundException.java deleted file mode 100644 index 3fce9639..00000000 --- a/backend/src/main/java/com/pickpick/exception/ChannelNotFoundException.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.pickpick.exception; - -public class ChannelNotFoundException extends NotFoundException { - - private static final String DEFAULT_MESSAGE = "채널을 찾지 못했습니다"; - - public ChannelNotFoundException(final Long id) { - super(String.format("%s -> channel id: %d", DEFAULT_MESSAGE, id)); - } - - public ChannelNotFoundException(final String slackId) { - super(String.format("%s -> channel slack id: %s", DEFAULT_MESSAGE, slackId)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/InvalidTokenException.java b/backend/src/main/java/com/pickpick/exception/InvalidTokenException.java deleted file mode 100644 index 419e95b6..00000000 --- a/backend/src/main/java/com/pickpick/exception/InvalidTokenException.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.pickpick.exception; - -public class InvalidTokenException extends BadRequestException { - - public InvalidTokenException(final String message) { - super(message); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/MemberInvalidUsernameException.java b/backend/src/main/java/com/pickpick/exception/MemberInvalidUsernameException.java deleted file mode 100644 index 870c8bf1..00000000 --- a/backend/src/main/java/com/pickpick/exception/MemberInvalidUsernameException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.pickpick.exception; - -public class MemberInvalidUsernameException extends BadRequestException { - - private static final String DEFAULT_MESSAGE = "유효하지 않은 사용자 이름입니다."; - - public MemberInvalidUsernameException(final String username) { - super(String.format("%s -> member username: %s", DEFAULT_MESSAGE, username)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/MemberNotFoundException.java b/backend/src/main/java/com/pickpick/exception/MemberNotFoundException.java deleted file mode 100644 index 09402233..00000000 --- a/backend/src/main/java/com/pickpick/exception/MemberNotFoundException.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.pickpick.exception; - -public class MemberNotFoundException extends NotFoundException { - - private static final String DEFAULT_MESSAGE = "사용자를 찾지 못했습니다"; - - public MemberNotFoundException(final Long id) { - super(String.format("%s -> member id: %d", DEFAULT_MESSAGE, id)); - } - - public MemberNotFoundException(final String slackId) { - super(String.format("%s -> member id: %s", DEFAULT_MESSAGE, slackId)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/MessageNotFoundException.java b/backend/src/main/java/com/pickpick/exception/MessageNotFoundException.java deleted file mode 100644 index 764d519f..00000000 --- a/backend/src/main/java/com/pickpick/exception/MessageNotFoundException.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.pickpick.exception; - -public class MessageNotFoundException extends NotFoundException { - - private static final String DEFAULT_MESSAGE = "메시지를 찾지 못했습니다"; - - public MessageNotFoundException(final Long id) { - super(String.format("%s -> message id: %d", DEFAULT_MESSAGE, id)); - } - - public MessageNotFoundException(final String slackId) { - super(String.format("%s -> message slack id: %s", DEFAULT_MESSAGE, slackId)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/NotFoundException.java b/backend/src/main/java/com/pickpick/exception/NotFoundException.java index 2d465072..6cf0e03d 100644 --- a/backend/src/main/java/com/pickpick/exception/NotFoundException.java +++ b/backend/src/main/java/com/pickpick/exception/NotFoundException.java @@ -2,7 +2,18 @@ public class NotFoundException extends RuntimeException { + private static final String ERROR_CODE = "NOT_FOUND"; + private static final String CLIENT_MESSAGE = "해당 정보를 조회하지 못했습니다."; + public NotFoundException(final String message) { super(message); } + + public String getErrorCode() { + return ERROR_CODE; + } + + public String getClientMessage() { + return CLIENT_MESSAGE; + } } diff --git a/backend/src/main/java/com/pickpick/exception/SlackApiCallException.java b/backend/src/main/java/com/pickpick/exception/SlackApiCallException.java new file mode 100644 index 00000000..b8aeebea --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/SlackApiCallException.java @@ -0,0 +1,12 @@ +package com.pickpick.exception; + +public class SlackApiCallException extends RuntimeException { + + public SlackApiCallException(final Exception e) { + super(e); + } + + public SlackApiCallException(final String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/pickpick/exception/SlackBadRequestException.java b/backend/src/main/java/com/pickpick/exception/SlackBadRequestException.java deleted file mode 100644 index d645ef36..00000000 --- a/backend/src/main/java/com/pickpick/exception/SlackBadRequestException.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.pickpick.exception; - -public class SlackBadRequestException extends BadRequestException { - - public SlackBadRequestException(final String message) { - super(message); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/SlackClientException.java b/backend/src/main/java/com/pickpick/exception/SlackClientException.java deleted file mode 100644 index 1f61d5ef..00000000 --- a/backend/src/main/java/com/pickpick/exception/SlackClientException.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.pickpick.exception; - -public class SlackClientException extends RuntimeException { - - public SlackClientException(final Exception e) { - super(e); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/SlackEventNotFoundException.java b/backend/src/main/java/com/pickpick/exception/SlackEventNotFoundException.java deleted file mode 100644 index 3b27b2cc..00000000 --- a/backend/src/main/java/com/pickpick/exception/SlackEventNotFoundException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.pickpick.exception; - -public class SlackEventNotFoundException extends NotFoundException { - - private static final String DEFAULT_MESSAGE = "지원하지 않는 Slack Event 입니다"; - - public SlackEventNotFoundException(final String type, final String subtype) { - super(String.format("%s -> type: %s, subtype: %s", DEFAULT_MESSAGE, type, subtype)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/SlackEventServiceNotFoundException.java b/backend/src/main/java/com/pickpick/exception/SlackEventServiceNotFoundException.java deleted file mode 100644 index 4b78e963..00000000 --- a/backend/src/main/java/com/pickpick/exception/SlackEventServiceNotFoundException.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.pickpick.exception; - -import com.pickpick.slackevent.application.SlackEvent; - -public class SlackEventServiceNotFoundException extends NotFoundException { - - private static final String DEFAULT_MESSAGE = "Service를 지원하지 않는 SlackEvent입니다."; - - public SlackEventServiceNotFoundException(final SlackEvent slackEvent) { - super(String.format("%s -> type: %s, subtype: %s", DEFAULT_MESSAGE, slackEvent.getType(), - slackEvent.getSubtype())); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/SubscriptionDuplicateException.java b/backend/src/main/java/com/pickpick/exception/SubscriptionDuplicateException.java deleted file mode 100644 index c94539fe..00000000 --- a/backend/src/main/java/com/pickpick/exception/SubscriptionDuplicateException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.pickpick.exception; - -public class SubscriptionDuplicateException extends BadRequestException { - - private static final String DEFAULT_MESSAGE = "이미 구독 중인 채널입니다."; - - public SubscriptionDuplicateException(final Long id) { - super(String.format("%s -> subscription id: %d", DEFAULT_MESSAGE, id)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/SubscriptionNotExistException.java b/backend/src/main/java/com/pickpick/exception/SubscriptionNotExistException.java deleted file mode 100644 index e1af43eb..00000000 --- a/backend/src/main/java/com/pickpick/exception/SubscriptionNotExistException.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.pickpick.exception; - -public class SubscriptionNotExistException extends BadRequestException { - - private static final String DEFAULT_MESSAGE = "구독 중인 채널이 아니라 취소할 수 없습니다."; - - public SubscriptionNotExistException(final Long id) { - super(String.format("%s -> subscription id: %d", DEFAULT_MESSAGE, id)); - } - - public SubscriptionNotExistException(String message) { - super(message); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/SubscriptionNotFoundException.java b/backend/src/main/java/com/pickpick/exception/SubscriptionNotFoundException.java deleted file mode 100644 index c9b37c13..00000000 --- a/backend/src/main/java/com/pickpick/exception/SubscriptionNotFoundException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.pickpick.exception; - -public class SubscriptionNotFoundException extends NotFoundException { - - private static final String DEFAULT_MESSAGE = "해당 멤버가 구독 중인 채널이 없습니다."; - - public SubscriptionNotFoundException(final Long memberId) { - super(String.format("%s -> member id: %d", DEFAULT_MESSAGE, memberId)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/SubscriptionOrderDuplicateException.java b/backend/src/main/java/com/pickpick/exception/SubscriptionOrderDuplicateException.java deleted file mode 100644 index fac3fc4a..00000000 --- a/backend/src/main/java/com/pickpick/exception/SubscriptionOrderDuplicateException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.pickpick.exception; - -public class SubscriptionOrderDuplicateException extends BadRequestException { - private static final String DEFAULT_MESSAGE = "요청한 구독 순서 내부에 중복이 존재합니다."; - - public SubscriptionOrderDuplicateException() { - super(DEFAULT_MESSAGE); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/SubscriptionOrderMinException.java b/backend/src/main/java/com/pickpick/exception/SubscriptionOrderMinException.java deleted file mode 100644 index 1fb995ae..00000000 --- a/backend/src/main/java/com/pickpick/exception/SubscriptionOrderMinException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.pickpick.exception; - -public class SubscriptionOrderMinException extends BadRequestException { - private static final String DEFAULT_MESSAGE = "구독 순서는 1 이상이여야합니다."; - - public SubscriptionOrderMinException() { - super(DEFAULT_MESSAGE); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/auth/ExpiredTokenException.java b/backend/src/main/java/com/pickpick/exception/auth/ExpiredTokenException.java new file mode 100644 index 00000000..4294fbf1 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/auth/ExpiredTokenException.java @@ -0,0 +1,18 @@ +package com.pickpick.exception.auth; + +import com.pickpick.exception.BadRequestException; + +public class ExpiredTokenException extends BadRequestException { + + private static final String DEFAULT_MESSAGE = "만료된 토큰으로 요청"; + private static final String CLIENT_MESSAGE = "만료된 토큰입니다."; + + public ExpiredTokenException(String token) { + super(String.format("%s -> token: %s", DEFAULT_MESSAGE, token)); + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/auth/InvalidTokenException.java b/backend/src/main/java/com/pickpick/exception/auth/InvalidTokenException.java new file mode 100644 index 00000000..5b504ae7 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/auth/InvalidTokenException.java @@ -0,0 +1,24 @@ +package com.pickpick.exception.auth; + +import com.pickpick.exception.BadRequestException; + +public class InvalidTokenException extends BadRequestException { + + private static final String ERROR_CODE = "INVALID_TOKEN"; + private static final String DEFAULT_MESSAGE = "유효하지 않은 토큰으로 요청"; + private static final String CLIENT_MESSAGE = "유효하지 않은 토큰입니다."; + + public InvalidTokenException(String token) { + super(String.format("%s -> token: %s", DEFAULT_MESSAGE, token)); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/channel/ChannelInvalidNameException.java b/backend/src/main/java/com/pickpick/exception/channel/ChannelInvalidNameException.java new file mode 100644 index 00000000..315a69df --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/channel/ChannelInvalidNameException.java @@ -0,0 +1,18 @@ +package com.pickpick.exception.channel; + +import com.pickpick.exception.BadRequestException; + +public class ChannelInvalidNameException extends BadRequestException { + + private static final String DEFAULT_MESSAGE = "유효하지 않은 채널 이름"; + private static final String CLIENT_MESSAGE = "유효하지 않은 채널 이름입니다."; + + public ChannelInvalidNameException(final String name) { + super(String.format("%s -> channel name: %s", DEFAULT_MESSAGE, name)); + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/channel/ChannelNotFoundException.java b/backend/src/main/java/com/pickpick/exception/channel/ChannelNotFoundException.java new file mode 100644 index 00000000..0467c2a6 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/channel/ChannelNotFoundException.java @@ -0,0 +1,28 @@ +package com.pickpick.exception.channel; + +import com.pickpick.exception.NotFoundException; + +public class ChannelNotFoundException extends NotFoundException { + + private static final String ERROR_CODE = "CHANNEL_NOT_FOUND"; + private static final String DEFAULT_MESSAGE = "존재하지 않는 채널 조회"; + private static final String CLIENT_MESSAGE = "채널을 찾지 못했습니다."; + + public ChannelNotFoundException(final Long id) { + super(String.format("%s -> channel id: %d", DEFAULT_MESSAGE, id)); + } + + public ChannelNotFoundException(final String slackId) { + super(String.format("%s -> channel slack id: %s", DEFAULT_MESSAGE, slackId)); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/channel/SubscriptionDuplicateException.java b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionDuplicateException.java new file mode 100644 index 00000000..4cb279b5 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionDuplicateException.java @@ -0,0 +1,24 @@ +package com.pickpick.exception.channel; + +import com.pickpick.exception.BadRequestException; + +public class SubscriptionDuplicateException extends BadRequestException { + + private static final String ERROR_CODE = "SUBSCRIPTION_DUPLICATE"; + private static final String DEFAULT_MESSAGE = "구독 중인 채널 중복 구독 시도"; + private static final String CLIENT_MESSAGE = "이미 구독 중인 채널입니다."; + + public SubscriptionDuplicateException(final Long channelId) { + super(String.format("%s -> subscription channel id: %d", DEFAULT_MESSAGE, channelId)); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/channel/SubscriptionInvalidOrderException.java b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionInvalidOrderException.java new file mode 100644 index 00000000..7fe86e22 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionInvalidOrderException.java @@ -0,0 +1,24 @@ +package com.pickpick.exception.channel; + +import com.pickpick.exception.BadRequestException; + +public class SubscriptionInvalidOrderException extends BadRequestException { + + private static final String ERROR_CODE = "SUBSCRIPTION_INVALID_ORDER"; + private static final String DEFAULT_MESSAGE = "구독 순서에 0 이하의 수 입력"; + private static final String CLIENT_MESSAGE = "구독 순서는 1 이상이여야합니다."; + + public SubscriptionInvalidOrderException(int order) { + super(String.format("%s -> order: %d", DEFAULT_MESSAGE, order)); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/channel/SubscriptionNotExistException.java b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionNotExistException.java new file mode 100644 index 00000000..e98ffbf1 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionNotExistException.java @@ -0,0 +1,28 @@ +package com.pickpick.exception.channel; + +import com.pickpick.exception.BadRequestException; + +public class SubscriptionNotExistException extends BadRequestException { + + private static final String ERROR_CODE = "SUBSCRIPTION_NOT_EXIST"; + private static final String DEFAULT_MESSAGE = "구독 중이 아닌 채널을 구독 조회"; + private static final String CLIENT_MESSAGE = "구독 중인 채널이 아니라 취소할 수 없습니다."; + + public SubscriptionNotExistException(final Long channelId) { + super(String.format("%s -> subscription channel id: %d", DEFAULT_MESSAGE, channelId)); + } + + public SubscriptionNotExistException(final String message) { + super(message); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/channel/SubscriptionNotFoundException.java b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionNotFoundException.java new file mode 100644 index 00000000..9ebf9686 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionNotFoundException.java @@ -0,0 +1,24 @@ +package com.pickpick.exception.channel; + +import com.pickpick.exception.NotFoundException; + +public class SubscriptionNotFoundException extends NotFoundException { + + private static final String ERROR_CODE = "SUBSCRIPTION_NOT_FOUND"; + private static final String DEFAULT_MESSAGE = "채널 구독이 0개인 멤버로 구독 목록 조회"; + private static final String CLIENT_MESSAGE = "해당 멤버가 구독 중인 채널이 없습니다."; + + public SubscriptionNotFoundException(final Long memberId) { + super(String.format("%s -> subscription member id: %d", DEFAULT_MESSAGE, memberId)); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/channel/SubscriptionOrderDuplicateException.java b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionOrderDuplicateException.java new file mode 100644 index 00000000..e436c383 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionOrderDuplicateException.java @@ -0,0 +1,24 @@ +package com.pickpick.exception.channel; + +import com.pickpick.exception.BadRequestException; + +public class SubscriptionOrderDuplicateException extends BadRequestException { + + private static final String ERROR_CODE = "SUBSCRIPTION_DUPLICATE"; + private static final String DEFAULT_MESSAGE = "중복된 구독 순서 요청"; + private static final String CLIENT_MESSAGE = "요청한 구독 순서 내부에 중복이 존재합니다."; + + public SubscriptionOrderDuplicateException() { + super(DEFAULT_MESSAGE); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/MemberInvalidThumbnailUrlException.java b/backend/src/main/java/com/pickpick/exception/member/MemberInvalidThumbnailUrlException.java similarity index 51% rename from backend/src/main/java/com/pickpick/exception/MemberInvalidThumbnailUrlException.java rename to backend/src/main/java/com/pickpick/exception/member/MemberInvalidThumbnailUrlException.java index 6955de92..df45cbcd 100644 --- a/backend/src/main/java/com/pickpick/exception/MemberInvalidThumbnailUrlException.java +++ b/backend/src/main/java/com/pickpick/exception/member/MemberInvalidThumbnailUrlException.java @@ -1,10 +1,18 @@ -package com.pickpick.exception; +package com.pickpick.exception.member; + +import com.pickpick.exception.BadRequestException; public class MemberInvalidThumbnailUrlException extends BadRequestException { - private static final String DEFAULT_MESSAGE = "유효하지 않은 이미지 주소입니다."; + private static final String DEFAULT_MESSAGE = "유효하지 않은 이미지 주소"; + private static final String CLIENT_MESSAGE = "유효하지 않은 이미지 주소입니다."; public MemberInvalidThumbnailUrlException(final String thumbnailUrl) { super(String.format("%s -> member thumbnail url: %s", DEFAULT_MESSAGE, thumbnailUrl)); } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } } diff --git a/backend/src/main/java/com/pickpick/exception/member/MemberInvalidUsernameException.java b/backend/src/main/java/com/pickpick/exception/member/MemberInvalidUsernameException.java new file mode 100644 index 00000000..21727bdc --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/member/MemberInvalidUsernameException.java @@ -0,0 +1,18 @@ +package com.pickpick.exception.member; + +import com.pickpick.exception.BadRequestException; + +public class MemberInvalidUsernameException extends BadRequestException { + + private static final String DEFAULT_MESSAGE = "유효하지 않은 사용자 이름"; + private static final String CLIENT_MESSAGE = "유효하지 않은 사용자 이름입니다."; + + public MemberInvalidUsernameException(final String username) { + super(String.format("%s -> member username: %s", DEFAULT_MESSAGE, username)); + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/member/MemberNotFoundException.java b/backend/src/main/java/com/pickpick/exception/member/MemberNotFoundException.java new file mode 100644 index 00000000..bf620097 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/member/MemberNotFoundException.java @@ -0,0 +1,28 @@ +package com.pickpick.exception.member; + +import com.pickpick.exception.NotFoundException; + +public class MemberNotFoundException extends NotFoundException { + + private static final String ERROR_CODE = "MEMBER_NOT_FOUND"; + private static final String DEFAULT_MESSAGE = "존재하지 않는 멤버 조회"; + private static final String CLIENT_MESSAGE = "사용자를 찾지 못했습니다."; + + public MemberNotFoundException(final Long id) { + super(String.format("%s -> member id: %d", DEFAULT_MESSAGE, id)); + } + + public MemberNotFoundException(final String slackId) { + super(String.format("%s -> member slack id: %s", DEFAULT_MESSAGE, slackId)); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/message/BookmarkDeleteFailureException.java b/backend/src/main/java/com/pickpick/exception/message/BookmarkDeleteFailureException.java new file mode 100644 index 00000000..b54acd5e --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/message/BookmarkDeleteFailureException.java @@ -0,0 +1,24 @@ +package com.pickpick.exception.message; + +import com.pickpick.exception.BadRequestException; + +public class BookmarkDeleteFailureException extends BadRequestException { + + private static final String ERROR_CODE = "BOOKMARK_DELETE_FAILURE"; + private static final String DEFAULT_MESSAGE = "외래키가 일치하는 북마크가 없어 삭제 목적 조회 실패"; + private static final String CLIENT_MESSAGE = "해당 북마크를 삭제할 수 없습니다."; + + public BookmarkDeleteFailureException(final Long messageId, final Long memberId) { + super(String.format("%s -> bookmark message id: %d, member id: %d", DEFAULT_MESSAGE, messageId, memberId)); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/message/BookmarkNotFoundException.java b/backend/src/main/java/com/pickpick/exception/message/BookmarkNotFoundException.java new file mode 100644 index 00000000..cb043ecb --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/message/BookmarkNotFoundException.java @@ -0,0 +1,24 @@ +package com.pickpick.exception.message; + +import com.pickpick.exception.NotFoundException; + +public class BookmarkNotFoundException extends NotFoundException { + + private static final String ERROR_CODE = "BOOKMARK_NOT_FOUND"; + private static final String DEFAULT_MESSAGE = "존재하지 않는 북마크 조회"; + private static final String CLIENT_MESSAGE = "북마크를 찾지 못했습니다."; + + public BookmarkNotFoundException(final Long id) { + super(String.format("%s -> bookmark id: %d", DEFAULT_MESSAGE, id)); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/message/MessageNotFoundException.java b/backend/src/main/java/com/pickpick/exception/message/MessageNotFoundException.java new file mode 100644 index 00000000..db4517f4 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/message/MessageNotFoundException.java @@ -0,0 +1,22 @@ +package com.pickpick.exception.message; + +import com.pickpick.exception.NotFoundException; + +public class MessageNotFoundException extends NotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 메시지 조회"; + private static final String CLIENT_MESSAGE = "메시지를 찾지 못했습니다."; + + public MessageNotFoundException(final Long id) { + super(String.format("%s -> message id: %d", DEFAULT_MESSAGE, id)); + } + + public MessageNotFoundException(final String slackId) { + super(String.format("%s -> message slack id: %s", DEFAULT_MESSAGE, slackId)); + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/message/ReminderDeleteFailureException.java b/backend/src/main/java/com/pickpick/exception/message/ReminderDeleteFailureException.java new file mode 100644 index 00000000..29c75fa9 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/message/ReminderDeleteFailureException.java @@ -0,0 +1,18 @@ +package com.pickpick.exception.message; + +import com.pickpick.exception.BadRequestException; + +public class ReminderDeleteFailureException extends BadRequestException { + + private static final String DEFAULT_MESSAGE = "외래키가 일치하는 리마인더가 없어 삭제 목적 조회 실패"; + private static final String CLIENT_MESSAGE = "해당 리마인더를 삭제할 수 없습니다."; + + public ReminderDeleteFailureException(final Long messageId, final Long memberId) { + super(String.format("%s -> reminder message id: %d, member id: %d", DEFAULT_MESSAGE, messageId, memberId)); + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/message/ReminderNotFoundException.java b/backend/src/main/java/com/pickpick/exception/message/ReminderNotFoundException.java new file mode 100644 index 00000000..b10ec7a1 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/message/ReminderNotFoundException.java @@ -0,0 +1,16 @@ +package com.pickpick.exception.message; + +import com.pickpick.exception.NotFoundException; + +public class ReminderNotFoundException extends NotFoundException { + + private static final String DEFAULT_MESSAGE = "리마인더를 찾지 못했습니다"; + + public ReminderNotFoundException(final Long id) { + super(String.format("%s -> reminder id: %d", DEFAULT_MESSAGE, id)); + } + + public ReminderNotFoundException(final Long messageId, final Long memberId) { + super(String.format("%s -> message id: %d, member id: %d", DEFAULT_MESSAGE, messageId, memberId)); + } +} diff --git a/backend/src/main/java/com/pickpick/exception/message/ReminderUpdateFailureException.java b/backend/src/main/java/com/pickpick/exception/message/ReminderUpdateFailureException.java new file mode 100644 index 00000000..e42bcba3 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/message/ReminderUpdateFailureException.java @@ -0,0 +1,18 @@ +package com.pickpick.exception.message; + +import com.pickpick.exception.BadRequestException; + +public class ReminderUpdateFailureException extends BadRequestException { + + private static final String DEFAULT_MESSAGE = "외래키가 일치하는 리마인더가 없어 수정 목적 조회 실패"; + private static final String CLIENT_MESSAGE = "해당 리마인더를 수정할 수 없습니다."; + + public ReminderUpdateFailureException(final Long messageId, final Long memberId) { + super(String.format("%s -> reminder message id: %d, member id: %d", DEFAULT_MESSAGE, messageId, memberId)); + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/message/SlackSendMessageFailureException.java b/backend/src/main/java/com/pickpick/exception/message/SlackSendMessageFailureException.java new file mode 100644 index 00000000..d04ec61e --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/message/SlackSendMessageFailureException.java @@ -0,0 +1,12 @@ +package com.pickpick.exception.message; + +import com.pickpick.exception.SlackApiCallException; + +public class SlackSendMessageFailureException extends SlackApiCallException { + + private static final String DEFAULT_MESSAGE = "슬랙 메시지 전송 API 호출 실패"; + + public SlackSendMessageFailureException(final String error) { + super(String.format("%s -> 에러: %s", DEFAULT_MESSAGE, error)); + } +} diff --git a/backend/src/main/java/com/pickpick/exception/slackevent/SlackEventNotFoundException.java b/backend/src/main/java/com/pickpick/exception/slackevent/SlackEventNotFoundException.java new file mode 100644 index 00000000..6e88ae18 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/slackevent/SlackEventNotFoundException.java @@ -0,0 +1,18 @@ +package com.pickpick.exception.slackevent; + +import com.pickpick.exception.NotFoundException; + +public class SlackEventNotFoundException extends NotFoundException { + + private static final String DEFAULT_MESSAGE = "대응하는 enum 값이 없는 Slack Event 요청"; + private static final String CLIENT_MESSAGE = "지원하지 않는 Slack Event 입니다."; + + public SlackEventNotFoundException(final String type, final String subtype) { + super(String.format("%s -> type: %s, subtype: %s", DEFAULT_MESSAGE, type, subtype)); + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/slackevent/SlackEventServiceNotFoundException.java b/backend/src/main/java/com/pickpick/exception/slackevent/SlackEventServiceNotFoundException.java new file mode 100644 index 00000000..bcb11f86 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/slackevent/SlackEventServiceNotFoundException.java @@ -0,0 +1,19 @@ +package com.pickpick.exception.slackevent; + +import com.pickpick.exception.NotFoundException; +import com.pickpick.slackevent.application.SlackEvent; + +public class SlackEventServiceNotFoundException extends NotFoundException { + + private static final String DEFAULT_MESSAGE = "대응하는 Service 클래스가 없는 Slack Event 요청";; + private static final String CLIENT_MESSAGE = "지원하지 않는 SlackEvent입니다."; + + public SlackEventServiceNotFoundException(final SlackEvent slackEvent) { + super(String.format("%s -> type: %s, subtype: %s", DEFAULT_MESSAGE, slackEvent.getType(), + slackEvent.getSubtype())); + } + + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/member/domain/Member.java b/backend/src/main/java/com/pickpick/member/domain/Member.java index 5b811a6c..c9a7a0fc 100644 --- a/backend/src/main/java/com/pickpick/member/domain/Member.java +++ b/backend/src/main/java/com/pickpick/member/domain/Member.java @@ -1,7 +1,7 @@ package com.pickpick.member.domain; -import com.pickpick.exception.MemberInvalidThumbnailUrlException; -import com.pickpick.exception.MemberInvalidUsernameException; +import com.pickpick.exception.member.MemberInvalidThumbnailUrlException; +import com.pickpick.exception.member.MemberInvalidUsernameException; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; diff --git a/backend/src/main/java/com/pickpick/member/domain/MemberRepository.java b/backend/src/main/java/com/pickpick/member/domain/MemberRepository.java index de6a97d0..23daf060 100644 --- a/backend/src/main/java/com/pickpick/member/domain/MemberRepository.java +++ b/backend/src/main/java/com/pickpick/member/domain/MemberRepository.java @@ -10,7 +10,7 @@ public interface MemberRepository extends Repository { Optional findBySlackId(String slackId); - void save(Member member); + Member save(Member member); void saveAll(Iterable members); diff --git a/backend/src/main/java/com/pickpick/message/application/BookmarkService.java b/backend/src/main/java/com/pickpick/message/application/BookmarkService.java index 45e85550..1ffe0fb2 100644 --- a/backend/src/main/java/com/pickpick/message/application/BookmarkService.java +++ b/backend/src/main/java/com/pickpick/message/application/BookmarkService.java @@ -1,9 +1,9 @@ package com.pickpick.message.application; -import com.pickpick.exception.BookmarkDeleteFailureException; -import com.pickpick.exception.BookmarkNotFoundException; -import com.pickpick.exception.MemberNotFoundException; -import com.pickpick.exception.MessageNotFoundException; +import com.pickpick.exception.member.MemberNotFoundException; +import com.pickpick.exception.message.BookmarkDeleteFailureException; +import com.pickpick.exception.message.BookmarkNotFoundException; +import com.pickpick.exception.message.MessageNotFoundException; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; import com.pickpick.message.domain.Bookmark; @@ -14,6 +14,7 @@ import com.pickpick.message.ui.dto.BookmarkRequest; import com.pickpick.message.ui.dto.BookmarkResponse; import com.pickpick.message.ui.dto.BookmarkResponses; +import com.pickpick.message.ui.dto.BookmarkFindRequest; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import java.time.LocalDateTime; @@ -27,8 +28,6 @@ @Service public class BookmarkService { - public static final int COUNT = 20; - private final BookmarkRepository bookmarks; private final MessageRepository messages; private final MemberRepository members; @@ -54,13 +53,13 @@ public void save(final Long memberId, final BookmarkRequest bookmarkRequest) { bookmarks.save(bookmark); } - public BookmarkResponses find(final Long bookmarkId, final Long memberId) { - List bookmarkList = findBookmarks(bookmarkId, memberId); + public BookmarkResponses find(final BookmarkFindRequest request, final Long memberId) { + List bookmarkList = findBookmarks(request, memberId); return new BookmarkResponses(toBookmarkResponseList(bookmarkList), isLast(bookmarkList, memberId)); } - private List findBookmarks(final Long bookmarkId, final Long memberId) { + private List findBookmarks(final BookmarkFindRequest request, final Long memberId) { return jpaQueryFactory .selectFrom(QBookmark.bookmark) .leftJoin(QBookmark.bookmark.message) @@ -68,9 +67,9 @@ private List findBookmarks(final Long bookmarkId, final Long memberId) .leftJoin(QBookmark.bookmark.member) .fetchJoin() .where(QBookmark.bookmark.member.id.eq(memberId)) - .where(bookmarkIdCondition(bookmarkId)) + .where(bookmarkIdCondition(request.getBookmarkId())) .orderBy(QBookmark.bookmark.message.postedDate.desc()) - .limit(COUNT) + .limit(request.getCount()) .fetch(); } @@ -117,10 +116,10 @@ private BooleanExpression meetIsLastCondition(final List bookmarkList) } @Transactional - public void delete(final Long bookmarkId, final Long memberId) { - bookmarks.findByIdAndMemberId(bookmarkId, memberId) - .orElseThrow(() -> new BookmarkDeleteFailureException(bookmarkId, memberId)); + public void delete(final Long messageId, final Long memberId) { + Bookmark bookmark = bookmarks.findByMessageIdAndMemberId(messageId, memberId) + .orElseThrow(() -> new BookmarkDeleteFailureException(messageId, memberId)); - bookmarks.deleteById(bookmarkId); + bookmarks.deleteById(bookmark.getId()); } } diff --git a/backend/src/main/java/com/pickpick/message/application/MessageService.java b/backend/src/main/java/com/pickpick/message/application/MessageService.java index 9a6c8d0d..30c2c166 100644 --- a/backend/src/main/java/com/pickpick/message/application/MessageService.java +++ b/backend/src/main/java/com/pickpick/message/application/MessageService.java @@ -2,29 +2,27 @@ import com.pickpick.channel.domain.ChannelSubscription; import com.pickpick.channel.domain.ChannelSubscriptionRepository; -import com.pickpick.exception.MemberNotFoundException; -import com.pickpick.exception.MessageNotFoundException; -import com.pickpick.exception.SubscriptionNotFoundException; -import com.pickpick.member.domain.Member; -import com.pickpick.member.domain.MemberRepository; -import com.pickpick.message.domain.Bookmark; -import com.pickpick.message.domain.BookmarkRepository; +import com.pickpick.exception.channel.SubscriptionNotFoundException; +import com.pickpick.exception.message.MessageNotFoundException; import com.pickpick.message.domain.Message; import com.pickpick.message.domain.MessageRepository; +import com.pickpick.message.domain.QBookmark; import com.pickpick.message.domain.QMessage; +import com.pickpick.message.domain.QReminder; import com.pickpick.message.ui.dto.MessageRequest; import com.pickpick.message.ui.dto.MessageResponse; import com.pickpick.message.ui.dto.MessageResponses; +import com.querydsl.core.types.ConstructorExpression; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Predicate; +import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.Clock; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Objects; -import java.util.Optional; import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -37,39 +35,35 @@ public class MessageService { private static final int FIRST_INDEX = 0; private static final int ONE_TO_GET_LAST_INDEX = 1; - private final MessageRepository messageRepository; + private final MessageRepository messages; private final ChannelSubscriptionRepository channelSubscriptions; - private final MemberRepository members; - private final BookmarkRepository bookmarks; private final JPAQueryFactory jpaQueryFactory; + private final Clock clock; - public MessageService(final MessageRepository messageRepository, + public MessageService(final MessageRepository messages, final ChannelSubscriptionRepository channelSubscriptions, - final MemberRepository members, - final BookmarkRepository bookmarks, - final JPAQueryFactory jpaQueryFactory) { - this.messageRepository = messageRepository; + final JPAQueryFactory jpaQueryFactory, + final Clock clock) { + this.messages = messages; this.channelSubscriptions = channelSubscriptions; - this.members = members; - this.bookmarks = bookmarks; this.jpaQueryFactory = jpaQueryFactory; + this.clock = clock; } public MessageResponses find(final Long memberId, final MessageRequest messageRequest) { List channelIds = findChannelId(memberId, messageRequest); - List messages = findMessages(channelIds, messageRequest); - boolean isLast = isLast(channelIds, messageRequest, messages); + List messageResponses = findMessages(memberId, channelIds, messageRequest); + boolean isLast = isLast(channelIds, messageRequest, messageResponses); - Member member = members.findById(memberId) - .orElseThrow(() -> new MemberNotFoundException(memberId)); - - return toSlackMessageResponse(messages, isLast, messageRequest.isNeedPastMessage(), member); + return new MessageResponses(messageResponses, isLast, messageRequest.isNeedPastMessage()); } private List findChannelId(final Long memberId, final MessageRequest messageRequest) { - if (Objects.nonNull(messageRequest.getChannelIds()) && !messageRequest.getChannelIds().isEmpty()) { - return messageRequest.getChannelIds(); + List channelIds = messageRequest.getChannelIds(); + + if (isNonNullNorEmpty(channelIds)) { + return channelIds; } ChannelSubscription firstSubscription = channelSubscriptions.findFirstByMemberIdOrderByViewOrderAsc(memberId) @@ -78,28 +72,62 @@ private List findChannelId(final Long memberId, final MessageRequest messa return List.of(firstSubscription.getChannelId()); } - private List findMessages(final List channelIds, final MessageRequest messageRequest) { + private static boolean isNonNullNorEmpty(final List channelIds) { + return Objects.nonNull(channelIds) && !channelIds.isEmpty(); + } + + private List findMessages(final Long memberId, final List channelIds, + final MessageRequest messageRequest) { boolean needPastMessage = messageRequest.isNeedPastMessage(); int messageCount = messageRequest.getMessageCount(); - List foundMessages = jpaQueryFactory - .selectFrom(QMessage.message) + List messageResponses = jpaQueryFactory + .select(getMessageResponseConstructor()) + .from(QMessage.message) .leftJoin(QMessage.message.member) - .fetchJoin() + .leftJoin(QBookmark.bookmark) + .on(existsBookmark(memberId)) + .leftJoin(QReminder.reminder) + .on(remainReminder(memberId)) .where(meetAllConditions(channelIds, messageRequest)) .orderBy(arrangeDateByNeedPastMessage(needPastMessage)) .limit(messageCount) .fetch(); if (needPastMessage) { - return foundMessages; + return messageResponses; } - return foundMessages.stream() - .sorted(Comparator.comparing(Message::getPostedDate).reversed()) + return messageResponses.stream() + .sorted(Comparator.comparing(MessageResponse::getPostedDate).reversed()) .collect(Collectors.toList()); } + private ConstructorExpression getMessageResponseConstructor() { + return Projections.constructor(MessageResponse.class, + QMessage.message.id, + QMessage.message.member.id, + QMessage.message.member.username, + QMessage.message.member.thumbnailUrl, + QMessage.message.text, + QMessage.message.postedDate, + QMessage.message.modifiedDate, + QBookmark.bookmark.id, + QReminder.reminder.id, + QReminder.reminder.remindDate); + } + + private BooleanExpression existsBookmark(final Long memberId) { + return QBookmark.bookmark.member.id.eq(memberId) + .and(QBookmark.bookmark.message.id.eq(QMessage.message.id)); + } + + private BooleanExpression remainReminder(final Long memberId) { + return QReminder.reminder.member.id.eq(memberId) + .and(QReminder.reminder.message.id.eq(QMessage.message.id)) + .and(QReminder.reminder.remindDate.after(LocalDateTime.now(clock))); + } + private BooleanExpression meetAllConditions(final List channelIds, final MessageRequest request) { return channelIdsIn(channelIds) .and(textContains(request.getKeyword())) @@ -136,7 +164,7 @@ private BooleanExpression messageHasText() { private Predicate messageIdCondition(final Long messageId, final boolean needPastMessage) { - Message message = messageRepository.findById(messageId) + Message message = messages.findById(messageId) .orElseThrow(() -> new MessageNotFoundException(messageId)); LocalDateTime messageDate = message.getPostedDate(); @@ -171,7 +199,7 @@ private OrderSpecifier arrangeDateByNeedPastMessage(final boolean } private boolean isLast(final List channelIds, final MessageRequest messageRequest, - final List messages) { + final List messages) { if (messages.isEmpty()) { return true; } @@ -186,15 +214,15 @@ private boolean isLast(final List channelIds, final MessageRequest message } private BooleanExpression meetAllIsLastCondition(final List channelIds, final MessageRequest request, - final List messages) { - Message targetMessage = findTargetMessage(messages, request.isNeedPastMessage()); + final List messages) { + MessageResponse targetMessage = findTargetMessage(messages, request.isNeedPastMessage()); return channelIdsIn(channelIds) .and(textContains(request.getKeyword())) .and(isBeforeOrAfterTarget(targetMessage.getPostedDate(), request.isNeedPastMessage())); } - private Message findTargetMessage(final List messages, final boolean needPastMessage) { + private MessageResponse findTargetMessage(final List messages, final boolean needPastMessage) { if (needPastMessage) { return messages.get(messages.size() - ONE_TO_GET_LAST_INDEX); } @@ -209,37 +237,4 @@ private BooleanExpression isBeforeOrAfterTarget(final LocalDateTime targetPostDa return QMessage.message.postedDate.after(targetPostDate); } - - private MessageResponses toSlackMessageResponse(final List messages, final boolean isLast, - final boolean needPastMessage, final Member member) { - return new MessageResponses(toSlackMessageResponses(messages, member), isLast, needPastMessage); - } - - private List toSlackMessageResponses(final List messages, final Member member) { - List messageResponses = new ArrayList<>(); - - for (Message message : messages) { - Optional bookmark = bookmarks.findByMessageIdAndMemberId(message.getId(), member.getId()); - boolean isBookmarked = bookmark.isPresent(); - - messageResponses.add(toMessageResponse(message, isBookmarked)); - } - - return messageResponses; - } - - private MessageResponse toMessageResponse(final Message message, final boolean isBookmarked) { - Member member = message.getMember(); - - return MessageResponse.builder() - .id(message.getId()) - .memberId(member.getId()) - .username(member.getUsername()) - .userThumbnail(member.getThumbnailUrl()) - .text(message.getText()) - .postedDate(message.getPostedDate()) - .modifiedDate(message.getModifiedDate()) - .isBookmarked(isBookmarked) - .build(); - } } diff --git a/backend/src/main/java/com/pickpick/message/application/ReminderSender.java b/backend/src/main/java/com/pickpick/message/application/ReminderSender.java new file mode 100644 index 00000000..42e3150d --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/application/ReminderSender.java @@ -0,0 +1,65 @@ +package com.pickpick.message.application; + +import com.pickpick.exception.message.SlackSendMessageFailureException; +import com.pickpick.message.domain.Reminder; +import com.pickpick.message.domain.ReminderRepository; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.chat.ChatPostMessageRequest; +import com.slack.api.methods.response.chat.ChatPostMessageResponse; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@Transactional +public class ReminderSender { + + private static final String REMINDER_TEXT_FORMAT = "리마인드 메시지가 도착했습니다!\uD83D\uDC39 \n> %s"; + private static final String ERROR_TEXT = ""; + + private final ReminderRepository reminders; + private final MethodsClient slackClient; + + public ReminderSender(final ReminderRepository reminders, final MethodsClient slackClient) { + this.reminders = reminders; + this.slackClient = slackClient; + } + + @Scheduled(cron = "0 */10 * * * *") + public void sendRemindMessage() { + LocalDateTime now = LocalDateTime.now() + .withSecond(0) + .withNano(0); + List foundReminders = reminders.findAllByRemindDate(now); + + for (Reminder reminder : foundReminders) { + try { + sendMessage(reminder); + } catch (IOException | SlackApiException | SlackSendMessageFailureException e) { + log.error(ERROR_TEXT, e); + } finally { + reminders.deleteById(reminder.getId()); + } + } + } + + private void sendMessage(final Reminder reminder) + throws IOException, SlackApiException, SlackSendMessageFailureException { + + ChatPostMessageRequest request = ChatPostMessageRequest.builder() + .channel(reminder.getMember().getSlackId()) + .text(String.format(REMINDER_TEXT_FORMAT, reminder.getMessage().getText())) + .build(); + + ChatPostMessageResponse response = slackClient.chatPostMessage(request); + if (!response.isOk()) { + throw new SlackSendMessageFailureException(response.getError()); + } + } +} diff --git a/backend/src/main/java/com/pickpick/message/application/ReminderService.java b/backend/src/main/java/com/pickpick/message/application/ReminderService.java new file mode 100644 index 00000000..eb0cc43d --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/application/ReminderService.java @@ -0,0 +1,172 @@ +package com.pickpick.message.application; + +import com.pickpick.exception.member.MemberNotFoundException; +import com.pickpick.exception.message.MessageNotFoundException; +import com.pickpick.exception.message.ReminderDeleteFailureException; +import com.pickpick.exception.message.ReminderNotFoundException; +import com.pickpick.exception.message.ReminderUpdateFailureException; +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import com.pickpick.message.domain.Message; +import com.pickpick.message.domain.MessageRepository; +import com.pickpick.message.domain.QReminder; +import com.pickpick.message.domain.Reminder; +import com.pickpick.message.domain.ReminderRepository; +import com.pickpick.message.ui.dto.ReminderFindRequest; +import com.pickpick.message.ui.dto.ReminderResponse; +import com.pickpick.message.ui.dto.ReminderResponses; +import com.pickpick.message.ui.dto.ReminderSaveRequest; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional(readOnly = true) +@Service +public class ReminderService { + + private final ReminderRepository reminders; + private final MemberRepository members; + private final MessageRepository messages; + private final JPAQueryFactory jpaQueryFactory; + private final Clock clock; + + public ReminderService(final ReminderRepository reminders, final MemberRepository members, + final MessageRepository messages, final JPAQueryFactory jpaQueryFactory, final Clock clock) { + this.reminders = reminders; + this.members = members; + this.messages = messages; + this.jpaQueryFactory = jpaQueryFactory; + this.clock = clock; + } + + @Transactional + public void save(final Long memberId, final ReminderSaveRequest request) { + Member member = members.findById(memberId) + .orElseThrow(() -> new MemberNotFoundException(memberId)); + + Message message = messages.findById(request.getMessageId()) + .orElseThrow(() -> new MessageNotFoundException(request.getMessageId())); + + Reminder reminder = new Reminder(member, message, request.getReminderDate()); + reminders.save(reminder); + } + + public ReminderResponse findOne(final Long messageId, final Long memberId) { + Reminder reminder = reminders.findByMessageIdAndMemberId(messageId, memberId) + .orElseThrow(() -> new ReminderNotFoundException(messageId, memberId)); + + return ReminderResponse.from(reminder); + } + + public ReminderResponses find(final ReminderFindRequest request, final Long memberId) { + List reminderList = findReminders(request, memberId); + + return new ReminderResponses(toReminderResponseList(reminderList), isLast(reminderList, memberId)); + } + + private List findReminders(final ReminderFindRequest request, final Long memberId) { + return jpaQueryFactory + .selectFrom(QReminder.reminder) + .leftJoin(QReminder.reminder.message) + .fetchJoin() + .where(QReminder.reminder.member.id.eq(memberId)) + .where(remindDateCondition(request.getReminderId())) + .orderBy(QReminder.reminder.remindDate.asc(), QReminder.reminder.id.asc()) + .limit(request.getCount()) + .fetch(); + } + + private List toReminderResponseList(final List foundReminders) { + return foundReminders.stream() + .map(ReminderResponse::from) + .collect(Collectors.toList()); + } + + private BooleanExpression remindDateCondition(final Long reminderId) { + if (Objects.isNull(reminderId)) { + return QReminder.reminder.remindDate.after(LocalDateTime.now(clock)); + } + + Reminder reminder = reminders.findById(reminderId) + .orElseThrow(() -> new ReminderNotFoundException(reminderId)); + + if (isTargetDateMessageLeft(reminder)) { + return (QReminder.reminder.remindDate.eq(reminder.getRemindDate()) + .and(QReminder.reminder.id.gt(reminderId))) + .or(QReminder.reminder.remindDate.after(reminder.getRemindDate())); + } + + return QReminder.reminder.remindDate.after(reminder.getRemindDate()); + } + + private boolean isTargetDateMessageLeft(final Reminder reminder) { + Optional max = reminders.findAllByRemindDate(reminder.getRemindDate()) + .stream() + .map(Reminder::getId) + .max(Long::compareTo); + + return max.isPresent() && max.get() > reminder.getId(); + } + + private boolean isLast(final List reminderList, final Long memberId) { + if (reminderList.isEmpty()) { + return true; + } + + if (isTargetDateMessageLeft(reminderList)) { + return false; + } + + Integer result = jpaQueryFactory + .selectOne() + .from(QReminder.reminder) + .where(QReminder.reminder.member.id.eq(memberId)) + .where(meetIsLastCondition(reminderList)) + .fetchFirst(); + + return Objects.isNull(result); + } + + private boolean isTargetDateMessageLeft(final List reminderList) { + Reminder targetReminder = reminderList.get(reminderList.size() - 1); + LocalDateTime remindDate = targetReminder.getRemindDate(); + Optional reminderId = reminders.findAllByRemindDate(remindDate) + .stream() + .map(Reminder::getId) + .filter(id -> id > targetReminder.getId()) + .findFirst(); + + return reminderId.isPresent(); + } + + private BooleanExpression meetIsLastCondition(final List reminderList) { + Reminder targetReminder = reminderList.get(reminderList.size() - 1); + + LocalDateTime remindDate = targetReminder.getRemindDate(); + + return QReminder.reminder.remindDate.after(remindDate); + } + + @Transactional + public void update(final Long memberId, final ReminderSaveRequest request) { + Reminder reminder = reminders.findByMessageIdAndMemberId(request.getMessageId(), memberId) + .orElseThrow(() -> new ReminderUpdateFailureException(request.getMessageId(), memberId)); + + reminder.updateRemindDate(request.getReminderDate()); + } + + @Transactional + public void delete(final Long messageId, final Long memberId) { + Reminder reminder = reminders.findByMessageIdAndMemberId(messageId, memberId) + .orElseThrow(() -> new ReminderDeleteFailureException(messageId, memberId)); + + reminders.deleteById(reminder.getId()); + } +} diff --git a/backend/src/main/java/com/pickpick/message/domain/BookmarkRepository.java b/backend/src/main/java/com/pickpick/message/domain/BookmarkRepository.java index 109c1819..b2ad249b 100644 --- a/backend/src/main/java/com/pickpick/message/domain/BookmarkRepository.java +++ b/backend/src/main/java/com/pickpick/message/domain/BookmarkRepository.java @@ -5,12 +5,10 @@ public interface BookmarkRepository extends Repository { - void save(Bookmark bookmark); + Bookmark save(Bookmark bookmark); Optional findById(Long id); - Optional findByIdAndMemberId(Long id, Long memberId); - Optional findByMessageIdAndMemberId(Long messageId, Long memberId); void deleteById(Long id); diff --git a/backend/src/main/java/com/pickpick/message/domain/MessageRepository.java b/backend/src/main/java/com/pickpick/message/domain/MessageRepository.java index 15022863..a50b7b0a 100644 --- a/backend/src/main/java/com/pickpick/message/domain/MessageRepository.java +++ b/backend/src/main/java/com/pickpick/message/domain/MessageRepository.java @@ -6,7 +6,7 @@ public interface MessageRepository extends Repository { - void save(Message message); + Message save(Message message); List findAll(); diff --git a/backend/src/main/java/com/pickpick/message/domain/Reminder.java b/backend/src/main/java/com/pickpick/message/domain/Reminder.java new file mode 100644 index 00000000..ee25c0b5 --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/domain/Reminder.java @@ -0,0 +1,48 @@ +package com.pickpick.message.domain; + +import com.pickpick.member.domain.Member; +import java.time.LocalDateTime; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; +import lombok.Getter; + +@Getter +@Table(name = "reminder") +@Entity +public class Reminder { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "message_id", nullable = false) + private Message message; + + @Column(name = "remind_date", nullable = false) + private LocalDateTime remindDate; + + protected Reminder() { + } + + public Reminder(final Member member, final Message message, final LocalDateTime remindDate) { + this.member = member; + this.message = message; + this.remindDate = remindDate; + } + + public void updateRemindDate(final LocalDateTime remindDate) { + this.remindDate = remindDate; + } +} diff --git a/backend/src/main/java/com/pickpick/message/domain/ReminderRepository.java b/backend/src/main/java/com/pickpick/message/domain/ReminderRepository.java new file mode 100644 index 00000000..6ac7516c --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/domain/ReminderRepository.java @@ -0,0 +1,19 @@ +package com.pickpick.message.domain; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.repository.Repository; + +public interface ReminderRepository extends Repository { + + Reminder save(Reminder reminder); + + Optional findById(Long id); + + Optional findByMessageIdAndMemberId(Long messageId, Long memberId); + + void deleteById(Long id); + + List findAllByRemindDate(LocalDateTime remindDate); +} diff --git a/backend/src/main/java/com/pickpick/message/ui/BookmarkController.java b/backend/src/main/java/com/pickpick/message/ui/BookmarkController.java index cecd5656..acd1876c 100644 --- a/backend/src/main/java/com/pickpick/message/ui/BookmarkController.java +++ b/backend/src/main/java/com/pickpick/message/ui/BookmarkController.java @@ -2,12 +2,12 @@ import com.pickpick.auth.support.AuthenticationPrincipal; import com.pickpick.message.application.BookmarkService; +import com.pickpick.message.ui.dto.BookmarkFindRequest; import com.pickpick.message.ui.dto.BookmarkRequest; import com.pickpick.message.ui.dto.BookmarkResponses; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -27,21 +27,18 @@ public BookmarkController(final BookmarkService bookmarkService) { @PostMapping @ResponseStatus(value = HttpStatus.CREATED) - public void save(final @AuthenticationPrincipal Long memberId, - final @RequestBody BookmarkRequest bookmarkRequest) { + public void save(@AuthenticationPrincipal final Long memberId, @RequestBody final BookmarkRequest bookmarkRequest) { bookmarkService.save(memberId, bookmarkRequest); } @GetMapping - public BookmarkResponses find(final @AuthenticationPrincipal Long memberId, - final @RequestParam(required = false) Long bookmarkId) { - return bookmarkService.find(bookmarkId, memberId); + public BookmarkResponses find(@AuthenticationPrincipal final Long memberId, final BookmarkFindRequest request) { + return bookmarkService.find(request, memberId); } - @DeleteMapping("/{bookmarkId}") + @DeleteMapping @ResponseStatus(HttpStatus.NO_CONTENT) - public void delete(final @AuthenticationPrincipal Long memberId, - final @PathVariable Long bookmarkId) { - bookmarkService.delete(bookmarkId, memberId); + public void delete(@AuthenticationPrincipal final Long memberId, @RequestParam final Long messageId) { + bookmarkService.delete(messageId, memberId); } } diff --git a/backend/src/main/java/com/pickpick/message/ui/ReminderController.java b/backend/src/main/java/com/pickpick/message/ui/ReminderController.java new file mode 100644 index 00000000..34871951 --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/ui/ReminderController.java @@ -0,0 +1,57 @@ +package com.pickpick.message.ui; + +import com.pickpick.auth.support.AuthenticationPrincipal; +import com.pickpick.message.application.ReminderService; +import com.pickpick.message.ui.dto.ReminderFindRequest; +import com.pickpick.message.ui.dto.ReminderSaveRequest; +import com.pickpick.message.ui.dto.ReminderResponse; +import com.pickpick.message.ui.dto.ReminderResponses; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/reminders") +public class ReminderController { + + private final ReminderService reminderService; + + public ReminderController(final ReminderService reminderService) { + this.reminderService = reminderService; + } + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping + public void save(@AuthenticationPrincipal final Long memberId, @RequestBody final ReminderSaveRequest reminderSaveRequest) { + reminderService.save(memberId, reminderSaveRequest); + } + + @GetMapping(params = "messageId") + public ReminderResponse findOne(final @AuthenticationPrincipal Long memberId, final @RequestParam Long messageId) { + return reminderService.findOne(messageId, memberId); + } + + @GetMapping + public ReminderResponses find(@AuthenticationPrincipal final Long memberId, final ReminderFindRequest request) { + return reminderService.find(request, memberId); + } + + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping + public void delete(@AuthenticationPrincipal final Long memberId, @RequestParam final Long messageId) { + reminderService.delete(messageId, memberId); + } + + @PutMapping + public void update(@AuthenticationPrincipal final Long memberId, + @RequestBody final ReminderSaveRequest request) { + reminderService.update(memberId, request); + } +} diff --git a/backend/src/main/java/com/pickpick/message/ui/dto/BookmarkFindRequest.java b/backend/src/main/java/com/pickpick/message/ui/dto/BookmarkFindRequest.java new file mode 100644 index 00000000..6dfaddf2 --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/ui/dto/BookmarkFindRequest.java @@ -0,0 +1,19 @@ +package com.pickpick.message.ui.dto; + +import java.util.Objects; +import lombok.Getter; + +@Getter +public class BookmarkFindRequest { + + private Long bookmarkId; + private int count = 20; + + public BookmarkFindRequest(final Long bookmarkId, final Integer count) { + this.bookmarkId = bookmarkId; + + if (Objects.nonNull(count)) { + this.count = count; + } + } +} diff --git a/backend/src/main/java/com/pickpick/message/ui/dto/BookmarkResponse.java b/backend/src/main/java/com/pickpick/message/ui/dto/BookmarkResponse.java index 009e1087..e1379b91 100644 --- a/backend/src/main/java/com/pickpick/message/ui/dto/BookmarkResponse.java +++ b/backend/src/main/java/com/pickpick/message/ui/dto/BookmarkResponse.java @@ -11,6 +11,7 @@ public class BookmarkResponse { private Long id; + private Long messageId; private Long memberId; private String username; private String userThumbnail; @@ -22,6 +23,7 @@ private BookmarkResponse() { } public BookmarkResponse(final Long id, + final Long messageId, final Long memberId, final String username, final String userThumbnail, @@ -29,6 +31,7 @@ public BookmarkResponse(final Long id, final LocalDateTime postedDate, final LocalDateTime modifiedDate) { this.id = id; + this.messageId = messageId; this.memberId = memberId; this.username = username; this.userThumbnail = userThumbnail; @@ -42,6 +45,7 @@ public static BookmarkResponse from(final Bookmark bookmark) { return BookmarkResponse.builder() .id(bookmark.getId()) + .messageId(message.getId()) .memberId(message.getMember().getId()) .username(message.getMember().getUsername()) .userThumbnail(message.getMember().getThumbnailUrl()) diff --git a/backend/src/main/java/com/pickpick/message/ui/dto/MessageResponse.java b/backend/src/main/java/com/pickpick/message/ui/dto/MessageResponse.java index e1a9df04..26f0e807 100644 --- a/backend/src/main/java/com/pickpick/message/ui/dto/MessageResponse.java +++ b/backend/src/main/java/com/pickpick/message/ui/dto/MessageResponse.java @@ -1,7 +1,9 @@ package com.pickpick.message.ui.dto; import com.fasterxml.jackson.annotation.JsonProperty; +import com.querydsl.core.annotations.QueryProjection; import java.time.LocalDateTime; +import java.util.Objects; import lombok.Builder; import lombok.Getter; @@ -15,9 +17,14 @@ public class MessageResponse { private String text; private LocalDateTime postedDate; private LocalDateTime modifiedDate; + private LocalDateTime remindDate; + @JsonProperty(value = "isBookmarked") private boolean bookmarked; + @JsonProperty(value = "isSetReminded") + private boolean setReminded; + private MessageResponse() { } @@ -29,7 +36,9 @@ public MessageResponse(final Long id, final String text, final LocalDateTime postedDate, final LocalDateTime modifiedDate, - final boolean isBookmarked) { + final boolean isBookmarked, + final boolean isSetReminded, + final LocalDateTime remindDate) { this.id = id; this.memberId = memberId; this.username = username; @@ -38,5 +47,30 @@ public MessageResponse(final Long id, this.postedDate = postedDate; this.modifiedDate = modifiedDate; this.bookmarked = isBookmarked; + this.setReminded = isSetReminded; + this.remindDate = remindDate; + } + + @QueryProjection + public MessageResponse(final Long id, + final Long memberId, + final String username, + final String userThumbnail, + final String text, + final LocalDateTime postedDate, + final LocalDateTime modifiedDate, + final Long bookmarkId, + final Long reminderId, + final LocalDateTime remindDate) { + this.id = id; + this.memberId = memberId; + this.username = username; + this.userThumbnail = userThumbnail; + this.text = text; + this.postedDate = postedDate; + this.modifiedDate = modifiedDate; + this.bookmarked = Objects.nonNull(bookmarkId); + this.setReminded = Objects.nonNull(reminderId); + this.remindDate = remindDate; } } diff --git a/backend/src/main/java/com/pickpick/message/ui/dto/ReminderFindRequest.java b/backend/src/main/java/com/pickpick/message/ui/dto/ReminderFindRequest.java new file mode 100644 index 00000000..846b7270 --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/ui/dto/ReminderFindRequest.java @@ -0,0 +1,19 @@ +package com.pickpick.message.ui.dto; + +import java.util.Objects; +import lombok.Getter; + +@Getter +public class ReminderFindRequest { + + private Long reminderId; + private int count = 20; + + public ReminderFindRequest(final Long reminderId, final Integer count) { + this.reminderId = reminderId; + + if (Objects.nonNull(count)) { + this.count = count; + } + } +} diff --git a/backend/src/main/java/com/pickpick/message/ui/dto/ReminderResponse.java b/backend/src/main/java/com/pickpick/message/ui/dto/ReminderResponse.java new file mode 100644 index 00000000..3867f701 --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/ui/dto/ReminderResponse.java @@ -0,0 +1,54 @@ +package com.pickpick.message.ui.dto; + +import com.pickpick.member.domain.Member; +import com.pickpick.message.domain.Message; +import com.pickpick.message.domain.Reminder; +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class ReminderResponse { + + private Long id; + private Long messageId; + private String username; + private String userThumbnail; + private String text; + private LocalDateTime postedDate; + private LocalDateTime modifiedDate; + private LocalDateTime remindDate; + + private ReminderResponse() { + } + + @Builder + public ReminderResponse(final Long id, final Long messageId, final String username, final String userThumbnail, + final String text, final LocalDateTime postedDate, final LocalDateTime modifiedDate, + final LocalDateTime remindDate) { + this.id = id; + this.messageId = messageId; + this.username = username; + this.userThumbnail = userThumbnail; + this.text = text; + this.postedDate = postedDate; + this.modifiedDate = modifiedDate; + this.remindDate = remindDate; + } + + public static ReminderResponse from(final Reminder reminder) { + Message message = reminder.getMessage(); + Member member = message.getMember(); + + return ReminderResponse.builder() + .id(reminder.getId()) + .messageId(message.getId()) + .username(member.getUsername()) + .userThumbnail(member.getThumbnailUrl()) + .text(message.getText()) + .postedDate(message.getPostedDate()) + .modifiedDate(message.getModifiedDate()) + .remindDate(reminder.getRemindDate()) + .build(); + } +} diff --git a/backend/src/main/java/com/pickpick/message/ui/dto/ReminderResponses.java b/backend/src/main/java/com/pickpick/message/ui/dto/ReminderResponses.java new file mode 100644 index 00000000..1ea4a0cc --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/ui/dto/ReminderResponses.java @@ -0,0 +1,22 @@ +package com.pickpick.message.ui.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import lombok.Getter; + +@Getter +public class ReminderResponses { + + private List reminders; + + @JsonProperty(value = "isLast") + private boolean last; + + private ReminderResponses() { + } + + public ReminderResponses(final List reminders, final boolean last) { + this.reminders = reminders; + this.last = last; + } +} diff --git a/backend/src/main/java/com/pickpick/message/ui/dto/ReminderSaveRequest.java b/backend/src/main/java/com/pickpick/message/ui/dto/ReminderSaveRequest.java new file mode 100644 index 00000000..a64285d1 --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/ui/dto/ReminderSaveRequest.java @@ -0,0 +1,19 @@ +package com.pickpick.message.ui.dto; + +import java.time.LocalDateTime; +import lombok.Getter; + +@Getter +public class ReminderSaveRequest { + + private Long messageId; + private LocalDateTime reminderDate; + + private ReminderSaveRequest() { + } + + public ReminderSaveRequest(final Long messageId, final LocalDateTime reminderDate) { + this.messageId = messageId; + this.reminderDate = reminderDate; + } +} diff --git a/backend/src/main/java/com/pickpick/slackevent/application/SlackEvent.java b/backend/src/main/java/com/pickpick/slackevent/application/SlackEvent.java index 3d46269f..d61b39dc 100644 --- a/backend/src/main/java/com/pickpick/slackevent/application/SlackEvent.java +++ b/backend/src/main/java/com/pickpick/slackevent/application/SlackEvent.java @@ -1,6 +1,6 @@ package com.pickpick.slackevent.application; -import com.pickpick.exception.SlackEventNotFoundException; +import com.pickpick.exception.slackevent.SlackEventNotFoundException; import java.util.Arrays; import java.util.Map; import lombok.Getter; @@ -11,9 +11,12 @@ public enum SlackEvent { MESSAGE_CREATED("message", ""), MESSAGE_CHANGED("message", "message_changed"), MESSAGE_DELETED("message", "message_deleted"), + MESSAGE_THREAD_BROADCAST("message", "thread_broadcast"), + MESSAGE_FILE_SHARE("message", "file_share"), CHANNEL_RENAME("channel_rename", ""), CHANNEL_DELETED("channel_deleted", ""), MEMBER_CHANGED("user_profile_changed", ""), + MEMBER_JOIN("team_join", ""), ; private final String type; @@ -38,4 +41,8 @@ public static SlackEvent of(final Map requestBody) { private static boolean isSameType(final SlackEvent event, final String type, final String subtype) { return event.type.equals(type) && event.subtype.equals(subtype); } + + public boolean isSameSubtype(final String subtype) { + return this.subtype.equals(subtype); + } } diff --git a/backend/src/main/java/com/pickpick/slackevent/application/SlackEventServiceFinder.java b/backend/src/main/java/com/pickpick/slackevent/application/SlackEventServiceFinder.java index a37e79f7..11d72c71 100644 --- a/backend/src/main/java/com/pickpick/slackevent/application/SlackEventServiceFinder.java +++ b/backend/src/main/java/com/pickpick/slackevent/application/SlackEventServiceFinder.java @@ -1,6 +1,6 @@ package com.pickpick.slackevent.application; -import com.pickpick.exception.SlackEventServiceNotFoundException; +import com.pickpick.exception.slackevent.SlackEventServiceNotFoundException; import java.util.List; import org.springframework.stereotype.Component; diff --git a/backend/src/main/java/com/pickpick/slackevent/application/channel/ChannelRenameService.java b/backend/src/main/java/com/pickpick/slackevent/application/channel/ChannelRenameService.java index 6c50e1a0..f8e47b11 100644 --- a/backend/src/main/java/com/pickpick/slackevent/application/channel/ChannelRenameService.java +++ b/backend/src/main/java/com/pickpick/slackevent/application/channel/ChannelRenameService.java @@ -2,7 +2,7 @@ import com.pickpick.channel.domain.Channel; import com.pickpick.channel.domain.ChannelRepository; -import com.pickpick.exception.ChannelNotFoundException; +import com.pickpick.exception.channel.ChannelNotFoundException; import com.pickpick.slackevent.application.SlackEvent; import com.pickpick.slackevent.application.SlackEventService; import com.pickpick.slackevent.application.channel.dto.SlackChannelRenameDto; diff --git a/backend/src/main/java/com/pickpick/slackevent/application/member/MemberChangedService.java b/backend/src/main/java/com/pickpick/slackevent/application/member/MemberChangedService.java index a045c6ec..d801783e 100644 --- a/backend/src/main/java/com/pickpick/slackevent/application/member/MemberChangedService.java +++ b/backend/src/main/java/com/pickpick/slackevent/application/member/MemberChangedService.java @@ -1,6 +1,6 @@ package com.pickpick.slackevent.application.member; -import com.pickpick.exception.MemberNotFoundException; +import com.pickpick.exception.member.MemberNotFoundException; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; import com.pickpick.slackevent.application.SlackEvent; diff --git a/backend/src/main/java/com/pickpick/slackevent/application/member/MemberJoinService.java b/backend/src/main/java/com/pickpick/slackevent/application/member/MemberJoinService.java new file mode 100644 index 00000000..8af8e302 --- /dev/null +++ b/backend/src/main/java/com/pickpick/slackevent/application/member/MemberJoinService.java @@ -0,0 +1,74 @@ +package com.pickpick.slackevent.application.member; + +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import com.pickpick.slackevent.application.SlackEvent; +import com.pickpick.slackevent.application.SlackEventService; +import com.pickpick.slackevent.application.member.dto.MemberJoinDto; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +@Transactional +@Service +public class MemberJoinService implements SlackEventService { + + private static final String USER = "user"; + private static final String PROFILE = "profile"; + private static final String SLACK_ID = "id"; + private static final String DISPLAY_NAME = "display_name"; + private static final String IMAGE_URL = "image_512"; + private static final String REAL_NAME = "real_name"; + private static final String EVENT = "event"; + + private final MemberRepository members; + + public MemberJoinService(final MemberRepository members) { + this.members = members; + } + + @Override + public void execute(final Map requestBody) { + MemberJoinDto memberJoinDto = convert(requestBody); + + Member newMember = toMember(memberJoinDto); + + members.save(newMember); + } + + private MemberJoinDto convert(final Map requestBody) { + Map event = (Map) requestBody.get(EVENT); + Map user = (Map) event.get(USER); + Map profile = (Map) user.get(PROFILE); + + return MemberJoinDto.builder() + .slackId((String) user.get(SLACK_ID)) + .username(extractUsername(profile)) + .thumbnailUrl((String) profile.get(IMAGE_URL)) + .build(); + } + + private String extractUsername(final Map profile) { + String username = (String) profile.get(DISPLAY_NAME); + + if (!StringUtils.hasText(username)) { + return (String) profile.get(REAL_NAME); + } + + return username; + } + + private Member toMember(final MemberJoinDto memberJoinDto) { + return new Member( + memberJoinDto.getSlackId(), + memberJoinDto.getUsername(), + memberJoinDto.getThumbnailUrl() + ); + } + + @Override + public boolean isSameSlackEvent(final SlackEvent slackEvent) { + return SlackEvent.MEMBER_JOIN == slackEvent; + } +} diff --git a/backend/src/main/java/com/pickpick/slackevent/application/member/dto/MemberJoinDto.java b/backend/src/main/java/com/pickpick/slackevent/application/member/dto/MemberJoinDto.java new file mode 100644 index 00000000..9d941f21 --- /dev/null +++ b/backend/src/main/java/com/pickpick/slackevent/application/member/dto/MemberJoinDto.java @@ -0,0 +1,19 @@ +package com.pickpick.slackevent.application.member.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class MemberJoinDto { + + private final String slackId; + private final String username; + private final String thumbnailUrl; + + @Builder + public MemberJoinDto(final String slackId, final String username, final String thumbnailUrl) { + this.slackId = slackId; + this.username = username; + this.thumbnailUrl = thumbnailUrl; + } +} diff --git a/backend/src/main/java/com/pickpick/slackevent/application/message/MessageChangedService.java b/backend/src/main/java/com/pickpick/slackevent/application/message/MessageChangedService.java index 361e3f17..15360071 100644 --- a/backend/src/main/java/com/pickpick/slackevent/application/message/MessageChangedService.java +++ b/backend/src/main/java/com/pickpick/slackevent/application/message/MessageChangedService.java @@ -1,6 +1,6 @@ package com.pickpick.slackevent.application.message; -import com.pickpick.exception.MessageNotFoundException; +import com.pickpick.exception.message.MessageNotFoundException; import com.pickpick.message.domain.Message; import com.pickpick.message.domain.MessageRepository; import com.pickpick.slackevent.application.SlackEvent; @@ -21,10 +21,14 @@ public class MessageChangedService implements SlackEventService { private static final String CLIENT_MSG_ID = "client_msg_id"; private static final String CHANNEL = "channel"; private static final String MESSAGE = "message"; + private static final String SUBTYPE = "subtype"; + private final MessageThreadBroadcastService messageThreadBroadcastService; private final MessageRepository messages; - public MessageChangedService(final MessageRepository messages) { + public MessageChangedService(final MessageThreadBroadcastService messageThreadBroadcastService, + final MessageRepository messages) { + this.messageThreadBroadcastService = messageThreadBroadcastService; this.messages = messages; } @@ -32,12 +36,25 @@ public MessageChangedService(final MessageRepository messages) { public void execute(final Map requestBody) { SlackMessageDto slackMessageDto = convert(requestBody); + if (isThreadBroadcastEvent(requestBody)) { + messageThreadBroadcastService.saveWhenSubtypeIsMessageChanged(slackMessageDto); + return; + } + Message message = messages.findBySlackId(slackMessageDto.getSlackId()) .orElseThrow(() -> new MessageNotFoundException(slackMessageDto.getSlackId())); message.changeText(slackMessageDto.getText(), slackMessageDto.getModifiedDate()); } + private boolean isThreadBroadcastEvent(final Map requestBody) { + Map event = (Map) requestBody.get(EVENT); + Map message = (Map) event.get(MESSAGE); + String subtype = message.get(SUBTYPE); + + return SlackEvent.MESSAGE_THREAD_BROADCAST.isSameSubtype(subtype); + } + private SlackMessageDto convert(final Map requestBody) { Map event = (Map) requestBody.get(EVENT); Map message = (Map) event.get(MESSAGE); diff --git a/backend/src/main/java/com/pickpick/slackevent/application/message/MessageCreatedService.java b/backend/src/main/java/com/pickpick/slackevent/application/message/MessageCreatedService.java index dd925468..01d0254d 100644 --- a/backend/src/main/java/com/pickpick/slackevent/application/message/MessageCreatedService.java +++ b/backend/src/main/java/com/pickpick/slackevent/application/message/MessageCreatedService.java @@ -1,19 +1,15 @@ package com.pickpick.slackevent.application.message; +import com.pickpick.channel.application.ChannelService; import com.pickpick.channel.domain.Channel; import com.pickpick.channel.domain.ChannelRepository; -import com.pickpick.exception.MemberNotFoundException; -import com.pickpick.exception.SlackClientException; +import com.pickpick.exception.member.MemberNotFoundException; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; import com.pickpick.message.domain.MessageRepository; import com.pickpick.slackevent.application.SlackEvent; import com.pickpick.slackevent.application.SlackEventService; import com.pickpick.slackevent.application.message.dto.SlackMessageDto; -import com.slack.api.methods.MethodsClient; -import com.slack.api.methods.SlackApiException; -import com.slack.api.model.Conversation; -import java.io.IOException; import java.util.Map; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -28,22 +24,27 @@ public class MessageCreatedService implements SlackEventService { private static final String TEXT = "text"; private static final String CLIENT_MSG_ID = "client_msg_id"; private static final String CHANNEL = "channel"; + private static final String THREAD_TIMESTAMP = "thread_ts"; private final MessageRepository messages; private final MemberRepository members; private final ChannelRepository channels; - private final MethodsClient slackClient; + private final ChannelService channelService; public MessageCreatedService(final MessageRepository messages, final MemberRepository members, - final ChannelRepository channels, final MethodsClient slackClient) { + final ChannelRepository channels, final ChannelService channelService) { this.messages = messages; this.members = members; this.channels = channels; - this.slackClient = slackClient; + this.channelService = channelService; } @Override public void execute(final Map requestBody) { + if (isReplyEvent(requestBody)) { + return; + } + SlackMessageDto slackMessageDto = convert(requestBody); String memberSlackId = slackMessageDto.getMemberSlackId(); @@ -53,32 +54,19 @@ public void execute(final Map requestBody) { String channelSlackId = slackMessageDto.getChannelSlackId(); Channel channel = channels.findBySlackId(channelSlackId) - .orElseGet(() -> createChannel(channelSlackId)); + .orElseGet(() -> channelService.createChannel(channelSlackId)); messages.save(slackMessageDto.toEntity(member, channel)); } - private Channel createChannel(final String channelSlackId) { - try { - Conversation conversation = slackClient.conversationsInfo( - request -> request.channel(channelSlackId) - ).getChannel(); - - Channel channel = toChannel(conversation); - channels.save(channel); - - return channel; - } catch (IOException | SlackApiException e) { - throw new SlackClientException(e); - } - } + private boolean isReplyEvent(final Map requestBody) { + Map event = (Map) requestBody.get(EVENT); - private Channel toChannel(final Conversation channel) { - return new Channel(channel.getId(), channel.getName()); + return event.containsKey(THREAD_TIMESTAMP); } private SlackMessageDto convert(final Map requestBody) { - final Map event = (Map) requestBody.get(EVENT); + Map event = (Map) requestBody.get(EVENT); return new SlackMessageDto( (String) event.get(USER), diff --git a/backend/src/main/java/com/pickpick/slackevent/application/message/MessageFileShareService.java b/backend/src/main/java/com/pickpick/slackevent/application/message/MessageFileShareService.java new file mode 100644 index 00000000..30a6ffbb --- /dev/null +++ b/backend/src/main/java/com/pickpick/slackevent/application/message/MessageFileShareService.java @@ -0,0 +1,73 @@ +package com.pickpick.slackevent.application.message; + +import com.pickpick.channel.application.ChannelService; +import com.pickpick.channel.domain.Channel; +import com.pickpick.channel.domain.ChannelRepository; +import com.pickpick.exception.member.MemberNotFoundException; +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import com.pickpick.message.domain.MessageRepository; +import com.pickpick.slackevent.application.SlackEvent; +import com.pickpick.slackevent.application.SlackEventService; +import com.pickpick.slackevent.application.message.dto.SlackMessageDto; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@Service +public class MessageFileShareService implements SlackEventService { + + private static final String EVENT = "event"; + private static final String USER = "user"; + private static final String TIMESTAMP = "ts"; + private static final String TEXT = "text"; + private static final String CLIENT_MSG_ID = "client_msg_id"; + private static final String CHANNEL = "channel"; + + private final MessageRepository messages; + private final MemberRepository members; + private final ChannelRepository channels; + private final ChannelService channelService; + + public MessageFileShareService(final MessageRepository messages, final MemberRepository members, + final ChannelRepository channels, final ChannelService channelService) { + this.messages = messages; + this.members = members; + this.channels = channels; + this.channelService = channelService; + } + + @Override + public void execute(final Map requestBody) { + SlackMessageDto slackMessageDto = convert(requestBody); + + String memberSlackId = slackMessageDto.getMemberSlackId(); + Member member = members.findBySlackId(memberSlackId) + .orElseThrow(() -> new MemberNotFoundException(memberSlackId)); + + String channelSlackId = slackMessageDto.getChannelSlackId(); + Channel channel = channels.findBySlackId(channelSlackId) + .orElseGet(() -> channelService.createChannel(channelSlackId)); + + messages.save(slackMessageDto.toEntity(member, channel)); + } + + private SlackMessageDto convert(final Map requestBody) { + Map event = (Map) requestBody.get(EVENT); + + return new SlackMessageDto( + (String) event.get(USER), + (String) event.get(CLIENT_MSG_ID), + (String) event.get(TIMESTAMP), + (String) event.get(TIMESTAMP), + (String) event.getOrDefault(TEXT, ""), + (String) event.get(CHANNEL) + ); + } + + @Override + public boolean isSameSlackEvent(final SlackEvent slackEvent) { + return SlackEvent.MESSAGE_FILE_SHARE == slackEvent; + } +} diff --git a/backend/src/main/java/com/pickpick/slackevent/application/message/MessageThreadBroadcastService.java b/backend/src/main/java/com/pickpick/slackevent/application/message/MessageThreadBroadcastService.java new file mode 100644 index 00000000..c667d538 --- /dev/null +++ b/backend/src/main/java/com/pickpick/slackevent/application/message/MessageThreadBroadcastService.java @@ -0,0 +1,86 @@ +package com.pickpick.slackevent.application.message; + +import com.pickpick.channel.application.ChannelService; +import com.pickpick.channel.domain.Channel; +import com.pickpick.channel.domain.ChannelRepository; +import com.pickpick.exception.member.MemberNotFoundException; +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import com.pickpick.message.domain.MessageRepository; +import com.pickpick.slackevent.application.SlackEvent; +import com.pickpick.slackevent.application.SlackEventService; +import com.pickpick.slackevent.application.message.dto.SlackMessageDto; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@Service +public class MessageThreadBroadcastService implements SlackEventService { + + private static final String EVENT = "event"; + private static final String USER = "user"; + private static final String TIMESTAMP = "ts"; + private static final String TEXT = "text"; + private static final String CLIENT_MSG_ID = "client_msg_id"; + private static final String CHANNEL = "channel"; + + private final MessageRepository messages; + private final MemberRepository members; + private final ChannelRepository channels; + private final ChannelService channelService; + + public MessageThreadBroadcastService(final MessageRepository messages, final MemberRepository members, + final ChannelRepository channels, final ChannelService channelService) { + this.messages = messages; + this.members = members; + this.channels = channels; + this.channelService = channelService; + } + + @Override + public void execute(final Map requestBody) { + SlackMessageDto slackMessageDto = convert(requestBody); + + save(slackMessageDto); + } + + private SlackMessageDto convert(final Map requestBody) { + Map event = (Map) requestBody.get(EVENT); + + return new SlackMessageDto( + (String) event.get(USER), + (String) event.get(CLIENT_MSG_ID), + (String) event.get(TIMESTAMP), + (String) event.get(TIMESTAMP), + (String) event.get(TEXT), + (String) event.get(CHANNEL) + ); + } + + public void saveWhenSubtypeIsMessageChanged(final SlackMessageDto slackMessageDto) { + messages.findBySlackId(slackMessageDto.getSlackId()) + .ifPresentOrElse( + message -> message.changeText(slackMessageDto.getText(), slackMessageDto.getModifiedDate()), + () -> save(slackMessageDto) + ); + } + + private void save(final SlackMessageDto slackMessageDto) { + String memberSlackId = slackMessageDto.getMemberSlackId(); + Member member = members.findBySlackId(memberSlackId) + .orElseThrow(() -> new MemberNotFoundException(memberSlackId)); + + String channelSlackId = slackMessageDto.getChannelSlackId(); + + Channel channel = channels.findBySlackId(channelSlackId) + .orElseGet(() -> channelService.createChannel(channelSlackId)); + + messages.save(slackMessageDto.toEntity(member, channel)); + } + + @Override + public boolean isSameSlackEvent(final SlackEvent slackEvent) { + return SlackEvent.MESSAGE_THREAD_BROADCAST == slackEvent; + } +} diff --git a/backend/src/main/resources/security b/backend/src/main/resources/security index 5b3bb8ff..b416c777 160000 --- a/backend/src/main/resources/security +++ b/backend/src/main/resources/security @@ -1 +1 @@ -Subproject commit 5b3bb8fffae1d226b1d783b4009f66f102617e26 +Subproject commit b416c7778bfe12edef1cbc0ac64addd1d6b3cd98 diff --git a/backend/src/test/java/com/pickpick/PickpickApplicationTests.java b/backend/src/test/java/com/pickpick/PickpickApplicationTests.java index 61528e2a..b428f0c3 100644 --- a/backend/src/test/java/com/pickpick/PickpickApplicationTests.java +++ b/backend/src/test/java/com/pickpick/PickpickApplicationTests.java @@ -9,5 +9,4 @@ class PickpickApplicationTests { @Test void contextLoads() { } - } diff --git a/backend/src/test/java/com/pickpick/acceptance/AcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/AcceptanceTest.java index 61e4afe2..35d468c1 100644 --- a/backend/src/test/java/com/pickpick/acceptance/AcceptanceTest.java +++ b/backend/src/test/java/com/pickpick/acceptance/AcceptanceTest.java @@ -3,10 +3,12 @@ import static org.assertj.core.api.Assertions.assertThat; import com.pickpick.auth.support.JwtTokenProvider; +import com.pickpick.config.DatabaseCleaner; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import java.util.Map; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator; @@ -18,20 +20,27 @@ @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class AcceptanceTest { +public class AcceptanceTest { @LocalServerPort int port; + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Autowired + private DatabaseCleaner databaseCleaner; @BeforeEach public void setUp() { RestAssured.port = port; } - @Autowired - private JwtTokenProvider jwtTokenProvider; + @AfterEach + void tearDown() { + databaseCleaner.clear(); + } - ExtractableResponse post(final String uri, final Object object) { + protected ExtractableResponse post(final String uri, final Object object) { return RestAssured.given().log().all() .body(object) .contentType(MediaType.APPLICATION_JSON_VALUE) @@ -41,7 +50,8 @@ ExtractableResponse post(final String uri, final Object object) { .extract(); } - ExtractableResponse postWithCreateToken(final String uri, final Object object, final Long memberId) { + protected ExtractableResponse postWithCreateToken(final String uri, final Object object, + final Long memberId) { String token = createToken(memberId); return RestAssured.given().log().all() @@ -54,7 +64,7 @@ ExtractableResponse postWithCreateToken(final String uri, final Object .extract(); } - ExtractableResponse get(final String uri) { + protected ExtractableResponse get(final String uri) { return RestAssured.given().log().all() .when() .get(uri) @@ -62,7 +72,7 @@ ExtractableResponse get(final String uri) { .extract(); } - ExtractableResponse get(final String uri, final Map queryParams) { + protected ExtractableResponse get(final String uri, final Map queryParams) { return RestAssured.given() .queryParams(queryParams) .log().all() @@ -72,7 +82,7 @@ ExtractableResponse get(final String uri, final Map qu .extract(); } - ExtractableResponse getWithCreateToken(final String uri, final Long memberId) { + protected ExtractableResponse getWithCreateToken(final String uri, final Long memberId) { String token = createToken(memberId); return RestAssured.given().log().all() @@ -83,8 +93,8 @@ ExtractableResponse getWithCreateToken(final String uri, final Long me .extract(); } - ExtractableResponse getWithCreateToken(final String uri, final Long memberId, - final Map request) { + protected ExtractableResponse getWithCreateToken(final String uri, final Long memberId, + final Map request) { String token = createToken(memberId); return RestAssured.given().log().all() @@ -96,7 +106,8 @@ ExtractableResponse getWithCreateToken(final String uri, final Long me .extract(); } - ExtractableResponse putWithCreateToken(final String uri, final Object object, final Long memberId) { + protected ExtractableResponse putWithCreateToken(final String uri, final Object object, + final Long memberId) { String token = createToken(memberId); return RestAssured.given().log().all() @@ -109,7 +120,7 @@ ExtractableResponse putWithCreateToken(final String uri, final Object .extract(); } - ExtractableResponse deleteWithCreateToken(final String uri, final Long memberId) { + protected ExtractableResponse deleteWithCreateToken(final String uri, final Long memberId) { String token = createToken(memberId); return RestAssured.given().log().all() @@ -124,15 +135,15 @@ private String createToken(final Long memberId) { return jwtTokenProvider.createToken(String.valueOf(memberId)); } - void 상태코드_200_확인(final ExtractableResponse response) { + protected void 상태코드_200_확인(final ExtractableResponse response) { assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); } - void 상태코드_400_확인(final ExtractableResponse response) { + protected void 상태코드_400_확인(final ExtractableResponse response) { assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()); } - void 상태코드_확인(final ExtractableResponse response, final HttpStatus httpStatus) { + protected void 상태코드_확인(final ExtractableResponse response, final HttpStatus httpStatus) { assertThat(response.statusCode()).isEqualTo(httpStatus.value()); } } diff --git a/backend/src/test/java/com/pickpick/acceptance/EventAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/EventAcceptanceTest.java deleted file mode 100644 index 63d8268f..00000000 --- a/backend/src/test/java/com/pickpick/acceptance/EventAcceptanceTest.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.pickpick.acceptance; - -import static org.assertj.core.api.Assertions.assertThat; - -import io.restassured.response.ExtractableResponse; -import io.restassured.response.Response; -import java.util.Map; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpStatus; -import org.springframework.test.context.jdbc.Sql; - -@Sql({"/truncate.sql", "/message.sql"}) -@DisplayName("메시지 기능") -@SuppressWarnings("NonAsciiCharacters") -class EventAcceptanceTest extends AcceptanceTest { - - private static final String API_URL = "/api/event"; - private static final String MESSAGE_DELETED = "message_deleted"; - private static final String MESSAGE_CHANGED = "message_changed"; - - private static Map createEventRequest(String subtype) { - String user = "U03MC231"; - String timestamp = "1234567890.123456"; - String text = "메시지 전송!"; - String slackMessageId = "db8a1f84-8acf-46ab-b93d-85177cee3e97"; - - String type = "event_callback"; - Map event = Map.of( - "type", "message", - "subtype", subtype, - "channel", "ABC1234", - "previous_message", Map.of("client_msg_id", slackMessageId), - "message", Map.of( - "user", user, - "ts", timestamp, - "text", text, - "client_msg_id", slackMessageId - ), - "client_msg_id", slackMessageId, - "text", text, - "user", user, - "ts", timestamp - ); - - return Map.of("type", type, "event", event); - } - - @Test - void URL_검증_요청_시_challenge_를_응답한다() { - // given - String token = "token"; - String type = "url_verification"; - String challenge = "example123token123"; - - Map request = Map.of("token", token, "type", type, "challenge", challenge); - - // when - ExtractableResponse result = post(API_URL, request); - - // then - assertThat(result.asString()).isEqualTo(challenge); - } - - @Test - void 메시지_저장_성공() { - // given - Map messageCreatedRequest = createEventRequest(""); - - // when - ExtractableResponse result = post(API_URL, messageCreatedRequest); - - // then - assertThat(result.statusCode()).isEqualTo(HttpStatus.OK.value()); - } - - @Test - void 메시지_수정_요청_시_메시지_내용과_수정_시간이_업데이트_된다() { - // given - Map messageCreatedRequest = createEventRequest(""); - post(API_URL, messageCreatedRequest); - - Map messageChangedRequest = createEventRequest(MESSAGE_CHANGED); - - // when - ExtractableResponse messageChangedResponse = post(API_URL, messageChangedRequest); - - // then - assertThat(messageChangedResponse.statusCode()).isEqualTo(HttpStatus.OK.value()); - } - - @Test - void 메시지_삭제_요청_시_메시지가_삭제_된다() { - // given - Map messageCreatedRequest = createEventRequest(""); - post(API_URL, messageCreatedRequest); - - Map messageDeletedRequest = createEventRequest(MESSAGE_DELETED); - - // when - ExtractableResponse messageChangedResponse = post(API_URL, messageDeletedRequest); - - // then - assertThat(messageChangedResponse.statusCode()).isEqualTo(HttpStatus.OK.value()); - } -} diff --git a/backend/src/test/java/com/pickpick/acceptance/MemberAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/MemberAcceptanceTest.java deleted file mode 100644 index a85ac6d9..00000000 --- a/backend/src/test/java/com/pickpick/acceptance/MemberAcceptanceTest.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.pickpick.acceptance; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; - -import com.pickpick.member.domain.Member; -import com.pickpick.member.domain.MemberRepository; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; - -@DisplayName("유저 기능") -@SuppressWarnings("NonAsciiCharacters") -public class MemberAcceptanceTest extends AcceptanceTest { - - @Autowired - private MemberRepository memberRepository; - - @Test - void 프로젝트_기동_시점에_유저가_저장되어_있어야_한다() { - List members = memberRepository.findAll(); - assertThat(members.isEmpty()).isFalse(); - } -} diff --git a/backend/src/test/java/com/pickpick/acceptance/AuthAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/auth/AuthAcceptanceTest.java similarity index 80% rename from backend/src/test/java/com/pickpick/acceptance/AuthAcceptanceTest.java rename to backend/src/test/java/com/pickpick/acceptance/auth/AuthAcceptanceTest.java index cbac6667..40fc3579 100644 --- a/backend/src/test/java/com/pickpick/acceptance/AuthAcceptanceTest.java +++ b/backend/src/test/java/com/pickpick/acceptance/auth/AuthAcceptanceTest.java @@ -1,10 +1,12 @@ -package com.pickpick.acceptance; +package com.pickpick.acceptance.auth; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import com.pickpick.acceptance.AcceptanceTest; import com.pickpick.auth.support.JwtTokenProvider; +import com.pickpick.config.dto.ErrorResponse; import com.slack.api.methods.MethodsClient; import com.slack.api.methods.SlackApiException; import com.slack.api.methods.request.oauth.OAuthV2AccessRequest; @@ -24,13 +26,13 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.jdbc.Sql; -@Sql({"/truncate.sql", "/member.sql"}) -@DisplayName("인증 기능") +@Sql({"/member.sql"}) +@DisplayName("인증 인가 기능") @SuppressWarnings("NonAsciiCharacters") public class AuthAcceptanceTest extends AcceptanceTest { - private static final String API_URL = "/api/slack-login"; - private static final String API_CERTIFICATION_URL = "/api/certification"; + private static final String LOGIN_API_URL = "/api/slack-login"; + private static final String CERTIFICATION_API_URL = "/api/certification"; private static final String MEMBER_SLACK_ID = "U03MC231"; @Value("${security.jwt.token.secret-key}") @@ -48,7 +50,7 @@ public class AuthAcceptanceTest extends AcceptanceTest { .willReturn(generateUsersIdentityResponse(MEMBER_SLACK_ID)); // when - ExtractableResponse response = get(API_URL, Map.of("code", "1234")); + ExtractableResponse response = get(LOGIN_API_URL, Map.of("code", "1234")); // then 상태코드_200_확인(response); @@ -78,7 +80,7 @@ private UsersIdentityResponse generateUsersIdentityResponse(final String slackId @Test void 유효한_토큰_검증() { // given & when - ExtractableResponse response = getWithCreateToken(API_CERTIFICATION_URL, 2L); + ExtractableResponse response = getWithCreateToken(CERTIFICATION_API_URL, 2L); // then 상태코드_200_확인(response); @@ -90,10 +92,11 @@ private UsersIdentityResponse generateUsersIdentityResponse(final String slackId String invalidToken = "abcde12345"; // when - ExtractableResponse response = getWithToken(API_CERTIFICATION_URL, invalidToken); + ExtractableResponse response = getWithToken(CERTIFICATION_API_URL, invalidToken); // then 상태코드_400_확인(response); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo("INVALID_TOKEN"); } private ExtractableResponse getWithToken(final String uri, final String token) { @@ -113,7 +116,7 @@ private ExtractableResponse getWithToken(final String uri, final Strin String invalidToken = jwtTokenProvider.createToken("1"); // when - ExtractableResponse response = getWithToken(API_CERTIFICATION_URL, invalidToken); + ExtractableResponse response = getWithToken(CERTIFICATION_API_URL, invalidToken); // then 상태코드_400_확인(response); @@ -126,9 +129,10 @@ private ExtractableResponse getWithToken(final String uri, final Strin String invalidToken = jwtTokenProvider.createToken("1"); // when - ExtractableResponse response = getWithToken(API_CERTIFICATION_URL, invalidToken); + ExtractableResponse response = getWithToken(CERTIFICATION_API_URL, invalidToken); // then 상태코드_400_확인(response); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo("INVALID_TOKEN"); } } diff --git a/backend/src/test/java/com/pickpick/acceptance/ChannelAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/channel/ChannelAcceptanceTest.java similarity index 86% rename from backend/src/test/java/com/pickpick/acceptance/ChannelAcceptanceTest.java rename to backend/src/test/java/com/pickpick/acceptance/channel/ChannelAcceptanceTest.java index 855c408d..f5a4362c 100644 --- a/backend/src/test/java/com/pickpick/acceptance/ChannelAcceptanceTest.java +++ b/backend/src/test/java/com/pickpick/acceptance/channel/ChannelAcceptanceTest.java @@ -1,7 +1,8 @@ -package com.pickpick.acceptance; +package com.pickpick.acceptance.channel; import static org.assertj.core.api.Assertions.assertThat; +import com.pickpick.acceptance.AcceptanceTest; import com.pickpick.channel.ui.dto.ChannelResponse; import com.pickpick.channel.ui.dto.ChannelSubscriptionRequest; import io.restassured.response.ExtractableResponse; @@ -12,35 +13,41 @@ import org.junit.jupiter.api.Test; import org.springframework.test.context.jdbc.Sql; -@Sql({"/truncate.sql", "/channel.sql"}) +@Sql({"/channel.sql"}) @DisplayName("채널 기능") @SuppressWarnings("NonAsciiCharacters") public class ChannelAcceptanceTest extends AcceptanceTest { - protected static final String API_CHANNEL_SUBSCRIPTION = "/api/channel-subscription"; + protected static final String CHANNEL_SUBSCRIPTION_API_URL = "/api/channel-subscription"; @Test void 유저_전체_채널_목록_조회() { + // given & when ExtractableResponse response = 유저_전체_채널_목록_조회_요청(); + // then 상태코드_200_확인(response); 조회된_채널_목록_개수_확인(response, 6); } @Test void 채널_구독() { + // given ExtractableResponse response = 유저_전체_채널_목록_조회_요청(); List unsubscribedChannelIds = 구독중이_아닌_채널_id_목록_추출(response); - Long channelIdToSubscribe = unsubscribedChannelIds.get(0); + + // when ExtractableResponse subscriptionResponse = 구독_요청(channelIdToSubscribe); + // then 상태코드_200_확인(subscriptionResponse); 채널_구독_완료_확인(channelIdToSubscribe); } @Test void 채널_구독_취소() { + // given ExtractableResponse response = 유저_전체_채널_목록_조회_요청(); List unsubscribedChannelIds = 구독중이_아닌_채널_id_목록_추출(response); @@ -50,8 +57,10 @@ public class ChannelAcceptanceTest extends AcceptanceTest { 구독_요청(channelIdToSubscribe); 구독_요청(channelIdToUnSubscribe); + // when ExtractableResponse unsubscribeResponse = 구독_취소_요청(channelIdToUnSubscribe); + // then 상태코드_200_확인(unsubscribeResponse); 채널_구독_취소_확인(channelIdToUnSubscribe); } @@ -60,7 +69,7 @@ public class ChannelAcceptanceTest extends AcceptanceTest { return getWithCreateToken("/api/channels", 2L); } - protected List 구독중이_아닌_채널_id_목록_추출(ExtractableResponse response) { + protected List 구독중이_아닌_채널_id_목록_추출(final ExtractableResponse response) { return response.jsonPath() .getList("channels.", ChannelResponse.class) .stream() @@ -71,11 +80,11 @@ public class ChannelAcceptanceTest extends AcceptanceTest { protected ExtractableResponse 구독_요청(final Long channelId) { ChannelSubscriptionRequest channelSubscriptionRequest = new ChannelSubscriptionRequest(channelId); - return postWithCreateToken(API_CHANNEL_SUBSCRIPTION, channelSubscriptionRequest, 2L); + return postWithCreateToken(CHANNEL_SUBSCRIPTION_API_URL, channelSubscriptionRequest, 2L); } protected ExtractableResponse 구독_취소_요청(final Long channelId) { - return deleteWithCreateToken(API_CHANNEL_SUBSCRIPTION + "?channelId=" + channelId, 2L); + return deleteWithCreateToken(CHANNEL_SUBSCRIPTION_API_URL + "?channelId=" + channelId, 2L); } private void 채널_구독_완료_확인(final Long channelIdToSubscribe) { diff --git a/backend/src/test/java/com/pickpick/acceptance/ChannelSubscriptionAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/channel/ChannelSubscriptionAcceptanceTest.java similarity index 79% rename from backend/src/test/java/com/pickpick/acceptance/ChannelSubscriptionAcceptanceTest.java rename to backend/src/test/java/com/pickpick/acceptance/channel/ChannelSubscriptionAcceptanceTest.java index 12b96fee..c9e3cb83 100644 --- a/backend/src/test/java/com/pickpick/acceptance/ChannelSubscriptionAcceptanceTest.java +++ b/backend/src/test/java/com/pickpick/acceptance/channel/ChannelSubscriptionAcceptanceTest.java @@ -1,10 +1,11 @@ -package com.pickpick.acceptance; +package com.pickpick.acceptance.channel; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import com.pickpick.channel.ui.dto.ChannelOrderRequest; import com.pickpick.channel.ui.dto.ChannelSubscriptionResponse; +import com.pickpick.config.dto.ErrorResponse; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import java.util.List; @@ -15,7 +16,7 @@ import org.junit.jupiter.params.provider.ValueSource; import org.springframework.test.context.jdbc.Sql; -@Sql({"/truncate.sql", "/channel.sql"}) +@Sql({"/channel.sql"}) @DisplayName("채널 구독 기능") @SuppressWarnings("NonAsciiCharacters") class ChannelSubscriptionAcceptanceTest extends ChannelAcceptanceTest { @@ -37,16 +38,20 @@ void subscribe() { @Test void 채널_구독_조회() { + // given & when ExtractableResponse response = 유저_구독_채널_목록_조회_요청(); + // then 상태코드_200_확인(response); 구독이_올바른_순서로_조회됨(response, channelIdToSubscribe1, channelIdToSubscribe2); } @Test void 구독_채널_순서_변경() { + // given & when ExtractableResponse response = 올바른_구독_채널_순서_변경_요청(); + // then 상태코드_200_확인(response); ExtractableResponse subscriptionResponse = 유저_구독_채널_목록_조회_요청(); @@ -56,30 +61,41 @@ void subscribe() { @ParameterizedTest @ValueSource(ints = {0, -1}) void 구독_채널_순서_변경_시_1보다_작은_순서가_들어올_경우_예외_발생(int invalidViewOrder) { + // given List request = List.of( new ChannelOrderRequest(channelIdToSubscribe1, invalidViewOrder), new ChannelOrderRequest(channelIdToSubscribe2, 1) ); + // when ExtractableResponse response = 구독_채널_순서_변경_요청(request); + // then 상태코드_400_확인(response); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo( + "SUBSCRIPTION_INVALID_ORDER"); } @Test void 구독_채널_순서_변경_시_중복된_순서가_들어올_경우_예외_발생() { + // given List request = List.of( new ChannelOrderRequest(channelIdToSubscribe1, 1), new ChannelOrderRequest(channelIdToSubscribe2, 1) ); + // when ExtractableResponse response = 구독_채널_순서_변경_요청(request); + // then 상태코드_400_확인(response); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo( + "SUBSCRIPTION_DUPLICATE"); } @Test void 구독_채널_순서_변경_시_해당_멤버가_구독한_적_없는_채널_ID가_포함된_경우_예외_발생() { + // given 구독_취소_요청(channelIdToSubscribe1); List request = List.of( @@ -87,40 +103,59 @@ void subscribe() { new ChannelOrderRequest(channelIdToSubscribe2, 2) ); + // when ExtractableResponse response = 구독_채널_순서_변경_요청(request); + // then 상태코드_400_확인(response); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo( + "SUBSCRIPTION_NOT_EXIST"); } @Test void 구독_채널_순서_변경_시_해당_멤버의_모든_구독_채널이_요청에_포함되지_않을_경우_예외_발생() { + // given List request = List.of( new ChannelOrderRequest(channelIdToSubscribe1, 1) ); + // when ExtractableResponse response = 구독_채널_순서_변경_요청(request); + // then 상태코드_400_확인(response); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo( + "SUBSCRIPTION_NOT_EXIST"); } @Test void 구독_중인_채널_다시_구독_요청() { + // given & when ExtractableResponse response = 구독_요청(channelIdToSubscribe1); + // then 상태코드_400_확인(response); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo( + "SUBSCRIPTION_DUPLICATE"); } @Test void 구독하지_않은_채널_구독_취소() { + // given 구독_취소_요청(channelIdToSubscribe1); + + // when ExtractableResponse response = 구독_취소_요청(channelIdToSubscribe1); + // then 상태코드_400_확인(response); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo( + "SUBSCRIPTION_NOT_EXIST"); } private ExtractableResponse 유저_구독_채널_목록_조회_요청() { - return getWithCreateToken(API_CHANNEL_SUBSCRIPTION, 2L); + return getWithCreateToken(CHANNEL_SUBSCRIPTION_API_URL, 2L); } private void 구독이_올바른_순서로_조회됨( @@ -141,7 +176,7 @@ void subscribe() { } private ExtractableResponse 구독_채널_순서_변경_요청(final List request) { - return putWithCreateToken(API_CHANNEL_SUBSCRIPTION, request, 2L); + return putWithCreateToken(CHANNEL_SUBSCRIPTION_API_URL, request, 2L); } private ExtractableResponse 올바른_구독_채널_순서_변경_요청() { diff --git a/backend/src/test/java/com/pickpick/acceptance/member/MemberAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/member/MemberAcceptanceTest.java new file mode 100644 index 00000000..a41b086f --- /dev/null +++ b/backend/src/test/java/com/pickpick/acceptance/member/MemberAcceptanceTest.java @@ -0,0 +1,77 @@ +package com.pickpick.acceptance.member; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.pickpick.acceptance.AcceptanceTest; +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("멤버 기능") +@SuppressWarnings("NonAsciiCharacters") +public class MemberAcceptanceTest extends AcceptanceTest { + + private static final String SLACK_EVENT_API_URL = "/api/event"; + private static final String SLACK_ID = "U03MKN0UW"; + + @Autowired + private MemberRepository members; + + @Disabled + @Test + void 프로젝트_기동_시점에_유저가_저장되어_있어야_한다() { + // given + List members = this.members.findAll(); + + // when & then + assertThat(members.isEmpty()).isFalse(); + } + + @Test + void 슬랙_워크스페이스에_신규_멤버가_참여하면_저장되어야_한다() { + // given + int 신규_참여_전_멤버_수 = members.findAll().size(); + Map teamJoinEvent = createTeamJoinEvent("진짜이름", "표시이름", "https://somebody.png"); + + // when + ExtractableResponse teamJoinEventResponse = post(SLACK_EVENT_API_URL, teamJoinEvent); + List 신규_참여_후_전체_멤버 = members.findAll(); + int 신규_참여_후_멤버_수 = 신규_참여_후_전체_멤버.size(); + Optional 신규_참여_멤버 = 신규_참여_후_전체_멤버.stream() + .max(Comparator.comparing(Member::getId)); + + // then + assertAll( + () -> 상태코드_200_확인(teamJoinEventResponse), + () -> assertThat(신규_참여_전_멤버_수 + 1).isEqualTo(신규_참여_후_멤버_수), + () -> assertThat(신규_참여_멤버).isPresent(), + () -> assertThat(신규_참여_멤버.get().getSlackId()).isEqualTo(SLACK_ID) + ); + } + + private Map createTeamJoinEvent(final String realName, final String displayName, + final String thumbnailUrl) { + return Map.of( + "event", Map.of( + "type", "team_join", + "user", Map.of( + "id", SLACK_ID, + "profile", Map.of( + "real_name", realName, + "display_name", displayName, + "image_512", thumbnailUrl + ) + ) + )); + } +} diff --git a/backend/src/test/java/com/pickpick/acceptance/BookmarkAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/message/BookmarkAcceptanceTest.java similarity index 79% rename from backend/src/test/java/com/pickpick/acceptance/BookmarkAcceptanceTest.java rename to backend/src/test/java/com/pickpick/acceptance/message/BookmarkAcceptanceTest.java index 84972001..87dcac2d 100644 --- a/backend/src/test/java/com/pickpick/acceptance/BookmarkAcceptanceTest.java +++ b/backend/src/test/java/com/pickpick/acceptance/message/BookmarkAcceptanceTest.java @@ -1,7 +1,9 @@ -package com.pickpick.acceptance; +package com.pickpick.acceptance.message; import static org.assertj.core.api.Assertions.assertThat; +import com.pickpick.acceptance.AcceptanceTest; +import com.pickpick.config.dto.ErrorResponse; import com.pickpick.message.ui.dto.BookmarkResponse; import com.pickpick.message.ui.dto.BookmarkResponses; import io.restassured.response.ExtractableResponse; @@ -14,17 +16,17 @@ import org.springframework.http.HttpStatus; import org.springframework.test.context.jdbc.Sql; -@Sql({"/truncate.sql", "/bookmark.sql"}) +@Sql({"/bookmark.sql"}) @DisplayName("북마크 기능") @SuppressWarnings("NonAsciiCharacters") public class BookmarkAcceptanceTest extends AcceptanceTest { - private static final String API_BOOKMARK = "/api/bookmarks"; + private static final String BOOKMARK_API_URL = "/api/bookmarks"; @Test void 북마크_생성() { // given & when - ExtractableResponse response = postWithCreateToken(API_BOOKMARK, Map.of("messageId", 1), 1L); + ExtractableResponse response = postWithCreateToken(BOOKMARK_API_URL, Map.of("messageId", 1), 1L); // then 상태코드_확인(response, HttpStatus.CREATED); @@ -38,7 +40,7 @@ public class BookmarkAcceptanceTest extends AcceptanceTest { boolean expectedIsLast = true; // when - ExtractableResponse response = getWithCreateToken(API_BOOKMARK, 2L, request); + ExtractableResponse response = getWithCreateToken(BOOKMARK_API_URL, 2L, request); // then 상태코드_확인(response, HttpStatus.OK); @@ -57,7 +59,7 @@ public class BookmarkAcceptanceTest extends AcceptanceTest { boolean expectedIsLast = false; // when - ExtractableResponse response = getWithCreateToken(API_BOOKMARK, 1L, request); + ExtractableResponse response = getWithCreateToken(BOOKMARK_API_URL, 1L, request); // then 상태코드_확인(response, HttpStatus.OK); @@ -77,24 +79,28 @@ private List convertToIds(final BookmarkResponses response) { @Test void 북마크_정상_삭제() { // given - long bookmarkId = 2L; + long messageId = 2L; // when - ExtractableResponse response = deleteWithCreateToken(API_BOOKMARK + "/" + bookmarkId, 1L); + ExtractableResponse response = deleteWithCreateToken(BOOKMARK_API_URL + "?messageId=" + messageId, + 1L); // then 상태코드_확인(response, HttpStatus.NO_CONTENT); } @Test - void 다른_사용자의_북마크_삭제() { + void 사용자에게_존재하지_않는_북마크_삭제() { // given - long bookmarkId = 1L; + long messageId = 1L; // when - ExtractableResponse response = deleteWithCreateToken(API_BOOKMARK + "/" + bookmarkId, 1L); + ExtractableResponse response = deleteWithCreateToken(BOOKMARK_API_URL + "?messageId=" + messageId, + 1L); // then 상태코드_확인(response, HttpStatus.BAD_REQUEST); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo( + "BOOKMARK_DELETE_FAILURE"); } } diff --git a/backend/src/test/java/com/pickpick/acceptance/MessageAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/message/MessageAcceptanceTest.java similarity index 71% rename from backend/src/test/java/com/pickpick/acceptance/MessageAcceptanceTest.java rename to backend/src/test/java/com/pickpick/acceptance/message/MessageAcceptanceTest.java index f2986aca..9bfa4aa2 100644 --- a/backend/src/test/java/com/pickpick/acceptance/MessageAcceptanceTest.java +++ b/backend/src/test/java/com/pickpick/acceptance/message/MessageAcceptanceTest.java @@ -1,11 +1,16 @@ -package com.pickpick.acceptance; +package com.pickpick.acceptance.message; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; +import com.pickpick.acceptance.AcceptanceTest; +import com.pickpick.message.ui.dto.MessageResponse; import com.pickpick.message.ui.dto.MessageResponses; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; +import java.time.Clock; +import java.time.Instant; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -18,18 +23,21 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; -import org.springframework.http.HttpStatus; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.jdbc.Sql; -@Sql({"/truncate.sql", "/message.sql"}) -@Sql(value = "/truncate.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) +@Sql({"/message.sql"}) + @DisplayName("메시지 기능") @SuppressWarnings("NonAsciiCharacters") class MessageAcceptanceTest extends AcceptanceTest { - private static final String API_URL = "/api/messages"; + private static final String MESSAGE_API_URL = "/api/messages"; private static final long MEMBER_ID = 1L; + @SpyBean + private Clock clock; + private static Stream methodSource() { return Stream.of( Arguments.of( @@ -83,7 +91,7 @@ private static Stream methodSource() { ); } - private static Map createQueryParams( + private static Map createQueryParams( final String keyword, final String date, final String channelIds, final String needPastMessage, final String messageId, final String messageCount ) { @@ -108,14 +116,14 @@ private static List createExpectedMessageIds(final Long startInclusive, fi @ParameterizedTest(name = "{0}") void 메시지_조회_API(final String description, final Map request, final boolean expectedIsLast, final List expectedMessageIds, final boolean expectedNeedPastMessage) { - // when - ExtractableResponse response = getWithCreateToken(API_URL, MEMBER_ID, request); + // given & when + ExtractableResponse response = getWithCreateToken(MESSAGE_API_URL, MEMBER_ID, request); // then MessageResponses messageResponses = response.as(MessageResponses.class); assertAll( - () -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()), + () -> 상태코드_200_확인(response), () -> assertThat(messageResponses.isLast()).isEqualTo(expectedIsLast), () -> assertThat(messageResponses.isNeedPastMessage()).isEqualTo(expectedNeedPastMessage), () -> assertThat(messageResponses.getMessages()) @@ -127,21 +135,71 @@ private static List createExpectedMessageIds(final Long startInclusive, fi @ValueSource(strings = {"", "true"}) @ParameterizedTest void 메시지_조회_시_needPastMessage_true_응답_확인(final String needPastMessage) { - Map request = createQueryParams("jupjup", "", "5", needPastMessage, "", ""); - ExtractableResponse response = getWithCreateToken(API_URL, MEMBER_ID, request); + // given + Map request = createQueryParams("jupjup", "", "5", needPastMessage, "", ""); + // when + ExtractableResponse response = getWithCreateToken(MESSAGE_API_URL, MEMBER_ID, request); MessageResponses messageResponses = response.as(MessageResponses.class); + // then + 상태코드_200_확인(response); assertThat(messageResponses.isNeedPastMessage()).isTrue(); } @Test void 메시지_조회_시_needPastMessage가_False일_경우_응답_확인() { - Map request = createQueryParams("jupjup", "", "5", "false", "", ""); - ExtractableResponse response = getWithCreateToken(API_URL, MEMBER_ID, request); + // given + Map request = createQueryParams("jupjup", "", "5", "false", "", ""); + // when + ExtractableResponse response = getWithCreateToken(MESSAGE_API_URL, MEMBER_ID, request); MessageResponses messageResponses = response.as(MessageResponses.class); + // then + 상태코드_200_확인(response); assertThat(messageResponses.isNeedPastMessage()).isFalse(); } + + @Test + void 이미_리마인드_완료된_메시지_조회_시_isSetReminded가_false이고_remindDate가_null() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-13T00:00:00Z")); + Map request = createQueryParams("", "", "5", "true", "", "1"); + + // when + ExtractableResponse response = getWithCreateToken(MESSAGE_API_URL, MEMBER_ID, request); + MessageResponse messageResponse = response.as(MessageResponses.class) + .getMessages() + .get(0); + + // then + 상태코드_200_확인(response); + assertAll( + () -> assertThat(messageResponse.isSetReminded()).isFalse(), + () -> assertThat(messageResponse.getRemindDate()).isNull() + ); + } + + @Test + void 리마인드_해야하는_메시지_조회_시_isSetReminded가_true이고_remindDate에_값이_존재() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + Map request = createQueryParams("", "", "5", "true", "", "1"); + + // when + ExtractableResponse response = getWithCreateToken(MESSAGE_API_URL, MEMBER_ID, request); + MessageResponse messageResponse = response.as(MessageResponses.class) + .getMessages() + .get(0); + + // then + 상태코드_200_확인(response); + assertAll( + () -> assertThat(messageResponse.isSetReminded()).isTrue(), + () -> assertThat(messageResponse.getRemindDate()).isNotNull() + ); + } } diff --git a/backend/src/test/java/com/pickpick/acceptance/message/ReminderAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/message/ReminderAcceptanceTest.java new file mode 100644 index 00000000..ebe1abdf --- /dev/null +++ b/backend/src/test/java/com/pickpick/acceptance/message/ReminderAcceptanceTest.java @@ -0,0 +1,265 @@ +package com.pickpick.acceptance.message; + + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; + +import com.pickpick.acceptance.AcceptanceTest; +import com.pickpick.message.ui.dto.ReminderResponse; +import com.pickpick.message.ui.dto.ReminderResponses; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.jdbc.Sql; + +@Sql({"/reminder.sql"}) +@DisplayName("리마인더 기능") +@SuppressWarnings("NonAsciiCharacters") +public class ReminderAcceptanceTest extends AcceptanceTest { + + private static final String REMINDER_API_URL = "/api/reminders"; + + @SpyBean + private Clock clock; + + @Test + void 리마인더_생성() { + // given & when + ExtractableResponse response = postWithCreateToken(REMINDER_API_URL, + Map.of("messageId", 1, "reminderDate", "2022-08-10T19:21:55"), 1L); + + // then + 상태코드_확인(response, HttpStatus.CREATED); + } + + @Test + void 리마인더_단건_조회_정상_응답() { + // given + Map request = Map.of("messageId", "1"); + + // when + ExtractableResponse response = getWithCreateToken(REMINDER_API_URL, 2L, request); + + // then + 상태코드_200_확인(response); + ReminderResponse reminderResponse = response.jsonPath().getObject("", ReminderResponse.class); + assertThat(reminderResponse.getId()).isEqualTo(1L); + } + + @Test + void 존재하지_않는_리마인더_조회시_404_응답() { + // given + Map request = Map.of("messageId", "100"); + + // when + ExtractableResponse response = getWithCreateToken(REMINDER_API_URL, 2L, request); + + // then + 상태코드_확인(response, HttpStatus.NOT_FOUND); + } + + @Test + void 멤버_ID_2번으로_리마인더_목록_조회() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + Map request = Map.of("reminderId", ""); + List expectedIds = List.of(1L); + boolean expectedIsLast = true; + + // when + ExtractableResponse response = getWithCreateToken(REMINDER_API_URL, 2L, request); + + // then + 상태코드_확인(response, HttpStatus.OK); + + ReminderResponses reminderResponses = response.jsonPath().getObject("", ReminderResponses.class); + assertAll( + () -> assertThat(reminderResponses.isLast()).isEqualTo(expectedIsLast), + () -> assertThat(convertToIds(reminderResponses)).containsExactlyElementsOf(expectedIds) + ); + } + + @Test + void 멤버_ID_1번이고_리마인더_ID_10번일_때_리마인더_목록_조회() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + Map request = Map.of("reminderId", "10"); + List expectedIds = List.of(11L, 12L, 13L, 14L, 15L, 16L, 17L, 18L, 19L, 20L, 21L, 22L, 23L); + boolean expectedIsLast = true; + + // when + ExtractableResponse response = getWithCreateToken(REMINDER_API_URL, 1L, request); + + // then + 상태코드_확인(response, HttpStatus.OK); + + ReminderResponses reminderResponses = response.jsonPath().getObject("", ReminderResponses.class); + assertAll( + () -> assertThat(reminderResponses.isLast()).isEqualTo(expectedIsLast), + () -> assertThat(convertToIds(reminderResponses)).containsExactlyElementsOf(expectedIds) + ); + } + + @Test + void 리마인더_조회_시_가장_최신인_리마인더가_포함된다면_isLast가_True다() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + Map request = Map.of("reminderId", ""); + List expectedIds = List.of(1L); + boolean expectedIsLast = true; + + // when + ExtractableResponse response = getWithCreateToken(REMINDER_API_URL, 2L, request); + + // then + 상태코드_확인(response, HttpStatus.OK); + + ReminderResponses reminderResponses = response.jsonPath().getObject("", ReminderResponses.class); + assertAll( + () -> assertThat(reminderResponses.isLast()).isEqualTo(expectedIsLast), + () -> assertThat(convertToIds(reminderResponses)).containsExactlyElementsOf(expectedIds) + ); + } + + @Test + void 리마인더_조회_시_가장_최신인_리마인더가_포함되지_않는다면_isLast가_False다() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + Map request = Map.of("reminderId", "2"); + List expectedIds = List.of( + 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L, 11L, 12L, 13L, 14L, 15L, 16L, 17L, 18L, 19L, 20L, 21L, 22L); + boolean expectedIsLast = false; + + // when + ExtractableResponse response = getWithCreateToken(REMINDER_API_URL, 1L, request); + + // then + 상태코드_확인(response, HttpStatus.OK); + + ReminderResponses reminderResponses = response.jsonPath().getObject("", ReminderResponses.class); + assertAll( + () -> assertThat(reminderResponses.isLast()).isEqualTo(expectedIsLast), + () -> assertThat(convertToIds(reminderResponses)).containsExactlyElementsOf(expectedIds) + ); + } + + private List convertToIds(final ReminderResponses response) { + return response.getReminders() + .stream() + .map(ReminderResponse::getId) + .collect(Collectors.toList()); + } + + @Test + void 리마인더_조회_시_count_값이_없으면_20개가_조회된다() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + Map request = Map.of("reminderId", ""); + + // when + ExtractableResponse response = getWithCreateToken(REMINDER_API_URL, 1L, request); + + // then + 상태코드_확인(response, HttpStatus.OK); + + int size = response.jsonPath() + .getObject("", ReminderResponses.class) + .getReminders() + .size(); + + assertThat(size).isEqualTo(20); + } + + @Test + void 리마인더_조회_시_count_값이_있다면_count_개수_만큼_조회된다() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + int count = 10; + Map request = Map.of("reminderId", "", "count", count); + + // when + ExtractableResponse response = getWithCreateToken(REMINDER_API_URL, 1L, request); + + // then + 상태코드_확인(response, HttpStatus.OK); + + int size = response.jsonPath() + .getObject("", ReminderResponses.class) + .getReminders() + .size(); + + assertThat(size).isEqualTo(count); + } + + @Test + void 리마인더_정상_수정() { + // given + Map request = Map.of("messageId", "2", "reminderDate", LocalDateTime.now().toString()); + + // when + ExtractableResponse response = putWithCreateToken(REMINDER_API_URL, request, 1L); + + // then + 상태코드_확인(response, HttpStatus.OK); + } + + @Test + void 사용자에게_존재하지_않는_리마인더_수정() { + // given + Map request = Map.of("messageId", "1", "reminderDate", LocalDateTime.now().toString()); + + // when + ExtractableResponse response = putWithCreateToken(REMINDER_API_URL, request, 1L); + + // then + 상태코드_확인(response, HttpStatus.BAD_REQUEST); + } + + @Test + void 리마인더_정상_삭제() { + // given + long messageId = 2L; + + // when + ExtractableResponse response = deleteWithCreateToken(REMINDER_API_URL + "?messageId=" + messageId, + 1L); + + // then + 상태코드_확인(response, HttpStatus.NO_CONTENT); + } + + @Test + void 사용자에게_존재하지_않는_리마인더_삭제() { + // given + long messageId = 1L; + + // when + ExtractableResponse response = deleteWithCreateToken(REMINDER_API_URL + "?messageId=" + messageId, + 1L); + + // then + 상태코드_확인(response, HttpStatus.BAD_REQUEST); + } +} diff --git a/backend/src/test/java/com/pickpick/acceptance/MemberEventAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/slackevent/MemberEventAcceptanceTest.java similarity index 84% rename from backend/src/test/java/com/pickpick/acceptance/MemberEventAcceptanceTest.java rename to backend/src/test/java/com/pickpick/acceptance/slackevent/MemberEventAcceptanceTest.java index a100871c..bbfc3154 100644 --- a/backend/src/test/java/com/pickpick/acceptance/MemberEventAcceptanceTest.java +++ b/backend/src/test/java/com/pickpick/acceptance/slackevent/MemberEventAcceptanceTest.java @@ -1,5 +1,6 @@ -package com.pickpick.acceptance; +package com.pickpick.acceptance.slackevent; +import com.pickpick.acceptance.AcceptanceTest; import com.pickpick.slackevent.application.SlackEvent; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; @@ -8,12 +9,12 @@ import org.junit.jupiter.api.Test; import org.springframework.test.context.jdbc.Sql; -@Sql({"/truncate.sql", "/member.sql"}) -@DisplayName("멤버 기능") +@Sql({"/member.sql"}) +@DisplayName("멤버 이벤트 기능") @SuppressWarnings("NonAsciiCharacters") class MemberEventAcceptanceTest extends AcceptanceTest { - private static final String API_URL = "/api/event"; + private static final String MEMBER_EVENT_API_URL = "/api/event"; @Test void 멤버_수정_발생_시_프로필_이미지와_이름이_업데이트_된다() { @@ -21,7 +22,7 @@ class MemberEventAcceptanceTest extends AcceptanceTest { Map memberUpdatedRequest = createEventRequest("실제이름", "표시이름", "test.png"); // when - ExtractableResponse memberChangedResponse = post(API_URL, memberUpdatedRequest); + ExtractableResponse memberChangedResponse = post(MEMBER_EVENT_API_URL, memberUpdatedRequest); // then 상태코드_200_확인(memberChangedResponse); diff --git a/backend/src/test/java/com/pickpick/acceptance/slackevent/MessageEventAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/slackevent/MessageEventAcceptanceTest.java new file mode 100644 index 00000000..a59805fa --- /dev/null +++ b/backend/src/test/java/com/pickpick/acceptance/slackevent/MessageEventAcceptanceTest.java @@ -0,0 +1,170 @@ +package com.pickpick.acceptance.slackevent; + +import com.pickpick.acceptance.AcceptanceTest; +import com.pickpick.slackevent.application.SlackEvent; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.jdbc.Sql; + +@Sql({"/message.sql"}) +@DisplayName("메시지 이벤트 기능") +@SuppressWarnings("NonAsciiCharacters") +class MessageEventAcceptanceTest extends AcceptanceTest { + + private static final String MESSAGE_EVENT_API_URL = "/api/event"; + + private static Map createEventRequest(final String subtype) { + String user = "U03MC231"; + String timestamp = "1234567890.123456"; + String text = "메시지 전송!"; + String slackMessageId = "db8a1f84-8acf-46ab-b93d-85177cee3e97"; + + String type = "event_callback"; + Map event = Map.of( + "type", "message", + "subtype", subtype, + "channel", "ABC1234", + "previous_message", Map.of("client_msg_id", slackMessageId), + "message", Map.of( + "user", user, + "ts", timestamp, + "text", text, + "client_msg_id", slackMessageId + ), + "client_msg_id", slackMessageId, + "text", text, + "user", user, + "ts", timestamp + ); + + return Map.of("type", type, "event", event); + } + + private static Map createThreadBroadcastEventRequest() { + String user = "U03MC231"; + String timestamp = "1234567890.123456"; + String text = "메시지 전송!"; + String slackMessageId = "db8a1f84-8acf-46ab-b93d-85177cee3e97"; + + String type = "event_callback"; + Map event = Map.of( + "type", "message", + "subtype", "message_changed", + "channel", "ABC1234", + "previous_message", Map.of("client_msg_id", slackMessageId), + "message", Map.of( + "type", "message", + "subtype", "thread_broadcast", + "user", user, + "ts", timestamp, + "text", text, + "client_msg_id", slackMessageId + ), + "client_msg_id", slackMessageId, + "user", user, + "ts", timestamp + ); + + return Map.of("type", type, "event", event); + } + + @Test + void URL_검증_요청_시_challenge_를_응답한다() { + // given + String token = "token"; + String type = "url_verification"; + String challenge = "example123token123"; + + Map request = Map.of("token", token, "type", type, "challenge", challenge); + + // when + ExtractableResponse response = post(MESSAGE_EVENT_API_URL, request); + + // then + 상태코드_200_확인(response); + } + + @Test + void 메시지_저장_성공() { + // given + Map messageCreatedRequest = createEventRequest(""); + + // when + ExtractableResponse response = post(MESSAGE_EVENT_API_URL, messageCreatedRequest); + + // then + 상태코드_200_확인(response); + } + + @Test + void 메시지_수정_요청_시_메시지_내용과_수정_시간이_업데이트_된다() { + // given + Map messageCreatedRequest = createEventRequest(""); + post(MESSAGE_EVENT_API_URL, messageCreatedRequest); + + Map messageChangedRequest = createEventRequest(SlackEvent.MESSAGE_CHANGED.getSubtype()); + + // when + ExtractableResponse response = post(MESSAGE_EVENT_API_URL, messageChangedRequest); + + // then + 상태코드_200_확인(response); + } + + @Test + void 메시지_삭제_요청_시_메시지가_삭제_된다() { + // given + Map messageCreatedRequest = createEventRequest(""); + post(MESSAGE_EVENT_API_URL, messageCreatedRequest); + + Map messageDeletedRequest = createEventRequest(SlackEvent.MESSAGE_DELETED.getSubtype()); + + // when + ExtractableResponse response = post(MESSAGE_EVENT_API_URL, messageDeletedRequest); + + // then + 상태코드_200_확인(response); + } + + @Test + void 스레드를_작성하면서_바로_채널로_전송_시_메시지가_저장된다() { + // given + Map messageThreadBroadcastRequest = createEventRequest("thread_broadcast"); + + // when + ExtractableResponse response = post(MESSAGE_EVENT_API_URL, messageThreadBroadcastRequest); + + // then + 상태코드_200_확인(response); + } + + @Test + void 스레드_작성_후_메뉴에서_채널로_전송_시_메시지가_저장된다() { + // given + Map messageThreadBroadcastRequest = createThreadBroadcastEventRequest(); + + // when + ExtractableResponse response = post(MESSAGE_EVENT_API_URL, messageThreadBroadcastRequest); + + // then + 상태코드_200_확인(response); + } + + @Test + void 파일_공유_메시지_요청_시_메시지가_저장된다() { + // given + Map messageCreatedRequest = createEventRequest(""); + post(MESSAGE_EVENT_API_URL, messageCreatedRequest); + + Map fileShareMessageRequest = createEventRequest(SlackEvent.MESSAGE_FILE_SHARE.getSubtype()); + + // when + ExtractableResponse response = post(MESSAGE_EVENT_API_URL, fileShareMessageRequest); + + // then + 상태코드_200_확인(response); + } +} diff --git a/backend/src/test/java/com/pickpick/auth/application/AuthServiceTest.java b/backend/src/test/java/com/pickpick/auth/application/AuthServiceTest.java index 84cb1dc7..c370e778 100644 --- a/backend/src/test/java/com/pickpick/auth/application/AuthServiceTest.java +++ b/backend/src/test/java/com/pickpick/auth/application/AuthServiceTest.java @@ -9,7 +9,8 @@ import com.pickpick.auth.support.JwtTokenProvider; import com.pickpick.auth.ui.dto.LoginResponse; -import com.pickpick.exception.InvalidTokenException; +import com.pickpick.exception.auth.ExpiredTokenException; +import com.pickpick.exception.auth.InvalidTokenException; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; import com.slack.api.methods.MethodsClient; @@ -28,10 +29,8 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.transaction.annotation.Transactional; @AutoConfigureMockMvc -@Transactional @SpringBootTest class AuthServiceTest { @@ -54,8 +53,7 @@ class AuthServiceTest { @Test void login() throws SlackApiException, IOException { // given - Member member = new Member("slackId", "username", "thumbnail.png"); - members.save(member); + Member member = members.save(new Member("slackId", "username", "thumbnail.png")); given(slackClient.oauthV2Access(any(OAuthV2AccessRequest.class))) .willReturn(generateOAuthV2AccessResponse()); @@ -109,8 +107,7 @@ void verifyInvalidToken() { // when & then assertThatThrownBy(() -> authService.verifyToken(token)) - .isInstanceOf(InvalidTokenException.class) - .hasMessageContaining("유효하지 않은 토큰입니다."); + .isInstanceOf(InvalidTokenException.class); } @DisplayName("만료된 토큰을 검증한다.") @@ -122,8 +119,7 @@ void verifyExpiredToken() { // when & then assertThatThrownBy(() -> authService.verifyToken(token)) - .isInstanceOf(InvalidTokenException.class) - .hasMessageContaining("만료된 토큰입니다."); + .isInstanceOf(ExpiredTokenException.class); } @DisplayName("시그니처가 다른 토큰을 검증한다.") @@ -135,7 +131,6 @@ void verifyDifferentSignatureToken() { // when & then assertThatThrownBy(() -> authService.verifyToken(token)) - .isInstanceOf(InvalidTokenException.class) - .hasMessageContaining("유효하지 않은 토큰입니다."); + .isInstanceOf(InvalidTokenException.class); } } diff --git a/backend/src/test/java/com/pickpick/auth/support/JwtTokenProviderTest.java b/backend/src/test/java/com/pickpick/auth/support/JwtTokenProviderTest.java index be555f4f..438c8b3a 100644 --- a/backend/src/test/java/com/pickpick/auth/support/JwtTokenProviderTest.java +++ b/backend/src/test/java/com/pickpick/auth/support/JwtTokenProviderTest.java @@ -3,7 +3,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.pickpick.exception.InvalidTokenException; +import com.pickpick.exception.auth.ExpiredTokenException; +import com.pickpick.exception.auth.InvalidTokenException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -32,14 +33,14 @@ void getPayload() { void validateExpiredToken() { // given long memberId = 1L; - JwtTokenProvider expiredTokenProvider = new JwtTokenProvider("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.ih1aovtQShabQ7l0cINw4k1fagApg3qLWiB8Kt59Lno", + JwtTokenProvider expiredTokenProvider = new JwtTokenProvider( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.ih1aovtQShabQ7l0cINw4k1fagApg3qLWiB8Kt59Lno", 0); String token = expiredTokenProvider.createToken(String.valueOf(memberId)); // when & then assertThatThrownBy(() -> expiredTokenProvider.validateToken(token)) - .isInstanceOf(InvalidTokenException.class) - .hasMessageContaining("만료된 토큰입니다."); + .isInstanceOf(ExpiredTokenException.class); } @DisplayName("유효하지 않은 토큰 검증") @@ -50,8 +51,7 @@ void validateInvalidToken() { // when & then assertThatThrownBy(() -> jwtTokenProvider.validateToken(token)) - .isInstanceOf(InvalidTokenException.class) - .hasMessageContaining("유효하지 않은 토큰입니다."); + .isInstanceOf(InvalidTokenException.class); } @DisplayName("다른 시그니쳐로 생성된 토큰 검증") @@ -64,7 +64,6 @@ void validateInvalidSignature() { // when & then assertThatThrownBy(() -> jwtTokenProvider.validateToken(token)) - .isInstanceOf(InvalidTokenException.class) - .hasMessageContaining("유효하지 않은 토큰입니다."); + .isInstanceOf(InvalidTokenException.class); } } diff --git a/backend/src/test/java/com/pickpick/channel/application/ChannelServiceTest.java b/backend/src/test/java/com/pickpick/channel/application/ChannelServiceTest.java new file mode 100644 index 00000000..abbd3b6b --- /dev/null +++ b/backend/src/test/java/com/pickpick/channel/application/ChannelServiceTest.java @@ -0,0 +1,82 @@ +package com.pickpick.channel.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import com.pickpick.channel.domain.Channel; +import com.pickpick.channel.domain.ChannelRepository; +import com.pickpick.exception.SlackApiCallException; +import com.slack.api.RequestConfigurator; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.conversations.ConversationsInfoRequest.ConversationsInfoRequestBuilder; +import com.slack.api.methods.response.conversations.ConversationsInfoResponse; +import com.slack.api.model.Conversation; +import java.io.IOException; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class ChannelServiceTest { + + @MockBean + private MethodsClient slackClient; + + @Autowired + private ChannelRepository channels; + + @Autowired + private ChannelService channelService; + + @DisplayName("채널을 저장한다.") + @Test + void save() throws SlackApiException, IOException { + // given + given(slackClient.conversationsInfo((RequestConfigurator) any())) + .willReturn(setUpChannelMockData()); + + Optional channelBeforeExecute = channels.findBySlackId("channelSlackId"); + + // when + channelService.createChannel("channelSlackId"); + + // then + Optional channelAfterExecute = channels.findBySlackId("channelSlackId"); + assertAll( + () -> assertThat(channelBeforeExecute).isEmpty(), + () -> assertThat(channelAfterExecute).isPresent() + ); + } + + @DisplayName("채널 저장 시 Exception이 발생하면 커스텀 예외인 SlackApiCallException을 호출한다") + @Test + void saveFailAndThrowCustomException() throws SlackApiException, IOException { + // given + given(slackClient.conversationsInfo((RequestConfigurator) any())) + .willThrow(SlackApiException.class); + + // when & then + assertThatThrownBy(() -> channelService.createChannel("channelSlackId")) + .isInstanceOf(SlackApiCallException.class); + } + + private ConversationsInfoResponse setUpChannelMockData() { + Conversation conversation = new Conversation(); + conversation.setId("channelSlackId"); + conversation.setName("channelName"); + + ConversationsInfoResponse conversationsInfoResponse = new ConversationsInfoResponse(); + conversationsInfoResponse.setChannel(conversation); + + return conversationsInfoResponse; + } +} diff --git a/backend/src/test/java/com/pickpick/channel/ChannelSubscriptionServiceTest.java b/backend/src/test/java/com/pickpick/channel/application/ChannelSubscriptionServiceTest.java similarity index 94% rename from backend/src/test/java/com/pickpick/channel/ChannelSubscriptionServiceTest.java rename to backend/src/test/java/com/pickpick/channel/application/ChannelSubscriptionServiceTest.java index e55f3a5e..c510f6d6 100644 --- a/backend/src/test/java/com/pickpick/channel/ChannelSubscriptionServiceTest.java +++ b/backend/src/test/java/com/pickpick/channel/application/ChannelSubscriptionServiceTest.java @@ -1,18 +1,17 @@ -package com.pickpick.channel; +package com.pickpick.channel.application; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.pickpick.channel.application.ChannelSubscriptionService; import com.pickpick.channel.domain.Channel; import com.pickpick.channel.domain.ChannelRepository; import com.pickpick.channel.domain.ChannelSubscription; import com.pickpick.channel.ui.dto.ChannelOrderRequest; import com.pickpick.channel.ui.dto.ChannelSubscriptionRequest; -import com.pickpick.exception.ChannelNotFoundException; -import com.pickpick.exception.SubscriptionDuplicateException; -import com.pickpick.exception.SubscriptionNotExistException; -import com.pickpick.exception.SubscriptionOrderDuplicateException; +import com.pickpick.exception.channel.ChannelNotFoundException; +import com.pickpick.exception.channel.SubscriptionDuplicateException; +import com.pickpick.exception.channel.SubscriptionNotExistException; +import com.pickpick.exception.channel.SubscriptionOrderDuplicateException; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; import java.util.List; @@ -231,14 +230,12 @@ void unsubscribeChannel() { } private Member saveMember() { - Member member = new Member("TESTMEMBER", "테스트 계정", "test.png"); - members.save(member); + Member member = members.save(new Member("TESTMEMBER", "테스트 계정", "test.png")); return member; } private Channel saveChannel(final String slackId, final String channelName) { - Channel channel = new Channel(slackId, channelName); - channels.save(channel); + Channel channel = channels.save(new Channel(slackId, channelName)); return channel; } diff --git a/backend/src/test/java/com/pickpick/channel/domain/ChannelSubscriptionTest.java b/backend/src/test/java/com/pickpick/channel/domain/ChannelSubscriptionTest.java index 79db9f41..63933945 100644 --- a/backend/src/test/java/com/pickpick/channel/domain/ChannelSubscriptionTest.java +++ b/backend/src/test/java/com/pickpick/channel/domain/ChannelSubscriptionTest.java @@ -2,7 +2,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.pickpick.exception.SubscriptionOrderMinException; +import com.pickpick.exception.channel.SubscriptionInvalidOrderException; import com.pickpick.member.domain.Member; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.params.ParameterizedTest; @@ -13,7 +13,7 @@ class ChannelSubscriptionTest { @DisplayName("채널 구독 순서는 1 미만일 수 없다.") @ValueSource(ints = {0, -1}) @ParameterizedTest - void changeOrder(int invalidOrder) { + void changeOrder(final int invalidOrder) { // given Channel channel = new Channel("slackId", "채널 이름"); Member member = new Member("slackId", "유저 이름", "Profile.png"); @@ -21,6 +21,6 @@ void changeOrder(int invalidOrder) { // when & then assertThatThrownBy(() -> channelSubscription.changeOrder(invalidOrder)) - .isInstanceOf(SubscriptionOrderMinException.class); + .isInstanceOf(SubscriptionInvalidOrderException.class); } } diff --git a/backend/src/test/java/com/pickpick/channel/domain/ChannelTest.java b/backend/src/test/java/com/pickpick/channel/domain/ChannelTest.java index 4b7a1593..3195e566 100644 --- a/backend/src/test/java/com/pickpick/channel/domain/ChannelTest.java +++ b/backend/src/test/java/com/pickpick/channel/domain/ChannelTest.java @@ -2,7 +2,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.pickpick.exception.SlackBadRequestException; +import com.pickpick.exception.channel.ChannelInvalidNameException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullAndEmptySource; @@ -12,13 +12,12 @@ class ChannelTest { @DisplayName("채널 이름은 빈 문자열, 또는 null로 변경할 수 없다") @NullAndEmptySource @ParameterizedTest - void changeName(String invalidName) { + void changeName(final String invalidName) { // given Channel channel = new Channel("slackId", "채널 이름"); // when & then assertThatThrownBy(() -> channel.changeName(invalidName)) - .isInstanceOf(SlackBadRequestException.class) - .hasMessageContaining("채널 이름이 유효하지 않습니다"); + .isInstanceOf(ChannelInvalidNameException.class); } } diff --git a/backend/src/test/java/com/pickpick/controller/ChannelControllerTest.java b/backend/src/test/java/com/pickpick/channel/ui/ChannelControllerTest.java similarity index 94% rename from backend/src/test/java/com/pickpick/controller/ChannelControllerTest.java rename to backend/src/test/java/com/pickpick/channel/ui/ChannelControllerTest.java index 9c62a2cc..988043d9 100644 --- a/backend/src/test/java/com/pickpick/controller/ChannelControllerTest.java +++ b/backend/src/test/java/com/pickpick/channel/ui/ChannelControllerTest.java @@ -1,4 +1,4 @@ -package com.pickpick.controller; +package com.pickpick.channel.ui; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @@ -9,6 +9,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.pickpick.auth.support.JwtTokenProvider; +import com.pickpick.config.RestDocsTestSupport; import org.apache.http.HttpHeaders; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -18,7 +19,7 @@ import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.test.context.jdbc.Sql; -@Sql({"/truncate.sql", "/channel.sql", "/channel-subscription.sql"}) +@Sql({"/channel.sql", "/channel-subscription.sql"}) class ChannelControllerTest extends RestDocsTestSupport { @MockBean diff --git a/backend/src/test/java/com/pickpick/controller/ChannelSubscriptionControllerTest.java b/backend/src/test/java/com/pickpick/channel/ui/ChannelSubscriptionControllerTest.java similarity index 97% rename from backend/src/test/java/com/pickpick/controller/ChannelSubscriptionControllerTest.java rename to backend/src/test/java/com/pickpick/channel/ui/ChannelSubscriptionControllerTest.java index 842f7dae..6f06c438 100644 --- a/backend/src/test/java/com/pickpick/controller/ChannelSubscriptionControllerTest.java +++ b/backend/src/test/java/com/pickpick/channel/ui/ChannelSubscriptionControllerTest.java @@ -1,4 +1,4 @@ -package com.pickpick.controller; +package com.pickpick.channel.ui; import static org.mockito.ArgumentMatchers.any; @@ -15,6 +15,7 @@ import com.pickpick.auth.support.JwtTokenProvider; import com.pickpick.channel.ui.dto.ChannelOrderRequest; import com.pickpick.channel.ui.dto.ChannelSubscriptionRequest; +import com.pickpick.config.RestDocsTestSupport; import java.util.List; import org.apache.http.HttpHeaders; import org.junit.jupiter.api.BeforeEach; @@ -27,7 +28,7 @@ import org.springframework.test.context.jdbc.Sql; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -@Sql({"/truncate.sql", "/channel.sql", "/channel-subscription.sql"}) +@Sql({"/channel.sql", "/channel-subscription.sql"}) class ChannelSubscriptionControllerTest extends RestDocsTestSupport { @MockBean diff --git a/backend/src/test/java/com/pickpick/config/DatabaseCleaner.java b/backend/src/test/java/com/pickpick/config/DatabaseCleaner.java new file mode 100644 index 00000000..3e769de4 --- /dev/null +++ b/backend/src/test/java/com/pickpick/config/DatabaseCleaner.java @@ -0,0 +1,66 @@ +package com.pickpick.config; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import org.hibernate.Session; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.stereotype.Component; + +@Component +public class DatabaseCleaner implements InitializingBean { + + @PersistenceContext + private EntityManager entityManager; + private List tableNames; + + @Override + public void afterPropertiesSet() { + try (Session session = entityManager.unwrap(Session.class)) { + session.doWork(this::extractTableNames); + } + } + + private void extractTableNames(Connection connection) throws SQLException { + List tableNames = new ArrayList<>(); + + ResultSet tables = connection + .getMetaData() + .getTables(connection.getCatalog(), "PUBLIC", "%", new String[]{"TABLE"}); + + try (tables) { + while (tables.next()) { + tableNames.add(tables.getString("table_name")); + } + + this.tableNames = tableNames; + } + } + + public void clear() { + try (Session session = entityManager.unwrap(Session.class)) { + session.doWork(this::cleanUpDatabase); + } + } + + private void cleanUpDatabase(Connection conn) throws SQLException { + try (Statement statement = conn.createStatement()) { + + statement.executeUpdate("SET REFERENTIAL_INTEGRITY FALSE"); + + for (String tableName : tableNames) { + + statement.executeUpdate("TRUNCATE TABLE " + tableName); + statement + .executeUpdate("ALTER TABLE " + tableName + " ALTER COLUMN id RESTART WITH 1"); + } + + statement.executeUpdate("SET REFERENTIAL_INTEGRITY TRUE"); + } + } +} diff --git a/backend/src/test/java/com/pickpick/controller/RestDocsConfiguration.java b/backend/src/test/java/com/pickpick/config/RestDocsConfiguration.java similarity index 91% rename from backend/src/test/java/com/pickpick/controller/RestDocsConfiguration.java rename to backend/src/test/java/com/pickpick/config/RestDocsConfiguration.java index 97abf865..6514fdbf 100644 --- a/backend/src/test/java/com/pickpick/controller/RestDocsConfiguration.java +++ b/backend/src/test/java/com/pickpick/config/RestDocsConfiguration.java @@ -1,4 +1,4 @@ -package com.pickpick.controller; +package com.pickpick.config; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; @@ -7,7 +7,7 @@ import org.springframework.restdocs.operation.preprocess.Preprocessors; @TestConfiguration -class RestDocsConfiguration { +public class RestDocsConfiguration { @Bean public RestDocumentationResultHandler write() { diff --git a/backend/src/test/java/com/pickpick/controller/RestDocsTestSupport.java b/backend/src/test/java/com/pickpick/config/RestDocsTestSupport.java similarity index 80% rename from backend/src/test/java/com/pickpick/controller/RestDocsTestSupport.java rename to backend/src/test/java/com/pickpick/config/RestDocsTestSupport.java index 7cb6fee2..34264cd2 100644 --- a/backend/src/test/java/com/pickpick/controller/RestDocsTestSupport.java +++ b/backend/src/test/java/com/pickpick/config/RestDocsTestSupport.java @@ -1,16 +1,13 @@ -package com.pickpick.controller; +package com.pickpick.config; import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; -import org.springframework.core.io.ResourceLoader; import org.springframework.restdocs.RestDocumentationContextProvider; import org.springframework.restdocs.RestDocumentationExtension; import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; @@ -24,7 +21,7 @@ @AutoConfigureMockMvc @ExtendWith(RestDocumentationExtension.class) @Import(RestDocsConfiguration.class) -class RestDocsTestSupport { +public class RestDocsTestSupport { @Autowired protected MockMvc mockMvc; @@ -33,13 +30,13 @@ class RestDocsTestSupport { protected RestDocumentationResultHandler restDocs; @Autowired - private ResourceLoader resourceLoader; + protected ObjectMapper objectMapper; @Autowired - protected ObjectMapper objectMapper; + private DatabaseCleaner databaseCleaner; @BeforeEach - void setUp( + public void setUp( final WebApplicationContext context, final RestDocumentationContextProvider provider ) { @@ -50,7 +47,8 @@ void setUp( .build(); } - protected static String readJson(final String path) throws IOException { - return new String(Files.readAllBytes(Paths.get("src/test/resources", path))); + @AfterEach + void clear() { + databaseCleaner.clear(); } } diff --git a/backend/src/test/java/com/pickpick/member/domain/MemberTest.java b/backend/src/test/java/com/pickpick/member/domain/MemberTest.java index 602ec874..73300853 100644 --- a/backend/src/test/java/com/pickpick/member/domain/MemberTest.java +++ b/backend/src/test/java/com/pickpick/member/domain/MemberTest.java @@ -2,8 +2,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.pickpick.exception.MemberInvalidThumbnailUrlException; -import com.pickpick.exception.MemberInvalidUsernameException; +import com.pickpick.exception.member.MemberInvalidThumbnailUrlException; +import com.pickpick.exception.member.MemberInvalidUsernameException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullAndEmptySource; diff --git a/backend/src/test/java/com/pickpick/message/application/BookmarkServiceTest.java b/backend/src/test/java/com/pickpick/message/application/BookmarkServiceTest.java index f3a69ff2..e4317e94 100644 --- a/backend/src/test/java/com/pickpick/message/application/BookmarkServiceTest.java +++ b/backend/src/test/java/com/pickpick/message/application/BookmarkServiceTest.java @@ -6,7 +6,7 @@ import com.pickpick.channel.domain.Channel; import com.pickpick.channel.domain.ChannelRepository; -import com.pickpick.exception.BookmarkDeleteFailureException; +import com.pickpick.exception.message.BookmarkDeleteFailureException; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; import com.pickpick.message.domain.Bookmark; @@ -16,6 +16,7 @@ import com.pickpick.message.ui.dto.BookmarkRequest; import com.pickpick.message.ui.dto.BookmarkResponse; import com.pickpick.message.ui.dto.BookmarkResponses; +import com.pickpick.message.ui.dto.BookmarkFindRequest; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -49,16 +50,23 @@ class BookmarkServiceTest { @Autowired private BookmarkRepository bookmarks; + private static Stream parameterProvider() { + return Stream.of( + Arguments.arguments("멤버 ID 2번으로 북마크를 조회한다", null, 2L, List.of(1L), true), + Arguments.arguments("멤버 ID가 1번이고 북마크 id 23번일 때 북마크 목록을 조회한다", 23L, 1L, + List.of(22L, 21L, 20L, 19L, 18L, 17L, 16L, 15L, 14L, 13L, 12L, 11L, 10L, 9L, 8L, 7L, 6L, 5L, 4L, + 3L), false), + Arguments.arguments("북마크 조회 시 가장 오래된 북마크가 포함된다면 isLast가 true이다", null, 2L, List.of(1L), true) + ); + } + @DisplayName("북마크를 생성한다") @Test void save() { // given - Member member = new Member("U1234", "사용자", "user.png"); - members.save(member); - Channel channel = new Channel("C1234", "기본채널"); - channels.save(channel); - Message message = new Message("M1234", "메시지", member, channel, LocalDateTime.now(), LocalDateTime.now()); - messages.save(message); + Member member = members.save(new Member("U1234", "사용자", "user.png")); + Channel channel = channels.save(new Channel("C1234", "기본채널")); + Message message = messages.save(new Message("M1234", "메시지", member, channel, LocalDateTime.now(), LocalDateTime.now())); BookmarkRequest bookmarkRequest = new BookmarkRequest(message.getId()); int beforeSize = findBookmarksSize(member); @@ -72,7 +80,7 @@ void save() { } private int findBookmarksSize(final Member member) { - return bookmarkService.find(null, member.getId()).getBookmarks().size(); + return bookmarkService.find(new BookmarkFindRequest(null, null), member.getId()).getBookmarks().size(); } @DisplayName("북마크 조회") @@ -81,7 +89,7 @@ private int findBookmarksSize(final Member member) { void findBookmarks(final String subscription, final Long bookmarkId, final Long memberId, final List expectedIds, final boolean expectedIsLast) { // given & when - BookmarkResponses response = bookmarkService.find(bookmarkId, memberId); + BookmarkResponses response = bookmarkService.find(new BookmarkFindRequest(bookmarkId, null), memberId); // then List ids = convertToIds(response); @@ -91,16 +99,6 @@ void findBookmarks(final String subscription, final Long bookmarkId, final Long ); } - private static Stream parameterProvider() { - return Stream.of( - Arguments.arguments("멤버 ID 2번으로 북마크를 조회한다", null, 2L, List.of(1L), true), - Arguments.arguments("멤버 ID가 1번이고 북마크 id 23번일 때 북마크 목록을 조회한다", 23L, 1L, - List.of(22L, 21L, 20L, 19L, 18L, 17L, 16L, 15L, 14L, 13L, 12L, 11L, 10L, 9L, 8L, 7L, 6L, 5L, 4L, - 3L), false), - Arguments.arguments("북마크 조회 시 가장 오래된 북마크가 포함된다면 isLast가 true이다", null, 2L, List.of(1L), true) - ); - } - private List convertToIds(final BookmarkResponses response) { return response.getBookmarks() .stream() @@ -112,17 +110,13 @@ private List convertToIds(final BookmarkResponses response) { @Test void delete() { // given - Member member = new Member("U1234", "사용자", "user.png"); - members.save(member); - Channel channel = new Channel("C1234", "기본채널"); - channels.save(channel); - Message message = new Message("M1234", "메시지", member, channel, LocalDateTime.now(), LocalDateTime.now()); - messages.save(message); - Bookmark bookmark = new Bookmark(member, message); - bookmarks.save(bookmark); + Member member = members.save(new Member("U1234", "사용자", "user.png")); + Channel channel = channels.save(new Channel("C1234", "기본채널")); + Message message = messages.save(new Message("M1234", "메시지", member, channel, LocalDateTime.now(), LocalDateTime.now())); + Bookmark bookmark = bookmarks.save(new Bookmark(member, message)); // when - bookmarkService.delete(bookmark.getId(), member.getId()); + bookmarkService.delete(message.getId(), member.getId()); // then Optional actual = bookmarks.findById(bookmark.getId()); @@ -133,14 +127,10 @@ void delete() { @Test void deleteOtherMembers() { // given - Member owner = new Member("U1234", "사용자", "user.png"); - members.save(owner); - Member other = new Member("U1235", "다른 사용자", "user.png"); - members.save(other); - Channel channel = new Channel("C1234", "기본채널"); - channels.save(channel); - Message message = new Message("M1234", "메시지", owner, channel, LocalDateTime.now(), LocalDateTime.now()); - messages.save(message); + Member owner = members.save(new Member("U1234", "사용자", "user.png")); + Member other = members.save(new Member("U1235", "다른 사용자", "user.png")); + Channel channel = channels.save(new Channel("C1234", "기본채널")); + Message message = messages.save(new Message("M1234", "메시지", owner, channel, LocalDateTime.now(), LocalDateTime.now())); Bookmark bookmark = new Bookmark(owner, message); bookmarks.save(bookmark); diff --git a/backend/src/test/java/com/pickpick/message/MessageServiceTest.java b/backend/src/test/java/com/pickpick/message/application/MessageServiceTest.java similarity index 56% rename from backend/src/test/java/com/pickpick/message/MessageServiceTest.java rename to backend/src/test/java/com/pickpick/message/application/MessageServiceTest.java index 9a8d1318..ca7d21b3 100644 --- a/backend/src/test/java/com/pickpick/message/MessageServiceTest.java +++ b/backend/src/test/java/com/pickpick/message/application/MessageServiceTest.java @@ -1,12 +1,15 @@ -package com.pickpick.message; +package com.pickpick.message.application; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; -import com.pickpick.message.application.MessageService; import com.pickpick.message.ui.dto.MessageRequest; import com.pickpick.message.ui.dto.MessageResponse; import com.pickpick.message.ui.dto.MessageResponses; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; import java.util.Collections; import java.util.Comparator; import java.util.List; @@ -18,25 +21,44 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.TestConstructor; -import org.springframework.test.context.TestConstructor.AutowireMode; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.jdbc.Sql; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @Sql({"/truncate.sql", "/message.sql"}) -@TestConstructor(autowireMode = AutowireMode.ALL) @Transactional @SpringBootTest class MessageServiceTest { private static final long MEMBER_ID = 1L; - private final MessageService messageService; + @Autowired + private MessageService messageService; - public MessageServiceTest(final MessageService messageService) { - this.messageService = messageService; + @SpyBean + private Clock clock; + + @DisplayName("메시지 조회 요청에 따른 메시지가 응답된다") + @MethodSource("slackMessageRequest") + @ParameterizedTest(name = "{0}") + void findMessages( + final String description, final MessageRequest messageRequest, + final List expectedMessageIds, final boolean expectedLast) { + // given + MessageResponses messageResponses = messageService.find(MEMBER_ID, messageRequest); + + // when + List messages = messageResponses.getMessages(); + boolean last = messageResponses.isLast(); + + // then + assertAll( + () -> assertThat(messages).extracting("id").isEqualTo(expectedMessageIds), + () -> assertThat(last).isEqualTo(expectedLast) + ); } private static Stream slackMessageRequest() { @@ -66,39 +88,90 @@ private static List createExpectedMessageIds(final long startInclusive, fi .collect(Collectors.toList()); } - @DisplayName("메시지 조회 요청에 따른 메시지가 응답된다") - @MethodSource("slackMessageRequest") - @ParameterizedTest(name = "{0}") - void findMessages( - final String description, final MessageRequest messageRequest, - final List expectedMessageIds, final boolean expectedLast) { + @DisplayName("메시지 조회 시, 텍스트가 비어있는 메시지는 필터링된다") + @Test + void emptyMessagesShouldBeFiltered() { // given - MessageResponses messageResponses = messageService.find(MEMBER_ID, messageRequest); + MessageRequest messageRequest = new MessageRequest("", "", List.of(5L), true, null, 200); // when + MessageResponses messageResponses = messageService.find(MEMBER_ID, messageRequest); List messages = messageResponses.getMessages(); - boolean last = messageResponses.isLast(); + boolean hasEmptyMessageResponse = messages.stream() + .anyMatch(message -> !StringUtils.hasText(message.getText())); // then - assertAll( - () -> assertThat(messages).extracting("id").isEqualTo(expectedMessageIds), - () -> assertThat(last).isEqualTo(expectedLast) + assertThat(hasEmptyMessageResponse).isFalse(); + } + + @DisplayName("메시지 조회 시 리마인더 여부 함께 조회된다") + @MethodSource("messageRequestWithReminder") + @ParameterizedTest(name = "{0}") + void findSetRemindedMessage(final String description, final String nowDate, final MessageRequest messageRequest, + final boolean expected) { + // given + given(clock.instant()) + .willReturn(Instant.parse(nowDate)); + + // when + MessageResponse message = messageService.find(MEMBER_ID, messageRequest) + .getMessages() + .get(0); + + // then + assertThat(message.isSetReminded()).isEqualTo(expected); + } + + private static Stream messageRequestWithReminder() { + return Stream.of( + Arguments.of("현재 시간보다 오래된 리마인더가 존재하면 isSetReminded가 false이다", + "2022-08-13T00:00:00Z", + new MessageRequest("", "", List.of(5L), true, null, 1), + false), + Arguments.of("현재 시간보다 최신인 리마인더가 존재하면 isSetReminded가 true이다", + "2022-08-10T00:00:00Z", + new MessageRequest("", "", List.of(5L), true, null, 1), + true), + Arguments.of("현재 시간과 동일한 리마인더가 존재하면 isSetReminded가 false이다", + "2022-08-12T14:20:00Z", + new MessageRequest("", "", List.of(5L), true, null, 1), + false) ); } - @DisplayName("메시지 조회 시, 텍스트가 비어있는 메시지는 필터링된다") + @DisplayName("메시지 조회 시, remindDate가 함께 전달된다") @Test - void emptyMessagesShouldBeFiltered() { + void checkRemindDate2() { // given - MessageRequest messageRequest = new MessageRequest("", "", List.of(5L), true, null, 200); + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + MessageRequest request = new MessageRequest("", "", List.of(5L), true, null, 1); // when - MessageResponses messageResponses = messageService.find(MEMBER_ID, messageRequest); - List messages = messageResponses.getMessages(); - boolean hasEmptyMessageResponse = messages.stream() - .anyMatch(message -> !StringUtils.hasText(message.getText())); + MessageResponse message = messageService.find(MEMBER_ID, request) + .getMessages() + .get(0); // then - assertThat(hasEmptyMessageResponse).isFalse(); + assertThat(message.getRemindDate()).isEqualTo(LocalDateTime.of(2022, 8, 12, 14, 20, 0)); + } + + @DisplayName("메시지 조회 시, remindDate 값이 없으면 빈 값으로 전달된다") + @Test + void checkRemindDate() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-12T14:20:00Z")); + + MessageRequest request = new MessageRequest("", "", List.of(5L), true, null, 1); + + // when + MessageResponse message = messageService.find(MEMBER_ID, request) + .getMessages() + .get(0); + + // then + assertThat(message.getRemindDate()).isNull(); } } diff --git a/backend/src/test/java/com/pickpick/message/application/ReminderServiceTest.java b/backend/src/test/java/com/pickpick/message/application/ReminderServiceTest.java new file mode 100644 index 00000000..c0c3b349 --- /dev/null +++ b/backend/src/test/java/com/pickpick/message/application/ReminderServiceTest.java @@ -0,0 +1,346 @@ +package com.pickpick.message.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; + +import com.pickpick.channel.domain.Channel; +import com.pickpick.channel.domain.ChannelRepository; +import com.pickpick.exception.message.ReminderDeleteFailureException; +import com.pickpick.exception.message.ReminderNotFoundException; +import com.pickpick.exception.message.ReminderUpdateFailureException; +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import com.pickpick.message.domain.Message; +import com.pickpick.message.domain.MessageRepository; +import com.pickpick.message.domain.Reminder; +import com.pickpick.message.domain.ReminderRepository; +import com.pickpick.message.ui.dto.ReminderFindRequest; +import com.pickpick.message.ui.dto.ReminderResponse; +import com.pickpick.message.ui.dto.ReminderResponses; +import com.pickpick.message.ui.dto.ReminderSaveRequest; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.jdbc.Sql; + +@Sql({"/truncate.sql", "/reminder.sql"}) +@Transactional +@SpringBootTest +class ReminderServiceTest { + + @Autowired + private ReminderService reminderService; + + @Autowired + private MemberRepository members; + + @Autowired + private MessageRepository messages; + + @Autowired + private ChannelRepository channels; + + @Autowired + private ReminderRepository reminders; + + @SpyBean + private Clock clock; + + private static Stream parameterProvider() { + return Stream.of( + Arguments.arguments("멤버 ID 2번으로 리마인더를 조회한다", null, 2L, List.of(1L), true), + Arguments.arguments("멤버 ID가 1번이고 리마인더 id 10번일 때 리마인더 목록을 조회한다", 10L, 1L, + List.of(11L, 12L, 13L, 14L, 15L, 16L, 17L, 18L, 19L, 20L, 21L, 22L, 23L), true), + Arguments.arguments("리마인더 조회 시 가장 최신인 리마인더가 포함된다면 isLast가 true이다", null, 2L, List.of(1L), true), + Arguments.arguments("리마인더 조회 시 가장 최신인 리마인더가 포함되지 않는다면 isLast가 false이다", 2L, 1L, + List.of(3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L, 11L, 12L, 13L, 14L, 15L, 16L, 17L, 18L, 19L, 20L, 21L, + 22L), false) + ); + } + + @DisplayName("리마인더를 생성한다") + @Sql("/truncate.sql") + @Test + void save() { + // given + Member member = members.save(new Member("U1234", "사용자", "user.png")); + Channel channel = channels.save(new Channel("C1234", "기본채널")); + Message message = messages.save( + new Message("M1234", "메시지", member, channel, LocalDateTime.now(), LocalDateTime.now())); + + ReminderSaveRequest request = new ReminderSaveRequest(message.getId(), LocalDateTime.now().plusDays(1)); + int beforeSize = findReminderSize(member); + + // when + reminderService.save(member.getId(), request); + + // then + int afterSize = findReminderSize(member); + assertThat(beforeSize + 1).isEqualTo(afterSize); + } + + private int findReminderSize(final Member member) { + return reminderService.find(new ReminderFindRequest(null, null), member.getId()).getReminders().size(); + } + + @DisplayName("리마인더 단건 조회") + @Test + void findOneReminder() { + // given + Long memberId = 2L; + Long messageId = 1L; + + // when + ReminderResponse reminder = reminderService.findOne(messageId, memberId); + + // then + assertAll( + () -> assertThat(reminder.getId()).isEqualTo(1L), + () -> assertThat(reminder.getRemindDate()).isEqualTo(LocalDateTime.of(2022, 8, 12, 14, 20)) + ); + } + + @DisplayName("리마인더가 존재하지 않는 메시지를 단건 조회할 경우 예외 발생") + @Test + void findNotExistOneThenThrowException() { + // given + Long memberId = 2L; + Long messageId = 20L; + + // when & then + assertThatThrownBy(() -> reminderService.findOne(messageId, memberId)) + .isInstanceOf(ReminderNotFoundException.class); + } + + @DisplayName("리마인더 목록 조회") + @ParameterizedTest(name = "{0}") + @MethodSource("parameterProvider") + void findReminders(final String subscription, final Long reminderId, final Long memberId, + final List expectedIds, final boolean expectedIsLast) { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + // when + ReminderResponses response = reminderService.find(new ReminderFindRequest(reminderId, null), memberId); + + // then + List ids = convertToIds(response); + assertAll( + () -> assertThat(ids).containsExactlyElementsOf(expectedIds), + () -> assertThat(response.isLast()).isEqualTo(expectedIsLast) + ); + } + + private List convertToIds(final ReminderResponses response) { + return response.getReminders() + .stream() + .map(ReminderResponse::getId) + .collect(Collectors.toList()); + } + + @DisplayName("리마인더 조회 시 count가 없으면 default 값을 20으로 세팅") + @Test + void findRemindersByDefaultCount() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + // when + ReminderResponses response = reminderService.find(new ReminderFindRequest(null, null), 1L); + + // then + int size = response.getReminders().size(); + assertThat(size).isEqualTo(20); + } + + @DisplayName("리마인더 조회 시 count 값이 10이면 10개 조회") + @Test + void findRemindersByCount() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + int count = 10; + + // when + ReminderResponses response = reminderService.find(new ReminderFindRequest(null, count), 1L); + + // then + int size = response.getReminders().size(); + assertThat(size).isEqualTo(count); + } + + @DisplayName("리마인더 조회 해당 날의 reminder개수와 count가 동일한 경우 미래의 리마인더가 존재하면 isLast가 false 이다.") + @Test + void findSameDayReminder() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2023-07-07T15:20:00Z")); + int count = 2; + + // when + ReminderResponses response = reminderService.find(new ReminderFindRequest(null, count), 3L); + + // then + int size = response.getReminders().size(); + assertAll( + () -> assertThat(size).isEqualTo(count), + () -> assertThat(response.isLast()).isFalse(), + () -> assertThat(response.getReminders()).extracting("id") + .containsExactly(29L, 30L) + ); + } + + @DisplayName("리마인더 조회 해당 날의 reminder의 개수보다 적은 COUNT로 조회할 경우 isLast가 false이다.") + @Test + void findSameDayReminderByCount() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2023-08-07T15:20:00Z")); + int count = 2; + + // when + ReminderResponses response = reminderService.find(new ReminderFindRequest(null, count), 3L); + + // then + int size = response.getReminders().size(); + assertAll( + () -> assertThat(size).isEqualTo(count), + () -> assertThat(response.isLast()).isFalse(), + () -> assertThat(response.getReminders()).extracting("id") + .containsExactly(25L, 26L) + ); + } + + @DisplayName("리마인더 조회 해당 날의 reminder개수와 count가 동일한 경우 미래의 리마인더가 존재하지 않으면 isLast가 true 이다.") + @Test + void findSameDayReminderByCountAndReminderId() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2023-08-07T15:20:00Z")); + int count = 2; + + // when + ReminderResponses response = reminderService.find(new ReminderFindRequest(26L, count), 3L); + + // then + int size = response.getReminders().size(); + assertAll( + () -> assertThat(size).isEqualTo(count), + () -> assertThat(response.isLast()).isTrue(), + () -> assertThat(response.getReminders()).extracting("id") + .containsExactly(27L, 28L) + ); + } + + @DisplayName("리마인더가 날짜 + ID 순으로 올바르게 조회되는지 확인한다") + @Test + void findRemindersOrderByDateAndId() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2023-06-07T15:20:00Z")); + int count = 7; + + // when + ReminderResponses response = reminderService.find(new ReminderFindRequest(null, count), 3L); + + // then + int size = response.getReminders().size(); + assertAll( + () -> assertThat(size).isEqualTo(count), + () -> assertThat(response.isLast()).isTrue(), + () -> assertThat(response.getReminders()).extracting("id") + .containsExactly(31L, 29L, 30L, 25L, 26L, 27L, 28L) + ); + } + + + @DisplayName("오늘 날짜보다 더 오래된 날짜에 리마인드한 내역은 조회되지 않는다.") + @Test + void findWithoutOldRemindDate() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + // when + ReminderResponses response = reminderService.find(new ReminderFindRequest(null, null), 1L); + + // then + List ids = convertToIds(response); + assertAll( + () -> assertThat(ids).doesNotContainAnyElementsOf(List.of(24L)), + () -> assertThat(response.isLast()).isFalse() + ); + } + + @DisplayName("리마인더 수정") + @Test + void update() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + LocalDateTime updateTime = LocalDateTime.now(clock).plusDays(1); + long memberId = 1L; + long messageId = 2L; + + // when + reminderService.update(memberId, new ReminderSaveRequest(messageId, updateTime)); + + // then + Optional expected = reminders.findByMessageIdAndMemberId(messageId, memberId); + + assertAll( + () -> assertThat(expected).isPresent(), + () -> assertThat(expected.get().getRemindDate()).isEqualTo(updateTime) + ); + } + + @DisplayName("다른 사용자의 리마인더 수정시 예외") + @Test + void updateOtherMembers() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + ReminderSaveRequest request = new ReminderSaveRequest(1L, LocalDateTime.now(clock).plusDays(1)); + + // when & then + assertThatThrownBy(() -> reminderService.update(1L, request)) + .isInstanceOf(ReminderUpdateFailureException.class); + } + + @DisplayName("리마인더 삭제") + @Test + void delete() { + // given & when + reminderService.delete(1L, 2L); + + // then + Optional actual = reminders.findById(1L); + assertThat(actual).isEmpty(); + } + + @DisplayName("다른 사용자의 리마인더 삭제시 예외") + @Test + void deleteOtherMembers() { + // given & when & then + assertThatThrownBy(() -> reminderService.delete(1L, 1L)) + .isInstanceOf(ReminderDeleteFailureException.class); + } +} diff --git a/backend/src/test/java/com/pickpick/message/ui/BookmarkControllerTest.java b/backend/src/test/java/com/pickpick/message/ui/BookmarkControllerTest.java new file mode 100644 index 00000000..a850f990 --- /dev/null +++ b/backend/src/test/java/com/pickpick/message/ui/BookmarkControllerTest.java @@ -0,0 +1,120 @@ +package com.pickpick.message.ui; + +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.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.pickpick.auth.support.JwtTokenProvider; +import com.pickpick.config.RestDocsTestSupport; +import com.pickpick.message.ui.dto.BookmarkRequest; +import org.apache.http.HttpHeaders; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +@Sql({"/bookmark.sql"}) +public class BookmarkControllerTest extends RestDocsTestSupport { + + private static final String BOOKMARK_API_URL = "/api/bookmarks"; + + @MockBean + private JwtTokenProvider jwtTokenProvider; + + @BeforeEach + void setup() { + given(jwtTokenProvider.getPayload(any(String.class))) + .willReturn("1"); + } + + @DisplayName("북마크를 조회한다") + @Test + void find() throws Exception { + mockMvc.perform(MockMvcRequestBuilders + .get(BOOKMARK_API_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer 1") + ) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)") + ), + responseFields( + fieldWithPath("bookmarks.[].id").type(JsonFieldType.NUMBER) + .description("북마크 아이디"), + fieldWithPath("bookmarks.[].messageId").type(JsonFieldType.NUMBER) + .description("메시지 아이디"), + fieldWithPath("bookmarks.[].memberId").type(JsonFieldType.NUMBER) + .description("유저 아이디"), + fieldWithPath("bookmarks.[].username").type(JsonFieldType.STRING) + .description("유저 이름"), + fieldWithPath("bookmarks.[].userThumbnail").type(JsonFieldType.STRING) + .description("유저 프로필 사진"), + fieldWithPath("bookmarks.[].text").type(JsonFieldType.STRING) + .description("메시지 내용"), + fieldWithPath("bookmarks.[].postedDate").type(JsonFieldType.STRING) + .description("메시지 게시 날짜"), + fieldWithPath("bookmarks.[].modifiedDate").type(JsonFieldType.STRING) + .description("메시지 수정 날짜"), + fieldWithPath("isLast").type(JsonFieldType.BOOLEAN).description("마지막 메시지 여부") + ) + )); + } + + @DisplayName("북마크를 추가한다") + @Test + void save() throws Exception { + String body = objectMapper.writeValueAsString( + new BookmarkRequest(1L) + ); + mockMvc.perform(MockMvcRequestBuilders + .post(BOOKMARK_API_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer 1") + .content(body) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isCreated()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)") + ), + requestFields( + fieldWithPath("messageId").type(JsonFieldType.NUMBER).description("북마크할 메세지 아이디") + ) + )); + } + + @DisplayName("북마크를 삭제한다") + @Test + void delete() throws Exception { + MultiValueMap requestParams = new LinkedMultiValueMap<>(); + requestParams.set("messageId", "2"); + mockMvc.perform(MockMvcRequestBuilders + .delete(BOOKMARK_API_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer 1") + .params(requestParams) + ) + .andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)") + ), + requestParameters( + parameterWithName("messageId").description("북마크 삭제할 메세지 아이디") + ) + )); + } +} diff --git a/backend/src/test/java/com/pickpick/controller/MessageControllerTest.java b/backend/src/test/java/com/pickpick/message/ui/MessageControllerTest.java similarity index 91% rename from backend/src/test/java/com/pickpick/controller/MessageControllerTest.java rename to backend/src/test/java/com/pickpick/message/ui/MessageControllerTest.java index 5185988c..2e220a32 100644 --- a/backend/src/test/java/com/pickpick/controller/MessageControllerTest.java +++ b/backend/src/test/java/com/pickpick/message/ui/MessageControllerTest.java @@ -1,4 +1,4 @@ -package com.pickpick.controller; +package com.pickpick.message.ui; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @@ -11,6 +11,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.pickpick.auth.support.JwtTokenProvider; +import com.pickpick.config.RestDocsTestSupport; import org.apache.http.HttpHeaders; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -23,7 +24,7 @@ import org.springframework.util.MultiValueMap; -@Sql({"/truncate.sql", "/message.sql"}) +@Sql({"/message.sql"}) class MessageControllerTest extends RestDocsTestSupport { @MockBean @@ -81,6 +82,9 @@ void findAllMessageWithCondition() throws Exception { .description("메시지 수정 날짜"), fieldWithPath("messages.[].isBookmarked").type(JsonFieldType.BOOLEAN) .description("북마크 여부"), + fieldWithPath("messages.[].isSetReminded").type(JsonFieldType.BOOLEAN) + .description("리마인더 등록 여부"), + fieldWithPath("messages.[].remindDate").type(JsonFieldType.STRING).optional().description("리마인더 등록된 날짜"), fieldWithPath("isLast").type(JsonFieldType.BOOLEAN).description("마지막 메시지 여부"), fieldWithPath("isNeedPastMessage").type(JsonFieldType.BOOLEAN) .description("위/아래 스크롤 방향") diff --git a/backend/src/test/java/com/pickpick/message/ui/ReminderControllerTest.java b/backend/src/test/java/com/pickpick/message/ui/ReminderControllerTest.java new file mode 100644 index 00000000..eb112d73 --- /dev/null +++ b/backend/src/test/java/com/pickpick/message/ui/ReminderControllerTest.java @@ -0,0 +1,202 @@ +package com.pickpick.message.ui; + +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.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.pickpick.auth.support.JwtTokenProvider; +import com.pickpick.config.RestDocsTestSupport; +import com.pickpick.message.ui.dto.ReminderSaveRequest; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import org.apache.http.HttpHeaders; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +@Sql({"/reminder.sql"}) +public class ReminderControllerTest extends RestDocsTestSupport { + + private static final String REMINDER_API_URL = "/api/reminders"; + + @SpyBean + private Clock clock; + + @MockBean + private JwtTokenProvider jwtTokenProvider; + + @BeforeEach + void setup() { + given(jwtTokenProvider.getPayload(any(String.class))) + .willReturn("1"); + } + + @DisplayName("리마인더를 추가한다") + @Test + void save() throws Exception { + String body = objectMapper.writeValueAsString( + new ReminderSaveRequest(1L, LocalDateTime.now().plusDays(2)) + ); + mockMvc.perform(MockMvcRequestBuilders + .post(REMINDER_API_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer 1") + .content(body) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isCreated()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)") + ), + requestFields( + fieldWithPath("messageId").type(JsonFieldType.NUMBER).description("리마인드할 메세지 아이디"), + fieldWithPath("reminderDate").type(JsonFieldType.STRING).description("리마인드할 날짜") + ) + )); + } + + @DisplayName("리마인더를 단건 조회한다") + @Test + void findOne() throws Exception { + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + MultiValueMap requestParams = new LinkedMultiValueMap<>(); + requestParams.set("messageId", "2"); + + mockMvc.perform(MockMvcRequestBuilders + .get(REMINDER_API_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer 1") + .params(requestParams) + ) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)") + ), + requestParameters( + parameterWithName("messageId").optional().description("메시지 아이디") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER) + .description("리마인더 아이디"), + fieldWithPath("messageId").type(JsonFieldType.NUMBER) + .description("메시지 아이디"), + fieldWithPath("username").type(JsonFieldType.STRING) + .description("유저 이름"), + fieldWithPath("userThumbnail").type(JsonFieldType.STRING) + .description("유저 프로필 사진"), + fieldWithPath("text").type(JsonFieldType.STRING) + .description("메시지 내용"), + fieldWithPath("postedDate").type(JsonFieldType.STRING) + .description("메시지 게시 날짜"), + fieldWithPath("modifiedDate").type(JsonFieldType.STRING) + .description("메시지 수정 날짜"), + fieldWithPath("remindDate").type(JsonFieldType.STRING) + .description("리마인드 날짜") + ) + )); + } + + @DisplayName("리마인더 목록을 조회한다") + @Test + void find() throws Exception { + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + mockMvc.perform(MockMvcRequestBuilders + .get(REMINDER_API_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer 1") + ) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)") + ), + requestParameters( + parameterWithName("reminderId").optional().description("리마인더 아이디") + ), + responseFields( + fieldWithPath("reminders.[].id").type(JsonFieldType.NUMBER) + .description("리마인더 아이디"), + fieldWithPath("reminders.[].messageId").type(JsonFieldType.NUMBER) + .description("메시지 아이디"), + fieldWithPath("reminders.[].username").type(JsonFieldType.STRING) + .description("유저 이름"), + fieldWithPath("reminders.[].userThumbnail").type(JsonFieldType.STRING) + .description("유저 프로필 사진"), + fieldWithPath("reminders.[].text").type(JsonFieldType.STRING) + .description("메시지 내용"), + fieldWithPath("reminders.[].postedDate").type(JsonFieldType.STRING) + .description("메시지 게시 날짜"), + fieldWithPath("reminders.[].modifiedDate").type(JsonFieldType.STRING) + .description("메시지 수정 날짜"), + fieldWithPath("reminders.[].remindDate").type(JsonFieldType.STRING) + .description("리마인드 날짜"), + fieldWithPath("isLast").type(JsonFieldType.BOOLEAN).description("마지막 리마인더 메시지 여부") + ) + )); + } + + @DisplayName("리마인더를 수정한다") + @Test + void update() throws Exception { + String body = objectMapper.writeValueAsString( + new ReminderSaveRequest(2L, LocalDateTime.now().plusDays(2)) + ); + mockMvc.perform(MockMvcRequestBuilders + .put(REMINDER_API_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer 1") + .content(body) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)") + ), + requestFields( + fieldWithPath("messageId").type(JsonFieldType.NUMBER) + .description("수정 예정인 리마인더의 메세지 아이디"), + fieldWithPath("reminderDate").type(JsonFieldType.STRING).description("수정할 날짜") + ) + )); + } + + @DisplayName("리마인더를 삭제한다") + @Test + void delete() throws Exception { + MultiValueMap requestParams = new LinkedMultiValueMap<>(); + requestParams.set("messageId", "2"); + mockMvc.perform(MockMvcRequestBuilders + .delete(REMINDER_API_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer 1") + .params(requestParams) + ) + .andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)") + ), + requestParameters( + parameterWithName("messageId").description("삭제 예정인 리마인더의 메세지 아이디") + ) + )); + } +} diff --git a/backend/src/test/java/com/pickpick/slackevent/SlackEventTest.java b/backend/src/test/java/com/pickpick/slackevent/SlackEventTest.java index 97a9432e..168eb781 100644 --- a/backend/src/test/java/com/pickpick/slackevent/SlackEventTest.java +++ b/backend/src/test/java/com/pickpick/slackevent/SlackEventTest.java @@ -3,7 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.pickpick.exception.SlackEventNotFoundException; +import com.pickpick.exception.slackevent.SlackEventNotFoundException; import com.pickpick.slackevent.application.SlackEvent; import java.util.Map; import java.util.stream.Stream; @@ -22,16 +22,21 @@ private static Stream methodSource() { SlackEvent.MESSAGE_CHANGED), Arguments.of(Map.of("event", Map.of("type", "message", "subtype", "message_deleted")), SlackEvent.MESSAGE_DELETED), + Arguments.of(Map.of("event", Map.of("type", "message", "subtype", "thread_broadcast")), + SlackEvent.MESSAGE_THREAD_BROADCAST), + Arguments.of(Map.of("event", Map.of("type", "message", "subtype", "file_share")), + SlackEvent.MESSAGE_FILE_SHARE), Arguments.of(Map.of("event", Map.of("type", "channel_rename")), SlackEvent.CHANNEL_RENAME), Arguments.of(Map.of("event", Map.of("type", "channel_deleted")), SlackEvent.CHANNEL_DELETED), - Arguments.of(Map.of("event", Map.of("type", "user_profile_changed")), SlackEvent.MEMBER_CHANGED) + Arguments.of(Map.of("event", Map.of("type", "user_profile_changed")), SlackEvent.MEMBER_CHANGED), + Arguments.of(Map.of("event", Map.of("type", "team_join")), SlackEvent.MEMBER_JOIN) ); } @DisplayName("type과 subtype에 따라 SlackEvent 탐색") @ParameterizedTest @MethodSource("methodSource") - void findSlackEventByTypeAndSubtype(Map request, SlackEvent expected) { + void findSlackEventByTypeAndSubtype(final Map request, final SlackEvent expected) { // given & when SlackEvent actual = SlackEvent.of(request); diff --git a/backend/src/test/java/com/pickpick/slackevent/application/channel/ChannelDeletedServiceTest.java b/backend/src/test/java/com/pickpick/slackevent/application/channel/ChannelDeletedServiceTest.java index 19b8f5e4..56657564 100644 --- a/backend/src/test/java/com/pickpick/slackevent/application/channel/ChannelDeletedServiceTest.java +++ b/backend/src/test/java/com/pickpick/slackevent/application/channel/ChannelDeletedServiceTest.java @@ -33,6 +33,7 @@ class ChannelDeletedServiceTest { LocalDateTime.now(), LocalDateTime.now() ); + private static final Message SAMPLE_MESSAGE_2 = new Message( "bbbb1f84-8acf-46ab-b93d-85177cee3e99", "두번째 메시지 전송!", diff --git a/backend/src/test/java/com/pickpick/slackevent/application/channel/ChannelRenameServiceTest.java b/backend/src/test/java/com/pickpick/slackevent/application/channel/ChannelRenameServiceTest.java index d5fb3fb8..7547303e 100644 --- a/backend/src/test/java/com/pickpick/slackevent/application/channel/ChannelRenameServiceTest.java +++ b/backend/src/test/java/com/pickpick/slackevent/application/channel/ChannelRenameServiceTest.java @@ -5,7 +5,7 @@ import com.pickpick.channel.domain.Channel; import com.pickpick.channel.domain.ChannelRepository; -import com.pickpick.exception.ChannelNotFoundException; +import com.pickpick.exception.channel.ChannelNotFoundException; import java.util.Map; import javax.transaction.Transactional; import org.junit.jupiter.api.DisplayName; @@ -27,8 +27,7 @@ class ChannelRenameServiceTest { @Test void channelNameShouldBeChangedOnChannelRenameEvent() { // given - Channel channel = new Channel("slackId", "channelName"); - channels.save(channel); + Channel channel = channels.save(new Channel("slackId", "channelName")); String expectedChannelName = "변경된 채널 이름"; Map request = Map.of( diff --git a/backend/src/test/java/com/pickpick/slackevent/MemberChangedServiceTest.java b/backend/src/test/java/com/pickpick/slackevent/application/member/MemberChangedServiceTest.java similarity index 90% rename from backend/src/test/java/com/pickpick/slackevent/MemberChangedServiceTest.java rename to backend/src/test/java/com/pickpick/slackevent/application/member/MemberChangedServiceTest.java index efcf1b50..b68697f5 100644 --- a/backend/src/test/java/com/pickpick/slackevent/MemberChangedServiceTest.java +++ b/backend/src/test/java/com/pickpick/slackevent/application/member/MemberChangedServiceTest.java @@ -1,11 +1,10 @@ -package com.pickpick.slackevent; +package com.pickpick.slackevent.application.member; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; -import com.pickpick.slackevent.application.member.MemberChangedService; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -33,8 +32,7 @@ class MemberChangedServiceTest { @ParameterizedTest(name = "{1}이 들어오는 경우 {2}") void changedUsername(final String realName, final String displayName, final String expectedName) { // given - Member member = new Member(SLACK_ID, "사용자", "test.png"); - members.save(member); + Member member = members.save(new Member(SLACK_ID, "사용자", "test.png")); Map request = memberChangedEvent(realName, displayName, "test.png"); @@ -54,8 +52,7 @@ void changedUsername(final String realName, final String displayName, final Stri @Test void changedThumbnailUrl() { // given - Member member = new Member(SLACK_ID, "사용자", "test.png"); - members.save(member); + Member member = members.save(new Member(SLACK_ID, "사용자", "test.png")); String thumbnailUrl = "new_test.png"; Map request = memberChangedEvent("사용자", "표시 이름", thumbnailUrl); diff --git a/backend/src/test/java/com/pickpick/slackevent/application/member/MemberJoinServiceTest.java b/backend/src/test/java/com/pickpick/slackevent/application/member/MemberJoinServiceTest.java new file mode 100644 index 00000000..dcdbb626 --- /dev/null +++ b/backend/src/test/java/com/pickpick/slackevent/application/member/MemberJoinServiceTest.java @@ -0,0 +1,79 @@ +package com.pickpick.slackevent.application.member; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import com.pickpick.slackevent.application.SlackEvent; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +@DisplayName("MemberJoinService는") +@Import(MemberJoinService.class) +@DataJpaTest +class MemberJoinServiceTest { + + private static final String SLACK_ID = "U03MKN0UW"; + + @Autowired + private MemberJoinService memberJoinService; + + @Autowired + private MemberRepository members; + + @DisplayName("MEMBER_JOIN 타입에 대해서만 true를 반환한다") + @CsvSource(value = {"MEMBER_JOIN,true", + "MESSAGE_CREATED,false", "MESSAGE_CHANGED,false", "MESSAGE_DELETED,false", + "CHANNEL_RENAME,false", "CHANNEL_DELETED,false", "MEMBER_CHANGED,false"}) + @ParameterizedTest(name = "SlackEvent: {0} - Supports: {1}") + void supportsMemberJoinEvent(final SlackEvent slackEvent, final boolean expected) { + // given & when + boolean isSameSlackEvent = memberJoinService.isSameSlackEvent(slackEvent); + + // then + assertThat(isSameSlackEvent).isEqualTo(expected); + } + + @DisplayName("신규 멤버를 저장한다") + @CsvSource(value = {"김진짜, 표시 이름, 표시 이름", "김진짜, '', 김진짜"}) + @ParameterizedTest(name = "RealName: {0}, DisplayName: {1} -> ExpectedName: {2}") + void teamJoinEvent(final String realName, final String displayName, final String expectedName) { + // given + Optional memberBeforeSave = members.findBySlackId(SLACK_ID); + Map teamJoinEvent = createTeamJoinEvent(realName, displayName, expectedName); + + // when + memberJoinService.execute(teamJoinEvent); + Optional memberAfterSave = members.findBySlackId(SLACK_ID); + + // then + assertAll( + () -> assertThat(memberBeforeSave).isNotPresent(), + () -> assertThat(memberAfterSave).isPresent(), + () -> assertThat(memberAfterSave.get().getUsername()).isEqualTo(expectedName) + ); + } + + private Map createTeamJoinEvent(final String realName, final String displayName, + final String thumbnailUrl) { + return Map.of( + "event", Map.of( + "type", "team_join", + "user", Map.of( + "id", SLACK_ID, + "profile", Map.of( + "real_name", realName, + "display_name", displayName, + "image_512", thumbnailUrl + ) + ) + )); + } +} diff --git a/backend/src/test/java/com/pickpick/slackevent/application/message/MessageChangedServiceTest.java b/backend/src/test/java/com/pickpick/slackevent/application/message/MessageChangedServiceTest.java index 874803f2..8d85d13e 100644 --- a/backend/src/test/java/com/pickpick/slackevent/application/message/MessageChangedServiceTest.java +++ b/backend/src/test/java/com/pickpick/slackevent/application/message/MessageChangedServiceTest.java @@ -9,6 +9,7 @@ import com.pickpick.member.domain.MemberRepository; import com.pickpick.message.domain.Message; import com.pickpick.message.domain.MessageRepository; +import com.pickpick.slackevent.application.SlackEvent; import com.pickpick.utils.TimeUtils; import java.time.LocalDateTime; import java.util.List; @@ -24,9 +25,9 @@ @SpringBootTest class MessageChangedServiceTest { - private static final Member SAMPLE_MEMBER = new Member("U03MKN0UW", "사용자", "test.png"); - private static final Channel SAMPLE_CHANNEL = new Channel("ASDFB", "채널"); - private static final Message SAMPLE_MESSAGE = new Message( + private final Member SAMPLE_MEMBER = new Member("U03MKN0UW", "사용자", "test.png"); + private final Channel SAMPLE_CHANNEL = new Channel("ASDFB", "채널"); + private final Message SAMPLE_MESSAGE = new Message( "db8a1f84-8acf-46ab-b93d-85177cee3e97", "메시지 전송!", SAMPLE_MEMBER, @@ -70,6 +71,29 @@ void changedMessage() { ); } + @DisplayName("subtype이 메시지 수정 이벤트 발생이지만, message 내부에 thread_broadcast 타입이 있다면 메시지 저장") + @Test + void saveThreadBroadcastMessage() { + // given + members.saveAll(List.of(SAMPLE_MEMBER)); + channels.save(SAMPLE_CHANNEL); + Map request = messageThreadBroadcastEvent(); + Optional beforeSaveMessage = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + // when + messageChangedService.execute(request); + + // then + Optional afterSaveMessage = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + assertAll( + () -> assertThat(beforeSaveMessage).isEmpty(), + () -> assertThat(afterSaveMessage).isPresent(), + () -> assertThat(afterSaveMessage.get().getSlackId()).isEqualTo(SAMPLE_MESSAGE.getSlackId()), + () -> assertThat(afterSaveMessage.get().getChannel()).isEqualTo(SAMPLE_CHANNEL) + ); + } + private void saveMessage() { members.saveAll(List.of(SAMPLE_MEMBER)); @@ -96,4 +120,26 @@ private Map messageChangedEvent(String updatedText, String modif Map request = Map.of("event", event); return request; } + + private Map messageThreadBroadcastEvent() { + Map event = Map.of( + "type", "message", + "subtype", "message_changed", + "channel", SAMPLE_CHANNEL.getSlackId(), + "message", Map.of( + "type", SlackEvent.MESSAGE_THREAD_BROADCAST.getType(), + "subtype", SlackEvent.MESSAGE_THREAD_BROADCAST.getSubtype(), + "user", SAMPLE_MEMBER.getSlackId(), + "ts", "1234567890.123456", + "text", "스레드의 메시지를 채널로 전송 텍스트", + "client_msg_id", SAMPLE_MESSAGE.getSlackId() + ), + "user", SAMPLE_MEMBER.getSlackId(), + "ts", "1234567890.123456", + "text", "스레드의 메시지를 채널로 전송 텍스트", + "client_msg_id", SAMPLE_MESSAGE.getSlackId()); + + Map request = Map.of("event", event); + return request; + } } diff --git a/backend/src/test/java/com/pickpick/slackevent/application/message/MessageCreatedServiceTest.java b/backend/src/test/java/com/pickpick/slackevent/application/message/MessageCreatedServiceTest.java index c772acb7..56d2a44d 100644 --- a/backend/src/test/java/com/pickpick/slackevent/application/message/MessageCreatedServiceTest.java +++ b/backend/src/test/java/com/pickpick/slackevent/application/message/MessageCreatedServiceTest.java @@ -53,6 +53,16 @@ class MessageCreatedServiceTest { "ts", "1234567890", "client_msg_id", SAMPLE_MESSAGE.getSlackId()) ); + private static final Map MESSAGE_REPLIED_REQUEST = + Map.of("event", Map.of( + "type", "message", + "channel", SAMPLE_CHANNEL.getSlackId(), + "text", SAMPLE_MESSAGE.getText(), + "user", SAMPLE_MEMBER.getSlackId(), + "ts", "1234567890", + "client_msg_id", SAMPLE_MESSAGE.getSlackId(), + "thread_ts", "1234599999") + ); private static final int FIRST_INDEX = 0; @Autowired @@ -130,4 +140,20 @@ void saveMessageWhenMessageCreatedEventPassed() { () -> assertThat(messageAfterSave).isPresent() ); } + + @DisplayName("메시지 댓글 생성 이벤트는 전달되어도 내용을 저장하지 않는다") + @Test + void doNotSaveReplyMessage() { + //given + members.save(SAMPLE_MEMBER); + channels.save(SAMPLE_CHANNEL); + + // when + messageCreatedService.execute(MESSAGE_REPLIED_REQUEST); + + // then + Optional message = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + assertThat(message).isEmpty(); + } } diff --git a/backend/src/test/java/com/pickpick/slackevent/application/message/MessageFileShareServiceTest.java b/backend/src/test/java/com/pickpick/slackevent/application/message/MessageFileShareServiceTest.java new file mode 100644 index 00000000..f1a6ca15 --- /dev/null +++ b/backend/src/test/java/com/pickpick/slackevent/application/message/MessageFileShareServiceTest.java @@ -0,0 +1,143 @@ +package com.pickpick.slackevent.application.message; + +import static com.pickpick.slackevent.application.SlackEvent.MESSAGE_FILE_SHARE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import com.pickpick.channel.domain.Channel; +import com.pickpick.channel.domain.ChannelRepository; +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import com.pickpick.message.domain.Message; +import com.pickpick.message.domain.MessageRepository; +import com.pickpick.utils.TimeUtils; +import com.slack.api.RequestConfigurator; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.conversations.ConversationsInfoRequest.ConversationsInfoRequestBuilder; +import com.slack.api.methods.response.conversations.ConversationsInfoResponse; +import com.slack.api.model.Conversation; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@AutoConfigureMockMvc +@SpringBootTest +class MessageFileShareServiceTest { + + private static final Member SAMPLE_MEMBER = new Member("U03MKN0UW", "사용자", "test.png"); + private static final Channel SAMPLE_CHANNEL = new Channel("ASDFB", "채널"); + private static final Message SAMPLE_MESSAGE = new Message( + "db8a1f84-8acf-46ab-b93d-85177cee3e97", + "메시지 전송!", + SAMPLE_MEMBER, + SAMPLE_CHANNEL, + TimeUtils.toLocalDateTime("1234567890"), + TimeUtils.toLocalDateTime("1234567890") + ); + + @Autowired + private MessageFileShareService messageFileShareService; + + @Autowired + private MessageRepository messages; + + @Autowired + private MemberRepository members; + + @Autowired + private ChannelRepository channels; + + @MockBean + private MethodsClient slackClient; + + @DisplayName("파일 공유 이벤트 전달 시 채널이 저장되어 있지 않으면 채널 신규 저장 후 메시지를 저장한다") + @ValueSource(strings = {"", " ", "파일과 함께 전송한 메시지 text"}) + @ParameterizedTest + void fileShareMessage(final String expectedText) throws SlackApiException, IOException { + // given + members.save(SAMPLE_MEMBER); + Optional channelBeforeSave = channels.findBySlackId(SAMPLE_CHANNEL.getSlackId()); + Optional messageBeforeSave = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + ConversationsInfoResponse conversationsInfoResponse = setUpChannelMockData(); + + given(slackClient.conversationsInfo((RequestConfigurator) any())) + .willReturn(conversationsInfoResponse); + + // when + messageFileShareService.execute(fileShareRequest(expectedText)); + Optional channelAfterSave = channels.findBySlackId(SAMPLE_CHANNEL.getSlackId()); + Optional messageAfterSave = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + // then + assertAll( + () -> assertThat(channelBeforeSave).isEmpty(), + () -> assertThat(messageBeforeSave).isEmpty(), + () -> assertThat(channelAfterSave).isPresent(), + () -> assertThat(messageAfterSave).isPresent(), + () -> assertThat(messageAfterSave.get().getText()).isEqualTo(expectedText) + ); + } + + @DisplayName("파일 공유 이벤트 전달 시 채널이 저장되어 있으면 채널 신규 저장 없이 메시지를 저장한다") + @ValueSource(strings = {"", " ", "파일과 함께 전송한 메시지 text"}) + @ParameterizedTest + void saveMessageWhenFileShareEventPassed(final String expectedText) { + // given + members.save(SAMPLE_MEMBER); + channels.save(SAMPLE_CHANNEL); + Optional channelBeforeSave = channels.findBySlackId(SAMPLE_CHANNEL.getSlackId()); + Optional messageBeforeSave = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + // when + messageFileShareService.execute(fileShareRequest(expectedText)); + Optional channelAfterSave = channels.findBySlackId(SAMPLE_CHANNEL.getSlackId()); + Optional messageAfterSave = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + // then + assertAll( + () -> assertThat(channelBeforeSave).isPresent(), + () -> assertThat(messageBeforeSave).isEmpty(), + () -> assertThat(channelAfterSave).isPresent(), + () -> assertThat(messageAfterSave).isPresent(), + () -> assertThat(messageAfterSave.get().getText()).isEqualTo(expectedText) + ); + } + + private ConversationsInfoResponse setUpChannelMockData() { + Conversation conversation = new Conversation(); + conversation.setId(SAMPLE_CHANNEL.getSlackId()); + conversation.setName(SAMPLE_CHANNEL.getName()); + + ConversationsInfoResponse conversationsInfoResponse = new ConversationsInfoResponse(); + conversationsInfoResponse.setChannel(conversation); + + return conversationsInfoResponse; + } + + private Map fileShareRequest(final String text) { + return Map.of("event", Map.of( + "type", MESSAGE_FILE_SHARE.getType(), + "subtype", MESSAGE_FILE_SHARE.getSubtype(), + "files", new ArrayList<>(), + "channel", SAMPLE_CHANNEL.getSlackId(), + "text", text, + "user", SAMPLE_MEMBER.getSlackId(), + "ts", "1234567890", + "client_msg_id", SAMPLE_MESSAGE.getSlackId()) + ); + } +} diff --git a/backend/src/test/java/com/pickpick/slackevent/application/message/MessageThreadBroadcastServiceTest.java b/backend/src/test/java/com/pickpick/slackevent/application/message/MessageThreadBroadcastServiceTest.java new file mode 100644 index 00000000..07cd7e26 --- /dev/null +++ b/backend/src/test/java/com/pickpick/slackevent/application/message/MessageThreadBroadcastServiceTest.java @@ -0,0 +1,193 @@ +package com.pickpick.slackevent.application.message; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import com.pickpick.channel.domain.Channel; +import com.pickpick.channel.domain.ChannelRepository; +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import com.pickpick.message.domain.Message; +import com.pickpick.message.domain.MessageRepository; +import com.pickpick.slackevent.application.SlackEvent; +import com.pickpick.slackevent.application.message.dto.SlackMessageDto; +import com.pickpick.utils.TimeUtils; +import com.slack.api.RequestConfigurator; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.conversations.ConversationsInfoRequest.ConversationsInfoRequestBuilder; +import com.slack.api.methods.response.conversations.ConversationsInfoResponse; +import com.slack.api.model.Conversation; +import java.io.IOException; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.jdbc.Sql; + +@Sql("/truncate.sql") +@SpringBootTest +class MessageThreadBroadcastServiceTest { + + private static final int FIRST_INDEX = 0; + private final Member SAMPLE_MEMBER = new Member("U03MKN0UW", "사용자", "test.png"); + private final Channel SAMPLE_CHANNEL = new Channel("ASDFB", "채널"); + private final Message SAMPLE_MESSAGE = new Message( + "db8a1f84-8acf-46ab-b93d-85177cee3e96", + "메시지 전송!", + SAMPLE_MEMBER, + SAMPLE_CHANNEL, + TimeUtils.toLocalDateTime("1234567890"), + TimeUtils.toLocalDateTime("1234567890") + ); + private final Map MESSAGE_THREAD_BROADCAST_REQUEST = + Map.of("event", Map.of( + "type", SlackEvent.MESSAGE_THREAD_BROADCAST.getType(), + "subtype", SlackEvent.MESSAGE_THREAD_BROADCAST.getSubtype(), + "channel", SAMPLE_CHANNEL.getSlackId(), + "text", SAMPLE_MESSAGE.getText(), + "user", SAMPLE_MEMBER.getSlackId(), + "ts", "1234567890", + "client_msg_id", SAMPLE_MESSAGE.getSlackId() + ) + ); + @Autowired + private MessageThreadBroadcastService messageThreadBroadcastService; + + @Autowired + private MessageRepository messages; + + @Autowired + private MemberRepository members; + + @Autowired + private ChannelRepository channels; + + @MockBean + private MethodsClient slackClient; + + @DisplayName("스레드 메시지 채널로 전송 이벤트 전달 시 채널이 없으면 채널 생성 후 메시지를 저장한다") + @Test + void saveChannelAndMessageDynamicallyWhenDoesNotExist() throws SlackApiException, IOException { + // given + members.save(SAMPLE_MEMBER); + Optional channelBeforeSave = channels.findBySlackId(SAMPLE_CHANNEL.getSlackId()); + Optional messageBeforeSave = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + ConversationsInfoResponse conversationsInfoResponse = setupMockData(); + + when(slackClient.conversationsInfo((RequestConfigurator) any())) + .thenReturn(conversationsInfoResponse); + + // when + messageThreadBroadcastService.execute(MESSAGE_THREAD_BROADCAST_REQUEST); + Optional channelAfterSave = channels.findBySlackId(SAMPLE_CHANNEL.getSlackId()); + Optional messageAfterSave = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + // then + assertAll( + () -> assertThat(channelBeforeSave).isEmpty(), + () -> assertThat(messageBeforeSave).isEmpty(), + () -> assertThat(channelAfterSave).isPresent(), + () -> assertThat(messageAfterSave).isPresent() + ); + } + + private ConversationsInfoResponse setupMockData() { + Conversation conversation = new Conversation(); + conversation.setId(SAMPLE_CHANNEL.getSlackId()); + conversation.setName(SAMPLE_CHANNEL.getName()); + + ConversationsInfoResponse conversationsInfoResponse = new ConversationsInfoResponse(); + conversationsInfoResponse.setChannel(conversation); + + return conversationsInfoResponse; + } + + @DisplayName("스레드 메시지 채널로 전송 이벤트 전달 시 채널이 저장되어 있으면 채널 신규 저장 없이 메시지를 저장한다") + @Test + void saveMessageWhenMessageCreatedEventPassed() { + // given + members.save(SAMPLE_MEMBER); + channels.save(SAMPLE_CHANNEL); + Optional channelBeforeSave = channels.findBySlackId(SAMPLE_CHANNEL.getSlackId()); + Optional messageBeforeSave = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + // when + messageThreadBroadcastService.execute(MESSAGE_THREAD_BROADCAST_REQUEST); + Optional channelAfterSave = channels.findBySlackId(SAMPLE_CHANNEL.getSlackId()); + Optional messageAfterSave = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + // then + assertAll( + () -> assertThat(channelBeforeSave).isPresent(), + () -> assertThat(messageBeforeSave).isEmpty(), + () -> assertThat(channelAfterSave).isPresent(), + () -> assertThat(messageAfterSave).isPresent() + ); + } + + @DisplayName("스레드 메시지 채널로 전송 이벤트 전달 시 subtype이 message_changed인 요청이 왔을 경우, DB에 저장되어 있는 메시지라면 수정한다.") + @Test + void notSave() { + // given + members.save(SAMPLE_MEMBER); + channels.save(SAMPLE_CHANNEL); + messages.save(SAMPLE_MESSAGE); + Optional messageBeforeExecute = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + SlackMessageDto messageDto = new SlackMessageDto( + SAMPLE_MEMBER.getSlackId(), + SAMPLE_MESSAGE.getSlackId(), + "1234567890", + "1234567890", + "수정된 메시지 텍스트", + SAMPLE_CHANNEL.getSlackId()); + + // when + messageThreadBroadcastService.saveWhenSubtypeIsMessageChanged(messageDto); + + // then + Optional messageAfterExecute = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + assertAll( + () -> assertThat(messageBeforeExecute.get().getText()).isNotEqualTo( + messageAfterExecute.get().getText()), + () -> assertThat(messageAfterExecute.get().getText()).isEqualTo("수정된 메시지 텍스트") + ); + } + + @DisplayName("스레드 메시지 채널로 전송 이벤트 전달 시 subtype이 message_changed인 요청이 왔을 경우, DB에 저장되지 않은 메시지라면 저장한다") + @Test + void save() { + // given + members.save(SAMPLE_MEMBER); + channels.save(SAMPLE_CHANNEL); + Optional messageBeforeExecute = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + SlackMessageDto messageDto = new SlackMessageDto( + SAMPLE_MEMBER.getSlackId(), + SAMPLE_MESSAGE.getSlackId(), + "1234567890", + "1234567890", + "messageText", + SAMPLE_CHANNEL.getSlackId()); + + // when + messageThreadBroadcastService.saveWhenSubtypeIsMessageChanged(messageDto); + + // then + Optional messageAfterExecute = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + assertAll( + () -> assertThat(messageBeforeExecute).isEmpty(), + () -> assertThat(messageAfterExecute).isPresent(), + () -> assertThat(messageAfterExecute.get().getText()).isEqualTo("messageText") + ); + } +} diff --git a/backend/src/test/resources/message.sql b/backend/src/test/resources/message.sql index d8510013..f631578d 100644 --- a/backend/src/test/resources/message.sql +++ b/backend/src/test/resources/message.sql @@ -54,3 +54,6 @@ insert into bookmark(id, member_id, message_id) values (1, 1, 1), (2, 1, 5), (3, 1, 10); + +insert into reminder (id, member_id, message_id, remind_date) +values (1, 1, 38, '2022-08-12 14:20:00'); diff --git a/backend/src/test/resources/reminder.sql b/backend/src/test/resources/reminder.sql new file mode 100644 index 00000000..f7a69961 --- /dev/null +++ b/backend/src/test/resources/reminder.sql @@ -0,0 +1,66 @@ +insert into channel (id, name, slack_id) +values (5, '임시 채널', 'ABC1234'); + +insert into member (id, slack_id, thumbnail_url, username, first_login) +values (1, 'U03MC231', 'https://summer.png', '써머', false), + (2, 'U03MC232', 'https://yeonlog.png', '연로그', false), + (3, 'U03MC233', 'https://bom.png', '봄', false); + +insert into message (id, modified_date, posted_date, text, member_id, channel_id, slack_message_id) +values (1, '2022-07-12 14:21:55', '2022-07-12 14:21:55', 'Sample Text', 1, 5, 'ABC1231'), + (2, '2022-07-12 15:21:55', '2022-07-12 15:21:55', 'Sample Text', 1, 5, 'ABC1232'), + (3, '2022-07-12 16:21:55', '2022-07-12 16:21:55', 'Sample Text', 1, 5, 'ABC1233'), + (4, '2022-07-12 17:21:55', '2022-07-12 17:21:55', '호 Sample Text', 1, 5, 'ABC1234'), + (5, '2022-07-13 18:21:55', '2022-07-13 18:21:55', '호 Sample Text', 1, 5, 'ABC1235'), + (6, '2022-07-13 19:21:55', '2022-07-13 19:21:55', '호 Sample Text', 1, 5, 'ABC1236'), + (7, '2022-07-13 20:21:55', '2022-07-13 20:21:55', '호 Sample Text', 1, 5, 'ABC1237'), + (8, '2022-07-13 21:21:55', '2022-07-13 21:21:55', 'Sample Text A', 1, 5, 'ABC1238'), + (9, '2022-07-13 22:21:55', '2022-07-13 22:21:55', 'Sample Text A', 1, 5, 'ABC1239'), + (10, '2022-07-14 13:21:55', '2022-07-14 13:21:55', 'Sample Text A', 1, 5, 'ABC12310'), + (11, '2022-07-14 14:21:55', '2022-07-14 14:21:55', 'Sample Text A', 1, 5, 'ABC12311'), + (12, '2022-07-14 15:21:55', '2022-07-14 15:21:55', 'Sample Text A', 1, 5, 'ABC12312'), + (13, '2022-07-14 16:21:55', '2022-07-14 16:21:55', 'Sample Text A', 1, 5, 'ABC12313'), + (14, '2022-07-14 17:21:55', '2022-07-14 17:21:55', 'jupjup Sample Text A', 1, 5, 'ABC12314'), + (15, '2022-07-15 13:21:55', '2022-07-15 13:21:55', 'jupjup Sample Text A', 1, 5, 'ABC12315'), + (16, '2022-07-15 14:21:55', '2022-07-15 14:21:55', 'jupjup Sample Text A', 1, 5, 'ABC12316'), + (17, '2022-07-15 15:21:55', '2022-07-15 15:21:55', 'jupjup Sample Text A', 1, 5, 'ABC12317'), + (18, '2022-07-15 16:21:55', '2022-07-15 16:21:55', 'jupjup Sample Text A', 1, 5, 'ABC12318'), + (19, '2022-07-16 13:21:55', '2022-07-16 13:21:55', 'Sample Text A', 1, 5, 'ABC12319'), + (20, '2022-07-16 14:21:55', '2022-07-16 14:21:55', 'Sample Text A', 1, 5, 'ABC12320'), + (21, '2022-07-16 15:21:55', '2022-07-16 15:21:55', 'Sample Text A', 1, 5, 'ABC12321'), + (22, '2022-07-16 17:21:55', '2022-07-16 17:21:55', 'Sample Text A', 1, 5, 'ABC12322'), + (23, '2022-07-17 13:21:55', '2022-07-17 13:21:55', '줍줍 Sample Text A', 1, 5, 'ABC12323'); + +insert into reminder (id, member_id, message_id, remind_date) +values (1, 2, 1, '2022-08-12 14:20:00'), + (2, 1, 2, '2022-08-12 15:20:00'), + (3, 1, 3, '2022-08-12 16:20:00'), + (4, 1, 4, '2022-08-12 17:20:00'), + (5, 1, 5, '2022-08-13 18:30:00'), + (6, 1, 6, '2022-08-13 19:30:00'), + (7, 1, 7, '2022-08-13 20:30:00'), + (8, 1, 8, '2022-08-13 21:30:00'), + (9, 1, 9, '2022-08-13 22:30:00'), + (10, 1, 10, '2022-08-17 14:20:00'), + (11, 1, 11, '2022-08-17 15:20:00'), + (12, 1, 12, '2022-08-17 17:20:00'), + (13, 1, 13, '2022-08-18 13:20:00'), + (14, 1, 14, '2022-08-18 14:20:00'), + (15, 1, 15, '2022-08-18 15:20:00'), + (16, 1, 16, '2022-08-18 16:20:00'), + (17, 1, 17, '2022-08-19 13:30:00'), + (18, 1, 18, '2022-08-19 14:30:00'), + (19, 1, 19, '2022-08-19 15:30:00'), + (20, 1, 20, '2022-08-19 16:30:00'), + (21, 1, 21, '2022-08-20 13:30:00'), + (22, 1, 22, '2022-08-20 14:30:00'), + (23, 1, 23, '2022-08-20 15:30:00'), + (24, 1, 23, '2021-08-08 15:30:00'), + (25, 3, 2, '2023-08-08 15:30:00'), + (26, 3, 3, '2023-08-08 15:30:00'), + (27, 3, 4, '2023-08-08 15:30:00'), + (28, 3, 5, '2023-08-08 15:30:00'), + (29, 3, 6, '2023-07-08 14:30:00'), + (30, 3, 7, '2023-07-08 14:30:00'), + (31, 3, 8, '2023-06-08 14:20:00'); + diff --git a/backend/src/test/resources/truncate.sql b/backend/src/test/resources/truncate.sql index 78f6e360..fcd23cc0 100644 --- a/backend/src/test/resources/truncate.sql +++ b/backend/src/test/resources/truncate.sql @@ -1,4 +1,6 @@ delete +from reminder; +delete from bookmark; delete from message; diff --git a/frontend/.storybook/preview.js b/frontend/.storybook/preview.js index 2fc87103..7d641b5a 100644 --- a/frontend/.storybook/preview.js +++ b/frontend/.storybook/preview.js @@ -1,6 +1,7 @@ import { MemoryRouter } from "react-router-dom"; import { ThemeProvider } from "styled-components"; import { LIGHT_MODE_THEME } from "@src/@styles/theme"; +import { RecoilRoot } from "recoil"; import GlobalStyle from "@src/@styles/GlobalStyle"; export const parameters = { @@ -16,10 +17,12 @@ export const parameters = { export const decorators = [ (Story) => ( - - - - + + + + + + ), ]; diff --git a/frontend/lighthouserc.js b/frontend/lighthouserc.js new file mode 100644 index 00000000..866de1df --- /dev/null +++ b/frontend/lighthouserc.js @@ -0,0 +1,14 @@ +/* eslint-disable no-undef */ + +module.exports = { + ci: { + collect: { + numberOfRuns: 1, + }, + upload: { + target: "filesystem", + outputDir: "./lhci_reports", + reportFilenamePattern: "%%PATHNAME%%-%%DATETIME%%-report.%%EXTENSION%%", + }, + }, +}; diff --git a/frontend/public/assets/icons/Calendar.svg b/frontend/public/assets/icons/Calendar.svg new file mode 100644 index 00000000..8e280e92 --- /dev/null +++ b/frontend/public/assets/icons/Calendar.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/icons/HomeIcon-Unfill.svg b/frontend/public/assets/icons/HomeIcon-Unfill.svg deleted file mode 100644 index 541f07f7..00000000 --- a/frontend/public/assets/icons/HomeIcon-Unfill.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/public/assets/icons/HomeIcon-Fill.svg b/frontend/public/assets/icons/HomeIcon.svg similarity index 100% rename from frontend/public/assets/icons/HomeIcon-Fill.svg rename to frontend/public/assets/icons/HomeIcon.svg diff --git a/frontend/public/assets/icons/MoonIcon.svg b/frontend/public/assets/icons/MoonIcon.svg new file mode 100644 index 00000000..7b5ac7af --- /dev/null +++ b/frontend/public/assets/icons/MoonIcon.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/assets/icons/AlarmIcon-Active.svg b/frontend/public/assets/icons/ReminderIcon-Active.svg similarity index 100% rename from frontend/public/assets/icons/AlarmIcon-Active.svg rename to frontend/public/assets/icons/ReminderIcon-Active.svg diff --git a/frontend/public/assets/icons/AlarmIcon-Inactive.svg b/frontend/public/assets/icons/ReminderIcon-Inactive.svg similarity index 100% rename from frontend/public/assets/icons/AlarmIcon-Inactive.svg rename to frontend/public/assets/icons/ReminderIcon-Inactive.svg diff --git a/frontend/public/assets/icons/RemoveIcon.svg b/frontend/public/assets/icons/RemoveIcon.svg deleted file mode 100644 index 2b2f35f2..00000000 --- a/frontend/public/assets/icons/RemoveIcon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/public/assets/icons/StarIcon-Fill.svg b/frontend/public/assets/icons/StarIcon-Fill.svg deleted file mode 100644 index c026d604..00000000 --- a/frontend/public/assets/icons/StarIcon-Fill.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/public/assets/icons/StarIcon-Unfill.svg b/frontend/public/assets/icons/StarIcon.svg similarity index 100% rename from frontend/public/assets/icons/StarIcon-Unfill.svg rename to frontend/public/assets/icons/StarIcon.svg diff --git a/frontend/public/assets/icons/SunIcon.svg b/frontend/public/assets/icons/SunIcon.svg new file mode 100644 index 00000000..d46d3f9b --- /dev/null +++ b/frontend/public/assets/icons/SunIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/icons/YoutubeIcon.svg b/frontend/public/assets/icons/YoutubeIcon.svg deleted file mode 100644 index 92e9f93e..00000000 --- a/frontend/public/assets/icons/YoutubeIcon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/public/assets/images/DefaultProfileImage.png b/frontend/public/assets/images/DefaultProfileImage.png new file mode 100644 index 00000000..b398aa2a Binary files /dev/null and b/frontend/public/assets/images/DefaultProfileImage.png differ diff --git a/frontend/public/assets/images/favicon.ico b/frontend/public/assets/images/favicon.ico new file mode 100644 index 00000000..617fccf3 Binary files /dev/null and b/frontend/public/assets/images/favicon.ico differ diff --git a/frontend/public/assets/images/pickpick.png b/frontend/public/assets/images/pickpick.png new file mode 100644 index 00000000..cc553fa4 Binary files /dev/null and b/frontend/public/assets/images/pickpick.png differ diff --git a/frontend/public/index.html b/frontend/public/index.html index 69843e90..8d9bf047 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,13 +1,19 @@ - - - - - 줍줍 - - -
-
- + + + + + + + + + + + 줍줍 + + +
+
+ diff --git a/frontend/src/@atoms/index.ts b/frontend/src/@atoms/index.ts index ea10870b..61543da1 100644 --- a/frontend/src/@atoms/index.ts +++ b/frontend/src/@atoms/index.ts @@ -1,5 +1,5 @@ -import { SNACKBAR_STATUS } from "@src/@constants"; -import { SnackbarStatus } from "@src/@types/shared"; +import { SNACKBAR_STATUS, THEME_KIND } from "@src/@constants"; +import { SnackbarStatus, ThemeKind } from "@src/@types/shared"; import { atom } from "recoil"; interface SnackbarState { @@ -16,3 +16,8 @@ export const snackbarState = atom({ status: SNACKBAR_STATUS.SUCCESS, }, }); + +export const themeState = atom({ + key: "themeState", + default: THEME_KIND.LIGHT, +}); diff --git a/frontend/src/@constants/index.ts b/frontend/src/@constants/index.ts index b4ea5466..2bab152b 100644 --- a/frontend/src/@constants/index.ts +++ b/frontend/src/@constants/index.ts @@ -1,10 +1,11 @@ export const PATH_NAME = { HOME: "/", FEED: "/feed", - ALARM: "/alarm", BOOKMARK: "/bookmark", + REMINDER: "/reminder", ADD_CHANNEL: "/add-channel", CERTIFICATION: "/certification", + SEARCH_RESULT: "/search-result", } as const; export const QUERY_KEY = { @@ -13,6 +14,8 @@ export const QUERY_KEY = { ALL_MESSAGES: "allMessages", SPECIFIC_DATE_MESSAGES: "specificDateMessages", BOOKMARKS: "bookmarks", + REMINDERS: "reminders", + REMINDER: "reminder", AUTHENTICATION: "authentication", SLACK_LOGIN: "slackLogin", } as const; @@ -22,6 +25,7 @@ export const API_ENDPOINT = { CHANNEL: "/api/channels", CHANNEL_SUBSCRIPTION: "/api/channel-subscription", BOOKMARKS: "/api/bookmarks", + REMINDERS: "/api/reminders", CERTIFICATION: "/api/certification", SLACK_LOGIN: "/api/slack-login", } as const; @@ -63,3 +67,29 @@ export const SNACKBAR_STATUS = { SUCCESS: "SUCCESS", FAIL: "FAIL", } as const; + +export const ERROR_MESSAGE_BY_CODE = { + MEMBER_NOT_FOUND: "로그인이 필요한 서비스 입니다.", + INVALID_TOKEN: "로그인이 필요한 서비스 입니다.", + CHANNEL_NOT_FOUND: "서버에 오류가 있습니다. 잠시 후에 다시 시도해주세요.", + SUBSCRIPTION_DUPLICATE: + "서버에 오류가 있습니다. 잠시 후에 다시 시도해주세요.", + SUBSCRIPTION_INVALID_ORDER: + "서버에 오류가 있습니다. 잠시 후에 다시 시도해주세요.", + SUBSCRIPTION_NOT_EXIST: + "서버에 오류가 있습니다. 잠시 후에 다시 시도해주세요.", + SUBSCRIPTION_ORDER_DUPLICATE: + "서버에 오류가 있습니다. 잠시 후에 다시 시도해주세요.", + BOOKMARK_DELETE_FAILURE: + "서버에 오류가 있습니다. 잠시 후에 다시 시도해주세요.", + SUBSCRIPTION_NOT_FOUND: + "현재 구독 중인 채널이 없습니다! 먼저 채널을 구독하세요!", + BOOKMARK_NOT_FOUND: "죄송합니다. 현재 메시지를 가져올 수 없습니다.", + MESSAGE_NOT_FOUND: "죄송합니다. 현재 메시지를 가져올 수 없습니다.", + DEFAULT_MESSAGE: "서버에 오류가 있습니다. 잠시 후에 다시 시도해주세요.", +} as const; + +export const THEME_KIND = { + LIGHT: "LIGHT", + DARK: "DARK", +} as const; diff --git a/frontend/src/@styles/GlobalStyle.ts b/frontend/src/@styles/GlobalStyle.ts index 5be25a45..3ff429ae 100644 --- a/frontend/src/@styles/GlobalStyle.ts +++ b/frontend/src/@styles/GlobalStyle.ts @@ -30,11 +30,14 @@ const GlobalStyle = createGlobalStyle<{ theme: Theme }>` *::before, *::after { box-sizing: border-box; + letter-spacing: -0.4px; + ${({ theme }) => css` font-family: ${theme.FONT.PRIMARY}; color: ${theme.COLOR.TEXT.DEFAULT}; `} } + body, h1, h2, @@ -50,20 +53,23 @@ const GlobalStyle = createGlobalStyle<{ theme: Theme }>` margin: 0; padding: 0; } + ul{ list-style: none; } + a { text-decoration: none; color: inherit; } + input, button, textarea, select { font: inherit; } - + body { ${({ theme }: { theme: Theme }) => css` background-color: ${theme.COLOR.BACKGROUND.PRIMARY}; diff --git a/frontend/src/@styles/colors.ts b/frontend/src/@styles/colors.ts index 4248ff93..8c24bd27 100644 --- a/frontend/src/@styles/colors.ts +++ b/frontend/src/@styles/colors.ts @@ -1,7 +1,11 @@ export const COLORS = { GREY: { - 90: "#121212", + 100: "#181818", + 95: "#121212", + 90: "#282828", + 85: "#404040", 80: "#8B8B8B", + 75: "#B3B3B3", 70: "rgba(0, 0, 0, 0.5)", 60: "rgba(0, 0, 0, 0.3)", 50: "#DCDCDC", @@ -17,6 +21,10 @@ export const COLORS = { ORANGE: { 90: "#FF9900", 50: "#FFC56E", + LIGHT_GRADIENT: + "linear-gradient(180deg, rgba(248,248,248,1) 0%, rgba(248,248,248,1) 57%, rgba(255,197,110,1) 81%, rgba(255,197,110,1) 91%, rgba(248,248,240,1) 100%)", + DARK_GRADIENT: + "linear-gradient(180deg, rgba(64,64,64,1) 0%, rgba(64,64,64,1) 57%, rgba(255,197,110,1) 81%, rgba(255,197,110,1) 91%, rgba(64,64,64,1) 100%)", }, RED: { 50: "#FF9494", diff --git a/frontend/src/@styles/theme.ts b/frontend/src/@styles/theme.ts index 732f9661..dd63d5a7 100644 --- a/frontend/src/@styles/theme.ts +++ b/frontend/src/@styles/theme.ts @@ -6,9 +6,10 @@ export const LIGHT_MODE_THEME = { PRIMARY: { DEFAULT: COLORS.ORANGE[90] }, SECONDARY: { DEFAULT: COLORS.GREY[80] }, TEXT: { - DEFAULT: COLORS.GREY[90], + DEFAULT: COLORS.GREY[95], DISABLED: COLORS.GREY[60], PLACEHOLDER: COLORS.GREY[80], + LIGHT_BLUE: COLORS.BLUE[50], WHITE: COLORS.WHITE, }, BACKGROUND: { @@ -26,6 +27,53 @@ export const LIGHT_MODE_THEME = { LIGHT_RED: COLORS.RED[50], LIGHT_BLUE: COLORS.BLUE[50], LIGHT_ORANGE: COLORS.ORANGE[50], + GRADIENT_ORANGE: COLORS.ORANGE.LIGHT_GRADIENT, + }, + BORDER: COLORS.GREY[50], + DIMMER: COLORS.GREY[70], + STAR_ICON_FILL: COLORS.ORANGE[90], + }, + FONT: { + PRIMARY: "Roboto", + SECONDARY: "Twayair", + }, + FONT_SIZE: { + TITLE: FONT_SIZE["34px"], + SUBTITLE: FONT_SIZE["20px"], + X_LARGE_BODY: FONT_SIZE["18px"], + LARGE_BODY: FONT_SIZE["14px"], + BODY: FONT_SIZE["12px"], + PLACEHOLDER: FONT_SIZE["12px"], + CAPTION: FONT_SIZE["10px"], + }, +} as const; + +export const DARK_MODE_THEME = { + COLOR: { + PRIMARY: { DEFAULT: COLORS.ORANGE[90] }, + SECONDARY: { DEFAULT: COLORS.GREY[75] }, + TEXT: { + DEFAULT: COLORS.WHITE, + DISABLED: COLORS.GREY[60], + PLACEHOLDER: COLORS.GREY[80], + WHITE: COLORS.WHITE, + }, + BACKGROUND: { + PRIMARY: COLORS.GREY[100], + SECONDARY: COLORS.GREY[85], + TERTIARY: COLORS.GREY[90], + }, + WRAPPER: { + DEFAULT: COLORS.GREY[85], + DISABLED: COLORS.GREY[40], + }, + CONTAINER: { + DEFAULT: COLORS.GREY[85], + WHITE: COLORS.GREY[85], + LIGHT_RED: COLORS.RED[50], + LIGHT_BLUE: COLORS.BLUE[50], + LIGHT_ORANGE: COLORS.ORANGE[50], + GRADIENT_ORANGE: COLORS.ORANGE.DARK_GRADIENT, }, BORDER: COLORS.GREY[50], DIMMER: COLORS.GREY[70], diff --git a/frontend/src/@types/shared.ts b/frontend/src/@types/shared.ts index 3c889df5..039f8f7b 100644 --- a/frontend/src/@types/shared.ts +++ b/frontend/src/@types/shared.ts @@ -1,21 +1,53 @@ -import { SNACKBAR_STATUS } from "@src/@constants"; +import { + SNACKBAR_STATUS, + ERROR_MESSAGE_BY_CODE, + THEME_KIND, +} from "@src/@constants"; import { LIGHT_MODE_THEME } from "@src/@styles/theme"; export type Theme = typeof LIGHT_MODE_THEME; +export type ThemeKind = keyof typeof THEME_KIND; + export interface StyledDefaultProps { theme: Theme; } export interface Message { - id: string; + id: number; username: string; postedDate: string; + remindDate: string; text: string; userThumbnail: string; isBookmarked: boolean; + isSetReminded: boolean; } -export type Bookmark = Omit; +export interface Bookmark { + id: number; + messageId: number; + username: string; + postedDate: string; + remindDate: string; + text: string; + userThumbnail: string; +} + +export interface Reminder { + id: number; + messageId: number; + username: string; + userThumbnail: string; + text: string; + postedDate: string; + remindDate: string; + modifyDate: string; +} + +export interface ResponseReminders { + reminders: Reminder[]; + isLast: boolean; +} export interface ResponseBookmarks { bookmarks: Bookmark[]; @@ -38,7 +70,7 @@ export interface ResponseChannels { } export interface SubscribedChannel { - id: string; + id: number; name: string; order: number; } @@ -53,3 +85,14 @@ export interface ResponseToken { token: string; isFirstLogin: boolean; } + +export interface CustomError { + response: { + data: Error; + }; +} + +export interface Error { + code: keyof typeof ERROR_MESSAGE_BY_CODE; + message: string; +} diff --git a/frontend/src/@utils/index.test.ts b/frontend/src/@utils/index.test.ts index ed85417d..4ad62deb 100644 --- a/frontend/src/@utils/index.test.ts +++ b/frontend/src/@utils/index.test.ts @@ -8,7 +8,7 @@ import { import { extractResponseBookmarks, extractResponseMessages, - getTimeStandard, + getMeridiemTime, ISOConverter, parseTime, } from "./index"; @@ -17,13 +17,19 @@ describe("24시간제의 시간이 입력되면 오전/오후 prefix를 붙여 test("11이 입력됐을 경우 오전 prefix를 붙여 '오전 11'을 반환한다.", () => { const inputTime = 11; - expect(getTimeStandard(inputTime)).toBe("오전 11"); + expect(getMeridiemTime(inputTime)).toEqual({ + meridiem: "오전", + hour: "11", + }); }); test("23이 입력됐을 경우 오후 prefix를 붙여 '오후 11'을 반환한다.", () => { const inputTime = 23; - expect(getTimeStandard(inputTime)).toBe("오후 11"); + expect(getMeridiemTime(inputTime)).toEqual({ + meridiem: "오후", + hour: "11", + }); }); }); diff --git a/frontend/src/@utils/index.ts b/frontend/src/@utils/index.ts index 8e98c777..9e7b131d 100644 --- a/frontend/src/@utils/index.ts +++ b/frontend/src/@utils/index.ts @@ -2,25 +2,28 @@ import { CONVERTER_SUFFIX, DATE, DAY, TIME } from "@src/@constants"; import { Bookmark, Message, + Reminder, ResponseBookmarks, ResponseMessages, + ResponseReminders, } from "@src/@types/shared"; import { InfiniteData } from "react-query"; -export const getTimeStandard = (time: number): string => { - if (time < TIME.NOON) return `${TIME.AM} ${time}`; - if (time === TIME.NOON) return `${TIME.PM} ${TIME.NOON}`; +export const getMeridiemTime = (time: number) => { + if (time < TIME.NOON) return { meridiem: TIME.AM, hour: time.toString() }; + if (time === TIME.NOON) + return { meridiem: TIME.PM, hour: TIME.NOON.toString() }; - return `${TIME.PM} ${time - TIME.NOON}`; + return { meridiem: TIME.PM, hour: (time - TIME.NOON).toString() }; }; export const parseTime = (date: string): string => { const dateInstance = new Date(date); const hour = dateInstance.getHours(); const minute = dateInstance.getMinutes(); - const timeStandard = getTimeStandard(Number(hour)); + const { meridiem, hour: parsedHour } = getMeridiemTime(Number(hour)); - return `${timeStandard}:${minute}`; + return `${meridiem} ${parsedHour}:${minute.toString().padStart(2, "0")}`; }; export const extractResponseMessages = ( @@ -39,6 +42,14 @@ export const extractResponseBookmarks = ( return data.pages.flatMap((arr) => arr.bookmarks); }; +export const extractResponseReminders = ( + data?: InfiniteData +): Reminder[] => { + if (!data) return []; + + return data.pages.flatMap((arr) => arr.reminders); +}; + export const setCookie = (key: string, value: string) => { document.cookie = `${key}=${value};`; }; @@ -54,7 +65,7 @@ export const deleteCookie = (key: string) => { document.cookie = key + "=; Max-Age=0"; }; -export const ISOConverter = (date: string): string => { +export const ISOConverter = (date: string, time?: string): string => { const today = new Date(); if (date === DATE.TODAY) { @@ -69,19 +80,30 @@ export const ISOConverter = (date: string): string => { const [year, month, day] = date.split("-"); + if (time) { + const [hour, minute] = time.split(":"); + + return `${year}-${month.padStart(2, "0")}-${day.padStart( + 2, + "0" + )}${`T${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:00`}`; + } + return `${year}-${month.padStart(2, "0")}-${day.padStart( 2, "0" )}${CONVERTER_SUFFIX}`; }; -const getDateInformation = (givenDate: Date) => { +export const getDateInformation = (givenDate: Date) => { const year = givenDate.getFullYear(); const month = givenDate.getMonth() + 1; const date = givenDate.getDate(); const day = DAY[givenDate.getDay()]; + const hour = givenDate.getHours(); + const minute = givenDate.getMinutes(); - return { year, month, date, day }; + return { year, month, date, day, hour, minute }; }; export const getMessagesDate = (postedDate: string): string => { @@ -102,3 +124,29 @@ export const getMessagesDate = (postedDate: string): string => { return `${givenDate.month}월 ${givenDate.date}일 ${givenDate.day}`; }; + +export const convertSeparatorToKey = ({ + value, + separator, + key, +}: { + value: string; + separator: string; + key: string; +}) => { + if (value.search(separator) === -1) { + return value; + } + // eslint-disable-next-line + return value.replace(/\,/g, key); +}; + +export const parsedOptionText = ({ + needZeroPaddingStart, + optionText, +}: { + needZeroPaddingStart: boolean; + optionText: string; +}): string => { + return needZeroPaddingStart ? optionText.padStart(2, "0") : optionText; +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3499a690..e330644f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,14 +1,46 @@ +import GlobalStyle from "@src/@styles/GlobalStyle"; +import Snackbar from "@src/components/Snackbar"; +import useApiError from "@src/hooks/useApiError"; +import routes from "@src/Routes"; +import queryClient from "@src/queryClient"; +import useModeTheme from "@src/hooks/useModeTheme"; import { useRoutes } from "react-router-dom"; -import Snackbar from "./components/Snackbar"; -import routes from "./Routes"; +import { ThemeProvider } from "styled-components"; +import { DARK_MODE_THEME, LIGHT_MODE_THEME } from "@src/@styles/theme"; +import { QueryClientProvider } from "react-query"; +import { useEffect } from "react"; +import { ReactQueryDevtools } from "react-query/devtools"; +import { THEME_KIND } from "@src/@constants"; function App() { + const { handleError } = useApiError(); + const { theme } = useModeTheme(); const element = useRoutes(routes); + + useEffect(() => { + queryClient.setDefaultOptions({ + queries: { + refetchOnWindowFocus: false, + onError: handleError, + retry: 0, + }, + mutations: { + onError: handleError, + }, + }); + }, []); + return ( - <> - {element} - - + + + + {element} + + + + ); } diff --git a/frontend/src/Routes.tsx b/frontend/src/Routes.tsx index 21762254..a55d4214 100644 --- a/frontend/src/Routes.tsx +++ b/frontend/src/Routes.tsx @@ -3,87 +3,103 @@ import { PATH_NAME } from "@src/@constants"; import LayoutContainer from "@src/components/@layouts/LayoutContainer"; import { AddChannel, - Alarm, Bookmark, + Reminder, Feed, SpecificDateFeed, Home, Certification, + SearchResult, } from "./pages"; import PrivateRouter from "@src/components/PrivateRouter"; import PublicRouter from "@src/components/PublicRouter"; const routes = [ + { + path: "", + element: ( + + + + + + ), + }, { path: PATH_NAME.HOME, - element: , + element: , children: [ - { - path: "", - element: ( - - - - ), - }, { path: PATH_NAME.ADD_CHANNEL, element: ( - + - + ), }, { - path: PATH_NAME.ALARM, + path: PATH_NAME.BOOKMARK, element: ( - - - + + + ), }, { - path: PATH_NAME.BOOKMARK, + path: PATH_NAME.REMINDER, element: ( - - - + + + ), }, { path: PATH_NAME.FEED, element: ( - + - + ), }, { path: `${PATH_NAME.FEED}/:channelId`, element: ( - + - + ), }, { path: `${PATH_NAME.FEED}/:channelId/:date`, element: ( - + - + ), }, + { - path: PATH_NAME.CERTIFICATION, - element: , + path: PATH_NAME.SEARCH_RESULT, + element: ( + + + + ), }, { path: "*", - element: , + element: ( + + + + ), }, ], }, + { + path: PATH_NAME.CERTIFICATION, + element: , + }, ]; export default routes; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 5c055c9d..d7084927 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,7 +1,7 @@ import { API_ENDPOINT } from "@src/@constants"; import { fetcher } from "."; import { ResponseToken } from "@src/@types/shared"; -import { getPrivateHeaders, getPublicHeaders } from "./utils"; +import { getPrivateHeaders, getPublicHeaders } from "@src/api/utils"; export const isCertificated = async () => { const { data } = await fetcher.get(API_ENDPOINT.CERTIFICATION, { @@ -11,11 +11,11 @@ export const isCertificated = async () => { }; export const slackLogin = async (code: string) => { - const { data } = await fetcher.get( - `${API_ENDPOINT.SLACK_LOGIN}?code=${code}`, - { - headers: { ...getPublicHeaders() }, - } - ); + const { data } = await fetcher.get(API_ENDPOINT.SLACK_LOGIN, { + headers: { ...getPublicHeaders() }, + params: { + code, + }, + }); return data; }; diff --git a/frontend/src/api/bookmarks.ts b/frontend/src/api/bookmarks.ts index 701af1ea..889026aa 100644 --- a/frontend/src/api/bookmarks.ts +++ b/frontend/src/api/bookmarks.ts @@ -1,7 +1,7 @@ import { API_ENDPOINT } from "@src/@constants"; import { ResponseBookmarks } from "@src/@types/shared"; import { fetcher } from "."; -import { getPrivateHeaders } from "./utils"; +import { getPrivateHeaders } from "@src/api/utils"; interface GetBookmarkParam { pageParam?: string; @@ -11,15 +11,18 @@ export const getBookmarks = async ( { pageParam }: GetBookmarkParam = { pageParam: "" } ) => { const { data } = await fetcher.get( - `${API_ENDPOINT.BOOKMARKS}?bookmarkId=${pageParam ?? ""}`, + API_ENDPOINT.BOOKMARKS, { headers: { ...getPrivateHeaders() }, + params: { + bookmarkId: pageParam ?? "", + }, } ); return data; }; -export const postBookmark = async (messageId: string) => { +export const postBookmark = async (messageId: number) => { await fetcher.post( API_ENDPOINT.BOOKMARKS, { messageId }, @@ -29,8 +32,11 @@ export const postBookmark = async (messageId: string) => { ); }; -export const deleteBookmark = async (bookmarkId: string) => { - await fetcher.delete(`${API_ENDPOINT.BOOKMARKS}/${bookmarkId}`, { +export const deleteBookmark = async (messageId: number) => { + await fetcher.delete(API_ENDPOINT.BOOKMARKS, { headers: { ...getPrivateHeaders() }, + params: { + messageId, + }, }); }; diff --git a/frontend/src/api/channels.ts b/frontend/src/api/channels.ts index 3b2d1c62..0dc8a0ea 100644 --- a/frontend/src/api/channels.ts +++ b/frontend/src/api/channels.ts @@ -4,7 +4,7 @@ import { ResponseSubscribedChannels, } from "@src/@types/shared"; import { fetcher } from "."; -import { getPrivateHeaders } from "./utils"; +import { getPrivateHeaders } from "@src/api/utils"; export const getChannels = async () => { const { data } = await fetcher.get(API_ENDPOINT.CHANNEL, { @@ -35,10 +35,10 @@ export const subscribeChannel = async (channelId: string) => { }; export const unsubscribeChannel = async (channelId: string) => { - await fetcher.delete( - `${API_ENDPOINT.CHANNEL_SUBSCRIPTION}?channelId=${channelId}`, - { - headers: { ...getPrivateHeaders() }, - } - ); + await fetcher.delete(API_ENDPOINT.CHANNEL_SUBSCRIPTION, { + headers: { ...getPrivateHeaders() }, + params: { + channelId, + }, + }); }; diff --git a/frontend/src/api/messages.ts b/frontend/src/api/messages.ts index e483b98d..398fe52f 100644 --- a/frontend/src/api/messages.ts +++ b/frontend/src/api/messages.ts @@ -1,7 +1,7 @@ import { API_ENDPOINT } from "@src/@constants"; import { ResponseMessages } from "@src/@types/shared"; import { fetcher } from "."; -import { getPrivateHeaders } from "./utils"; +import { getPrivateHeaders } from "@src/api/utils"; interface PageParam { messageId: string; @@ -12,6 +12,7 @@ interface PageParam { interface HighOrderParam { date?: string; channelId?: string; + keyword?: string; } interface ReturnFunctionParam { @@ -19,15 +20,21 @@ interface ReturnFunctionParam { } export const getMessages = - ({ date = "", channelId = "" }: HighOrderParam) => + ({ date = "", channelId = "", keyword = "" }: HighOrderParam) => async ({ pageParam }: ReturnFunctionParam) => { if (!pageParam) { const { data } = await fetcher.get( `${API_ENDPOINT.MESSAGES}?channelIds=${ !channelId || channelId === "main" ? "" : channelId - }&messageId=&needPastMessage=${true}&date=${date ?? ""}`, + }`, { headers: { ...getPrivateHeaders() }, + params: { + keyword: keyword ?? "", + messageId: "", + needPastMessage: true, + date: date ?? "", + }, } ); @@ -43,9 +50,15 @@ export const getMessages = const { data } = await fetcher.get( `${API_ENDPOINT.MESSAGES}?channelIds=${ !channelId || channelId === "main" ? "" : channelId - }&messageId=${messageId}&needPastMessage=${needPastMessage}&date=${currentDate}`, + }`, { headers: { ...getPrivateHeaders() }, + params: { + keyword, + messageId, + needPastMessage, + date: currentDate, + }, } ); diff --git a/frontend/src/api/reminders.ts b/frontend/src/api/reminders.ts new file mode 100644 index 00000000..a712ac00 --- /dev/null +++ b/frontend/src/api/reminders.ts @@ -0,0 +1,52 @@ +import { API_ENDPOINT } from "@src/@constants"; +import { ResponseReminders } from "@src/@types/shared"; +import { fetcher } from "."; +import { getPrivateHeaders } from "./utils"; + +interface ReminderProps { + messageId: number; + reminderDate: string; +} + +interface GetReminderParam { + pageParam?: string; +} + +export const getReminders = async ({ pageParam }: GetReminderParam) => { + const { data } = await fetcher.get( + API_ENDPOINT.REMINDERS, + { + headers: { ...getPrivateHeaders() }, + params: { + reminderId: pageParam ?? "", + }, + } + ); + + return data; +}; + +export const postReminder = async (postData: ReminderProps) => { + await fetcher.post(API_ENDPOINT.REMINDERS, postData, { + headers: { ...getPrivateHeaders() }, + }); +}; + +export const deleteReminder = async (messageId: number) => { + await fetcher.delete(API_ENDPOINT.REMINDERS, { + headers: { ...getPrivateHeaders() }, + params: { + messageId, + }, + }); +}; + +export const putReminder = async (modifyData: ReminderProps) => { + await fetcher.put( + API_ENDPOINT.REMINDERS, + { ...modifyData }, + { + headers: { ...getPrivateHeaders() }, + } + ); +}; diff --git a/frontend/src/api/utils.ts b/frontend/src/api/utils.ts index 4e8b087c..31c65e03 100644 --- a/frontend/src/api/utils.ts +++ b/frontend/src/api/utils.ts @@ -1,5 +1,9 @@ import { ACCESS_TOKEN_KEY } from "@src/@constants"; -import { ResponseMessages, ResponseBookmarks } from "@src/@types/shared"; +import { + ResponseMessages, + ResponseBookmarks, + ResponseReminders, +} from "@src/@types/shared"; import { getCookie } from "@src/@utils"; export const getPublicHeaders = () => { @@ -42,3 +46,12 @@ export const nextBookmarksCallback = ({ return bookmarks[bookmarks.length - 1]?.id; } }; + +export const nextRemindersCallback = ({ + isLast, + reminders, +}: ResponseReminders) => { + if (!isLast) { + return reminders[reminders.length - 1]?.id; + } +}; diff --git a/frontend/src/components/@layouts/Footer/index.tsx b/frontend/src/components/@layouts/Footer/index.tsx index a4842484..f6858c74 100644 --- a/frontend/src/components/@layouts/Footer/index.tsx +++ b/frontend/src/components/@layouts/Footer/index.tsx @@ -1,20 +1,21 @@ import GithubIcon from "@public/assets/icons/GithubIcon.svg"; -import YoutubeIcon from "@public/assets/icons/YoutubeIcon.svg"; import WrapperButton from "@src/components/@shared/WrapperButton"; import * as Styled from "./style"; function Footer() { return ( - Contact: rybshk@gmail.com ©2022 pickpick. - - - - - - + + + + + ); diff --git a/frontend/src/components/@layouts/LayoutContainer/index.tsx b/frontend/src/components/@layouts/LayoutContainer/index.tsx index 92577eaf..16fa11d9 100644 --- a/frontend/src/components/@layouts/LayoutContainer/index.tsx +++ b/frontend/src/components/@layouts/LayoutContainer/index.tsx @@ -1,23 +1,26 @@ +import { PropsWithChildren } from "react"; +import * as Styled from "./style"; import Header from "@src/components/@layouts/Header"; import Footer from "@src/components/@layouts/Footer"; -import * as Styled from "./style"; -import Navigation from "../Navigation"; +import Navigation from "@src/components/@layouts/Navigation"; import { useLocation } from "react-router-dom"; -import { Outlet } from "react-router-dom"; import { PATH_NAME } from "@src/@constants"; -function LayoutContainer() { +function LayoutContainer({ children }: PropsWithChildren) { const { pathname } = useLocation(); const hasHeader = () => pathname === PATH_NAME.HOME; - const hasNavBar = () => pathname !== PATH_NAME.HOME; + const hasNavBar = () => { + if (pathname === PATH_NAME.HOME) return false; + if (pathname === PATH_NAME.ADD_CHANNEL) return false; + + return true; + }; return ( {hasHeader() &&
} - - - + {children}