diff --git a/.gitignore b/.gitignore index 761264ac..ef40c631 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ src/main/resources/application-open-ai.yml src/main/resources/application-opensearch-local.yml src/main/resources/application-opensearch-dev.yml src/main/resources/application-opensearch-prod.yml + src/test/resources/application-storage-s3.yml src/test/resources/application-storage-s3-prod.yml src/test/resources/application-open-ai.yml diff --git a/build.gradle b/build.gradle index a210ad53..2b27277c 100644 --- a/build.gradle +++ b/build.gradle @@ -107,6 +107,7 @@ dependencies { test { useJUnitPlatform() outputs.dir snippetsDir + jvmArgs '-Xmx1024m', '-Xms512m' } clean { diff --git a/deploy-dev.sh b/deploy-dev.sh index 8da80efb..e3f9f3f0 100644 --- a/deploy-dev.sh +++ b/deploy-dev.sh @@ -22,4 +22,5 @@ echo "> Build Docker image" docker build -t "$APP_NAME" "$REPOSITORY" echo "> Run the Docker container" -docker run -d -p 8080:8080 --name "$APP_NAME" "$APP_NAME" \ No newline at end of file + +docker run -d -p 8080:8080 --name "$APP_NAME" "$APP_NAME" diff --git a/deploy-prod.sh b/deploy-prod.sh index 8bd61b5b..1ef09e52 100644 --- a/deploy-prod.sh +++ b/deploy-prod.sh @@ -22,4 +22,5 @@ echo "> Build Docker image" docker build -t "$APP_NAME" "$REPOSITORY" echo "> Run the Docker container" -docker run -d -p 8080:8080 --name "$APP_NAME" -v /home/ubuntu/pinpoint-agent-3.0.0:/pinpoint-agent "$APP_NAME" \ No newline at end of file + +docker run -d -p 8080:8080 --name "$APP_NAME" -v /home/ubuntu/pinpoint-agent-3.0.0:/pinpoint-agent "$APP_NAME" diff --git a/src/docs/asciidoc/api/mypage/mypage.adoc b/src/docs/asciidoc/api/mypage/mypage.adoc index d4b198dc..b11e06bb 100644 --- a/src/docs/asciidoc/api/mypage/mypage.adoc +++ b/src/docs/asciidoc/api/mypage/mypage.adoc @@ -6,3 +6,4 @@ include::exit-member.adoc[] include::exit-survey.adoc[] include::record-exit-survey.adoc[] include::comment-get.adoc[] +include::subscribed-companies.adoc[] diff --git a/src/docs/asciidoc/api/mypage/subscribed-companies.adoc b/src/docs/asciidoc/api/mypage/subscribed-companies.adoc new file mode 100644 index 00000000..2f731683 --- /dev/null +++ b/src/docs/asciidoc/api/mypage/subscribed-companies.adoc @@ -0,0 +1,34 @@ +[[Subscribed-Companies]] +== 회원이 구독한 기업 목록 조회 API(GET: /devdevdev/api/v1/mypage/subscriptions/companies) + +* 모든 회원은 자신이 구독한 기업 목록을 조회할 수 있다. + +=== 정상 요청/응답 + +==== HTTP Request + +include::{snippets}/subscribed-companies/http-request.adoc[] + +==== HTTP Request Header Fields + +include::{snippets}/subscribed-companies/request-headers.adoc[] + +==== HTTP Request Query Parameters Fields + +include::{snippets}/subscribed-companies/query-parameters.adoc[] + +==== HTTP Response + +include::{snippets}/subscribed-companies/http-response.adoc[] + +==== HTTP Response Fields + +include::{snippets}/subscribed-companies/response-fields.adoc[] + +=== 예외 + +==== HTTP Response + +* `회원을 찾을 수 없습니다.`: 회원이 존재하지 않는 경우 + +include::{snippets}/subscribed-companies-not-found-member-exception/response-body.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/api/notification/get-notification-popup.adoc b/src/docs/asciidoc/api/notification/get-notification-popup.adoc new file mode 100644 index 00000000..0dd2dc36 --- /dev/null +++ b/src/docs/asciidoc/api/notification/get-notification-popup.adoc @@ -0,0 +1,36 @@ +[[Get-Notification-Popup]] +== 알림 팝업 조회 API (GET: /devdevdev/api/v1/notifications/popup) + +* 회원이 알림 팝업 리스트를 조회한다. + +=== 정상 요청/응답 + +==== HTTP Request + +include::{snippets}/get-notification-popup/http-request.adoc[] + +==== HTTP Request Header Fields + +include::{snippets}/get-notification-popup/request-headers.adoc[] + +==== HTTP Request Query Parameters + +include::{snippets}/get-notification-popup/query-parameters.adoc[] + +==== HTTP Response + +include::{snippets}/get-notification-popup/http-response.adoc[] + +==== HTTP Response Fields + +include::{snippets}/get-notification-popup/response-fields.adoc[] + + +=== 예외 + +==== HTTP Response + +* `익명 회원은 사용할 수 없는 기능 입니다.`: 익명 회원인 경우 +* `회원을 찾을 수 없습니다.`: 회원이 존재하지 않는 경우 + +include::{snippets}/not-found-member-exception/response-body.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/api/notification/get-notification-unread-count.adoc b/src/docs/asciidoc/api/notification/get-notification-unread-count.adoc new file mode 100644 index 00000000..1bdb0165 --- /dev/null +++ b/src/docs/asciidoc/api/notification/get-notification-unread-count.adoc @@ -0,0 +1,32 @@ +[[Get-Notification-Unread-Count]] +== 알림 개수 조회 API (GET: /devdevdev/api/v1/notifications/unread-count) + +* 회원이 읽지 않은 알림의 총 개수를 조회한다. + +=== 정상 요청/응답 + +==== HTTP Request + +include::{snippets}/get-notification-unread-count/http-request.adoc[] + +==== HTTP Request Header Fields + +include::{snippets}/get-notification-unread-count/request-headers.adoc[] + +==== HTTP Response + +include::{snippets}/get-notification-unread-count/http-response.adoc[] + +==== HTTP Response Fields + +include::{snippets}/get-notification-unread-count/response-fields.adoc[] + + +=== 예외 + +==== HTTP Response + +* `익명 회원은 사용할 수 없는 기능 입니다.`: 익명 회원인 경우 +* `회원을 찾을 수 없습니다.`: 회원이 존재하지 않는 경우 + +include::{snippets}/not-found-member-exception/response-body.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/api/notification/get-notifications.adoc b/src/docs/asciidoc/api/notification/get-notifications.adoc new file mode 100644 index 00000000..fa67b4a8 --- /dev/null +++ b/src/docs/asciidoc/api/notification/get-notifications.adoc @@ -0,0 +1,37 @@ +[[Get-Notifications]] +== 알림 페이지 조회 API (GET: /devdevdev/api/v1/notifications/page) + +* 회원은 알림 페이지에서 자신에게 온 알림을 무한스크롤링으로 조회할 수 있다. +* (추후 추가) 알림 유형에 따라 필터링하여 조회할 수 있다. + +=== 정상 요청/응답 + +==== HTTP Request + +include::{snippets}/get-notifications/http-request.adoc[] + +==== HTTP Request Header Fields + +include::{snippets}/get-notifications/request-headers.adoc[] + +==== HTTP Request Query Parameters + +include::{snippets}/get-notifications/query-parameters.adoc[] + +==== HTTP Response + +include::{snippets}/get-notifications/http-response.adoc[] + +==== HTTP Response Fields + +include::{snippets}/get-notifications/response-fields.adoc[] + + +=== 예외 + +==== HTTP Response + +* `익명 회원은 사용할 수 없는 기능 입니다.`: 익명 회원인 경우 +* `회원을 찾을 수 없습니다.`: 회원이 존재하지 않는 경우 + +include::{snippets}/not-found-member-exception/response-body.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/api/notification/notification.adoc b/src/docs/asciidoc/api/notification/notification.adoc new file mode 100644 index 00000000..bf45bf9e --- /dev/null +++ b/src/docs/asciidoc/api/notification/notification.adoc @@ -0,0 +1,9 @@ += 알림 + +include::notifications.adoc[] +include::publish-notifications.adoc[] +include::read-notification.adoc[] +include::read-all-notification.adoc[] +include::get-notifications.adoc[] +include::get-notification-popup.adoc[] +include::get-notification-unread-count.adoc[] diff --git a/src/docs/asciidoc/api/notification/notifications.adoc b/src/docs/asciidoc/api/notification/notifications.adoc new file mode 100644 index 00000000..40985efd --- /dev/null +++ b/src/docs/asciidoc/api/notification/notifications.adoc @@ -0,0 +1,74 @@ +[[Notifications]] +== 실시간 알림 수신 API(GET: /devdevdev/api/v1/notifications) + +* 회원이 알림을 실시간으로 수신한다. +* 알림 처리 방식은 https://developer.mozilla.org/ko/docs/Web/API/Server-sent_events/Using_server-sent_events[Server-Sent Events]를 사용한다. +** 5분의 타임아웃, 30초 마다 heartbeat 를 전송한다. +* *기술블로그 알림 시나리오* +** SSE 구독 상태인 경우 +1. SSE 구독 +2. 읽지 않은 알림이 있는 경우 +a. `"읽지 않은 알림이 %d개 있어요!"` 전송 +b. 새로운 글 발행 +c. `"%s에서 새로운 기슬블로그 %d개가 올라왔어요!"` 전송 +3. 읽지 않은 알림이 없는 경우 +a. 새로운 글 발행 +b. `"%s에서 새로운 기슬블로그 %d개가 올라왔어요!"` 전송 +** SSE 구독 상태가 아닌 경우 +1. 새로운 글 발행 +2. SSE 구독 +a. `"읽지 않은 알림이 %d개 있어요!"` 전송 + +=== 정상 요청/응답 + +==== HTTP Request + +include::{snippets}/notifications/http-request.adoc[] + +==== HTTP Request Header Fields + +include::{snippets}/notifications/request-headers.adoc[] + +==== HTTP Response + +include::{snippets}/notifications/http-response.adoc[] + +==== HTTP Response Header Fields + +include::{snippets}/notifications/response-headers.adoc[] + +==== HTTP Response Body + +include::{snippets}/notifications/response-body.adoc[] + +|=== +|Path |Type |Optional |Description |Format + +|data +|Object +| +|알림 +| + +|message +|String +| +|알림 메시지 +| + +|createdAt +|String +| +|알림 발생 일시 +|'yyyy-MM-dd'T'HH:mm:ss.SSSZ' +|=== + +=== 예외 + +==== HTTP Response + +* `익명 회원은 사용할 수 없는 기능 입니다.`: 익명 회원인 경우 +* `회원을 찾을 수 없습니다.`: 회원이 존재하지 않는 경우 +* `유효하지 않은 회원 입니다.`: 회원이 유효하지 않은 경우 + +include::{snippets}/not-found-member-exception/response-body.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/api/notification/publish-notifications.adoc b/src/docs/asciidoc/api/notification/publish-notifications.adoc new file mode 100644 index 00000000..4b87a692 --- /dev/null +++ b/src/docs/asciidoc/api/notification/publish-notifications.adoc @@ -0,0 +1,41 @@ +[[Publish-Notifications]] +== 알림 생성 API(POST: /devdevdev/api/v1/notifications/{channel}) + +* 알림을 생성한다. +** service-name, api-key 가 일치 하지 않으면 호출 할 수 없다. + +=== 정상 요청/응답 + +==== HTTP Request + +include::{snippets}/publish-notifications/http-request.adoc[] + +==== HTTP Request Header Fields + +include::{snippets}/publish-notifications/request-headers.adoc[] + +==== HTTP Request Fields + +include::{snippets}/publish-notifications/request-fields.adoc[] + +==== HTTP Request Path Parameters Fields + +include::{snippets}/publish-notifications/path-parameters.adoc[] + +==== HTTP Response + +include::{snippets}/publish-notifications/http-response.adoc[] + +==== HTTP Response Body Fields + +include::{snippets}/publish-notifications/response-fields.adoc[] + +=== 예외 + +==== HTTP Response + +* `올바른 입력 값이 아닙니다.`: 잘못된 알림 채널을 입력한 경우 +* `접근할 수 없는 권한 입니다.`: 서비스 이름과 api-key가 일치하지 않는 경우 +* `지원하는 서비스가 아닙니다.`: 지원하는 서비스가 아닌 경우 + +include::{snippets}/publish-notifications-exception/response-body.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/api/notification/read-all-notification.adoc b/src/docs/asciidoc/api/notification/read-all-notification.adoc new file mode 100644 index 00000000..7e126696 --- /dev/null +++ b/src/docs/asciidoc/api/notification/read-all-notification.adoc @@ -0,0 +1,33 @@ +[[Read-All-Notifications]] +== 모든 알림 읽기 API (PATCH: /devdevdev/api/v1/notifications/read-all) + +* 회원이 자신에게 온 모든 알림을 읽기 처리한다. +* 회원이 읽을 알림이 하나도 없어도 예외가 발생하지 않고 성공한다. + +=== 정상 요청/응답 + +==== HTTP Request + +include::{snippets}/read-all-notifications/http-request.adoc[] + +==== HTTP Request Header Fields + +include::{snippets}/read-all-notifications/request-headers.adoc[] + +==== HTTP Response + +include::{snippets}/read-all-notifications/http-response.adoc[] + +==== HTTP Response Fields + +include::{snippets}/read-all-notifications/response-fields.adoc[] + + +=== 예외 + +==== HTTP Response + +* `익명 회원은 사용할 수 없는 기능 입니다.`: 익명 회원인 경우 +* `회원을 찾을 수 없습니다.`: 회원이 존재하지 않는 경우 + +include::{snippets}/not-found-member-exception/response-body.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/api/notification/read-notification.adoc b/src/docs/asciidoc/api/notification/read-notification.adoc new file mode 100644 index 00000000..5ced5c7f --- /dev/null +++ b/src/docs/asciidoc/api/notification/read-notification.adoc @@ -0,0 +1,36 @@ +[[Read-Notification]] +== 단건 알림 읽기 API (PATCH: /devdevdev/api/v1/notifications/{notificationId}/read) + +* 회원이 자신에게 온 단건 알림을 읽기 처리한다. + +=== 정상 요청/응답 + +==== HTTP Request + +include::{snippets}/read-notification/http-request.adoc[] + +==== HTTP Request Header Fields + +include::{snippets}/read-notification/request-headers.adoc[] + +==== HTTP Request Path Parameters + +include::{snippets}/read-notification/path-parameters.adoc[] + +==== HTTP Response + +include::{snippets}/read-notification/http-response.adoc[] + +==== HTTP Response Fields + +include::{snippets}/read-notification/response-fields.adoc[] + +=== 예외 + +==== HTTP Response + +* `익명 회원은 사용할 수 없는 기능 입니다.`: 익명 회원인 경우 +* `회원을 찾을 수 없습니다.`: 회원이 존재하지 않는 경우 +* `존재하지 않는 알림입니다.`: 알림이 존재하지 않거나 회원의 알림이 아닐 경우 + +include::{snippets}/read-notification-not-found/response-body.adoc[] diff --git a/src/docs/asciidoc/api/subscription/subscribable-companies.adoc b/src/docs/asciidoc/api/subscription/subscribable-companies.adoc new file mode 100644 index 00000000..cd7cbe94 --- /dev/null +++ b/src/docs/asciidoc/api/subscription/subscribable-companies.adoc @@ -0,0 +1,26 @@ +[[Subscribable-Companies]] +== 구독 가능한 기업 목록 조회 API(GET: /devdevdev/api/v1/subscriptions/companies) + +* 회원 또는 익명회원이 구독 가능한 기업 목록을 조회한다. + +=== 정상 요청/응답 + +==== HTTP Request + +include::{snippets}/subscribable-companies/http-request.adoc[] + +==== HTTP Request Header Fields + +include::{snippets}/subscribable-companies/request-headers.adoc[] + +==== HTTP Request Query Parameters Fields + +include::{snippets}/subscribable-companies/query-parameters.adoc[] + +==== HTTP Response + +include::{snippets}/subscribable-companies/http-response.adoc[] + +==== HTTP Response Fields + +include::{snippets}/subscribable-companies/response-fields.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/api/subscription/subscribable-company-detail.adoc b/src/docs/asciidoc/api/subscription/subscribable-company-detail.adoc new file mode 100644 index 00000000..6032dc03 --- /dev/null +++ b/src/docs/asciidoc/api/subscription/subscribable-company-detail.adoc @@ -0,0 +1,35 @@ +[[Subscribable-Companies-Detail]] +== 구독 가능한 기업 상세 조회 API(GET: /devdevdev/api/v1/subscriptions/companies/{companyId}) + +* 모든 회원은 구독 가능한 기업의 상세 정보를 조회할 수 있다. + +=== 정상 요청/응답 + +==== HTTP Request + +include::{snippets}/subscribable-company-detail/http-request.adoc[] + +==== HTTP Request Header Fields + +include::{snippets}/subscribable-company-detail/request-headers.adoc[] + +==== HTTP Request Path Parameters Fields + +include::{snippets}/subscribable-company-detail/path-parameters.adoc[] + +==== HTTP Response + +include::{snippets}/subscribable-company-detail/http-response.adoc[] + +==== HTTP Response Fields + +include::{snippets}/subscribable-company-detail/response-fields.adoc[] + +=== 예외 + +==== HTTP Response + +* `회원을 찾을 수 없습니다.`: 회원이 존재하지 않는 경우 +* `존재하지 않는 기업 입니다.`: 존재하지 않는 기업인 경우 + +include::{snippets}/subscribable-company-detail-not-found-exception/response-body.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/api/subscription/subscribe.adoc b/src/docs/asciidoc/api/subscription/subscribe.adoc new file mode 100644 index 00000000..bb5190d4 --- /dev/null +++ b/src/docs/asciidoc/api/subscription/subscribe.adoc @@ -0,0 +1,38 @@ +[[Subscribe]] +== 기업 구독 API(POST: /devdevdev/api/v1/subscriptions) + +* 회원은 구독한 가능한 기업을 구독 할 수 있다. + +=== 정상 요청/응답 + +==== HTTP Request + +include::{snippets}/subscribe-company/http-request.adoc[] + +==== HTTP Request Header Fields + +include::{snippets}/subscribe-company/request-headers.adoc[] + +==== HTTP Request Fields + +include::{snippets}/subscribe-company/request-fields.adoc[] + +==== HTTP Response + +include::{snippets}/subscribe-company/http-response.adoc[] + +==== HTTP Response Fields + +include::{snippets}/subscribe-company/response-fields.adoc[] + +=== 예외 + +==== HTTP Response + +* `익명 회원은 사용할 수 없는 기능 입니다.`: 익명 회원인 경우 +* `회원을 찾을 수 없습니다.`: 회원이 존재하지 않는 경우 +* `이미 구독하고 있는 기업입니다.`: 이미 구독하고 있는 기업인 경우 +* `존재하지 않는 기업 입니다.`: 존재하지 않는 기업인 경우 +* `기업 아이디는 필수 입니다.`: 기업 아이디가 null 인 경우 + +include::{snippets}/subscribe-company-not-found-company/response-body.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/api/subscription/subscription.adoc b/src/docs/asciidoc/api/subscription/subscription.adoc new file mode 100644 index 00000000..332a1ddf --- /dev/null +++ b/src/docs/asciidoc/api/subscription/subscription.adoc @@ -0,0 +1,6 @@ += 기술 블로그 구독 + +include::subscribe.adoc[] +include::unsubscribe.adoc[] +include::subscribable-companies.adoc[] +include::subscribable-company-detail.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/api/subscription/unsubscribe.adoc b/src/docs/asciidoc/api/subscription/unsubscribe.adoc new file mode 100644 index 00000000..0281d376 --- /dev/null +++ b/src/docs/asciidoc/api/subscription/unsubscribe.adoc @@ -0,0 +1,37 @@ +[[Ununsubscribe]] +== 기업 구독 취소 API(DELETE: /devdevdev/api/v1/subscriptions) + +* 회원은 구독한 가능한 기업을 구독 할 수 있다. + +=== 정상 요청/응답 + +==== HTTP Request + +include::{snippets}/unsubscribe-company/http-request.adoc[] + +==== HTTP Request Header Fields + +include::{snippets}/unsubscribe-company/request-headers.adoc[] + +==== HTTP Request Fields + +include::{snippets}/unsubscribe-company/request-fields.adoc[] + +==== HTTP Response + +include::{snippets}/unsubscribe-company/http-response.adoc[] + +==== HTTP Response Fields + +include::{snippets}/unsubscribe-company/response-fields.adoc[] + +=== 예외 + +==== HTTP Response + +* `익명 회원은 사용할 수 없는 기능 입니다.`: 익명 회원인 경우 +* `회원을 찾을 수 없습니다.`: 회원이 존재하지 않는 경우 +* `구독 이력이 없습니다.`: 구독 이력이 없는 경우 +* `기업 아이디는 필수 입니다.`: 기업 아이디가 null 인 경우 + +include::{snippets}/unsubscribe-company-not-found-subscription/response-body.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index 4a2a2a3e..d0caf71f 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -18,3 +18,5 @@ include::api/pick/pick.adoc[] include::api/pick-commnet/pick-comment.adoc[] include::api/tech-article/tech-article.adoc[] include::api/tech-article-comment/tech-article-comment.adoc[] +include::api/subscription/subscription.adoc[] +include::api/notification/notification.adoc[] diff --git a/src/main/java/com/dreamypatisiel/devdevdev/DevdevdevApplication.java b/src/main/java/com/dreamypatisiel/devdevdev/DevdevdevApplication.java index 325493e8..3debb559 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/DevdevdevApplication.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/DevdevdevApplication.java @@ -7,7 +7,11 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling +@EnableAsync @EnableJpaRepositories(basePackages = {"com.dreamypatisiel.devdevdev.domain.repository"}) @ConfigurationPropertiesScan @EnableJpaAuditing diff --git a/src/main/java/com/dreamypatisiel/devdevdev/LocalInitData.java b/src/main/java/com/dreamypatisiel/devdevdev/LocalInitData.java index 8b42e96d..a3dc2f7e 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/LocalInitData.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/LocalInitData.java @@ -1,28 +1,16 @@ package com.dreamypatisiel.devdevdev; -import com.dreamypatisiel.devdevdev.domain.entity.BlameType; -import com.dreamypatisiel.devdevdev.domain.entity.Bookmark; -import com.dreamypatisiel.devdevdev.domain.entity.Company; -import com.dreamypatisiel.devdevdev.domain.entity.Member; -import com.dreamypatisiel.devdevdev.domain.entity.MemberNicknameDictionary; -import com.dreamypatisiel.devdevdev.domain.entity.Pick; -import com.dreamypatisiel.devdevdev.domain.entity.PickOption; -import com.dreamypatisiel.devdevdev.domain.entity.PickOptionImage; -import com.dreamypatisiel.devdevdev.domain.entity.PickVote; -import com.dreamypatisiel.devdevdev.domain.entity.TechArticle; +import com.dreamypatisiel.devdevdev.domain.entity.*; import com.dreamypatisiel.devdevdev.domain.entity.embedded.CompanyName; import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; import com.dreamypatisiel.devdevdev.domain.entity.embedded.PickOptionContents; import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; import com.dreamypatisiel.devdevdev.domain.entity.embedded.Url; import com.dreamypatisiel.devdevdev.domain.entity.embedded.Word; -import com.dreamypatisiel.devdevdev.domain.entity.enums.ContentStatus; -import com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType; -import com.dreamypatisiel.devdevdev.domain.entity.enums.Role; -import com.dreamypatisiel.devdevdev.domain.entity.enums.SocialType; -import com.dreamypatisiel.devdevdev.domain.entity.enums.WordType; +import com.dreamypatisiel.devdevdev.domain.entity.enums.*; import com.dreamypatisiel.devdevdev.domain.policy.PickPopularScorePolicy; import com.dreamypatisiel.devdevdev.domain.repository.BlameTypeRepository; +import com.dreamypatisiel.devdevdev.domain.repository.notification.NotificationRepository; import com.dreamypatisiel.devdevdev.domain.repository.techArticle.BookmarkRepository; import com.dreamypatisiel.devdevdev.domain.repository.CompanyRepository; import com.dreamypatisiel.devdevdev.domain.repository.member.MemberRepository; @@ -82,6 +70,7 @@ public class LocalInitData { private final CompanyRepository companyRepository; private final MemberNicknameDictionaryRepository memberNicknameDictionaryRepository; private final BlameTypeRepository blameTypeRepository; + private final NotificationRepository notificationRepository; @EventListener(ApplicationReadyEvent.class) public void dataInsert() { @@ -118,6 +107,27 @@ public void dataInsert() { List blameTypes = createBlameTypes(); blameTypeRepository.saveAll(blameTypes); + + List notifications = createNotifications(member, techArticles); + notificationRepository.saveAll(notifications); + } + + private List createNotifications(Member member, List techArticles) { + List notifications = new ArrayList<>(); + techArticles.forEach(techArticle -> { + notifications.add(createNotification(member, techArticle)); + }); + return notifications; + } + + private static Notification createNotification(Member member, TechArticle techArticle) { + return Notification.builder() + .isRead(false) + .type(NotificationType.SUBSCRIPTION) + .member(member) + .message("새로운 글 등록되었습니다.") + .techArticle(techArticle) + .build(); } private List createNicknameDictionaryWords() { @@ -154,13 +164,13 @@ private static SocialMemberDto createSocialMemberDto(String username, String use private List createCompanies() { List companies = new ArrayList<>(); companies.add(createCompany("Toss", "https://toss.tech", - "https://toss.im/career/jobs")); + "https://toss.im/career/jobs", "https://company.net/image.png", "토스", "금융")); companies.add(createCompany("우아한 형제들", "https://techblog.woowahan.com", - "https://career.woowahan.com")); + "https://career.woowahan.com", "https://company.net/image.png", "우아한 형제들", "푸드")); companies.add(createCompany("AWS", "https://aws.amazon.com/ko/blogs/tech", - "https://aws.amazon.com/ko/careers")); + "https://aws.amazon.com/ko/careers", "https://company.net/image.png", "AWS", "클라우드")); companies.add(createCompany("채널톡", "https://channel.io/ko/blog", - "https://channel.io/ko/jobs")); + "https://channel.io/ko/jobs", "https://company.net/image.png", "채널톡", "채팅")); return companies; } @@ -172,11 +182,15 @@ private static Map getCompanyIdMap(List companies) { )); } - private static Company createCompany(String companyName, String officialUrl, String careerUrl) { + private static Company createCompany(String companyName, String officialUrl, String careerUrl, + String imageUrl, String description, String industry) { return Company.builder() .name(new CompanyName(companyName)) .careerUrl(new Url(careerUrl)) .officialUrl(new Url(officialUrl)) + .officialImageUrl(new Url(imageUrl)) + .description(description) + .industry(industry) .build(); } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/ApiKey.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/ApiKey.java new file mode 100644 index 00000000..a29b74f4 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/ApiKey.java @@ -0,0 +1,32 @@ +package com.dreamypatisiel.devdevdev.domain.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ApiKey extends BasicTime { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String serviceName; + private String apiKey; + + @Builder + private ApiKey(String serviceName, String apiKey) { + this.serviceName = serviceName; + this.apiKey = apiKey; + } + + public boolean isEqualsKey(String apiKey) { + return this.apiKey.equals(apiKey); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Bookmark.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Bookmark.java index 1bffca9c..d4ffc7bf 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Bookmark.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Bookmark.java @@ -22,7 +22,7 @@ public class Bookmark extends BasicTime { private Long id; @Column(nullable = false) - private boolean status; + private Boolean status; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Company.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Company.java index f1d59d94..86d0d9b6 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Company.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Company.java @@ -37,7 +37,11 @@ public class Company extends BasicTime { ) private Url officialUrl; - private String officialImageUrl; + @Embedded + @AttributeOverride(name = "url", + column = @Column(name = "official_image_url") + ) + private Url officialImageUrl; @Embedded @AttributeOverride(name = "url", @@ -45,21 +49,31 @@ public class Company extends BasicTime { ) private Url careerUrl; + @Column(length = 10) + private String industry; + + @Column(length = 500) + private String description; + @OneToMany(mappedBy = "company") private List techArticles = new ArrayList<>(); @Builder - private Company(CompanyName name, Url officialUrl, String officialImageUrl, Url careerUrl) { + private Company(CompanyName name, Url officialUrl, Url officialImageUrl, Url careerUrl, String industry, + String description) { this.name = name; this.officialUrl = officialUrl; this.officialImageUrl = officialImageUrl; this.careerUrl = careerUrl; + this.industry = industry; + this.description = description; + } + + public Company(Long id) { + this.id = id; } - public void changeTechArticles(List techArticles) { - for (TechArticle techArticle : techArticles) { - techArticle.changeCompany(this); - this.getTechArticles().add(techArticle); - } + public boolean isEqualsId(Long id) { + return this.id.equals(id); } } \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Member.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Member.java index f2107899..88f31f84 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Member.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Member.java @@ -110,6 +110,10 @@ public class Member extends BasicTime { @OneToMany(mappedBy = "member") private List recommends = new ArrayList<>(); + + public Member(Long id) { + this.id = id; + } @Builder private Member(String name, Nickname nickname, Email email, String password, String userId, String profileImage, diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Notification.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Notification.java index 8076fb46..d8da10aa 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Notification.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Notification.java @@ -5,30 +5,88 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; import lombok.NoArgsConstructor; @Entity +@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(indexes = { + @Index(name = "idx_notification_01", columnList = "id, member_id"), + @Index(name = "idx_notification_02", columnList = "member_id, is_read"), + @Index(name = "idx_notification_03", columnList = "member_id, type"), + @Index(name = "idx_notification_04", columnList = "member_id, tech_article_id"), +}) public class Notification extends BasicTime { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(length = 255, nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false, foreignKey = @ForeignKey(name = "fk_notification_01")) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tech_article_id", foreignKey = @ForeignKey(name = "fk_notification_02")) + private TechArticle techArticle; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tech_comment_id", foreignKey = @ForeignKey(name = "fk_notification_03")) + private TechComment techComment; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "pick_comment_id", foreignKey = @ForeignKey(name = "fk_notification_04")) + private PickComment pickComment; + + @Column(nullable = false) private String message; @Enumerated(value = EnumType.STRING) @Column(nullable = false) private NotificationType type; - @ManyToOne - @JoinColumn(name = "member_id", nullable = false) - private Member member; + @Column(nullable = false) + private Boolean isRead = false; + + @Builder + private Notification(Member member, TechArticle techArticle, TechComment techComment, PickComment pickComment, + String message, NotificationType type, Boolean isRead) { + this.member = member; + this.techArticle = techArticle; + this.techComment = techComment; + this.pickComment = pickComment; + this.message = message; + this.type = type; + this.isRead = isRead; + } + + public static Notification createTechArticleNotification(Member member, TechArticle techArticle, String message) { + return Notification.builder() + .member(member) + .techArticle(techArticle) + .message(message) + .type(NotificationType.SUBSCRIPTION) + .isRead(false) + .build(); + } + + public void markAsRead() { + this.isRead = true; + } + + public boolean isEqualsMember(Member member) { + return this.member.isEqualsId(member.getId()); + } } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Subscription.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Subscription.java index b8a928ea..9f704a93 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Subscription.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Subscription.java @@ -2,26 +2,54 @@ import jakarta.persistence.Entity; import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; import lombok.NoArgsConstructor; @Entity +@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(indexes = { + @Index(name = "idx_subscription_01", columnList = "member_id, company_id") +}) public class Subscription extends BasicTime { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "member_id", nullable = false) + @JoinColumn(name = "member_id", nullable = false, foreignKey = @ForeignKey(name = "fk_subscription_01")) private Member member; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "company_id", nullable = false) + @JoinColumn(name = "company_id", nullable = false, foreignKey = @ForeignKey(name = "fk_subscription_02")) private Company company; + + @Builder + private Subscription(Member member, Company company) { + this.member = member; + this.company = company; + } + + public static Subscription create(Member member, Company company) { + Subscription subscription = new Subscription(); + subscription.member = member; + subscription.company = company; + + return subscription; + } + + public boolean isEqualsCompany(Company company) { + return this.company.isEqualsId(company.getId()); + } } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/TechArticle.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/TechArticle.java index 9b49e445..a4d0a31b 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/TechArticle.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/TechArticle.java @@ -64,7 +64,7 @@ public class TechArticle extends BasicTime { @Embedded @AttributeOverride(name = "url", - column = @Column(name = "tech_article_url", length = 255) + column = @Column(name = "tech_article_url") ) private Url techArticleUrl; @@ -81,6 +81,10 @@ public class TechArticle extends BasicTime { @OneToMany(mappedBy = "techArticle") private List recommends = new ArrayList<>(); + public TechArticle(Long id) { + this.id = id; + } + @Builder private TechArticle(Title title, Count viewTotalCount, Count recommendTotalCount, Count commentTotalCount, Count popularScore, diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/TechArticleRecommend.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/TechArticleRecommend.java index d9302eb2..ef8b8c4a 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/TechArticleRecommend.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/TechArticleRecommend.java @@ -15,7 +15,7 @@ public class TechArticleRecommend extends BasicTime { private Long id; @Column(nullable = false) - private boolean status; + private Boolean status; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") @@ -30,7 +30,7 @@ public class TechArticleRecommend extends BasicTime { private TechArticle techArticle; @Builder - private TechArticleRecommend(boolean status, Member member, AnonymousMember anonymousMember, TechArticle techArticle) { + private TechArticleRecommend(Boolean status, Member member, AnonymousMember anonymousMember, TechArticle techArticle) { this.status = status; this.member = member; this.anonymousMember = anonymousMember; diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/embedded/Url.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/embedded/Url.java index 527e847e..05c67522 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/embedded/Url.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/embedded/Url.java @@ -2,7 +2,6 @@ import com.dreamypatisiel.devdevdev.exception.UrlException; import jakarta.persistence.Embeddable; -import java.util.regex.Pattern; import lombok.AccessLevel; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -15,8 +14,6 @@ @EqualsAndHashCode public class Url { - private static final String URL_REGEX = - "^((http|https)://)?([a-zA-Z0-9-]+(\\.[a-zA-Z]{2,})+)(/[a-zA-Z0-9-]*)*(\\?\\S*)?$"; public static final String INVALID_URL_MESSAGE = "알맞은 URL 형식이 아닙니다."; private String url; @@ -32,12 +29,5 @@ private void urlValidation(String url) { if (!valid) { throw new UrlException(INVALID_URL_MESSAGE); } -// if (!isValidUrl(url)) { -// throw new UrlException(INVALID_URL_MESSAGE); -// } - } - - private boolean isValidUrl(String url) { - return Pattern.matches(URL_REGEX, url); } } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/enums/NotificationType.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/enums/NotificationType.java index 050fcca2..6412f90b 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/enums/NotificationType.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/enums/NotificationType.java @@ -3,23 +3,18 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; +import java.util.List; + @Getter @RequiredArgsConstructor public enum NotificationType { - // 구독 알림 - // 댓글, 대댓글 - SUBSCRIPTION { - @Override - public String createMessage() { - return null; - } - }, COMMENT_AND_REPLY { - @Override - public String createMessage() { - return null; - } - }; + SUBSCRIPTION, // 구독 알림 + COMMENT_AND_REPLY; // 댓글, 대댓글 + + // 현재 서비스 제공 중인 알림 타입 리스트 + private static final List ENABLED_TYPES = List.of(SUBSCRIPTION); - private String message; - abstract public String createMessage(); + public static List getEnabledTypes() { + return ENABLED_TYPES; + } } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/exception/CompanyExceptionMessage.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/exception/CompanyExceptionMessage.java new file mode 100644 index 00000000..3cf475ff --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/exception/CompanyExceptionMessage.java @@ -0,0 +1,5 @@ +package com.dreamypatisiel.devdevdev.domain.exception; + +public class CompanyExceptionMessage { + public static final String NOT_FOUND_COMPANY_MESSAGE = "존재하지 않는 기업 입니다."; +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/exception/NotificationExceptionMessage.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/exception/NotificationExceptionMessage.java new file mode 100644 index 00000000..fcbf3081 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/exception/NotificationExceptionMessage.java @@ -0,0 +1,6 @@ +package com.dreamypatisiel.devdevdev.domain.exception; + +public class NotificationExceptionMessage { + public static final String NOT_FOUND_NOTIFICATION_MESSAGE = "존재하지 않는 알림입니다."; + public static final String NOT_FOUND_NOTIFICATION_TYPE = "존재하지 않는 알림 타입 입니다."; +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/exception/SubscriptionExceptionMessage.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/exception/SubscriptionExceptionMessage.java new file mode 100644 index 00000000..a0175ff7 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/exception/SubscriptionExceptionMessage.java @@ -0,0 +1,6 @@ +package com.dreamypatisiel.devdevdev.domain.exception; + +public class SubscriptionExceptionMessage { + public static final String NOT_FOUND_SUBSCRIPTION_MESSAGE = "구독 이력이 없습니다."; + public static final String ALREADY_SUBSCRIBED_COMPANY_MESSAGE = "이미 구독하고 있는 기업입니다."; +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/ApiKeyRepository.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/ApiKeyRepository.java new file mode 100644 index 00000000..82d18e3f --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/ApiKeyRepository.java @@ -0,0 +1,9 @@ +package com.dreamypatisiel.devdevdev.domain.repository; + +import com.dreamypatisiel.devdevdev.domain.entity.ApiKey; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ApiKeyRepository extends JpaRepository { + Optional findByServiceName(String serviceName); +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/CompanyRepository.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/CompanyRepository.java index 7834eae9..cc031919 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/CompanyRepository.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/CompanyRepository.java @@ -1,7 +1,8 @@ package com.dreamypatisiel.devdevdev.domain.repository; import com.dreamypatisiel.devdevdev.domain.entity.Company; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.custom.CompanyRepositoryCustom; import org.springframework.data.jpa.repository.JpaRepository; -public interface CompanyRepository extends JpaRepository { +public interface CompanyRepository extends JpaRepository, CompanyRepositoryCustom { } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/SseEmitterRepository.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/SseEmitterRepository.java new file mode 100644 index 00000000..e1292a4f --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/SseEmitterRepository.java @@ -0,0 +1,33 @@ +package com.dreamypatisiel.devdevdev.domain.repository; + +import com.dreamypatisiel.devdevdev.domain.entity.Member; +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.stereotype.Repository; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Repository +public class SseEmitterRepository { + private final Map sseEmitters = new ConcurrentHashMap<>(); + + public SseEmitter save(Member member, SseEmitter sseEmitter) { + return sseEmitters.put(member.getId(), sseEmitter); + } + + public void remove(Member member) { + sseEmitters.remove(member.getId()); + } + + public SseEmitter findByMemberId(Member member) { + return sseEmitters.get(member.getId()); + } + + public Collection findAll() { + return sseEmitters.values(); + } + + public boolean existByMember(Member member) { + return sseEmitters.containsKey(member.getId()); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/notification/NotificationRepository.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/notification/NotificationRepository.java new file mode 100644 index 00000000..31d71ca5 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/notification/NotificationRepository.java @@ -0,0 +1,18 @@ +package com.dreamypatisiel.devdevdev.domain.repository.notification; + +import com.dreamypatisiel.devdevdev.domain.entity.Member; +import com.dreamypatisiel.devdevdev.domain.entity.Notification; +import com.dreamypatisiel.devdevdev.domain.repository.notification.custom.NotificationRepositoryCustom; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationRepository extends JpaRepository, NotificationRepositoryCustom { + Optional findByIdAndMember(Long id, Member member); + + List findAllByMemberId(Long memberId); + + Long countByMemberAndIsReadIsFalse(Member member); + + void deleteAllByMember(Member member); +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/notification/custom/NotificationRepositoryCustom.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/notification/custom/NotificationRepositoryCustom.java new file mode 100644 index 00000000..10c516a5 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/notification/custom/NotificationRepositoryCustom.java @@ -0,0 +1,20 @@ +package com.dreamypatisiel.devdevdev.domain.repository.notification.custom; + +import com.dreamypatisiel.devdevdev.domain.entity.Member; +import com.dreamypatisiel.devdevdev.domain.entity.Notification; +import com.dreamypatisiel.devdevdev.domain.entity.enums.NotificationType; +import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Set; + +public interface NotificationRepositoryCustom { + void bulkMarkAllAsReadByMemberId(Long memberId); + SliceCustom findNotificationsByMemberAndTypeOrderByCreatedAtDesc( + Pageable pageable, List notificationTypes, Member member); + + SliceCustom findNotificationsByMemberAndCursor(Pageable pageable, Long notificationId, Member member); + List findByMemberInAndTechArticleIdInOrderByNull(Set members, Set techArticleIds); + Long countByMemberAndIsReadFalse(Member member); +} \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/notification/custom/NotificationRepositoryImpl.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/notification/custom/NotificationRepositoryImpl.java new file mode 100644 index 00000000..b2191df3 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/notification/custom/NotificationRepositoryImpl.java @@ -0,0 +1,92 @@ +package com.dreamypatisiel.devdevdev.domain.repository.notification.custom; + +import com.dreamypatisiel.devdevdev.domain.entity.Member; +import com.dreamypatisiel.devdevdev.domain.entity.Notification; +import com.dreamypatisiel.devdevdev.domain.entity.enums.NotificationType; +import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; +import com.querydsl.core.types.dsl.BooleanExpression; + +import static com.dreamypatisiel.devdevdev.domain.entity.QNotification.notification; +import static com.querydsl.core.types.dsl.Expressions.stringTemplate; + +import com.querydsl.jpa.JPQLQueryFactory; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +@RequiredArgsConstructor +public class NotificationRepositoryImpl implements NotificationRepositoryCustom { + + private final JPQLQueryFactory query; + + @Override + public void bulkMarkAllAsReadByMemberId(Long memberId) { + query.update(notification) + .set(notification.isRead, true) + .where(notification.member.id.eq(memberId) + .and(notification.isRead.isFalse())) + .execute(); + } + + @Override + public SliceCustom findNotificationsByMemberAndTypeOrderByCreatedAtDesc(Pageable pageable, + List notificationTypes, + Member member) { + List contents = query.selectFrom(notification) + .where(notification.member.eq(member) + .and(notification.type.in(notificationTypes))) + .orderBy(notification.createdAt.desc(), notification.id.desc()) + .limit(pageable.getPageSize()) + .fetch(); + + // 회원이 읽지 않은 알림 개수 + long unReadNotificationTotalCount = countByMemberAndIsReadFalse(member); + + return new SliceCustom<>(contents, pageable, false, unReadNotificationTotalCount); + } + + @Override + public SliceCustom findNotificationsByMemberAndCursor(Pageable pageable, Long notificationId, Member member) { + List contents = query.selectFrom(notification) + .where(notification.member.eq(member) + .and(getCursorCondition(notificationId))) + .orderBy(notification.createdAt.desc(), notification.id.desc()) + .limit(pageable.getPageSize()) + .fetch(); + + // 회원이 읽지 않은 알림 개수 + long unReadNotificationTotalCount = countByMemberAndIsReadFalse(member); + + return new SliceCustom<>(contents, pageable, unReadNotificationTotalCount); + } + + @Override + public List findByMemberInAndTechArticleIdInOrderByNull(Set members, Set techArticleIds) { + + return query.selectFrom(notification) + .where(notification.member.in(members) + .and(notification.techArticle.id.in(techArticleIds))) + .orderBy(stringTemplate("null").asc()) + .fetch(); + } + + @Override + public Long countByMemberAndIsReadFalse(Member member) { + return query.select(notification.count()) + .from(notification) + .where(notification.member.eq(member) + .and(notification.isRead.isFalse())) + .fetchOne(); + } + + private BooleanExpression getCursorCondition(Long notificationId) { + if (notificationId == null) { + return null; + } + + return notification.id.lt(notificationId); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/PickSort.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/PickSort.java index ee8c458a..71c069af 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/PickSort.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/PickSort.java @@ -1,5 +1,6 @@ package com.dreamypatisiel.devdevdev.domain.repository.pick; + import static com.dreamypatisiel.devdevdev.domain.entity.QPick.pick; import com.dreamypatisiel.devdevdev.domain.entity.Pick; @@ -15,7 +16,7 @@ public enum PickSort { LATEST("최신순") { @Override - public OrderSpecifier getOrderSpecifierByPickSort() { + public OrderSpecifier getOrderSpecifierByPickSort() { return new OrderSpecifier<>(Order.DESC, pick.createdAt); } @@ -26,7 +27,7 @@ public BooleanExpression getCursorCondition(Pick findPick) { }, POPULAR("인기순") { @Override - public OrderSpecifier getOrderSpecifierByPickSort() { + public OrderSpecifier getOrderSpecifierByPickSort() { return new OrderSpecifier<>(Order.DESC, pick.popularScore.count); } @@ -39,7 +40,7 @@ public BooleanExpression getCursorCondition(Pick findPick) { }, MOST_VIEWED("조회수") { @Override - public OrderSpecifier getOrderSpecifierByPickSort() { + public OrderSpecifier getOrderSpecifierByPickSort() { return new OrderSpecifier<>(Order.DESC, pick.viewTotalCount.count); } @@ -52,7 +53,7 @@ public BooleanExpression getCursorCondition(Pick findPick) { }, MOST_COMMENTED("댓글순") { @Override - public OrderSpecifier getOrderSpecifierByPickSort() { + public OrderSpecifier getOrderSpecifierByPickSort() { return new OrderSpecifier<>(Order.DESC, pick.commentTotalCount.count); } @@ -65,7 +66,8 @@ public BooleanExpression getCursorCondition(Pick findPick) { }; - abstract public OrderSpecifier getOrderSpecifierByPickSort(); + abstract public OrderSpecifier getOrderSpecifierByPickSort(); + abstract public BooleanExpression getCursorCondition(Pick pick); private final String description; diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/custom/PickCommentRepositoryImpl.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/custom/PickCommentRepositoryImpl.java index f67f74f5..f1deb0a6 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/custom/PickCommentRepositoryImpl.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/custom/PickCommentRepositoryImpl.java @@ -1,12 +1,11 @@ package com.dreamypatisiel.devdevdev.domain.repository.pick.custom; +import com.dreamypatisiel.devdevdev.domain.entity.PickComment; import static com.dreamypatisiel.devdevdev.domain.entity.QMember.member; import static com.dreamypatisiel.devdevdev.domain.entity.QPick.pick; import static com.dreamypatisiel.devdevdev.domain.entity.QPickComment.pickComment; import static com.dreamypatisiel.devdevdev.domain.entity.QPickOption.pickOption; import static com.dreamypatisiel.devdevdev.domain.entity.QPickVote.pickVote; - -import com.dreamypatisiel.devdevdev.domain.entity.PickComment; import com.dreamypatisiel.devdevdev.domain.entity.enums.ContentStatus; import com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType; import com.dreamypatisiel.devdevdev.domain.repository.comment.MyWrittenCommentDto; @@ -120,7 +119,7 @@ public SliceCustom findMyWrittenPickCommentsByCursor(Long m .and(pickComment.deletedAt.isNull())) .fetchCount(); - return new SliceCustom<>(contents, pageable, hasNextPage(contents, pageable.getPageSize()), totalElements); + return new SliceCustom<>(contents, pageable, totalElements); } private static BooleanExpression pickOptionTypeIn(EnumSet pickOptionTypes) { diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/BookmarkSort.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/BookmarkSort.java index 3a12c193..6214778d 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/BookmarkSort.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/BookmarkSort.java @@ -1,5 +1,8 @@ package com.dreamypatisiel.devdevdev.domain.repository.techArticle; +import static com.dreamypatisiel.devdevdev.domain.entity.QBookmark.bookmark; +import static com.dreamypatisiel.devdevdev.domain.entity.QTechArticle.techArticle; + import com.dreamypatisiel.devdevdev.domain.entity.Bookmark; import com.dreamypatisiel.devdevdev.domain.entity.TechArticle; import com.querydsl.core.types.Order; @@ -8,9 +11,6 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; -import static com.dreamypatisiel.devdevdev.domain.entity.QBookmark.bookmark; -import static com.dreamypatisiel.devdevdev.domain.entity.QTechArticle.techArticle; - @Getter @RequiredArgsConstructor public enum BookmarkSort { @@ -60,5 +60,6 @@ public BooleanExpression getCursorCondition(Bookmark findBookmark, TechArticle f private final String description; abstract public OrderSpecifier getOrderSpecifierByBookmarkSort(); + abstract public BooleanExpression getCursorCondition(Bookmark findBookmark, TechArticle findTechArticle); } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/SubscriptionRepository.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/SubscriptionRepository.java new file mode 100644 index 00000000..9c53e7b3 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/SubscriptionRepository.java @@ -0,0 +1,19 @@ +package com.dreamypatisiel.devdevdev.domain.repository.techArticle; + +import com.dreamypatisiel.devdevdev.domain.entity.Company; +import com.dreamypatisiel.devdevdev.domain.entity.Member; +import com.dreamypatisiel.devdevdev.domain.entity.Subscription; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.custom.SubscriptionRepositoryCustom; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SubscriptionRepository extends JpaRepository, SubscriptionRepositoryCustom { + Optional findByMemberAndCompanyId(Member member, Long companyId); + + List findByMemberAndCompanyIn(Member member, List companies); + + @EntityGraph(attributePaths = {"member"}) + List findWithMemberByCompanyIdOrderByMemberDesc(Long companyId); +} \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/TechArticleRepository.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/TechArticleRepository.java index 25955ff9..270fcbde 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/TechArticleRepository.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/TechArticleRepository.java @@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface TechArticleRepository extends JpaRepository, TechArticleRepositoryCustom { + Long countByCompanyId(Long companyId); } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/CompanyRepositoryCustom.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/CompanyRepositoryCustom.java new file mode 100644 index 00000000..c137b6b2 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/CompanyRepositoryCustom.java @@ -0,0 +1,17 @@ +package com.dreamypatisiel.devdevdev.domain.repository.techArticle.custom; + +import com.dreamypatisiel.devdevdev.domain.entity.Company; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.custom.dto.CompanyDetailDto; +import java.util.Optional; + +import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +public interface CompanyRepositoryCustom { + Slice findCompanyByCursor(Pageable pageable, Long companyId); + + Optional findCompanyDetailDtoByMemberIdAndCompanyId(Long memberId, Long companyId); + + SliceCustom findSubscribedCompaniesByMemberByCursor(Pageable pageable, Long companyId, Long memberId); +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/CompanyRepositoryImpl.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/CompanyRepositoryImpl.java new file mode 100644 index 00000000..d8d07b74 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/CompanyRepositoryImpl.java @@ -0,0 +1,92 @@ +package com.dreamypatisiel.devdevdev.domain.repository.techArticle.custom; + +import static com.dreamypatisiel.devdevdev.domain.entity.QCompany.company; +import static com.dreamypatisiel.devdevdev.domain.entity.QSubscription.subscription; + +import com.dreamypatisiel.devdevdev.domain.entity.Company; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.custom.dto.CompanyDetailDto; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.custom.dto.QCompanyDetailDto; +import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPQLQueryFactory; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +@RequiredArgsConstructor +public class CompanyRepositoryImpl implements CompanyRepositoryCustom { + + private final JPQLQueryFactory query; + + @Override + public Slice findCompanyByCursor(Pageable pageable, Long companyId) { + List contents = query.selectFrom(company) + .where(getCursorCondition(companyId)) + .orderBy(company.id.desc()) + .limit(pageable.getPageSize()) + .fetch(); + + return new SliceImpl<>(contents, pageable, contents.size() >= pageable.getPageSize()); + } + + @Override + public Optional findCompanyDetailDtoByMemberIdAndCompanyId(Long memberId, Long companyId) { + + CompanyDetailDto companyDetailDto = query.select( + new QCompanyDetailDto( + company.id, + company.careerUrl.url, + company.name.companyName, + company.industry, + company.officialImageUrl.url, + company.description, + subscription.id + )) + .from(company) + .leftJoin(subscription).on(subscription.company.id.eq(company.id) + .and(subscription.member.id.eq(memberId))) + .where(company.id.eq(companyId)) + .fetchOne(); + + return Optional.ofNullable(companyDetailDto); + } + + @Override + public SliceCustom findSubscribedCompaniesByMemberByCursor(Pageable pageable, Long companyId, Long memberId) { + // cursor 기준으로 회사 조회하되, subscription 테이블과 left join하여 member의 구독 여부가 있는지 확인해서 구독한 회사만 + List contents = query.selectFrom(company) + .leftJoin(subscription).on(subscription.company.id.eq(company.id) + .and(subscription.member.id.eq(memberId))) + .where(getCursorCondition(companyId), + (subscription.id.isNotNull())) + .orderBy(company.id.desc()) + .limit(pageable.getPageSize()) + .fetch(); + + // 다음 페이지 존재 여부 + boolean hasNext = contents.size() >= pageable.getPageSize(); + + // 구독한 회사 총 갯수 + Long subscribedCompanyTotalCount = countSubscribedCompany(memberId); + + return new SliceCustom<>(contents, pageable, hasNext, subscribedCompanyTotalCount); + } + + // 구독한 회사 총 개수 + private Long countSubscribedCompany(Long memberId) { + return query.selectFrom(subscription) + .where(subscription.member.id.eq(memberId)) + .fetchCount(); + } + + private BooleanExpression getCursorCondition(Long companyId) { + if (companyId == null) { + return null; + } + + return company.id.lt(companyId); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/SubscriptionRepositoryCustom.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/SubscriptionRepositoryCustom.java new file mode 100644 index 00000000..7b285fed --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/SubscriptionRepositoryCustom.java @@ -0,0 +1,5 @@ +package com.dreamypatisiel.devdevdev.domain.repository.techArticle.custom; + +public interface SubscriptionRepositoryCustom { + Boolean existsByMemberIdAndCompanyId(Long memberId, Long companyId); +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/SubscriptionRepositoryImpl.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/SubscriptionRepositoryImpl.java new file mode 100644 index 00000000..463eca90 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/SubscriptionRepositoryImpl.java @@ -0,0 +1,24 @@ +package com.dreamypatisiel.devdevdev.domain.repository.techArticle.custom; + +import static com.dreamypatisiel.devdevdev.domain.entity.QSubscription.subscription; + +import com.querydsl.jpa.JPQLQueryFactory; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class SubscriptionRepositoryImpl implements SubscriptionRepositoryCustom { + + private final JPQLQueryFactory query; + + // exists 직접 구현, jpa 에서 exists 사용하면 count(1) 로 조회하기 때문 + @Override + public Boolean existsByMemberIdAndCompanyId(Long memberId, Long companyId) { + + Integer fetchFirst = query.selectOne().from(subscription) + .where(subscription.member.id.eq(memberId) + .and(subscription.company.id.eq(companyId))) + .fetchFirst(); + + return fetchFirst != null; + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/dto/CompanyDetailDto.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/dto/CompanyDetailDto.java new file mode 100644 index 00000000..a7957b46 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/dto/CompanyDetailDto.java @@ -0,0 +1,27 @@ +package com.dreamypatisiel.devdevdev.domain.repository.techArticle.custom.dto; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.Getter; + +@Getter +public class CompanyDetailDto { + private Long companyId; + private String careerUrl; + private String name; + private String industry; + private String officialImageUrl; + private String description; + private Long subscriptionId; + + @QueryProjection + public CompanyDetailDto(Long companyId, String careerUrl, String name, String industry, String officialImageUrl, + String description, Long subscriptionId) { + this.companyId = companyId; + this.careerUrl = careerUrl; + this.name = name; + this.industry = industry; + this.officialImageUrl = officialImageUrl; + this.description = description; + this.subscriptionId = subscriptionId; + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/ApiKeyService.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/ApiKeyService.java new file mode 100644 index 00000000..e9244314 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/ApiKeyService.java @@ -0,0 +1,29 @@ +package com.dreamypatisiel.devdevdev.domain.service; + +import static com.dreamypatisiel.devdevdev.domain.service.notification.NotificationService.ACCESS_DENIED_MESSAGE; + +import com.dreamypatisiel.devdevdev.domain.entity.ApiKey; +import com.dreamypatisiel.devdevdev.domain.repository.ApiKeyRepository; +import com.dreamypatisiel.devdevdev.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ApiKeyService { + private final ApiKeyRepository apiKeyRepository; + + public void validateApiKey(String serviceName, String apiKey) { + + // API Key 조회 + ApiKey findApiKey = apiKeyRepository.findByServiceName(serviceName) + .orElseThrow(() -> new NotFoundException("지원하는 서비스가 아닙니다.")); + + if (!findApiKey.isEqualsKey(apiKey)) { + throw new AccessDeniedException(ACCESS_DENIED_MESSAGE); + } + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/member/MemberService.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/member/MemberService.java index ae0a1772..5aca6bbe 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/member/MemberService.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/member/MemberService.java @@ -1,15 +1,8 @@ package com.dreamypatisiel.devdevdev.domain.service.member; -import static com.dreamypatisiel.devdevdev.domain.exception.MemberExceptionMessage.MEMBER_INCOMPLETE_SURVEY_MESSAGE; - -import com.dreamypatisiel.devdevdev.domain.entity.Member; -import com.dreamypatisiel.devdevdev.domain.entity.Pick; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyAnswer; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyQuestion; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyQuestionOption; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyVersionQuestionMapper; -import com.dreamypatisiel.devdevdev.domain.entity.TechArticle; +import com.dreamypatisiel.devdevdev.domain.entity.*; import com.dreamypatisiel.devdevdev.domain.entity.embedded.CustomSurveyAnswer; +import com.dreamypatisiel.devdevdev.domain.repository.CompanyRepository; import com.dreamypatisiel.devdevdev.domain.repository.comment.CommentRepository; import com.dreamypatisiel.devdevdev.domain.repository.comment.MyWrittenCommentDto; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; @@ -33,12 +26,9 @@ import com.dreamypatisiel.devdevdev.web.dto.response.member.MemberExitSurveyQuestionResponse; import com.dreamypatisiel.devdevdev.web.dto.response.member.MemberExitSurveyResponse; import com.dreamypatisiel.devdevdev.web.dto.response.pick.MyPickMainResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscribedCompanyResponse; import com.dreamypatisiel.devdevdev.web.dto.response.techArticle.CompanyResponse; import com.dreamypatisiel.devdevdev.web.dto.response.techArticle.TechArticleMainResponse; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -47,6 +37,14 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static com.dreamypatisiel.devdevdev.domain.exception.MemberExceptionMessage.MEMBER_INCOMPLETE_SURVEY_MESSAGE; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -62,6 +60,7 @@ public class MemberService { private final SurveyQuestionOptionRepository surveyQuestionOptionRepository; private final SurveyAnswerJdbcTemplateRepository surveyAnswerJdbcTemplateRepository; private final CommentRepository commentRepository; + private final CompanyRepository companyRepository; /** * 회원 탈퇴 회원의 북마크와 회원 정보를 삭제합니다. @@ -263,4 +262,28 @@ public SliceCustom findMyWrittenComments(Pageable page return new SliceCustom<>(myWrittenCommentResponses, pageable, hasNext, totalElements); } + + /** + * @Note: 회원이 구독한 기업 목록을 조회합니다. + * @Author: 유소영 + * @Since: 2025.03.23 + */ + public SliceCustom findMySubscribedCompanies(Pageable pageable, + Long companyId, + Authentication authentication) { + // 회원 조회 + Member findMember = memberProvider.getMemberByAuthentication(authentication); + + // 회원이 구독한 기업 목록 조회(구독 조인) + SliceCustom subscribedCompanies = companyRepository.findSubscribedCompaniesByMemberByCursor(pageable, + companyId, findMember.getId()); + + // 데이터 가공 + List subscribedCompanyResponses = subscribedCompanies.getContent().stream().map( + company -> { + return SubscribedCompanyResponse.createWithIsSubscribed(company, true); + }).collect(Collectors.toList()); + + return new SliceCustom<>(subscribedCompanyResponses, pageable, subscribedCompanies.getTotalElements()); + } } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/notification/NotificationService.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/notification/NotificationService.java new file mode 100644 index 00000000..20345407 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/notification/NotificationService.java @@ -0,0 +1,389 @@ +package com.dreamypatisiel.devdevdev.domain.service.notification; + +import com.dreamypatisiel.devdevdev.domain.entity.Company; +import com.dreamypatisiel.devdevdev.domain.entity.Member; +import com.dreamypatisiel.devdevdev.domain.entity.Notification; +import com.dreamypatisiel.devdevdev.domain.entity.Subscription; +import com.dreamypatisiel.devdevdev.domain.entity.TechArticle; +import com.dreamypatisiel.devdevdev.domain.entity.enums.NotificationType; +import com.dreamypatisiel.devdevdev.domain.exception.NotificationExceptionMessage; +import com.dreamypatisiel.devdevdev.domain.repository.SseEmitterRepository; +import com.dreamypatisiel.devdevdev.domain.repository.notification.NotificationRepository; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.SubscriptionRepository; +import com.dreamypatisiel.devdevdev.domain.service.techArticle.techArticle.TechArticleCommonService; +import com.dreamypatisiel.devdevdev.elastic.domain.document.ElasticTechArticle; +import com.dreamypatisiel.devdevdev.exception.NotFoundException; +import com.dreamypatisiel.devdevdev.global.common.MemberProvider; +import com.dreamypatisiel.devdevdev.global.common.TimeProvider; +import com.dreamypatisiel.devdevdev.redis.pub.NotificationPublisher; +import com.dreamypatisiel.devdevdev.redis.sub.NotificationMessageDto; +import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; +import com.dreamypatisiel.devdevdev.web.dto.request.publish.PublishTechArticle; +import com.dreamypatisiel.devdevdev.web.dto.request.publish.PublishTechArticleRequest; +import com.dreamypatisiel.devdevdev.web.dto.request.publish.RedisPublishRequest; +import com.dreamypatisiel.devdevdev.web.dto.response.notification.NotificationNewArticleResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.notification.NotificationPopupNewArticleResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.notification.NotificationPopupResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.notification.NotificationReadResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.notification.NotificationResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.techArticle.CompanyResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.techArticle.TechArticleMainResponse; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.ObjectUtils; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class NotificationService { + + public static final long TIMEOUT = 5 * 60 * 1000L; + public static final long HEARTBEAT_INTERVAL = 5 * 60 * 1000L; + public static final String UNREAD_NOTIFICATION_FORMAT = "읽지 않은 알림이 %d개가 있어요."; + public static final String MAIN_TECH_ARTICLE_NOTIFICATION_FORMAT = "%s에서 새로운 기슬블로그 %d개가 올라왔어요!"; + public static final String TECH_ARTICLE_NOTIFICATION_FORMAT = "%s에서 새로운 글이 올라왔어요!"; + public static final String ACCESS_DENIED_MESSAGE = "접근할 수 없는 권한 입니다."; + + private final MemberProvider memberProvider; + private final TimeProvider timeProvider; + private final NotificationPublisher notificationPublisher; + + private final TechArticleCommonService techArticleCommonService; + + private final NotificationRepository notificationRepository; + private final SseEmitterRepository sseEmitterRepository; + private final SubscriptionRepository subscriptionRepository; + + public SseEmitter createSseEmitter(Long timeout) { + return new SseEmitter(timeout); + } + + /** + * @param notificationId 알림 ID + * @param authentication 회원 정보 + * @return NotificationReadResponse + * @Note: 알림 단건 읽기 + * @Author: 유소영 + * @Since: 2025.03.28 + */ + @Transactional + public NotificationReadResponse readNotification(Long notificationId, Authentication authentication) { + // 회원 조회 + Member findMember = memberProvider.getMemberByAuthentication(authentication); + + // 알림 조회 + Notification findNotification = notificationRepository.findByIdAndMember(notificationId, findMember) + .orElseThrow(() -> new NotFoundException(NotificationExceptionMessage.NOT_FOUND_NOTIFICATION_MESSAGE)); + + // 알림 읽기 처리 (이미 읽은 알림의 경우이라도 예외를 발생시키지 않고 처리) + if (!findNotification.getIsRead()) { + findNotification.markAsRead(); + } + + // 응답 반환 + return NotificationReadResponse.from(findNotification); + } + + /** + * @param authentication 회원 인증 정보 + * @Note: 회원의 모든 알림을 읽음 처리 + * @Author: 유소영 + * @Since: 2025.03.29 + */ + @Transactional + public void readAllNotifications(Authentication authentication) { + // 회원 조회 + Member findMember = memberProvider.getMemberByAuthentication(authentication); + + // 읽지 않은 모든 알림 조회 + notificationRepository.bulkMarkAllAsReadByMemberId(findMember.getId()); + } + + /** + * @param pageable 페이징 정보 + * @param authentication 회원 인증 정보 + * @Note: 알림 팝업 조회 + * @Author: 유소영 + * @Since: 2025.04.09 + */ + public SliceCustom getNotificationPopup(Pageable pageable, Authentication authentication) { + // 회원 조회 + Member findMember = memberProvider.getMemberByAuthentication(authentication); + + // 최근 알림 조회(default: 5개) + SliceCustom notifications = + notificationRepository.findNotificationsByMemberAndTypeOrderByCreatedAtDesc(pageable, + NotificationType.getEnabledTypes(), findMember); + + // 데이터 가공 + // NotificationType 에 따라 다른 DTO로 변환 + List response = notifications.getContent().stream() + .map(this::mapToPopupResponse) + .toList(); + + return new SliceCustom<>(response, pageable, notifications.hasNext(), notifications.getTotalElements()); + } + + private static final Map> POPUP_RESPONSE_MAPPER = + Map.of( + // TODO: 현재는 SUBSCRIPTION 타입만 제공, 알림 타입이 추가될 경우 각 타입에 맞는 응답 DTO 변환 매핑 필요 + NotificationType.SUBSCRIPTION, NotificationPopupNewArticleResponse::from + ); + + private NotificationPopupResponse mapToPopupResponse(Notification notification) { + return POPUP_RESPONSE_MAPPER + .getOrDefault(notification.getType(), n -> { + throw new NotFoundException(NotificationExceptionMessage.NOT_FOUND_NOTIFICATION_TYPE); + }) + .apply(notification); + } + + /** + * @param pageable 페이징 정보 + * @param notificationId 커서용 알림 ID + * @param authentication 회원 인증 정보 + * @Note: 알림 페이지 조회 + * @Author: 유소영 + * @Since: 2025.04.11 + */ + public SliceCustom getNotifications(Pageable pageable, Long notificationId, + Authentication authentication) { + // 회원 조회 + Member findMember = memberProvider.getMemberByAuthentication(authentication); + + // 회원 알림 페이징 조회 + SliceCustom notifications = notificationRepository.findNotificationsByMemberAndCursor(pageable, + notificationId, findMember); + + // 데이터 가공 + // NotificationType 에 따라 다른 DTO로 변환 + Map elasticTechArticles = getTechArticleIdToElastic(notifications.getContent()); + + List response = notifications.getContent().stream() + .map(notification -> mapToNotificationResponse(notification, elasticTechArticles)) + .toList(); + + return new SliceCustom<>(response, pageable, notifications.hasNext(), notifications.getTotalElements()); + } + + private NotificationResponse mapToNotificationResponse(Notification notification, + Map elasticTechArticles) { + // TODO: 현재는 SUBSCRIPTION 타입만 제공, 알림 타입이 추가될 경우 각 타입에 맞는 응답 DTO 변환 매핑 필요 + if (notification.getType() == NotificationType.SUBSCRIPTION) { + return NotificationNewArticleResponse.from(notification, + getTechArticleMainResponse(notification, elasticTechArticles)); + } + throw new NotFoundException(NotificationExceptionMessage.NOT_FOUND_NOTIFICATION_TYPE); + } + + // NotificationType.SUBSCRIPTION 알림의 경우 TechArticleMainResponse 생성 + private TechArticleMainResponse getTechArticleMainResponse(Notification notification, + Map elasticTechArticles) { + TechArticle techArticle = notification.getTechArticle(); + CompanyResponse companyResponse = CompanyResponse.from(techArticle.getCompany()); + ElasticTechArticle elasticTechArticle = elasticTechArticles.get(notification.getId()); + + return TechArticleMainResponse.of(techArticle, elasticTechArticle, companyResponse); + } + + // 알림 ID를 키로 하고, ElasticTechArticle 을 값으로 가지는 맵을 반환 + private Map getTechArticleIdToElastic(List notifications) { + // 1. NotificationType.SUBSCRIPTION 알림만 필터링하여 ElasticTechArticle 리스트 생성 + List techArticles = notifications.stream() + .filter(notification -> notification.getType() == NotificationType.SUBSCRIPTION) + .map(Notification::getTechArticle) + .toList(); + + List elasticTechArticles = techArticleCommonService.findElasticTechArticlesByTechArticles( + techArticles); + + // 2. ElasticID → ElasticTechArticle 매핑 + Map elasticIdToElastic = elasticTechArticles.stream() + .collect(Collectors.toMap( + ElasticTechArticle::getId, + elasticTechArticle -> elasticTechArticle + )); + + // 3. Notification ID → ElasticTechArticle 매핑 + return notifications.stream() + .filter(notification -> notification.getType() == NotificationType.SUBSCRIPTION) + .collect(Collectors.toMap( + Notification::getId, + notification -> elasticIdToElastic.get(notification.getTechArticle().getElasticId()) + )); + } + + /** + * @Note: 실시간 알림을 받을 구독자 추가 및 알림 전송 + * @Author: 장세웅 + * @Since: 2025.03.31 + */ + @Transactional + public SseEmitter addClientAndSendNotification(Authentication authentication) { + + // 회원 조회 + Member findMember = memberProvider.getMemberByAuthentication(authentication); + + SseEmitter sseEmitter = addClient(findMember); + + sendUnreadNotifications(findMember, sseEmitter); + + return sseEmitter; + } + + private void sendUnreadNotifications(Member findMember, SseEmitter sseEmitter) { + // 회원에게 안읽은 알림이 있는지 조회 + Long unreadNotificationCount = notificationRepository.countByMemberAndIsReadIsFalse(findMember); + + // 알림이 존재하면 구독자에게 알림 전송 + if (unreadNotificationCount > 0) { + try { + // 알림 메시지 생성 + String notificationMessage = String.format(UNREAD_NOTIFICATION_FORMAT, unreadNotificationCount); + NotificationMessageDto notificationMessageDto = new NotificationMessageDto( + notificationMessage, timeProvider.getLocalDateTimeNow()); + + // 알림 전송 + sseEmitter.send(notificationMessageDto); + } catch (Exception e) { + // 구독자 제거 + sseEmitterRepository.remove(findMember); + } + } + } + + private SseEmitter addClient(Member findMember) { + // 구독자 존재 여부 확인 + boolean isExists = sseEmitterRepository.existByMember(findMember); + + if (isExists) { + return sseEmitterRepository.findByMemberId(findMember); + } + + // 구독자 생성 + SseEmitter sseEmitter = createSseEmitter(TIMEOUT); + sseEmitterRepository.save(findMember, sseEmitter); + + sseEmitter.onCompletion(() -> sseEmitterRepository.remove(findMember)); + sseEmitter.onTimeout(() -> sseEmitterRepository.remove(findMember)); + sseEmitter.onError(throwable -> sseEmitterRepository.remove(findMember)); + + return sseEmitter; + } + + /** + * @Note: 구독자에게 알림 전송 + * @Author: 장세웅 + * @Since: 2025.03.31 + */ + @Transactional + public void sendNotification(NotificationMessageDto notificationMessageDto, Member member) { + // 구독자 조회 + SseEmitter sseEmitter = sseEmitterRepository.findByMemberId(member); + if (!ObjectUtils.isEmpty(sseEmitter)) { + try { + // 알림 전송 + sseEmitter.send(notificationMessageDto); + } catch (Exception e) { + // 구독자 제거 + sseEmitterRepository.remove(member); + } + } + } + + @Transactional + public void sendMainTechArticleNotifications(PublishTechArticleRequest publishTechArticleRequest) { + // 기업 구독 목록 조회(member fetch join) + List findSubscriptions = subscriptionRepository.findWithMemberByCompanyIdOrderByMemberDesc( + publishTechArticleRequest.getCompanyId()); + + if (findSubscriptions.isEmpty()) { + return; + } + + // 기업을 구독중인 모든 회원 추출 + Set members = findSubscriptions.stream() + .map(Subscription::getMember) + .collect(Collectors.toCollection(LinkedHashSet::new)); // 순서 유지 + + // 새롭게 publish 된 기술블로그 아이디 추출 + Set techArticleIds = publishTechArticleRequest.getTechArticles().stream() + .map(PublishTechArticle::getId) + .collect(Collectors.toSet()); + + // 회원들의 기술블로그 구독 알림 이력 조회 + List findSubscriptionNotifications = notificationRepository.findByMemberInAndTechArticleIdInOrderByNull( + members, techArticleIds); + + // 구독한 기업에 대해서 새로운 글 알림이 존재하지 않은 회원 추출 + Set membersWithoutNotifications = members.stream() + .filter(member -> findSubscriptionNotifications.stream() + .noneMatch(notification -> notification.isEqualsMember(member))) + .collect(Collectors.toSet()); + + // 알림 메시지 생성 + Company company = findSubscriptions.getFirst().getCompany(); + String companyName = company.getName().getCompanyName(); + String notificationMessage = String.format(TECH_ARTICLE_NOTIFICATION_FORMAT, companyName); + + // 알림이 없는 회원들의 알림 이력 생성 + membersWithoutNotifications.forEach(memberWithoutNotification -> + techArticleIds.forEach(techArticleId -> { + // 알림 이력 생성 및 저장 + Notification notification = Notification.createTechArticleNotification( + memberWithoutNotification, new TechArticle(techArticleId), notificationMessage); + notificationRepository.save(notification); + })); + + // 메인 알림 메시지 생성 + String mainNotificationMessage = String.format(MAIN_TECH_ARTICLE_NOTIFICATION_FORMAT, + companyName, techArticleIds.size()); + + // 메인 알림 전송 + membersWithoutNotifications.forEach(memberWithoutNotification -> { + NotificationMessageDto notificationMessageDto = new NotificationMessageDto(mainNotificationMessage, + timeProvider.getLocalDateTimeNow()); + sendNotification(notificationMessageDto, memberWithoutNotification); + }); + } + + public Long publish(NotificationType channel, T message) { + return notificationPublisher.publish(channel, message); + } + + /** + * @Note: 회원이 읽지 않은 알림 총 개수 조회 + * @Author: 유소영 + * @Since: 2025.04.29 + */ + public Long getUnreadNotificationCount(Authentication authentication) { + // 회원 조회 + Member findMember = memberProvider.getMemberByAuthentication(authentication); + + // 회원이 읽지 않은 알림 개수 조회 + return notificationRepository.countByMemberAndIsReadFalse(findMember); + } + + /** + * @Note: 회원의 모든 알림 삭제 + * @Author: 유소영 + */ + @Transactional + public void deleteAllByMember(Authentication authentication) { + // 회원 조회 + Member findMember = memberProvider.getMemberByAuthentication(authentication); + + // 회원의 모든 알림 삭제 + notificationRepository.deleteAllByMember(findMember); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickService.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickService.java index 45e2ccd1..9274b365 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickService.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickService.java @@ -1,5 +1,9 @@ package com.dreamypatisiel.devdevdev.domain.service.pick; +import static com.dreamypatisiel.devdevdev.domain.exception.PickExceptionMessage.INVALID_CAN_NOT_VOTE_SAME_PICK_OPTION_MESSAGE; +import static com.dreamypatisiel.devdevdev.domain.exception.PickExceptionMessage.INVALID_NOT_APPROVAL_STATUS_PICK_MESSAGE; +import static com.dreamypatisiel.devdevdev.domain.exception.PickExceptionMessage.INVALID_NOT_FOUND_PICK_MESSAGE; + import com.dreamypatisiel.devdevdev.domain.entity.AnonymousMember; import com.dreamypatisiel.devdevdev.domain.entity.Pick; import com.dreamypatisiel.devdevdev.domain.entity.PickOption; @@ -8,7 +12,11 @@ import com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType; import com.dreamypatisiel.devdevdev.domain.policy.PickBestCommentsPolicy; import com.dreamypatisiel.devdevdev.domain.policy.PickPopularScorePolicy; -import com.dreamypatisiel.devdevdev.domain.repository.pick.*; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickCommentRecommendRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickCommentRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickVoteRepository; import com.dreamypatisiel.devdevdev.domain.service.member.AnonymousMemberService; import com.dreamypatisiel.devdevdev.domain.service.pick.dto.VotePickOptionDto; import com.dreamypatisiel.devdevdev.exception.NotFoundException; @@ -18,7 +26,20 @@ import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingsService; import com.dreamypatisiel.devdevdev.web.dto.request.pick.ModifyPickRequest; import com.dreamypatisiel.devdevdev.web.dto.request.pick.RegisterPickRequest; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.*; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailOptionResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickModifyResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickRegisterResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickUploadImageResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.VotePickOptionResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.VotePickResponse; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.domain.SliceImpl; @@ -28,20 +49,11 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.math.BigDecimal; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - -import static com.dreamypatisiel.devdevdev.domain.exception.PickExceptionMessage.*; - @Service @Transactional(readOnly = true) public class GuestPickService extends PickCommonService implements PickService { public static final String INVALID_ANONYMOUS_CAN_NOT_USE_THIS_FUNCTION_MESSAGE = "비회원은 현재 해당 기능을 이용할 수 없습니다."; - public static final int SIMILARITY_PICK_MAX_COUNT = 3; private final PickPopularScorePolicy pickPopularScorePolicy; private final PickVoteRepository pickVoteRepository; diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickMultiServiceHandler.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickFacadeService.java similarity index 98% rename from src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickMultiServiceHandler.java rename to src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickFacadeService.java index f08c526d..6ea030fc 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickMultiServiceHandler.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickFacadeService.java @@ -16,7 +16,7 @@ @Service @Transactional(readOnly = true) @RequiredArgsConstructor -public class PickMultiServiceHandler { +public class PickFacadeService { private PickService pickService; private final EmbeddingsService embeddingsService; diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/TechArticleServiceStrategy.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/TechArticleServiceStrategy.java index 1c1bad65..e1dbb80f 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/TechArticleServiceStrategy.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/TechArticleServiceStrategy.java @@ -1,5 +1,8 @@ package com.dreamypatisiel.devdevdev.domain.service.techArticle; +import com.dreamypatisiel.devdevdev.domain.service.techArticle.subscription.GuestSubscriptionService; +import com.dreamypatisiel.devdevdev.domain.service.techArticle.subscription.MemberSubscriptionService; +import com.dreamypatisiel.devdevdev.domain.service.techArticle.subscription.SubscriptionService; import com.dreamypatisiel.devdevdev.domain.service.techArticle.techArticle.GuestTechArticleService; import com.dreamypatisiel.devdevdev.domain.service.techArticle.techArticle.MemberTechArticleService; import com.dreamypatisiel.devdevdev.domain.service.techArticle.techArticle.TechArticleService; @@ -18,16 +21,23 @@ public class TechArticleServiceStrategy { private final ApplicationContext applicationContext; public TechArticleService getTechArticleService() { - if(AuthenticationMemberUtils.isAnonymous()) { + if (AuthenticationMemberUtils.isAnonymous()) { return applicationContext.getBean(GuestTechArticleService.class); } return applicationContext.getBean(MemberTechArticleService.class); } public TechCommentService getTechCommentService() { - if(AuthenticationMemberUtils.isAnonymous()) { + if (AuthenticationMemberUtils.isAnonymous()) { return applicationContext.getBean(GuestTechCommentService.class); } return applicationContext.getBean(MemberTechCommentService.class); } + + public SubscriptionService getSubscriptionService() { + if (AuthenticationMemberUtils.isAnonymous()) { + return applicationContext.getBean(GuestSubscriptionService.class); + } + return applicationContext.getBean(MemberSubscriptionService.class); + } } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/subscription/GuestSubscriptionService.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/subscription/GuestSubscriptionService.java new file mode 100644 index 00000000..9f540fc1 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/subscription/GuestSubscriptionService.java @@ -0,0 +1,78 @@ +package com.dreamypatisiel.devdevdev.domain.service.techArticle.subscription; + +import static com.dreamypatisiel.devdevdev.domain.exception.GuestExceptionMessage.INVALID_ANONYMOUS_CAN_NOT_USE_THIS_FUNCTION_MESSAGE; + +import com.dreamypatisiel.devdevdev.domain.entity.Company; +import com.dreamypatisiel.devdevdev.domain.exception.CompanyExceptionMessage; +import com.dreamypatisiel.devdevdev.domain.repository.CompanyRepository; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.TechArticleRepository; +import com.dreamypatisiel.devdevdev.exception.NotFoundException; +import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.CompanyDetailResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscriableCompanyResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscriptionResponse; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GuestSubscriptionService implements SubscriptionService { + + private final CompanyRepository companyRepository; + private final TechArticleRepository techArticleRepository; + + @Override + public SubscriptionResponse subscribe(Long companyId, Authentication authentication) { + throw new AccessDeniedException(INVALID_ANONYMOUS_CAN_NOT_USE_THIS_FUNCTION_MESSAGE); + } + + @Override + public void unsubscribe(Long companyId, Authentication authentication) { + throw new AccessDeniedException(INVALID_ANONYMOUS_CAN_NOT_USE_THIS_FUNCTION_MESSAGE); + } + + @Override + public Slice getSubscribableCompany(Pageable pageable, Long companyId, + Authentication authentication) { + + // 익명 회원인지 검증 + AuthenticationMemberUtils.validateAnonymousMethodCall(authentication); + + // 기업 목록 조회 + Slice findCompanies = companyRepository.findCompanyByCursor(pageable, companyId); + + // 데이터 가공 + List response = findCompanies.getContent().stream() + .map(SubscriableCompanyResponse::create) + .collect(Collectors.toList()); + + // 응답 생성 + return new SliceImpl<>(response, pageable, findCompanies.hasNext()); + } + + @Override + public CompanyDetailResponse getCompanyDetail(Long companyId, Authentication authentication) { + + // 익명 회원인지 검증 + AuthenticationMemberUtils.validateAnonymousMethodCall(authentication); + + // 기업 조회 + Company findCompany = companyRepository.findById(companyId) + .orElseThrow(() -> new NotFoundException(CompanyExceptionMessage.NOT_FOUND_COMPANY_MESSAGE)); + + // 회사의 기술 블로그 총 갯수 조회 + Long techArticleTotalCount = techArticleRepository.countByCompanyId(companyId); + + // 응답 생성 + return CompanyDetailResponse.createGuestCompanyDetailResponse(findCompany, techArticleTotalCount); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/subscription/MemberSubscriptionService.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/subscription/MemberSubscriptionService.java new file mode 100644 index 00000000..da730c93 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/subscription/MemberSubscriptionService.java @@ -0,0 +1,140 @@ +package com.dreamypatisiel.devdevdev.domain.service.techArticle.subscription; + +import com.dreamypatisiel.devdevdev.domain.entity.Company; +import com.dreamypatisiel.devdevdev.domain.entity.Member; +import com.dreamypatisiel.devdevdev.domain.entity.Subscription; +import com.dreamypatisiel.devdevdev.domain.exception.CompanyExceptionMessage; +import com.dreamypatisiel.devdevdev.domain.exception.SubscriptionExceptionMessage; +import com.dreamypatisiel.devdevdev.domain.repository.CompanyRepository; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.SubscriptionRepository; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.TechArticleRepository; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.custom.dto.CompanyDetailDto; +import com.dreamypatisiel.devdevdev.exception.NotFoundException; +import com.dreamypatisiel.devdevdev.exception.SubscriptionException; +import com.dreamypatisiel.devdevdev.global.common.MemberProvider; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.CompanyDetailResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscriableCompanyResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscriptionResponse; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberSubscriptionService implements SubscriptionService { + + private final MemberProvider memberProvider; + + private final CompanyRepository companyRepository; + private final SubscriptionRepository subscriptionRepository; + private final TechArticleRepository techArticleRepository; + + /** + * @Note: 기업 구독하기 + * @Author: 장세웅 + * @Since: 2025-02-23 + */ + @Transactional + public SubscriptionResponse subscribe(Long companyId, Authentication authentication) { + + // 회원 조회 + Member findMember = memberProvider.getMemberByAuthentication(authentication); + + // 기업 기술블로그 구독 이력 조회 + Boolean isSubscription = subscriptionRepository.existsByMemberIdAndCompanyId(findMember.getId(), companyId); + + // 이미 구독한 경우 + if (isSubscription) { + throw new SubscriptionException(SubscriptionExceptionMessage.ALREADY_SUBSCRIBED_COMPANY_MESSAGE); + } + + // 기업 조회 + Company findCompany = companyRepository.findById(companyId) + .orElseThrow(() -> new NotFoundException(CompanyExceptionMessage.NOT_FOUND_COMPANY_MESSAGE)); + + // 생성 및 저장 + Subscription subscription = subscriptionRepository.save(Subscription.create(findMember, findCompany)); + + return new SubscriptionResponse(subscription.getId()); + } + + /** + * @Note: 기업 구독 취소 + * @Author: 장세웅 + * @Since: 2025-02-24 + */ + @Transactional + public void unsubscribe(Long companyId, Authentication authentication) { + + // 회원 조회 + Member findMember = memberProvider.getMemberByAuthentication(authentication); + + // 기업 기술블로그 구독 이력 조회 + // Todo: 소영님 아래 쿼리에서 member, company left join 발생하는데 이유를 알까요? + Subscription findSubscription = subscriptionRepository.findByMemberAndCompanyId(findMember, companyId) + .orElseThrow(() -> new NotFoundException(SubscriptionExceptionMessage.NOT_FOUND_SUBSCRIPTION_MESSAGE)); + + // 삭제 + subscriptionRepository.delete(findSubscription); + } + + /** + * @Note: 구독 가능한 기업 목록 조회 + * @Author: 장세웅 + * @Since: 2025-03-08 + */ + public Slice getSubscribableCompany(Pageable pageable, Long companyId, + Authentication authentication) { + + // 회원 조회 + Member findMember = memberProvider.getMemberByAuthentication(authentication); + + // 기업 목록 조회 + Slice findCompanies = companyRepository.findCompanyByCursor(pageable, companyId); + + // 회원의 구독 여부 추출 + List findSubscriptions = subscriptionRepository.findByMemberAndCompanyIn( + findMember, findCompanies.getContent()); + + // 데이터 가공 + List response = findCompanies.getContent().stream() + .map(company -> { + // 구독 여부 + boolean isSubscribed = findSubscriptions.stream() + .anyMatch(subscription -> subscription.isEqualsCompany(company)); + // 응답 생성 + return SubscriableCompanyResponse.createWithIsSubscribed(company, isSubscribed); + }) + .collect(Collectors.toList()); + + return new SliceImpl<>(response, pageable, findCompanies.hasNext()); + } + + /** + * @Note: 회원이 구독 가능한 기업 상세 정보를 조회한다. + * @Author: 장세웅 + * @Since: 2025-03-12 + */ + @Override + public CompanyDetailResponse getCompanyDetail(Long companyId, Authentication authentication) { + + Member findMember = memberProvider.getMemberByAuthentication(authentication); + + // 기업 조회(구독 조인) + CompanyDetailDto findCompanyDetailDto = companyRepository.findCompanyDetailDtoByMemberIdAndCompanyId( + findMember.getId(), companyId) + .orElseThrow(() -> new NotFoundException(CompanyExceptionMessage.NOT_FOUND_COMPANY_MESSAGE)); + + // 회사의 기술 블로그 총 갯수 조회 + Long techArticleTotalCount = techArticleRepository.countByCompanyId(companyId); + + return CompanyDetailResponse.createMemberCompanyDetailResponse(findCompanyDetailDto, techArticleTotalCount); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/subscription/SubscriptionService.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/subscription/SubscriptionService.java new file mode 100644 index 00000000..1d7f1f32 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/subscription/SubscriptionService.java @@ -0,0 +1,19 @@ +package com.dreamypatisiel.devdevdev.domain.service.techArticle.subscription; + +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.CompanyDetailResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscriableCompanyResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscriptionResponse; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.security.core.Authentication; + +public interface SubscriptionService { + SubscriptionResponse subscribe(Long companyId, Authentication authentication); + + void unsubscribe(Long companyId, Authentication authentication); + + Slice getSubscribableCompany(Pageable pageable, Long companyId, + Authentication authentication); + + CompanyDetailResponse getCompanyDetail(Long companyId, Authentication authentication); +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/exception/SubscriptionException.java b/src/main/java/com/dreamypatisiel/devdevdev/exception/SubscriptionException.java new file mode 100644 index 00000000..8d403b5f --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/exception/SubscriptionException.java @@ -0,0 +1,7 @@ +package com.dreamypatisiel.devdevdev.exception; + +public class SubscriptionException extends IllegalArgumentException { + public SubscriptionException(String s) { + super(s); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/config/AsyncConfig.java b/src/main/java/com/dreamypatisiel/devdevdev/global/config/AsyncConfig.java new file mode 100644 index 00000000..ab93c4ef --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/config/AsyncConfig.java @@ -0,0 +1,37 @@ +package com.dreamypatisiel.devdevdev.global.config; + +import java.util.concurrent.Executor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class AsyncConfig { + + /** + * Heartbeat 전송용 ThreadPoolTaskExecutor를 생성합니다. + *

+ * 동작 흐름: + *

    + *
  • 요청이 들어오면 CorePoolSize 내에서 스레드를 생성해 즉시 실행합니다.
  • + *
  • CorePoolSize를 초과하면 요청은 Queue에 저장됩니다.
  • + *
  • Queue가 꽉 차면 MaxPoolSize까지 스레드를 추가 생성합니다.
  • + *
  • MaxPoolSize까지 모두 사용되면 거절 정책이 발동하여 작업이 거절됩니다.
  • + *
+ * + * @return heartbeat 전송을 위한 Executor + * @Author: 장세웅 + * @Since: 2025.04.30 + */ + @Bean("heartbeatExecutor") + public Executor heartbeatExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(10); + executor.setMaxPoolSize(100); + executor.setQueueCapacity(500); + executor.setThreadNamePrefix("AsyncHeartbeat-"); + executor.initialize(); + + return executor; + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/constant/SecurityConstant.java b/src/main/java/com/dreamypatisiel/devdevdev/global/constant/SecurityConstant.java index b0db258c..2e249471 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/global/constant/SecurityConstant.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/constant/SecurityConstant.java @@ -32,9 +32,11 @@ public class SecurityConstant { "/devdevdev/api/v1/token/**", "/devdevdev/api/v1/picks/**", "/devdevdev/api/v1/articles/**", - "/devdevdev/api/v1/keywords/**" + "/devdevdev/api/v1/keywords/**", + "/devdevdev/api/v1/subscriptions/**", + "/devdevdev/api/v1/notifications/**" }; - + public static final String[] DEV_JWT_FILTER_WHITELIST_URL = new String[]{ "/docs/index.html", "/swagger-ui", @@ -45,7 +47,8 @@ public class SecurityConstant { "/devdevdev/api/v1/oauth2/authorization", "/devdevdev/api/v1/login/oauth2/code", "/devdevdev/api/v1/test", - "/devdevdev/api/v1/token" + "/devdevdev/api/v1/token", + "/devdevdev/api/v1/notifications/SUBSCRIPTION" }; public static final String[] PROD_WHITELIST_URL = new String[]{ @@ -66,7 +69,9 @@ public class SecurityConstant { "/devdevdev/api/v1/token/**", "/devdevdev/api/v1/picks/**", "/devdevdev/api/v1/articles/**", - "/devdevdev/api/v1/keywords/**" + "/devdevdev/api/v1/keywords/**", + "/devdevdev/api/v1/subscriptions/**", + "/devdevdev/api/v1/notifications/**" }; public static final String[] PROD_JWT_FILTER_WHITELIST_URL = new String[]{ diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/properties/CorsProperties.java b/src/main/java/com/dreamypatisiel/devdevdev/global/properties/CorsProperties.java index c4ebd503..74614d51 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/global/properties/CorsProperties.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/properties/CorsProperties.java @@ -1,22 +1,24 @@ package com.dreamypatisiel.devdevdev.global.properties; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.context.properties.ConfigurationProperties; - import java.util.ArrayList; import java.util.Collections; import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties("cors") @RequiredArgsConstructor public class CorsProperties { private List origin = new ArrayList<>(); + public List getOrigin() { return this.origin; } + public List getUnmodifiableOrigins() { return Collections.unmodifiableList(origin); } + public void setOrigin(List origin) { this.origin = origin; } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/security/jwt/filter/DevJwtAuthenticationFilter.java b/src/main/java/com/dreamypatisiel/devdevdev/global/security/jwt/filter/DevJwtAuthenticationFilter.java index af9244cd..72533193 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/global/security/jwt/filter/DevJwtAuthenticationFilter.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/security/jwt/filter/DevJwtAuthenticationFilter.java @@ -17,6 +17,7 @@ import java.util.Arrays; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpMethod; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -36,9 +37,8 @@ public class DevJwtAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - log.info("DevJwtAuthenticationFilter 시작"); - String accessToken = tokenService.getAccessTokenByHttpRequest(request); + String accessToken = tokenService.getAccessTokenByHttpRequest(request); User sentryUser = new User(); // JWT 토큰이 유효한 경우에만, Authentication 객체 셋팅 @@ -73,6 +73,12 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse @Override public boolean shouldNotFilter(HttpServletRequest request) { String requestURI = request.getRequestURI(); + String method = request.getMethod(); + + if (requestURI.contains("/notifications") && method.equals(HttpMethod.POST.name())) { + return true; + } + return Arrays.stream(SecurityConstant.DEV_JWT_FILTER_WHITELIST_URL) .anyMatch(whiteList -> StringUtils.startsWithIgnoreCase(requestURI, whiteList)); } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/security/jwt/filter/ProdJwtAuthenticationFilter.java b/src/main/java/com/dreamypatisiel/devdevdev/global/security/jwt/filter/ProdJwtAuthenticationFilter.java index be7e2960..97b584e9 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/global/security/jwt/filter/ProdJwtAuthenticationFilter.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/security/jwt/filter/ProdJwtAuthenticationFilter.java @@ -36,8 +36,7 @@ public class ProdJwtAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - - log.info("ProdJwtAuthenticationFilter 시작"); + String accessToken = tokenService.getAccessTokenByHttpRequest(request); // 센트리 회원 diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/security/jwt/model/TokenExpireTime.java b/src/main/java/com/dreamypatisiel/devdevdev/global/security/jwt/model/TokenExpireTime.java index c3f5b6ab..8b49bd10 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/global/security/jwt/model/TokenExpireTime.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/security/jwt/model/TokenExpireTime.java @@ -1,7 +1,18 @@ package com.dreamypatisiel.devdevdev.global.security.jwt.model; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component public class TokenExpireTime { - public static final long ACCESS_TOKEN_EXPIRE_TIME = 1_000 * 60 * 30; // 30분 - public static final long REFRESH_TOKEN_EXPIRE_TIME = 1_000 * 60 * 60 * 24 * 7; // 7일 - public static final long TEST_TOKEN_EXPIRE_TIME = 1_000 * 60 * 60 * 24 * 21; // 21일 + public static long ACCESS_TOKEN_EXPIRE_TIME; + public static long REFRESH_TOKEN_EXPIRE_TIME; + + public TokenExpireTime( + @Value("${jwt.expire.access}") long accessTokenExpireTime, + @Value("${jwt.expire.refresh}") long refreshTokenExpireTime + ) { + ACCESS_TOKEN_EXPIRE_TIME = accessTokenExpireTime; + REFRESH_TOKEN_EXPIRE_TIME = refreshTokenExpireTime; + } } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/validator/EnumValidator.java b/src/main/java/com/dreamypatisiel/devdevdev/global/validator/EnumValidator.java index 72104151..e4c221c9 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/global/validator/EnumValidator.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/validator/EnumValidator.java @@ -16,14 +16,14 @@ public void initialize(ValidEnum constraintAnnotation) { public boolean isValid(String target, ConstraintValidatorContext constraintValidatorContext) { Object[] enumValues = this.annotation.enumClass().getEnumConstants(); - if(enumValues != null) { - for(Object enumValue : enumValues) { - if(target.equals(enumValue.toString())) { - return true; - } + if (enumValues != null) { + for (Object enumValue : enumValues) { + if (target.equals(enumValue.toString())) { + return true; + } } } - return false; + throw new IllegalArgumentException(this.annotation.message()); } } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/config/EmbeddedRedisConfig.java b/src/main/java/com/dreamypatisiel/devdevdev/redis/config/EmbeddedRedisConfig.java similarity index 68% rename from src/main/java/com/dreamypatisiel/devdevdev/global/config/EmbeddedRedisConfig.java rename to src/main/java/com/dreamypatisiel/devdevdev/redis/config/EmbeddedRedisConfig.java index 41ccbeb9..76ed2e8a 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/global/config/EmbeddedRedisConfig.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/redis/config/EmbeddedRedisConfig.java @@ -1,4 +1,4 @@ -package com.dreamypatisiel.devdevdev.global.config; +package com.dreamypatisiel.devdevdev.redis.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -8,7 +8,8 @@ import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @@ -27,12 +28,19 @@ public RedisConnectionFactory redisConnectionFactory() { return new LettuceConnectionFactory(redisHost, redisPort); } + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + return container; + } + @Bean public RedisTemplate redisTemplate() { RedisTemplate redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory()); redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); return redisTemplate; } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/config/EmbeddedRedisServerConfig.java b/src/main/java/com/dreamypatisiel/devdevdev/redis/config/EmbeddedRedisServerConfig.java similarity index 99% rename from src/main/java/com/dreamypatisiel/devdevdev/global/config/EmbeddedRedisServerConfig.java rename to src/main/java/com/dreamypatisiel/devdevdev/redis/config/EmbeddedRedisServerConfig.java index f406465c..b4593f9d 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/global/config/EmbeddedRedisServerConfig.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/redis/config/EmbeddedRedisServerConfig.java @@ -1,4 +1,4 @@ -package com.dreamypatisiel.devdevdev.global.config; +package com.dreamypatisiel.devdevdev.redis.config; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/config/RedisConfig.java b/src/main/java/com/dreamypatisiel/devdevdev/redis/config/RedisConfig.java similarity index 71% rename from src/main/java/com/dreamypatisiel/devdevdev/global/config/RedisConfig.java rename to src/main/java/com/dreamypatisiel/devdevdev/redis/config/RedisConfig.java index ec43e400..1ce82b0f 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/global/config/RedisConfig.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/redis/config/RedisConfig.java @@ -1,4 +1,4 @@ -package com.dreamypatisiel.devdevdev.global.config; +package com.dreamypatisiel.devdevdev.redis.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -8,8 +8,9 @@ import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisKeyValueAdapter; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; -import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @@ -31,12 +32,19 @@ public RedisConnectionFactory redisConnectionFactory() { return new LettuceConnectionFactory(redisHost, redisPort); } + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + return container; + } + @Bean public RedisTemplate redisTemplate() { RedisTemplate redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory()); redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); return redisTemplate; } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/redis/config/RedisSubscribeConfig.java b/src/main/java/com/dreamypatisiel/devdevdev/redis/config/RedisSubscribeConfig.java new file mode 100644 index 00000000..961c750e --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/redis/config/RedisSubscribeConfig.java @@ -0,0 +1,23 @@ +package com.dreamypatisiel.devdevdev.redis.config; + +import com.dreamypatisiel.devdevdev.domain.entity.enums.NotificationType; +import com.dreamypatisiel.devdevdev.redis.sub.RedisNotificationSubscriber; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.listener.PatternTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; + +@Configuration +@RequiredArgsConstructor +public class RedisSubscribeConfig { + + private final RedisMessageListenerContainer redisMessageListenerContainer; + private final RedisNotificationSubscriber redisNotificationSubscriber; + + @PostConstruct + public void init() { + redisMessageListenerContainer.addMessageListener(redisNotificationSubscriber, + new PatternTopic(NotificationType.SUBSCRIPTION.name())); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/redis/pub/NotificationPublisher.java b/src/main/java/com/dreamypatisiel/devdevdev/redis/pub/NotificationPublisher.java new file mode 100644 index 00000000..6a164dcc --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/redis/pub/NotificationPublisher.java @@ -0,0 +1,7 @@ +package com.dreamypatisiel.devdevdev.redis.pub; + +import com.dreamypatisiel.devdevdev.domain.entity.enums.NotificationType; + +public interface NotificationPublisher { + Long publish(NotificationType channel, T message); +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/redis/pub/RedisNotificationPublisher.java b/src/main/java/com/dreamypatisiel/devdevdev/redis/pub/RedisNotificationPublisher.java new file mode 100644 index 00000000..ff0f981b --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/redis/pub/RedisNotificationPublisher.java @@ -0,0 +1,18 @@ +package com.dreamypatisiel.devdevdev.redis.pub; + +import com.dreamypatisiel.devdevdev.domain.entity.enums.NotificationType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RedisNotificationPublisher implements NotificationPublisher { + + private final RedisTemplate redisTemplate; + + @Override + public Long publish(NotificationType channel, T message) { + return redisTemplate.convertAndSend(channel.name(), message); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/redis/sub/NotificationMessageDto.java b/src/main/java/com/dreamypatisiel/devdevdev/redis/sub/NotificationMessageDto.java new file mode 100644 index 00000000..45f9438d --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/redis/sub/NotificationMessageDto.java @@ -0,0 +1,17 @@ +package com.dreamypatisiel.devdevdev.redis.sub; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import lombok.Data; + +@Data +public class NotificationMessageDto implements Serializable { + private String message; + private String createdAt; + + public NotificationMessageDto(String message, LocalDateTime createdAt) { + this.message = message; + this.createdAt = createdAt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/redis/sub/RedisNotificationSubscriber.java b/src/main/java/com/dreamypatisiel/devdevdev/redis/sub/RedisNotificationSubscriber.java new file mode 100644 index 00000000..8550ba3f --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/redis/sub/RedisNotificationSubscriber.java @@ -0,0 +1,48 @@ +package com.dreamypatisiel.devdevdev.redis.sub; + +import com.dreamypatisiel.devdevdev.domain.entity.enums.NotificationType; +import com.dreamypatisiel.devdevdev.domain.service.notification.NotificationService; +import com.dreamypatisiel.devdevdev.web.dto.request.publish.PublishTechArticleRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import javax.annotation.Nullable; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RedisNotificationSubscriber implements MessageListener { + + private final NotificationService notificationService; + + /** + * @Note: redis pub 발생시 구독자에게 메시지 전송 + * @Author: 장세웅 + * @Since: 2025.03.27 + */ + @Transactional + @Override + public void onMessage(@Nullable Message message, byte[] pattern) { + try { + // 채널 파싱 + String channel = new String(pattern, StandardCharsets.UTF_8); + + // 구독 채널인 경우 + if (channel.equals(NotificationType.SUBSCRIPTION.name())) { + ObjectMapper om = new ObjectMapper(); + PublishTechArticleRequest publishTechArticleRequest = om.readValue(message.getBody(), + PublishTechArticleRequest.class); + + // 구독자에게 메인 알림 전송 및 알림 저장 + notificationService.sendMainTechArticleNotifications(publishTechArticleRequest); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/scheduler/AsyncHeartbeatSender.java b/src/main/java/com/dreamypatisiel/devdevdev/scheduler/AsyncHeartbeatSender.java new file mode 100644 index 00000000..3d419169 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/scheduler/AsyncHeartbeatSender.java @@ -0,0 +1,30 @@ +package com.dreamypatisiel.devdevdev.scheduler; + +import com.dreamypatisiel.devdevdev.global.common.TimeProvider; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Component +@RequiredArgsConstructor +public class AsyncHeartbeatSender { + + private final TimeProvider timeProvider; + + @Async + public void sendHeartbeat(SseEmitter sseEmitter) { + try { + sseEmitter.send(createHeartbeatEvent()); + } catch (IOException e) { + sseEmitter.complete(); + } + } + + private SseEmitter.SseEventBuilder createHeartbeatEvent() { + return SseEmitter.event() + .name("heartbeat") + .data(timeProvider.getLocalDateTimeNow()); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/scheduler/SseEmitterHeartbeatScheduler.java b/src/main/java/com/dreamypatisiel/devdevdev/scheduler/SseEmitterHeartbeatScheduler.java new file mode 100644 index 00000000..7faa1636 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/scheduler/SseEmitterHeartbeatScheduler.java @@ -0,0 +1,25 @@ +package com.dreamypatisiel.devdevdev.scheduler; + +import com.dreamypatisiel.devdevdev.domain.repository.SseEmitterRepository; +import java.util.Collection; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Component +@RequiredArgsConstructor +public class SseEmitterHeartbeatScheduler { + + private final SseEmitterRepository sseEmitterRepository; + private final AsyncHeartbeatSender asyncHeartbeatSender; + + // 30초 마다 실행 + @Scheduled(fixedRate = 30_000) + public void scheduleHeartbeat() { + Collection findSseEmitter = sseEmitterRepository.findAll(); + for (SseEmitter sseEmitter : findSseEmitter) { + asyncHeartbeatSender.sendHeartbeat(sseEmitter); + } + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/test/TestController.java b/src/main/java/com/dreamypatisiel/devdevdev/test/TestController.java index 7981ddca..294e7929 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/test/TestController.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/test/TestController.java @@ -1,9 +1,14 @@ package com.dreamypatisiel.devdevdev.test; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; +import com.dreamypatisiel.devdevdev.domain.service.notification.NotificationService; +import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingRequestHandler; import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingsService; import java.util.List; + +import com.dreamypatisiel.devdevdev.web.dto.response.BasicResponse; +import io.swagger.v3.oas.annotations.Operation; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -12,6 +17,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -27,6 +33,15 @@ public class TestController { private final EmbeddingRequestHandler embeddingRequestHandler; private final EmbeddingsService embeddingsService; private final PickRepository pickRepository; + private final NotificationService notificationService; + + @Operation(summary = "알림 제거", description = "회원에게 생성된 모든 알림을 제거") + @DeleteMapping("/notifications") + public ResponseEntity> delete() { + Authentication authentication = AuthenticationMemberUtils.getAuthentication(); + notificationService.deleteAllByMember(authentication); + return ResponseEntity.ok(com.dreamypatisiel.devdevdev.web.dto.response.BasicResponse.success()); + } @GetMapping("/members") public BasicResponse getMembers() { @@ -59,7 +74,6 @@ public ResponseEntity userTest() { public ResponseEntity publicTest() { return new ResponseEntity<>("모두에게 공개된 페이지", HttpStatus.OK); } - @Data static class Member { diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/controller/member/MypageController.java b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/member/MypageController.java index 655def08..f90a8c94 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/web/controller/member/MypageController.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/member/MypageController.java @@ -12,6 +12,7 @@ import com.dreamypatisiel.devdevdev.web.dto.response.comment.MyWrittenCommentResponse; import com.dreamypatisiel.devdevdev.web.dto.response.member.MemberExitSurveyResponse; import com.dreamypatisiel.devdevdev.web.dto.response.pick.MyPickMainResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscribedCompanyResponse; import com.dreamypatisiel.devdevdev.web.dto.response.techArticle.TechArticleMainResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -118,4 +119,18 @@ public ResponseEntity>> getM return ResponseEntity.ok(BasicResponse.success(myWrittenComments)); } + + @Operation(summary = "내가 구독한 기업 목록 조회", description = "본인이 구독한 기업을 무한 스크롤 방식으로 조회합니다.") + @GetMapping("/mypage/subscriptions/companies") + public ResponseEntity>> getMySubscribedCompanies( + @PageableDefault(size = 8) Pageable pageable, + @RequestParam(required = false) Long companyId) { + + Authentication authentication = AuthenticationMemberUtils.getAuthentication(); + + SliceCustom mySubscribedCompanies = memberService.findMySubscribedCompanies( + pageable, companyId, authentication); + + return ResponseEntity.ok(BasicResponse.success(mySubscribedCompanies)); + } } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/controller/notification/NotificationController.java b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/notification/NotificationController.java new file mode 100644 index 00000000..d42a4a61 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/notification/NotificationController.java @@ -0,0 +1,120 @@ +package com.dreamypatisiel.devdevdev.web.controller.notification; + +import com.dreamypatisiel.devdevdev.domain.entity.enums.NotificationType; +import com.dreamypatisiel.devdevdev.domain.service.ApiKeyService; +import com.dreamypatisiel.devdevdev.domain.service.notification.NotificationService; +import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; + +import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; + +import com.dreamypatisiel.devdevdev.global.validator.ValidEnum; +import com.dreamypatisiel.devdevdev.web.dto.request.publish.PublishTechArticleRequest; + +import com.dreamypatisiel.devdevdev.web.dto.response.BasicResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.notification.NotificationPopupResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.notification.NotificationReadResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.notification.NotificationResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + + +@Tag(name = "알림 API", description = "알림 관련 API") +@RestController +@RequestMapping("/devdevdev/api/v1") +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + private final ApiKeyService apiKeyService; + + @Operation(summary = "알림 단건 읽음 처리") + @PatchMapping("/notifications/{notificationId}/read") + public ResponseEntity> readNotification( + @PathVariable Long notificationId + ) { + Authentication authentication = AuthenticationMemberUtils.getAuthentication(); + NotificationReadResponse response = notificationService.readNotification(notificationId, authentication); + return ResponseEntity.ok(BasicResponse.success(response)); + } + + @Operation(summary = "모든 알림 읽음 처리") + @PatchMapping("/notifications/read-all") + public ResponseEntity> readAllNotifications() { + Authentication authentication = AuthenticationMemberUtils.getAuthentication(); + notificationService.readAllNotifications(authentication); + return ResponseEntity.ok(BasicResponse.success()); + } + + @Operation(summary = "알림 팝업 조회") + @GetMapping("/notifications/popup") + public ResponseEntity>> getNotificationPopup( + @PageableDefault(size = 5) Pageable pageable + ) { + Authentication authentication = AuthenticationMemberUtils.getAuthentication(); + SliceCustom response = notificationService.getNotificationPopup(pageable, authentication); + return ResponseEntity.ok(BasicResponse.success(response)); + } + + @Operation(summary = "알림 페이지 조회") + @GetMapping("/notifications/page") + public ResponseEntity>> getNotifications( + @PageableDefault Pageable pageable, + @RequestParam(required = false) Long notificationId + ) { + Authentication authentication = AuthenticationMemberUtils.getAuthentication(); + SliceCustom response = notificationService.getNotifications(pageable, notificationId, + authentication); + return ResponseEntity.ok(BasicResponse.success(response)); + } + + @Operation(summary = "알림 전체 개수 조회") + @GetMapping("/notifications/unread-count") + public ResponseEntity> getUnreadNotificationCount() { + Authentication authentication = AuthenticationMemberUtils.getAuthentication(); + Long response = notificationService.getUnreadNotificationCount(authentication); + return ResponseEntity.ok(BasicResponse.success(response)); + } + + @Operation(summary = "실시간 알림 수신 활성화", description = "실시간 알림 수신을 활성화 합니다.") + @GetMapping(value = "/notifications", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter notifications() { + Authentication authentication = AuthenticationMemberUtils.getAuthentication(); + return notificationService.addClientAndSendNotification(authentication); + } + + @Operation(summary = "알림 생성", description = "알림을 생성 합니다.") + @PostMapping("/notifications/{channel}") + public ResponseEntity> publish( + @PathVariable @ValidEnum(enumClass = NotificationType.class) String channel, + @RequestBody @Validated PublishTechArticleRequest publishTechArticleRequest, + @RequestHeader(name = "service-name") String serviceName, + @RequestHeader(name = "api-key") String apiKey) { + + // API Key 검증 + apiKeyService.validateApiKey(serviceName, apiKey); + + // 알림 발행 + notificationService.publish(NotificationType.valueOf(channel), publishTechArticleRequest); + + return ResponseEntity.ok(BasicResponse.success()); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickController.java b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickController.java index 6d5364af..1ce1ab1e 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickController.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickController.java @@ -3,26 +3,26 @@ import static com.dreamypatisiel.devdevdev.web.WebConstant.HEADER_ANONYMOUS_MEMBER_ID; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; -import com.dreamypatisiel.devdevdev.domain.service.pick.PickMultiServiceHandler; +import com.dreamypatisiel.devdevdev.domain.service.pick.PickFacadeService; import com.dreamypatisiel.devdevdev.domain.service.pick.PickService; import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceStrategy; import com.dreamypatisiel.devdevdev.domain.service.pick.dto.VotePickOptionDto; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickModifyResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickRegisterResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickUploadImageResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.VotePickResponse; import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; -import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingRequestHandler; import com.dreamypatisiel.devdevdev.openai.data.request.EmbeddingRequest; import com.dreamypatisiel.devdevdev.openai.data.response.Embedding; import com.dreamypatisiel.devdevdev.openai.data.response.OpenAIResponse; +import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingRequestHandler; import com.dreamypatisiel.devdevdev.web.dto.request.pick.ModifyPickRequest; import com.dreamypatisiel.devdevdev.web.dto.request.pick.RegisterPickRequest; import com.dreamypatisiel.devdevdev.web.dto.request.pick.VotePickOptionRequest; import com.dreamypatisiel.devdevdev.web.dto.response.BasicResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickModifyResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickRegisterResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickUploadImageResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.VotePickResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; @@ -55,7 +55,7 @@ public class PickController { private final PickServiceStrategy pickServiceStrategy; - private final PickMultiServiceHandler pickMultiServiceHandler; + private final PickFacadeService pickFacadeService; private final EmbeddingRequestHandler embeddingRequestHandler; @Operation(summary = "픽픽픽 메인 조회", description = "픽픽픽 메인 페이지에 필요한 데이터를 커서 방식으로 조회합니다.") @@ -111,10 +111,10 @@ public ResponseEntity> registerPick( PickService pickService = pickServiceStrategy.getPickService(); - pickMultiServiceHandler.injectPickService(pickService); + pickFacadeService.injectPickService(pickService); // 픽픽픽 작성 및 embedding 저장 - PickRegisterResponse response = pickMultiServiceHandler.registerPickAndSaveEmbedding( + PickRegisterResponse response = pickFacadeService.registerPickAndSaveEmbedding( registerPickRequest, authentication, embeddingOpenAIResponse); return ResponseEntity.ok(BasicResponse.success(response)); @@ -134,10 +134,10 @@ public ResponseEntity> modifyPick( PickService pickService = pickServiceStrategy.getPickService(); - pickMultiServiceHandler.injectPickService(pickService); + pickFacadeService.injectPickService(pickService); // 픽픽픽 수정 및 embedding 저장 - PickModifyResponse response = pickMultiServiceHandler.modifyPickAndSaveEmbedding(pickId, + PickModifyResponse response = pickFacadeService.modifyPickAndSaveEmbedding(pickId, modifyPickRequest, authentication, embeddingOpenAIResponse); return ResponseEntity.ok(BasicResponse.success(response)); diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/controller/subscription/SubscriptionController.java b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/subscription/SubscriptionController.java new file mode 100644 index 00000000..cafa13d3 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/subscription/SubscriptionController.java @@ -0,0 +1,86 @@ +package com.dreamypatisiel.devdevdev.web.controller.subscription; + +import com.dreamypatisiel.devdevdev.domain.service.techArticle.TechArticleServiceStrategy; +import com.dreamypatisiel.devdevdev.domain.service.techArticle.subscription.SubscriptionService; +import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; +import com.dreamypatisiel.devdevdev.web.dto.request.subscription.SubscribeCompanyRequest; +import com.dreamypatisiel.devdevdev.web.dto.response.BasicResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.CompanyDetailResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscriableCompanyResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscriptionResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.validation.annotation.Validated; +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; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "구독 API", description = "구독 관련 기능") +@RestController +@RequestMapping("/devdevdev/api/v1") +@RequiredArgsConstructor +public class SubscriptionController { + + private final TechArticleServiceStrategy techArticleServiceStrategy; + + @Operation(summary = "기업 구독하기", description = "구독 가능한 기업을 구독합니다.") + @PostMapping("/subscriptions") + public ResponseEntity> subscribe( + @RequestBody @Validated SubscribeCompanyRequest request) { + Authentication authentication = AuthenticationMemberUtils.getAuthentication(); + SubscriptionService subscriptionService = techArticleServiceStrategy.getSubscriptionService(); + + SubscriptionResponse response = subscriptionService.subscribe(request.getCompanyId(), authentication); + + return ResponseEntity.ok(BasicResponse.success(response)); + } + + @Operation(summary = "기업 구독 취소", description = "구독한 기업을 구독 취소 합니다.") + @DeleteMapping("/subscriptions") + public ResponseEntity> unsubscribe(@RequestBody @Validated SubscribeCompanyRequest request) { + Authentication authentication = AuthenticationMemberUtils.getAuthentication(); + SubscriptionService subscriptionService = techArticleServiceStrategy.getSubscriptionService(); + + subscriptionService.unsubscribe(request.getCompanyId(), authentication); + + return ResponseEntity.ok(BasicResponse.success()); + } + + @Operation(summary = "구독한 가능한 기업 목록 조회", description = "구독 가능한 기업 목록을 조회합니다.") + @GetMapping("/subscriptions/companies") + public ResponseEntity>> getSubscriptions( + @PageableDefault(size = 20) Pageable pageable, + @RequestParam(required = false) Long companyId) { + + Authentication authentication = AuthenticationMemberUtils.getAuthentication(); + SubscriptionService subscriptionService = techArticleServiceStrategy.getSubscriptionService(); + + Slice response = subscriptionService.getSubscribableCompany(pageable, + companyId, authentication); + + return ResponseEntity.ok(BasicResponse.success(response)); + } + + @Operation(summary = "구독 가능한 기업 상세 정보 조회", description = "구독 가능한 기업의 상세 정보를 조회합니다.") + @GetMapping("/subscriptions/companies/{companyId}") + public ResponseEntity> getCompanyDetail(@PathVariable Long companyId) { + + Authentication authentication = AuthenticationMemberUtils.getAuthentication(); + SubscriptionService subscriptionService = techArticleServiceStrategy.getSubscriptionService(); + + CompanyDetailResponse response = subscriptionService.getCompanyDetail(companyId, authentication); + + return ResponseEntity.ok(BasicResponse.success(response)); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/SliceCustom.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/SliceCustom.java index fa6470ab..daad3db8 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/SliceCustom.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/SliceCustom.java @@ -14,4 +14,13 @@ public SliceCustom(List content, Pageable pageable, boolean hasNext, Long tot super(content, pageable, hasNext); this.totalElements = totalElements; } + + public SliceCustom(List content, Pageable pageable, Long totalElements) { + super(content, pageable, hasNextPage(content, pageable.getPageSize())); + this.totalElements = totalElements; + } + + private static boolean hasNextPage(List contents, int pageSize) { + return contents.size() >= pageSize; + } } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/request/publish/PublishTechArticle.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/request/publish/PublishTechArticle.java new file mode 100644 index 00000000..3b99ee7d --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/request/publish/PublishTechArticle.java @@ -0,0 +1,17 @@ +package com.dreamypatisiel.devdevdev.web.dto.request.publish; + +import jakarta.validation.constraints.NotNull; +import java.io.Serializable; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class PublishTechArticle implements Serializable, RedisPublishRequest { + @NotNull(message = "기술 블로그 아이디는 필수 입니다.") + private Long id; + + public PublishTechArticle(Long id) { + this.id = id; + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/request/publish/PublishTechArticleRequest.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/request/publish/PublishTechArticleRequest.java new file mode 100644 index 00000000..627547dd --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/request/publish/PublishTechArticleRequest.java @@ -0,0 +1,24 @@ +package com.dreamypatisiel.devdevdev.web.dto.request.publish; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.io.Serializable; +import java.util.List; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class PublishTechArticleRequest implements Serializable, RedisPublishRequest { + + @NotNull(message = "회사 아이디는 필수 입니다.") + private Long companyId; + + @NotEmpty(message = "기술 블로그는 필수 입니다.") + private List techArticles; + + public PublishTechArticleRequest(Long companyId, List techArticles) { + this.companyId = companyId; + this.techArticles = techArticles; + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/request/publish/RedisPublishRequest.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/request/publish/RedisPublishRequest.java new file mode 100644 index 00000000..bd921fe1 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/request/publish/RedisPublishRequest.java @@ -0,0 +1,4 @@ +package com.dreamypatisiel.devdevdev.web.dto.request.publish; + +public interface RedisPublishRequest { +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/request/subscription/SubscribeCompanyRequest.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/request/subscription/SubscribeCompanyRequest.java new file mode 100644 index 00000000..f6297295 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/request/subscription/SubscribeCompanyRequest.java @@ -0,0 +1,17 @@ +package com.dreamypatisiel.devdevdev.web.dto.request.subscription; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class SubscribeCompanyRequest { + + @NotNull(message = "기업 아이디는 필수 입니다.") + private Long companyId; + + public SubscribeCompanyRequest(Long companyId) { + this.companyId = companyId; + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/notification/NotificationNewArticleResponse.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/notification/NotificationNewArticleResponse.java new file mode 100644 index 00000000..dcccf17a --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/notification/NotificationNewArticleResponse.java @@ -0,0 +1,31 @@ +package com.dreamypatisiel.devdevdev.web.dto.response.notification; + +import com.dreamypatisiel.devdevdev.domain.entity.Notification; +import com.dreamypatisiel.devdevdev.domain.entity.enums.NotificationType; +import com.dreamypatisiel.devdevdev.web.dto.response.techArticle.TechArticleMainResponse; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +public class NotificationNewArticleResponse extends NotificationResponse { + private final TechArticleMainResponse techArticle; + + @Builder + public NotificationNewArticleResponse(Long notificationId, LocalDateTime createdAt, + boolean isRead, TechArticleMainResponse techArticle) { + super(notificationId, NotificationType.SUBSCRIPTION, createdAt, isRead); + this.techArticle = techArticle; + } + + public static NotificationNewArticleResponse from(Notification notification, TechArticleMainResponse techArticle) { + return NotificationNewArticleResponse.builder() + .notificationId(notification.getId()) + .createdAt(notification.getCreatedAt()) + .isRead(notification.getIsRead()) + .techArticle(techArticle) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/notification/NotificationPopupNewArticleResponse.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/notification/NotificationPopupNewArticleResponse.java new file mode 100644 index 00000000..4ece24d2 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/notification/NotificationPopupNewArticleResponse.java @@ -0,0 +1,34 @@ +package com.dreamypatisiel.devdevdev.web.dto.response.notification; + +import com.dreamypatisiel.devdevdev.domain.entity.Notification; +import com.dreamypatisiel.devdevdev.domain.entity.enums.NotificationType; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +public class NotificationPopupNewArticleResponse extends NotificationPopupResponse { + private final String companyName; + private final Long techArticleId; + + @Builder + public NotificationPopupNewArticleResponse(Long id, String title, LocalDateTime createdAt, + boolean isRead, String companyName, Long techArticleId) { + super(id, NotificationType.SUBSCRIPTION, title, createdAt, isRead); + this.companyName = companyName; + this.techArticleId = techArticleId; + } + + public static NotificationPopupNewArticleResponse from(Notification notification) { + return NotificationPopupNewArticleResponse.builder() + .id(notification.getId()) + .createdAt(notification.getCreatedAt()) + .isRead(notification.getIsRead()) + .title(notification.getTechArticle().getTitle().getTitle()) + .companyName(notification.getTechArticle().getCompany().getName().getCompanyName()) + .techArticleId(notification.getTechArticle().getId()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/notification/NotificationPopupResponse.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/notification/NotificationPopupResponse.java new file mode 100644 index 00000000..a7457a3c --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/notification/NotificationPopupResponse.java @@ -0,0 +1,25 @@ +package com.dreamypatisiel.devdevdev.web.dto.response.notification; + +import com.dreamypatisiel.devdevdev.domain.entity.enums.NotificationType; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +public abstract class NotificationPopupResponse { + private final Long id; + private final NotificationType type; + private final String title; + private final LocalDateTime createdAt; + private final Boolean isRead; + + public NotificationPopupResponse(Long id, NotificationType type, String title, LocalDateTime createdAt, + boolean isRead) { + this.id = id; + this.type = type; + this.title = title; + this.createdAt = createdAt; + this.isRead = isRead; + } +} \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/notification/NotificationReadResponse.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/notification/NotificationReadResponse.java new file mode 100644 index 00000000..fb81590f --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/notification/NotificationReadResponse.java @@ -0,0 +1,24 @@ +package com.dreamypatisiel.devdevdev.web.dto.response.notification; + +import com.dreamypatisiel.devdevdev.domain.entity.Notification; +import lombok.Builder; +import lombok.Data; + +@Data +public class NotificationReadResponse { + public final Long id; + public final Boolean isRead; + + @Builder + public NotificationReadResponse(Long id, Boolean isRead) { + this.id = id; + this.isRead = isRead; + } + + public static NotificationReadResponse from(Notification notification) { + return NotificationReadResponse.builder() + .id(notification.getId()) + .isRead(notification.getIsRead()) + .build(); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/notification/NotificationResponse.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/notification/NotificationResponse.java new file mode 100644 index 00000000..83f13148 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/notification/NotificationResponse.java @@ -0,0 +1,22 @@ +package com.dreamypatisiel.devdevdev.web.dto.response.notification; + +import com.dreamypatisiel.devdevdev.domain.entity.enums.NotificationType; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +public abstract class NotificationResponse { + private final Long notificationId; + private final NotificationType type; + private final LocalDateTime createdAt; + private final Boolean isRead; + + public NotificationResponse(Long notificationId, NotificationType type, LocalDateTime createdAt, boolean isRead) { + this.notificationId = notificationId; + this.type = type; + this.createdAt = createdAt; + this.isRead = isRead; + } +} \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/subscription/CompanyDetailResponse.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/subscription/CompanyDetailResponse.java new file mode 100644 index 00000000..dd0d7acb --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/subscription/CompanyDetailResponse.java @@ -0,0 +1,62 @@ +package com.dreamypatisiel.devdevdev.web.dto.response.subscription; + +import com.dreamypatisiel.devdevdev.domain.entity.Company; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.custom.dto.CompanyDetailDto; +import lombok.Builder; +import lombok.Data; + +@Data +public class CompanyDetailResponse { + private final Long companyId; + private final String companyName; + private final String industry; + private final String companyDescription; + private final String companyOfficialImageUrl; + private final String companyCareerUrl; + private final Long techArticleTotalCount; + private final Boolean isSubscribed; + + @Builder + public CompanyDetailResponse(Long companyId, String companyName, String industry, + String companyDescription, String companyOfficialImageUrl, + String companyCareerUrl, Long techArticleTotalCount, Boolean isSubscribed) { + this.companyId = companyId; + this.companyName = companyName; + this.industry = industry; + this.companyDescription = companyDescription; + this.companyOfficialImageUrl = companyOfficialImageUrl; + this.companyCareerUrl = companyCareerUrl; + this.techArticleTotalCount = techArticleTotalCount; + this.isSubscribed = isSubscribed; + } + + public static CompanyDetailResponse createMemberCompanyDetailResponse(CompanyDetailDto companyDetailDto, + Long techArticleTotalCount) { + + boolean isSubscribed = companyDetailDto.getSubscriptionId() != null; + + return CompanyDetailResponse.builder() + .companyId(companyDetailDto.getCompanyId()) + .companyName(companyDetailDto.getName()) + .industry(companyDetailDto.getIndustry()) + .companyDescription(companyDetailDto.getDescription()) + .companyOfficialImageUrl(companyDetailDto.getOfficialImageUrl()) + .companyCareerUrl(companyDetailDto.getCareerUrl()) + .techArticleTotalCount(techArticleTotalCount) + .isSubscribed(isSubscribed) + .build(); + } + + public static CompanyDetailResponse createGuestCompanyDetailResponse(Company company, Long techArticleTotalCount) { + return CompanyDetailResponse.builder() + .companyId(company.getId()) + .companyName(company.getName().getCompanyName()) + .industry(company.getIndustry()) + .companyDescription(company.getDescription()) + .companyOfficialImageUrl(company.getOfficialImageUrl().getUrl()) + .companyCareerUrl(company.getCareerUrl().getUrl()) + .techArticleTotalCount(techArticleTotalCount) + .isSubscribed(false) + .build(); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/subscription/SubscriableCompanyResponse.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/subscription/SubscriableCompanyResponse.java new file mode 100644 index 00000000..47b33071 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/subscription/SubscriableCompanyResponse.java @@ -0,0 +1,39 @@ +package com.dreamypatisiel.devdevdev.web.dto.response.subscription; + +import com.dreamypatisiel.devdevdev.domain.entity.Company; +import lombok.Builder; +import lombok.Data; + +@Data +public class SubscriableCompanyResponse { + private final Long companyId; + private final String companyName; + private final String companyImageUrl; + private final Boolean isSubscribed; + + @Builder + public SubscriableCompanyResponse(Long companyId, String companyName, String companyImageUrl, Boolean isSubscribed) { + this.companyId = companyId; + this.companyName = companyName; + this.companyImageUrl = companyImageUrl; + this.isSubscribed = isSubscribed; + } + + public static SubscriableCompanyResponse create(Company company) { + return SubscriableCompanyResponse.builder() + .companyId(company.getId()) + .companyName(company.getName().getCompanyName()) + .companyImageUrl(company.getOfficialImageUrl().getUrl()) + .isSubscribed(false) + .build(); + } + + public static SubscriableCompanyResponse createWithIsSubscribed(Company company, Boolean isSubscribed) { + return SubscriableCompanyResponse.builder() + .companyId(company.getId()) + .companyName(company.getName().getCompanyName()) + .companyImageUrl(company.getOfficialImageUrl().getUrl()) + .isSubscribed(isSubscribed) + .build(); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/subscription/SubscribedCompanyResponse.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/subscription/SubscribedCompanyResponse.java new file mode 100644 index 00000000..89877a5e --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/subscription/SubscribedCompanyResponse.java @@ -0,0 +1,39 @@ +package com.dreamypatisiel.devdevdev.web.dto.response.subscription; + +import com.dreamypatisiel.devdevdev.domain.entity.Company; +import lombok.Builder; +import lombok.Data; + +@Data +public class SubscribedCompanyResponse { + private final Long companyId; + private final String companyName; + private final String companyImageUrl; + private final Boolean isSubscribed; + + @Builder + public SubscribedCompanyResponse(Long companyId, String companyName, String companyImageUrl, Boolean isSubscribed) { + this.companyId = companyId; + this.companyName = companyName; + this.companyImageUrl = companyImageUrl; + this.isSubscribed = isSubscribed; + } + + public static SubscribedCompanyResponse create(Company company) { + return SubscribedCompanyResponse.builder() + .companyId(company.getId()) + .companyName(company.getName().getCompanyName()) + .companyImageUrl(company.getOfficialImageUrl().getUrl()) + .isSubscribed(false) + .build(); + } + + public static SubscribedCompanyResponse createWithIsSubscribed(Company company, Boolean isSubscribed) { + return SubscribedCompanyResponse.builder() + .companyId(company.getId()) + .companyName(company.getName().getCompanyName()) + .companyImageUrl(company.getOfficialImageUrl().getUrl()) + .isSubscribed(isSubscribed) + .build(); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/subscription/SubscriptionResponse.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/subscription/SubscriptionResponse.java new file mode 100644 index 00000000..ee283888 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/subscription/SubscriptionResponse.java @@ -0,0 +1,8 @@ +package com.dreamypatisiel.devdevdev.web.dto.response.subscription; + +import lombok.Data; + +@Data +public class SubscriptionResponse { + private final Long id; +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/techArticle/CompanyResponse.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/techArticle/CompanyResponse.java index edf6f6b7..00813e7c 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/techArticle/CompanyResponse.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/techArticle/CompanyResponse.java @@ -20,11 +20,12 @@ private CompanyResponse(Long id, String name, String careerUrl, String officialI this.officialImageUrl = officialImageUrl; } - public static CompanyResponse of(Long id, String name, String careerUrl) { + public static CompanyResponse of(Long id, String name, String careerUrl, String officialImageUrl) { return CompanyResponse.builder() .id(id) .name(name) .careerUrl(careerUrl) + .officialImageUrl(officialImageUrl) .build(); } @@ -33,7 +34,7 @@ public static CompanyResponse from(Company company) { .id(company.getId()) .name(company.getName().getCompanyName()) .careerUrl(company.getCareerUrl().getUrl()) - .officialImageUrl(company.getOfficialImageUrl()) + .officialImageUrl(company.getOfficialImageUrl().getUrl()) .build(); } } diff --git a/src/main/resources/application-jwt-local.yml b/src/main/resources/application-jwt-local.yml index 1f338628..ff611e63 100644 --- a/src/main/resources/application-jwt-local.yml +++ b/src/main/resources/application-jwt-local.yml @@ -2,4 +2,8 @@ jwt: header: Authorization secret: devdevdevtestdevdevdevtestdevdevdevtestdevdevdevtest redirectUri: - path: / \ No newline at end of file + path: / + + expire: + access: 1800000 # 30분 + refresh: 604800000 # 7일 \ No newline at end of file diff --git a/src/main/resources/mapper/commment/Comment.xml b/src/main/resources/mapper/comment/Comment.xml similarity index 100% rename from src/main/resources/mapper/commment/Comment.xml rename to src/main/resources/mapper/comment/Comment.xml diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/ApiKeyServiceTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/ApiKeyServiceTest.java new file mode 100644 index 00000000..21ae4e7c --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/ApiKeyServiceTest.java @@ -0,0 +1,75 @@ +package com.dreamypatisiel.devdevdev.domain.service; + +import static com.dreamypatisiel.devdevdev.domain.service.notification.NotificationService.ACCESS_DENIED_MESSAGE; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.dreamypatisiel.devdevdev.domain.entity.ApiKey; +import com.dreamypatisiel.devdevdev.domain.repository.ApiKeyRepository; +import com.dreamypatisiel.devdevdev.exception.NotFoundException; +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.security.access.AccessDeniedException; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class ApiKeyServiceTest { + + @Autowired + ApiKeyService apiKeyService; + + @Autowired + ApiKeyRepository apiKeyRepository; + + @Test + @DisplayName("외부 service 에 대한 apiKey 를 검증한다.") + void validateApiKey() { + // given + String serviceName = "test-service"; + String key = "test-key"; + + ApiKey apiKey = ApiKey.builder() + .serviceName(serviceName) + .apiKey(key) + .build(); + + apiKeyRepository.save(apiKey); + + // when // then + assertThatCode(() -> apiKeyService.validateApiKey(serviceName, key)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("apiKey 가 존재하지 않으면 예외가 발생한다.") + void validateApiKeyNotFoundException() { + // given // when // then + assertThatThrownBy(() -> apiKeyService.validateApiKey("test-service", "test-key")) + .isInstanceOf(NotFoundException.class) + .hasMessage("지원하는 서비스가 아닙니다."); + } + + @Test + @DisplayName("apiKey 가 일치하지 않으면 예외가 발생한다.") + void validateApiKeyAccessDeniedException() { + // given + String serviceName = "test-service"; + String key = "test-key"; + String invalidKey = "invalid-key"; + + ApiKey apiKey = ApiKey.builder() + .serviceName(serviceName) + .apiKey(key) + .build(); + + apiKeyRepository.save(apiKey); + + // when // then + assertThatThrownBy(() -> apiKeyService.validateApiKey(serviceName, invalidKey)) + .isInstanceOf(AccessDeniedException.class) + .hasMessage(ACCESS_DENIED_MESSAGE); + } +} \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/member/MemberServiceTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/member/MemberServiceTest.java index 23c19471..80716cf4 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/member/MemberServiceTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/member/MemberServiceTest.java @@ -9,30 +9,13 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import com.dreamypatisiel.devdevdev.domain.entity.Bookmark; -import com.dreamypatisiel.devdevdev.domain.entity.Company; -import com.dreamypatisiel.devdevdev.domain.entity.Member; -import com.dreamypatisiel.devdevdev.domain.entity.Pick; -import com.dreamypatisiel.devdevdev.domain.entity.PickComment; -import com.dreamypatisiel.devdevdev.domain.entity.PickOption; -import com.dreamypatisiel.devdevdev.domain.entity.PickVote; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyAnswer; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyQuestion; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyQuestionOption; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyVersion; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyVersionQuestionMapper; -import com.dreamypatisiel.devdevdev.domain.entity.TechArticle; -import com.dreamypatisiel.devdevdev.domain.entity.TechComment; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.CommentContents; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.CompanyName; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.CustomSurveyAnswer; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.PickOptionContents; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; +import com.dreamypatisiel.devdevdev.domain.entity.*; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.*; import com.dreamypatisiel.devdevdev.domain.entity.enums.ContentStatus; import com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType; import com.dreamypatisiel.devdevdev.domain.entity.enums.Role; import com.dreamypatisiel.devdevdev.domain.entity.enums.SocialType; +import com.dreamypatisiel.devdevdev.domain.exception.CompanyExceptionMessage; import com.dreamypatisiel.devdevdev.domain.repository.CompanyRepository; import com.dreamypatisiel.devdevdev.domain.repository.member.MemberRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickCommentRepository; @@ -45,6 +28,7 @@ import com.dreamypatisiel.devdevdev.domain.repository.survey.SurveyVersionQuestionMapperRepository; import com.dreamypatisiel.devdevdev.domain.repository.survey.SurveyVersionRepository; import com.dreamypatisiel.devdevdev.domain.repository.techArticle.BookmarkRepository; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.SubscriptionRepository; import com.dreamypatisiel.devdevdev.domain.repository.techArticle.TechArticleRepository; import com.dreamypatisiel.devdevdev.domain.repository.techArticle.TechCommentRepository; import com.dreamypatisiel.devdevdev.elastic.domain.service.ElasticsearchSupportTest; @@ -63,9 +47,11 @@ import com.dreamypatisiel.devdevdev.web.dto.response.member.MemberExitSurveyQuestionResponse; import com.dreamypatisiel.devdevdev.web.dto.response.member.MemberExitSurveyResponse; import com.dreamypatisiel.devdevdev.web.dto.response.pick.MyPickMainResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscribedCompanyResponse; import com.dreamypatisiel.devdevdev.web.dto.response.techArticle.TechArticleMainResponse; import jakarta.persistence.EntityManager; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import org.assertj.core.groups.Tuple; @@ -128,6 +114,8 @@ class MemberServiceTest extends ElasticsearchSupportTest { TechCommentRepository techCommentRepository; @Autowired PickCommentRepository pickCommentRepository; + @Autowired + SubscriptionRepository subscriptionRepository; @Test @DisplayName("회원이 회원탈퇴 설문조사를 완료하지 않으면 탈퇴가 불가능하다.") @@ -445,7 +433,7 @@ void getBookmarkedTechArticles() { } @Test - @DisplayName("커서 방식으로 기술블로그 북마크 목록을 조회할 때 회원이 없으면 예외가 발생한다.") + @DisplayName("회원이 커서 방식으로 기술블로그 북마크 목록을 조회할 때 회원이 없으면 예외가 발생한다.") void getBookmarkedTechArticlesNotFoundMemberException() { // given Pageable pageable = PageRequest.of(0, 10); @@ -1062,6 +1050,145 @@ void findMyWrittenComments_TECH() { ); } + @Test + @DisplayName("회원이 커서 방식으로 자신이 구독한 기업 목록을 조회하여 응답을 생성한다.") + void findMySubscribedCompanies() { + // given + Pageable pageable = PageRequest.of(0, 1); + + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 회사 생성 + Company company1 = createCompany("Toss", "https://toss.tech", + "https://toss.im/career/jobs", "https://company.net/image.png", "토스", "금융"); + Company company2 = createCompany("우아한 형제들", "https://techblog.woowahan.com", + "https://career.woowahan.com", "https://company.net/image.png", "우아한 형제들", "푸드"); + Company company3 = createCompany("AWS", "https://aws.amazon.com/ko/blogs/tech", + "https://aws.amazon.com/ko/careers", "https://company.net/image.png", "AWS", "클라우드"); + Company company4 = createCompany("채널톡", "https://channel.io/ko/blog", + "https://channel.io/ko/jobs", "https://company.net/image.png", "채널톡", "채팅"); + + List companies = List.of(company1, company2, company3, company4); + companyRepository.saveAll(companies); + + // 회원 구독 + Subscription subscription1 = Subscription.create(member, company1); + Subscription subscription2 = Subscription.create(member, company2); + List subscriptions = List.of(subscription1, subscription2); + subscriptionRepository.saveAll(subscriptions); + + em.flush(); + em.clear(); + + // when + SliceCustom mySubscribedCompanies = memberService.findMySubscribedCompanies(pageable, null, authentication); + + // then + assertThat(mySubscribedCompanies) + .hasSize(pageable.getPageSize()) + .extracting( + SubscribedCompanyResponse::getCompanyId, + SubscribedCompanyResponse::getCompanyName, + SubscribedCompanyResponse::getCompanyImageUrl, + SubscribedCompanyResponse::getIsSubscribed) + .containsExactly( + Tuple.tuple(company2.getId(), company2.getName().getCompanyName(), company2.getOfficialImageUrl().getUrl(), true) + ); + } + + @Test + @DisplayName("회원이 커서 방식으로 다음페이지의 자신이 구독한 기업 목록을 조회하여 응답을 생성한다.") + void findMySubscribedCompaniesByCursor() { + // given + Pageable pageable = PageRequest.of(0, 1); + + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 회사 생성 + Company company1 = createCompany("Toss", "https://toss.tech", + "https://toss.im/career/jobs", "https://company.net/image.png", "토스", "금융"); + Company company2 = createCompany("우아한 형제들", "https://techblog.woowahan.com", + "https://career.woowahan.com", "https://company.net/image.png", "우아한 형제들", "푸드"); + Company company3 = createCompany("AWS", "https://aws.amazon.com/ko/blogs/tech", + "https://aws.amazon.com/ko/careers", "https://company.net/image.png", "AWS", "클라우드"); + Company company4 = createCompany("채널톡", "https://channel.io/ko/blog", + "https://channel.io/ko/jobs", "https://company.net/image.png", "채널톡", "채팅"); + + List companies = List.of(company1, company2, company3, company4); + companyRepository.saveAll(companies); + + // 회원 구독 + Subscription subscription1 = Subscription.create(member, company1); + Subscription subscription2 = Subscription.create(member, company2); + List subscriptions = List.of(subscription1, subscription2); + subscriptionRepository.saveAll(subscriptions); + + em.flush(); + em.clear(); + + // when + SliceCustom mySubscribedCompanies = memberService.findMySubscribedCompanies(pageable, company2.getId(), authentication); + + // then + assertThat(mySubscribedCompanies) + .hasSize(pageable.getPageSize()) + .extracting( + SubscribedCompanyResponse::getCompanyId, + SubscribedCompanyResponse::getCompanyName, + SubscribedCompanyResponse::getCompanyImageUrl, + SubscribedCompanyResponse::getIsSubscribed) + .containsExactly( + Tuple.tuple(company1.getId(), company1.getName().getCompanyName(), company1.getOfficialImageUrl().getUrl(), true) + ); + } + + @Test + @DisplayName("회원이 커서 방식으로 자신이 구독한 기업 목록을 조회할 때 회원이 없으면 예외가 발생한다.") + void findMySubscribedCompaniesNotFoundMemberException() { + // given + Pageable pageable = PageRequest.of(0, 10); + + UserPrincipal userPrincipal = UserPrincipal.createByEmailAndRoleAndSocialType(email, role, socialType); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // when // then + assertThatThrownBy( + () -> memberService.findMySubscribedCompanies(pageable, null, authentication)) + .isInstanceOf(MemberException.class) + .hasMessage(INVALID_MEMBER_NOT_FOUND_MESSAGE); + } + + private static Company createCompany(String companyName, String officialUrl, String careerUrl, + String imageUrl, String description, String industry) { + return Company.builder() + .name(new CompanyName(companyName)) + .careerUrl(new Url(careerUrl)) + .officialUrl(new Url(officialUrl)) + .officialImageUrl(new Url(imageUrl)) + .description(description) + .industry(industry) + .build(); + } + private static TechComment createTechComment(TechArticle techArticle, Member member, TechComment originParent, TechComment parent, String contents, Long recommendTotalCount) { return TechComment.builder() diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/notification/MockNotificationServiceTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/notification/MockNotificationServiceTest.java new file mode 100644 index 00000000..28e505c1 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/notification/MockNotificationServiceTest.java @@ -0,0 +1,200 @@ +package com.dreamypatisiel.devdevdev.domain.service.notification; + +import static com.dreamypatisiel.devdevdev.domain.service.notification.NotificationService.UNREAD_NOTIFICATION_FORMAT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import com.dreamypatisiel.devdevdev.domain.entity.Member; +import com.dreamypatisiel.devdevdev.domain.repository.SseEmitterRepository; +import com.dreamypatisiel.devdevdev.domain.repository.notification.NotificationRepository; +import com.dreamypatisiel.devdevdev.global.common.MemberProvider; +import com.dreamypatisiel.devdevdev.global.common.TimeProvider; +import com.dreamypatisiel.devdevdev.redis.sub.NotificationMessageDto; +import java.io.IOException; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@SpringBootTest +@Transactional +class MockNotificationServiceTest { + + @MockBean + TimeProvider timeProvider; + + @MockBean + private MemberProvider memberProvider; + + @MockBean + private NotificationRepository notificationRepository; + + @MockBean + private SseEmitterRepository sseEmitterRepository; + + @SpyBean + private NotificationService notificationService; + + @ParameterizedTest + @ValueSource(longs = {1, 2, 10, 50, 100}) + @DisplayName("읽지 않은 알림이 존재할 때, SSE 구독 등록 후 알림을 전송한다.") + void addClientAndSendNotificationHaveUnreadNotification(long unreadNotificationCount) throws Exception { + // given + Authentication mockAuthentication = mock(Authentication.class); + Member mockMember = mock(Member.class); + SseEmitter realEmitter = new SseEmitter(); + SseEmitter spyEmitter = spy(realEmitter); + + given(timeProvider.getLocalDateTimeNow()).willReturn(LocalDateTime.of(2025, 1, 1, 0, 0, 0)); + given(memberProvider.getMemberByAuthentication(mockAuthentication)).willReturn(mockMember); + given(notificationRepository.countByMemberAndIsReadIsFalse(mockMember)).willReturn(unreadNotificationCount); + given(notificationService.createSseEmitter(anyLong())).willReturn(spyEmitter); + + String notificationMessage = String.format(UNREAD_NOTIFICATION_FORMAT, unreadNotificationCount); + NotificationMessageDto notificationMessageDto = new NotificationMessageDto(notificationMessage, + timeProvider.getLocalDateTimeNow()); + + // when + SseEmitter resultEmitter = notificationService.addClientAndSendNotification(mockAuthentication); + + // then + verify(memberProvider).getMemberByAuthentication(mockAuthentication); + verify(sseEmitterRepository).save(mockMember, resultEmitter); + verify(notificationRepository).countByMemberAndIsReadIsFalse(mockMember); + verify(resultEmitter).send(notificationMessageDto); + verify(sseEmitterRepository, never()).remove(mockMember); + } + + @Test + @DisplayName("읽지 않은 알림이 없을 때, 구독자만 등록하고 알림은 전송하지 않는다.") + void addClientAndSendNotificationHaveNotUnreadNotification() throws IOException { + // given + Authentication mockAuthentication = mock(Authentication.class); + Member mockMember = mock(Member.class); + SseEmitter realEmitter = new SseEmitter(); + SseEmitter spyEmitter = spy(realEmitter); + + given(memberProvider.getMemberByAuthentication(mockAuthentication)).willReturn(mockMember); + given(notificationRepository.countByMemberAndIsReadIsFalse(mockMember)).willReturn(0L); + given(notificationService.createSseEmitter(anyLong())).willReturn(spyEmitter); + + // when + SseEmitter resultEmitter = notificationService.addClientAndSendNotification(mockAuthentication); + + // then + verify(memberProvider).getMemberByAuthentication(mockAuthentication); + verify(sseEmitterRepository).save(mockMember, resultEmitter); + verify(notificationRepository).countByMemberAndIsReadIsFalse(mockMember); + verify(resultEmitter, never()).send(String.format(UNREAD_NOTIFICATION_FORMAT, any())); + verify(sseEmitterRepository, never()).remove(mockMember); + } + + @ParameterizedTest + @ValueSource(longs = {1, 2, 10, 50, 100}) + @DisplayName("알림 전송 중 예외가 발생하면 구독자를 제거한다.") + void addClientAndSendNotificationIoException(Long unreadNotificationCount) throws IOException { + // given + Authentication mockAuthentication = mock(Authentication.class); + Member mockMember = mock(Member.class); + SseEmitter realEmitter = new SseEmitter(); + SseEmitter spyEmitter = spy(realEmitter); + + given(timeProvider.getLocalDateTimeNow()).willReturn(LocalDateTime.of(2025, 1, 1, 0, 0, 0)); + given(memberProvider.getMemberByAuthentication(mockAuthentication)).willReturn(mockMember); + given(notificationRepository.countByMemberAndIsReadIsFalse(mockMember)).willReturn(unreadNotificationCount); + given(notificationService.createSseEmitter(anyLong())).willReturn(spyEmitter); + + String notificationMessage = String.format(UNREAD_NOTIFICATION_FORMAT, unreadNotificationCount); + NotificationMessageDto notificationMessageDto = new NotificationMessageDto(notificationMessage, + timeProvider.getLocalDateTimeNow()); + + doThrow(new IOException()).when(spyEmitter).send(notificationMessageDto); + + // when // then + assertThatCode(() -> notificationService.addClientAndSendNotification(mockAuthentication)) + .doesNotThrowAnyException(); + + verify(memberProvider).getMemberByAuthentication(mockAuthentication); + verify(sseEmitterRepository).save(mockMember, spyEmitter); + verify(notificationRepository).countByMemberAndIsReadIsFalse(mockMember); + verify(spyEmitter).send(notificationMessageDto); + verify(sseEmitterRepository).remove(mockMember); + } + + @ParameterizedTest + @ValueSource(longs = {1, 2, 10, 50, 100}) + @DisplayName("구독자가 이미 존재하는 경우 기존의 SSEmitter 를 반환합니다.") + void addClientAndSendNotificationIsExistMember(Long unreadNotificationCount) throws IOException { + // given + Authentication mockAuthentication = mock(Authentication.class); + Member mockMember = mock(Member.class); + SseEmitter realEmitter = new SseEmitter(); + SseEmitter spyEmitter = spy(realEmitter); + + given(timeProvider.getLocalDateTimeNow()).willReturn(LocalDateTime.of(2025, 1, 1, 0, 0, 0)); + given(memberProvider.getMemberByAuthentication(mockAuthentication)).willReturn(mockMember); + given(sseEmitterRepository.save(mockMember, spyEmitter)).willReturn(spyEmitter); + given(sseEmitterRepository.existByMember(mockMember)).willReturn(true); + given(sseEmitterRepository.findByMemberId(mockMember)).willReturn(spyEmitter); + + // when + SseEmitter sseEmitter = notificationService.addClientAndSendNotification(mockAuthentication); + + // then + assertThat(sseEmitter).isEqualTo(spyEmitter); + } + + @Test + @DisplayName("구독자에게 알림을 전송합니다.") + void sendNotification() throws IOException { + // given + Member mockMember = mock(Member.class); + NotificationMessageDto mockMessageDto = mock(NotificationMessageDto.class); + + // SseEmitter를 모킹해줍니다. + SseEmitter mockEmitter = mock(SseEmitter.class); + given(sseEmitterRepository.findByMemberId(mockMember)).willReturn(mockEmitter); + + // when + notificationService.sendNotification(mockMessageDto, mockMember); + + verify(mockEmitter).send(mockMessageDto); + verify(sseEmitterRepository, never()).remove(mockMember); + } + + @Test + @DisplayName("구독자에게 알림 전송 중 예외가 발생하면 구독자를 제거합니다.") + void sendNotificationIoException() throws IOException { + // given + Member mockMember = mock(Member.class); + NotificationMessageDto mockMessageDto = mock(NotificationMessageDto.class); + + // SseEmitter를 모킹해줍니다. + SseEmitter mockEmitter = mock(SseEmitter.class); + given(sseEmitterRepository.findByMemberId(mockMember)).willReturn(mockEmitter); + + doThrow(new IOException()).when(mockEmitter).send(mockMessageDto); + + // when // then + assertThatCode(() -> notificationService.sendNotification(mockMessageDto, mockMember)) + .doesNotThrowAnyException(); + + verify(mockEmitter).send(mockMessageDto); + verify(sseEmitterRepository).remove(mockMember); + } +} \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/notification/NotificationServiceTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/notification/NotificationServiceTest.java new file mode 100644 index 00000000..5ba792d3 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/notification/NotificationServiceTest.java @@ -0,0 +1,760 @@ +package com.dreamypatisiel.devdevdev.domain.service.notification; + + +import com.dreamypatisiel.devdevdev.domain.entity.*; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.CompanyName; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Url; +import com.dreamypatisiel.devdevdev.domain.entity.enums.NotificationType; +import com.dreamypatisiel.devdevdev.domain.entity.enums.Role; +import com.dreamypatisiel.devdevdev.domain.entity.enums.SocialType; +import com.dreamypatisiel.devdevdev.domain.exception.NotificationExceptionMessage; +import com.dreamypatisiel.devdevdev.domain.repository.CompanyRepository; +import com.dreamypatisiel.devdevdev.domain.repository.member.MemberRepository; +import com.dreamypatisiel.devdevdev.domain.repository.notification.NotificationRepository; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.SubscriptionRepository; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.TechArticleRepository; +import com.dreamypatisiel.devdevdev.elastic.domain.document.ElasticTechArticle; +import com.dreamypatisiel.devdevdev.elastic.domain.repository.ElasticTechArticleRepository; +import com.dreamypatisiel.devdevdev.exception.NotFoundException; +import com.dreamypatisiel.devdevdev.global.security.oauth2.model.SocialMemberDto; +import com.dreamypatisiel.devdevdev.global.security.oauth2.model.UserPrincipal; +import com.dreamypatisiel.devdevdev.web.dto.request.publish.PublishTechArticle; +import com.dreamypatisiel.devdevdev.web.dto.request.publish.PublishTechArticleRequest; +import com.dreamypatisiel.devdevdev.web.dto.response.notification.NotificationNewArticleResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.notification.NotificationPopupNewArticleResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.notification.NotificationReadResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.notification.NotificationResponse; +import jakarta.persistence.EntityManager; +import org.assertj.core.api.AssertionsForClassTypes; +import org.assertj.core.api.AssertionsForInterfaceTypes; +import org.junit.jupiter.api.AfterAll; +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; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static com.dreamypatisiel.devdevdev.domain.service.notification.NotificationService.ACCESS_DENIED_MESSAGE; +import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.AssertionsForClassTypes.tuple; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@Transactional +class NotificationServiceTest { + + @Autowired + EntityManager em; + @Autowired + NotificationService notificationService; + @Autowired + MemberRepository memberRepository; + @Autowired + NotificationRepository notificationRepository; + @Autowired + CompanyRepository companyRepository; + @Autowired + TechArticleRepository techArticleRepository; + @Autowired + ElasticTechArticleRepository elasticTechArticleRepository; + @Autowired + SubscriptionRepository subscriptionRepository; + + String userId = "dreamy5patisiel"; + String name = "꿈빛파티시엘"; + String nickname = "행복한 꿈빛파티시엘"; + String email = "dreamy5patisiel@kakao.com"; + String password = "password"; + String socialType = SocialType.KAKAO.name(); + String role = Role.ROLE_USER.name(); + + @AfterAll + static void tearDown(@Autowired ElasticTechArticleRepository elasticTechArticleRepository, + @Autowired TechArticleRepository techArticleRepository) { + elasticTechArticleRepository.deleteAll(); + techArticleRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("회원이 단건 알림을 읽으면 isRead가 true로 변경된다.") + void readNotification() { + // given + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 알림 생성 + Notification notification = createNotification(member, "새로운 소식이 도착했습니다.", NotificationType.SUBSCRIPTION, false); + notificationRepository.save(notification); + + // when + NotificationReadResponse notificationReadResponse = notificationService.readNotification(notification.getId(), + authentication); + + em.flush(); + em.clear(); // 영속성 컨텍스트 초기화 + + // then + Notification findNotification = notificationRepository.findById(notification.getId()).orElseThrow(); + assertThat(findNotification.getIsRead()).isTrue(); + + assertAll( + () -> assertThat(notificationReadResponse.getId()).isEqualTo(notification.getId()), + () -> assertThat(notificationReadResponse.getIsRead()).isTrue() + ); + } + + @Test + @DisplayName("회원이 자신의 알림이 아닌 알림을 조회하면 예외가 발생한다.") + void readNotificationNotOwnerException() { + // given + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + Member owner = Member.createMemberBy( + createSocialDto("owner", name, nickname, password, "owner@dreamy5patisiel.com", socialType, role)); + memberRepository.save(owner); + + // 알림 생성 + Notification notification = createNotification(owner, "새로운 소식이 도착했습니다.", NotificationType.SUBSCRIPTION, false); + notificationRepository.save(notification); + + // when // then + assertThatThrownBy(() -> notificationService.readNotification(notification.getId(), authentication)) + .isInstanceOf(NotFoundException.class) + .hasMessage(NotificationExceptionMessage.NOT_FOUND_NOTIFICATION_MESSAGE); + } + + @Test + @DisplayName("회원이 모든 알림을 읽으면 isRead가 true로 일괄 업데이트된다.") + void readAllNotifications() { + // given + SocialMemberDto socialMemberDto = createSocialDto( + "dreamy", "꿈빛파티시엘", "행복한 꿈빛", "pass123", "dreamy@kakao.com", "KAKAO", "ROLE_USER" + ); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal principal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken( + principal, principal.getAuthorities(), principal.getSocialType().name() + )); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 안 읽은 알림 여러 개 저장 + notificationRepository.saveAll(List.of( + createNotification(member, false), + createNotification(member, false), + createNotification(member, false) + )); + + // when + notificationService.readAllNotifications(authentication); + + em.flush(); + em.clear(); + + // then + List allNotifications = notificationRepository.findAllByMemberId(member.getId()); + assertThat(allNotifications) + .hasSize(3) + .allMatch(Notification::getIsRead); + } + + @Test + @DisplayName("회원이 읽을 알림이 하나도 없어도 readAllNotifications는 성공한다.") + void readAllNotificationsWhenNoUnreadNotifications() { + // given + SocialMemberDto socialMemberDto = createSocialDto( + "dreamy", "꿈빛파티시엘", "행복한 꿈빛", "pass123", "dreamy@kakao.com", "KAKAO", "ROLE_USER" + ); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal principal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken( + principal, principal.getAuthorities(), principal.getSocialType().name() + )); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 읽은 알림만 저장 + notificationRepository.saveAll(List.of( + createNotification(member, true), + createNotification(member, true) + )); + + // when + notificationService.readAllNotifications(authentication); + + em.flush(); + em.clear(); + + // then + List allNotifications = notificationRepository.findAllByMemberId(member.getId()); + assertThat(allNotifications) + .hasSize(2) + .allMatch(Notification::getIsRead); // 여전히 모두 읽음 상태 + } + + @Test + @DisplayName("회원이 알림 팝업을 조회하면 최신 알림이 반환된다.") + void getNotificationPopup() { + // given + SocialMemberDto socialMemberDto = createSocialDto( + "dreamy", "꿈빛파티시엘", "행복한 꿈빛", "pass123", "dreamy@kakao.com", "KAKAO", "ROLE_USER" + ); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal principal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken( + principal, principal.getAuthorities(), principal.getSocialType().name() + )); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 알림 6개 저장 + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); + companyRepository.save(company); + + List techArticles = new ArrayList<>(); + List notifications = new ArrayList<>(); + + for (int i = 0; i < 6; i++) { + TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목 "+i), new Url("https://example.com"), + new Count(1L), new Count(1L), new Count(1L), new Count(1L), null, company); + + techArticles.add(techArticle); + notifications.add(createNotification(member, "알림 메시지 " + i, NotificationType.SUBSCRIPTION, false, techArticle)); + } + + techArticleRepository.saveAll(techArticles); + notificationRepository.saveAll(notifications); + + em.flush(); + em.clear(); + + Pageable pageable = PageRequest.of(0, 5); + + // when + var response = notificationService.getNotificationPopup(pageable, authentication); + + // then + assertThat(response.getContent()).hasSize(5); + assertThat(response.getTotalElements()).isEqualTo(6); // 읽지 않은 알림 총 개수 + + List content = response.getContent().stream() + .map(p -> (NotificationPopupNewArticleResponse) p) + .toList(); + + assertThat(content).allSatisfy(popup -> { + assertThat(popup.getId()).isInstanceOf(Long.class); + assertThat(popup.getTechArticleId()).isInstanceOf(Long.class); + }); + + assertThat(content).hasSize(5) + .extracting( + NotificationPopupNewArticleResponse::getTitle, + NotificationPopupNewArticleResponse::getCompanyName, + NotificationPopupNewArticleResponse::getType, + NotificationPopupNewArticleResponse::getIsRead + ) + .containsExactly( + tuple("기술블로그 제목 5", "꿈빛 파티시엘", NotificationType.SUBSCRIPTION, false), + tuple("기술블로그 제목 4", "꿈빛 파티시엘", NotificationType.SUBSCRIPTION, false), + tuple("기술블로그 제목 3", "꿈빛 파티시엘", NotificationType.SUBSCRIPTION, false), + tuple("기술블로그 제목 2", "꿈빛 파티시엘", NotificationType.SUBSCRIPTION, false), + tuple("기술블로그 제목 1", "꿈빛 파티시엘", NotificationType.SUBSCRIPTION, false) + ); + + assertThat(content) + .extracting(NotificationPopupNewArticleResponse::getCreatedAt) + .allSatisfy(createdAt -> assertThat(createdAt).isInstanceOf(LocalDateTime.class)); + } + + @Test + @DisplayName("회원이 알림 페이지를 조회하면 알림 목록이 커서 기반으로 반환된다.") + void getNotifications() { + // given + SocialMemberDto socialMemberDto = createSocialDto( + "dreamy", "꿈빛파티시엘", "행복한 꿈빛", "pass123", "dreamy@kakao.com", "KAKAO", "ROLE_USER" + ); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal principal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken( + principal, principal.getAuthorities(), principal.getSocialType().name() + )); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 알림 10개 저장 + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); + companyRepository.save(company); + + + List elasticTechArticles = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + ElasticTechArticle elasticTechArticle = createElasticTechArticle("elasticId" + i, "기술블로그 제목 "+i, + LocalDate.now(), "기술블로그 내용", "https://example.com", "기술블로그 설명", + "https://example.com/thumbnail.png", "작성자", "회사명", company.getId(), + 1L, 1L, 1L, 1L); + elasticTechArticles.add(elasticTechArticle); + } + elasticTechArticleRepository.saveAll(elasticTechArticles); + + List techArticles = new ArrayList<>(); + List notifications = new ArrayList<>(); + + for (int i = 0; i < 10; i++) { + TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목 "+i), new Url("https://example.com"), + new Count(1L), new Count(1L), new Count(1L), new Count(1L), elasticTechArticles.get(i).getId(), company); + + techArticles.add(techArticle); + notifications.add(createNotification(member, "알림 메시지 " + i, NotificationType.SUBSCRIPTION, false, techArticle)); + } + + techArticleRepository.saveAll(techArticles); + notificationRepository.saveAll(notifications); + + em.flush(); + em.clear(); + + Pageable pageable = PageRequest.of(0, 5); + + // when + var response = notificationService.getNotifications(pageable, null, authentication); + + // then + assertThat(response.getContent()).hasSize(pageable.getPageSize()); + assertThat(response.getTotalElements()).isEqualTo(10); // 읽지 않은 알림 총 개수 + assertThat(response.hasNext()).isTrue(); // 다음 페이지가 존재하는지 확인 + + List content = response.getContent().stream() + .map(n -> (NotificationNewArticleResponse) n) + .toList(); + + assertThat(content).allSatisfy(notificationNewArticleResponse -> { + assertThat(notificationNewArticleResponse.getNotificationId()).isInstanceOf(Long.class); + assertThat(notificationNewArticleResponse.getTechArticle().getId()).isInstanceOf(Long.class); + assertThat(notificationNewArticleResponse.getTechArticle().getElasticId()).isInstanceOf(String.class); + assertThat(notificationNewArticleResponse.getTechArticle().getCompany().getId()).isInstanceOf(Long.class); + }); + + assertThat(content).hasSize(5) + .extracting( + NotificationNewArticleResponse::getNotificationId, + NotificationNewArticleResponse::getType, + NotificationNewArticleResponse::getIsRead, + r -> r.getTechArticle().getThumbnailUrl(), + r -> r.getTechArticle().getIsLogoImage(), + r -> r.getTechArticle().getTechArticleUrl(), + r -> r.getTechArticle().getTitle(), + r -> r.getTechArticle().getContents(), + r -> r.getTechArticle().getViewTotalCount(), + r -> r.getTechArticle().getRecommendTotalCount(), + r -> r.getTechArticle().getCommentTotalCount(), + r -> r.getTechArticle().getPopularScore(), + r -> r.getTechArticle().getIsBookmarked(), + r -> r.getTechArticle().getCompany().getName(), + r -> r.getTechArticle().getCompany().getCareerUrl(), + r -> r.getTechArticle().getCompany().getOfficialImageUrl() + ) + .containsExactly( + tuple( + notifications.get(9).getId(), NotificationType.SUBSCRIPTION, false, + "https://example.com/thumbnail.png", false, "https://example.com", "기술블로그 제목 9", "기술블로그 내용", + 1L, 1L, 1L, 1L, false, "꿈빛 파티시엘", "https://example.com", "https://example.com/company.png" + ), + tuple( + notifications.get(8).getId(), NotificationType.SUBSCRIPTION, false, + "https://example.com/thumbnail.png", false, "https://example.com", "기술블로그 제목 8", "기술블로그 내용", + 1L, 1L, 1L, 1L, false, "꿈빛 파티시엘", "https://example.com", "https://example.com/company.png" + ), + tuple( + notifications.get(7).getId(), NotificationType.SUBSCRIPTION, false, + "https://example.com/thumbnail.png", false, "https://example.com", "기술블로그 제목 7", "기술블로그 내용", + 1L, 1L, 1L, 1L, false, "꿈빛 파티시엘", "https://example.com", "https://example.com/company.png" + ), + tuple( + notifications.get(6).getId(), NotificationType.SUBSCRIPTION, false, + "https://example.com/thumbnail.png", false, "https://example.com", "기술블로그 제목 6", "기술블로그 내용", + 1L, 1L, 1L, 1L, false, "꿈빛 파티시엘", "https://example.com", "https://example.com/company.png" + ), + tuple( + notifications.get(5).getId(), NotificationType.SUBSCRIPTION, false, + "https://example.com/thumbnail.png", false, "https://example.com", "기술블로그 제목 5", "기술블로그 내용", + 1L, 1L, 1L, 1L, false, "꿈빛 파티시엘", "https://example.com", "https://example.com/company.png" + ) ); + + assertThat(content) + .extracting(NotificationResponse::getCreatedAt) + .allSatisfy(createdAt -> assertThat(createdAt).isInstanceOf(LocalDateTime.class)); + } + + @DisplayName("구독자에게 메인 알림을 전송할 때 알림이 없으면 알림 이력을 저장하고 전송한다.") + void sendMainTechArticleNotifications() { + // given + // 회원 생성 + Member member = createMember(); + memberRepository.save(member); + + // 회사 생성 + Company company = createCompany("트이다"); + companyRepository.save(company); + + // 기술블로그 생성 + TechArticle techArticle = createTechArticle(company); + techArticleRepository.save(techArticle); + + // 기업 구독 생성 + Subscription subscription = createSubscription(company, member); + subscriptionRepository.save(subscription); + + // 기술블로그 발행 요청 생성 + PublishTechArticleRequest publishTechArticleRequest = new PublishTechArticleRequest( + company.getId(), + List.of(new PublishTechArticle(techArticle.getId()))); + + // when // then + assertThatCode(() -> notificationService.sendMainTechArticleNotifications(publishTechArticleRequest)) + .doesNotThrowAnyException(); + + // 알림 이력 확인 + List findNotifications = notificationRepository.findByMemberInAndTechArticleIdInOrderByNull( + Set.of(member), Set.of(techArticle.getId())); + Notification notification = findNotifications.get(0); + assertAll( + () -> AssertionsForClassTypes.assertThat(notification.getMember()).isEqualTo(member), + () -> AssertionsForClassTypes.assertThat(notification.getTechArticle().getId()) + .isEqualTo(techArticle.getId()), + () -> AssertionsForClassTypes.assertThat(notification.getMessage()).isNotBlank(), + () -> AssertionsForClassTypes.assertThat(notification.getIsRead()).isFalse() + ); + } + + @Test + @DisplayName("구독자에게 메인 알림을 전송할 때 기업을 구독한 회원이 없으면 알림 이력을 저장하지 않고 알림도 전송하지 않는다.") + void sendMainTechArticleNotificationsSubscriptionIsEmpty() { + // given + // 회원 생성 + Member member = createMember(); + memberRepository.save(member); + + // 회사 생성 + Company company = createCompany("트이다"); + companyRepository.save(company); + + // 기술블로그 생성 + TechArticle techArticle = createTechArticle(company); + techArticleRepository.save(techArticle); + + // 기술블로그 발행 요청 생성 + PublishTechArticleRequest publishTechArticleRequest = new PublishTechArticleRequest( + company.getId(), + List.of(new PublishTechArticle(techArticle.getId()))); + + // when // then + assertThatCode(() -> notificationService.sendMainTechArticleNotifications(publishTechArticleRequest)) + .doesNotThrowAnyException(); + + // 알림 이력 확인 + List findNotifications = notificationRepository.findByMemberInAndTechArticleIdInOrderByNull( + Set.of(member), Set.of(techArticle.getId())); + AssertionsForInterfaceTypes.assertThat(findNotifications).isEmpty(); + } + + @Test + @DisplayName("알림을 생성한다.") + void publish() { + // given + // 회사 생성 + Company company = createCompany("트이다"); + companyRepository.save(company); + + // 기술블로그 생성 + TechArticle techArticle = createTechArticle(company); + techArticleRepository.save(techArticle); + + // 기술블로그 발행 요청 생성 + PublishTechArticleRequest publishTechArticleRequest = new PublishTechArticleRequest( + company.getId(), List.of(new PublishTechArticle(techArticle.getId()))); + + // when + Long publish = notificationService.publish(NotificationType.SUBSCRIPTION, publishTechArticleRequest); + + // then + AssertionsForClassTypes.assertThat(publish).isGreaterThan(0); + } + + @Disabled + @Test + @DisplayName("알림을 생성 할 때 회원이 어드민 권한이 아니면 예외가 발생한다.") + void publishIsNotAdmin() { + // given + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, + Role.ROLE_USER.name()); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 회사 생성 + Company company = createCompany("트이다"); + companyRepository.save(company); + + // 기술블로그 생성 + TechArticle techArticle = createTechArticle(company); + techArticleRepository.save(techArticle); + + // 기술블로그 발행 요청 생성 + PublishTechArticleRequest publishTechArticleRequest = new PublishTechArticleRequest( + company.getId(), List.of(new PublishTechArticle(techArticle.getId()))); + + // when // then + assertThatThrownBy( + () -> notificationService.publish(NotificationType.SUBSCRIPTION, + publishTechArticleRequest)) + .isInstanceOf(AccessDeniedException.class) + .hasMessage(ACCESS_DENIED_MESSAGE); + } + + @Test + @DisplayName("회원이 알림 개수를 조회하면 회원이 아직 읽지 않은 알림의 총 개수가 반환된다.") + void getUnreadNotificationCount() { + // given + SocialMemberDto socialMemberDto = createSocialDto( + "dreamy", "꿈빛파티시엘", "행복한 꿈빛", "pass123", "dreamy@kakao.com", "KAKAO", "ROLE_USER" + ); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal principal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken( + principal, principal.getAuthorities(), principal.getSocialType().name() + )); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 알림 10개 저장 + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); + companyRepository.save(company); + + List techArticles = new ArrayList<>(); + List notifications = new ArrayList<>(); + + for (int i = 0; i < 10; i++) { + TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목 "+i), new Url("https://example.com"), + new Count(1L), new Count(1L), new Count(1L), new Count(1L), null, company); + + techArticles.add(techArticle); + notifications.add(createNotification(member, "알림 메시지 " + i, NotificationType.SUBSCRIPTION, false, techArticle)); + } + + // 3개 읽기 처리 + for (int i = 0; i < 3; i++) { + Notification notification = notifications.get(i); + notification.markAsRead(); + } + + techArticleRepository.saveAll(techArticles); + notificationRepository.saveAll(notifications); + + em.flush(); + em.clear(); + + // when + Long response = notificationService.getUnreadNotificationCount(authentication); + + // then + assertThat(response).isEqualTo(7); + } + + @Test + @DisplayName("회원이 알림을 전체 삭제한다.") + void deleteAllByMember() { + // given + SocialMemberDto socialMemberDto = createSocialDto( + "dreamy", "꿈빛파티시엘", "행복한 꿈빛", "pass123", "dreamy@kakao.com", "KAKAO", "ROLE_USER" + ); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal principal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken( + principal, principal.getAuthorities(), principal.getSocialType().name() + )); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 알림 10개 저장 + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); + companyRepository.save(company); + + List techArticles = new ArrayList<>(); + List notifications = new ArrayList<>(); + + for (int i = 0; i < 10; i++) { + TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목 "+i), new Url("https://example.com"), + new Count(1L), new Count(1L), new Count(1L), new Count(1L), null, company); + + techArticles.add(techArticle); + notifications.add(createNotification(member, "알림 메시지 " + i, NotificationType.SUBSCRIPTION, false, techArticle)); + } + + techArticleRepository.saveAll(techArticles); + notificationRepository.saveAll(notifications); + + em.flush(); + em.clear(); + + // when + notificationService.deleteAllByMember(authentication); + + // then + long response = notificationService.getUnreadNotificationCount(authentication); + assertThat(response).isEqualTo(0); + } + + private static Subscription createSubscription(Company company, Member member) { + return Subscription.builder() + .company(company) + .member(member) + .build(); + } + + private static Member createMember() { + return Member.builder() + .isDeleted(false) + .build(); + } + + private static TechArticle createTechArticle(Company company) { + return TechArticle.builder() + .company(company) + .build(); + } + + private static Company createCompany(String name) { + return Company.builder() + .name(new CompanyName(name)) + .build(); + } + + private Notification createNotification(Member member, boolean isRead) { + return Notification.builder() + .member(member) + .message("테스트 알림") + .type(NotificationType.SUBSCRIPTION) + .isRead(isRead) + .build(); + } + + private Notification createNotification(Member member, String message, NotificationType type, boolean isRead) { + return Notification.builder() + .member(member) + .message(message) + .type(type) + .isRead(isRead) + .build(); + } + + private Notification createNotification(Member member, String message, NotificationType type, boolean isRead, + TechArticle techArticle) { + return Notification.builder() + .member(member) + .message(message) + .type(type) + .isRead(isRead) + .type(NotificationType.SUBSCRIPTION) + .techArticle(techArticle) + .build(); + } + + private SocialMemberDto createSocialDto(String userId, String name, String nickName, String password, String email, + String socialType, String role) { + return SocialMemberDto.builder() + .userId(userId) + .name(name) + .nickname(nickName) + .password(password) + .email(email) + .socialType(SocialType.valueOf(socialType)) + .role(Role.valueOf(role)) + .build(); + } + + private static Company createCompany(String companyName, String officialImageUrl, String officialUrl, + String careerUrl) { + return Company.builder() + .name(new CompanyName(companyName)) + .officialImageUrl(new Url(officialImageUrl)) + .careerUrl(new Url(careerUrl)) + .officialUrl(new Url(officialUrl)) + .build(); + } + + private static ElasticTechArticle createElasticTechArticle(String id, String title, LocalDate regDate, + String contents, String techArticleUrl, + String description, String thumbnailUrl, String author, + String company, Long companyId, + Long viewTotalCount, Long recommendTotalCount, + Long commentTotalCount, Long popularScore) { + return ElasticTechArticle.builder() + .id(id) + .title(title) + .regDate(regDate) + .contents(contents) + .techArticleUrl(techArticleUrl) + .description(description) + .thumbnailUrl(thumbnailUrl) + .author(author) + .company(company) + .companyId(companyId) + .viewTotalCount(viewTotalCount) + .recommendTotalCount(recommendTotalCount) + .commentTotalCount(commentTotalCount) + .popularScore(popularScore) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/response/TechArticleMainResponseTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/response/TechArticleMainResponseTest.java deleted file mode 100644 index 5f473d96..00000000 --- a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/response/TechArticleMainResponseTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.dreamypatisiel.devdevdev.domain.service.response; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.dreamypatisiel.devdevdev.domain.entity.Company; -import com.dreamypatisiel.devdevdev.domain.entity.TechArticle; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.CompanyName; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.Url; -import com.dreamypatisiel.devdevdev.elastic.domain.document.ElasticTechArticle; -import com.dreamypatisiel.devdevdev.web.dto.response.techArticle.CompanyResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.techArticle.TechArticleMainResponse; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -class TechArticleMainResponseTest { - - @Test - @DisplayName("기술블로그 썸네일 이미지가 없으면 회사 로고 이미지가 썸네일로 들어간다.") - void companyLogoAsDefaultThumbnailWhenNoThumbnail() { - // given - ElasticTechArticle elasticTechArticle = createElasticTechArticle("elasticId", "타이틀", "내용", - "http://example.com/", "설명", null, "작성자", - "꿈빛 파티시엘", 1L, 1L, 1L, 1L, 1L); - - Company company = createCompany("꿈빛 파티시엘", "https://companylogo.png", "https://example.com", - "https://example.com"); - - TechArticle techArticle = TechArticle.createTechArticle(elasticTechArticle, company); - CompanyResponse companyResponse = CompanyResponse.from(company); - - // when - TechArticleMainResponse techArticleMainResponse = TechArticleMainResponse.of(techArticle, elasticTechArticle, - companyResponse); - - // then - assertThat(techArticleMainResponse.getThumbnailUrl()).isEqualTo(company.getOfficialImageUrl()); - } - - private static Company createCompany(String companyName, String officialImageUrl, String officialUrl, - String careerUrl) { - return Company.builder() - .name(new CompanyName(companyName)) - .officialImageUrl(officialImageUrl) - .careerUrl(new Url(careerUrl)) - .officialUrl(new Url(officialUrl)) - .build(); - } - - private static ElasticTechArticle createElasticTechArticle(String id, String title, - String contents, - String techArticleUrl, - String description, String thumbnailUrl, String author, - String company, Long companyId, - Long viewTotalCount, Long recommendTotalCount, - Long commentTotalCount, - Long popularScore) { - return ElasticTechArticle.builder() - .id(id) - .title(title) - .contents(contents) - .techArticleUrl(techArticleUrl) - .description(description) - .thumbnailUrl(thumbnailUrl) - .author(author) - .company(company) - .companyId(companyId) - .viewTotalCount(viewTotalCount) - .recommendTotalCount(recommendTotalCount) - .commentTotalCount(commentTotalCount) - .popularScore(popularScore) - .build(); - } -} \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/response/util/PickResponseUtilsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/response/util/PickResponseUtilsTest.java deleted file mode 100644 index bccda1a9..00000000 --- a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/response/util/PickResponseUtilsTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.dreamypatisiel.devdevdev.domain.service.response.util; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.dreamypatisiel.devdevdev.web.dto.util.CommonResponseUtil; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; - -class PickResponseUtilsTest { - - @ParameterizedTest - @CsvSource(value = {"alsdudr97@naver.com:als******", "merooongg@naver.com:mer******", - "dreamy5patisiel@gmail.com:dre************", "mmj9908@naver.com:mmj****"}, delimiter = ':') - @DisplayName("이메일 도메인을 제거하고 아이디를 마스킹 처리한다.") - void sliceAndMaskEmail(String email, String expected) { - // given // when - String result = CommonResponseUtil.sliceAndMaskEmail(email); - - // then - assertThat(result).isEqualTo(expected); - } -} \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/GuestSubscriptionServiceTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/GuestSubscriptionServiceTest.java new file mode 100644 index 00000000..a01b8ec4 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/GuestSubscriptionServiceTest.java @@ -0,0 +1,169 @@ +package com.dreamypatisiel.devdevdev.domain.service.techArticle; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.when; + +import com.dreamypatisiel.devdevdev.domain.entity.Company; +import com.dreamypatisiel.devdevdev.domain.entity.TechArticle; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.CompanyName; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Url; +import com.dreamypatisiel.devdevdev.domain.repository.CompanyRepository; +import com.dreamypatisiel.devdevdev.domain.repository.member.MemberRepository; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.SubscriptionRepository; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.TechArticleRepository; +import com.dreamypatisiel.devdevdev.domain.service.techArticle.subscription.GuestSubscriptionService; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.CompanyDetailResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscriableCompanyResponse; +import jakarta.persistence.EntityManager; +import java.util.List; +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class GuestSubscriptionServiceTest { + + @Autowired + EntityManager em; + @Autowired + GuestSubscriptionService guestSubscriptionService; + @Autowired + CompanyRepository companyRepository; + @Autowired + MemberRepository memberRepository; + @Autowired + SubscriptionRepository subscriptionRepository; + @Autowired + private TechArticleRepository techArticleRepository; + + @Mock + Authentication authentication; + @Mock + SecurityContext securityContext; + + @BeforeEach + void setUp() { + // given + when(authentication.getPrincipal()).thenReturn("anonymousUser"); + when(securityContext.getAuthentication()).thenReturn(authentication); + SecurityContextHolder.setContext(securityContext); + } + + @Test + @DisplayName("구독 가능한 기업 목록을 커서 방식으로 조회한다.") + void getSubscribableCompany() { + // given + // 회사 생성 + Company company1 = createCompany("teuida1", "https://www.teuida1.net"); + Company company2 = createCompany("teuida2", "https://www.teuida2.net"); + Company company3 = createCompany("teuida3", "https://www.teuida3.net"); + Company company4 = createCompany("teuida4", "https://www.teuida4.net"); + Company company5 = createCompany("teuida5", "https://www.teuida5.net"); + companyRepository.saveAll(List.of(company1, company2, company3, company4, company5)); + + Pageable pageable = PageRequest.of(0, 2); + + // when + Slice subscribableCompany1 = guestSubscriptionService.getSubscribableCompany( + pageable, null, authentication); + // then + assertThat(subscribableCompany1).hasSize(2) + .extracting("companyId", "companyImageUrl", "isSubscribed") + .containsExactly( + Tuple.tuple(company5.getId(), company5.getOfficialImageUrl().getUrl(), false), + Tuple.tuple(company4.getId(), company4.getOfficialImageUrl().getUrl(), false) + ); + + // when + Slice subscribableCompany2 = guestSubscriptionService.getSubscribableCompany( + pageable, company4.getId(), authentication); + // then + assertThat(subscribableCompany2).hasSize(2) + .extracting("companyId", "companyImageUrl", "isSubscribed") + .containsExactly( + Tuple.tuple(company3.getId(), company3.getOfficialImageUrl().getUrl(), false), + Tuple.tuple(company2.getId(), company2.getOfficialImageUrl().getUrl(), false) + ); + + // when + Slice subscribableCompany3 = guestSubscriptionService.getSubscribableCompany( + pageable, company2.getId(), authentication); + + // then + assertThat(subscribableCompany3).hasSize(1) + .extracting("companyId", "companyImageUrl", "isSubscribed") + .containsExactly( + Tuple.tuple(company1.getId(), company1.getOfficialImageUrl().getUrl(), false) + ); + } + + @Test + @DisplayName("회원이 구독하지 않은 구독 가능한 기업 상세 정보를 조회한다.") + void getCompanyDetailNotSubscribe() { + // given + // 기업 생성 + Company company = createCompany("트이다", "교육", "트이다는..", "https://www.teuida.net/iamge.png", + "https://www.teuida.net/career"); + companyRepository.save(company); + + // 기술 블로그 생성 + TechArticle techArticle1 = createTechArticle(company); + TechArticle techArticle2 = createTechArticle(company); + TechArticle techArticle3 = createTechArticle(company); + techArticleRepository.saveAll(List.of(techArticle1, techArticle2, techArticle3)); + + // when + CompanyDetailResponse companyDetail = guestSubscriptionService.getCompanyDetail(company.getId(), + authentication); + + // then + assertAll( + () -> assertThat(companyDetail.getCompanyId()).isEqualTo(company.getId()), + () -> assertThat(companyDetail.getCompanyName()).isEqualTo(company.getName().getCompanyName()), + () -> assertThat(companyDetail.getIndustry()).isEqualTo(company.getIndustry()), + () -> assertThat(companyDetail.getCompanyDescription()).isEqualTo(company.getDescription()), + () -> assertThat(companyDetail.getCompanyCareerUrl()).isEqualTo(company.getCareerUrl().getUrl()), + () -> assertThat(companyDetail.getCompanyOfficialImageUrl()).isEqualTo( + company.getOfficialImageUrl().getUrl()), + () -> assertThat(companyDetail.getTechArticleTotalCount()).isEqualTo(3L), + () -> assertThat(companyDetail.getIsSubscribed()).isFalse() + ); + } + + private static Company createCompany(String companyName, String industry, String description, + String officialImageUrl, String careerUrl) { + return Company.builder() + .name(new CompanyName(companyName)) + .industry(industry) + .description(description) + .officialImageUrl(new Url(officialImageUrl)) + .careerUrl(new Url(careerUrl)) + .build(); + } + + private static TechArticle createTechArticle(Company company) { + return TechArticle.builder() + .company(company) + .build(); + } + + private static Company createCompany(String companyName, String officialImageUrl) { + return Company.builder() + .name(new CompanyName(companyName)) + .officialImageUrl(new Url(officialImageUrl)) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/GuestTechCommentServiceTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/GuestTechCommentServiceTest.java index 1618d4f7..eade4b29 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/GuestTechCommentServiceTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/GuestTechCommentServiceTest.java @@ -1,12 +1,5 @@ package com.dreamypatisiel.devdevdev.domain.service.techArticle; -import static com.dreamypatisiel.devdevdev.domain.exception.GuestExceptionMessage.INVALID_ANONYMOUS_CAN_NOT_USE_THIS_FUNCTION_MESSAGE; -import static com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils.INVALID_METHODS_CALL_MESSAGE; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - import com.dreamypatisiel.devdevdev.domain.entity.Company; import com.dreamypatisiel.devdevdev.domain.entity.Member; import com.dreamypatisiel.devdevdev.domain.entity.TechArticle; @@ -19,6 +12,7 @@ import com.dreamypatisiel.devdevdev.domain.entity.embedded.Url; import com.dreamypatisiel.devdevdev.domain.entity.enums.Role; import com.dreamypatisiel.devdevdev.domain.entity.enums.SocialType; +import static com.dreamypatisiel.devdevdev.domain.exception.GuestExceptionMessage.INVALID_ANONYMOUS_CAN_NOT_USE_THIS_FUNCTION_MESSAGE; import com.dreamypatisiel.devdevdev.domain.repository.CompanyRepository; import com.dreamypatisiel.devdevdev.domain.repository.member.MemberRepository; import com.dreamypatisiel.devdevdev.domain.repository.techArticle.TechArticleRepository; @@ -30,6 +24,7 @@ import com.dreamypatisiel.devdevdev.global.security.oauth2.model.SocialMemberDto; import com.dreamypatisiel.devdevdev.global.security.oauth2.model.UserPrincipal; import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; +import static com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils.INVALID_METHODS_CALL_MESSAGE; import com.dreamypatisiel.devdevdev.web.dto.SliceCommentCustom; import com.dreamypatisiel.devdevdev.web.dto.request.techArticle.RegisterTechCommentRequest; import com.dreamypatisiel.devdevdev.web.dto.response.techArticle.TechCommentsResponse; @@ -38,9 +33,13 @@ import jakarta.persistence.EntityManager; import java.time.LocalDateTime; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.assertj.core.groups.Tuple; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.domain.PageRequest; @@ -98,7 +97,8 @@ void registerTechComment() { Authentication authentication = mock(Authentication.class); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -126,7 +126,8 @@ void registerRepliedTechComment() { Authentication authentication = mock(Authentication.class); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -159,7 +160,8 @@ void recommendTechComment() { Authentication authentication = mock(Authentication.class); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -189,7 +191,8 @@ void getTechCommentsSortByOLDEST() { Authentication authentication = mock(Authentication.class); when(authentication.getPrincipal()).thenReturn(AuthenticationMemberUtils.ANONYMOUS_USER); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -482,7 +485,8 @@ void getTechCommentsSortByLATEST() { Authentication authentication = mock(Authentication.class); when(authentication.getPrincipal()).thenReturn(AuthenticationMemberUtils.ANONYMOUS_USER); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -650,7 +654,8 @@ void getTechCommentsSortByMostCommented() { Authentication authentication = mock(Authentication.class); when(authentication.getPrincipal()).thenReturn(AuthenticationMemberUtils.ANONYMOUS_USER); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -943,7 +948,8 @@ void getTechCommentsSortByMostRecommended() { Authentication authentication = mock(Authentication.class); when(authentication.getPrincipal()).thenReturn(AuthenticationMemberUtils.ANONYMOUS_USER); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -1090,7 +1096,8 @@ void getTechCommentsByCursor() { Authentication authentication = mock(Authentication.class); when(authentication.getPrincipal()).thenReturn(AuthenticationMemberUtils.ANONYMOUS_USER); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -1264,7 +1271,8 @@ void findTechBestComments() { memberRepository.saveAll(List.of(member1, member2, member3)); // 회사 생성 - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); // 기술 블로그 생성 @@ -1455,7 +1463,7 @@ private static Company createCompany(String companyName, String officialImageUrl .name(new CompanyName(companyName)) .officialUrl(new Url(officialUrl)) .careerUrl(new Url(careerUrl)) - .officialImageUrl(officialImageUrl) + .officialImageUrl(new Url(officialImageUrl)) .build(); } } diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/MemberSubscriptionServiceTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/MemberSubscriptionServiceTest.java new file mode 100644 index 00000000..6898f552 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/MemberSubscriptionServiceTest.java @@ -0,0 +1,488 @@ +package com.dreamypatisiel.devdevdev.domain.service.techArticle; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.dreamypatisiel.devdevdev.domain.entity.Company; +import com.dreamypatisiel.devdevdev.domain.entity.Member; +import com.dreamypatisiel.devdevdev.domain.entity.Subscription; +import com.dreamypatisiel.devdevdev.domain.entity.TechArticle; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.CompanyName; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Url; +import com.dreamypatisiel.devdevdev.domain.entity.enums.Role; +import com.dreamypatisiel.devdevdev.domain.entity.enums.SocialType; +import com.dreamypatisiel.devdevdev.domain.exception.CompanyExceptionMessage; +import com.dreamypatisiel.devdevdev.domain.exception.MemberExceptionMessage; +import com.dreamypatisiel.devdevdev.domain.exception.SubscriptionExceptionMessage; +import com.dreamypatisiel.devdevdev.domain.repository.CompanyRepository; +import com.dreamypatisiel.devdevdev.domain.repository.member.MemberRepository; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.SubscriptionRepository; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.TechArticleRepository; +import com.dreamypatisiel.devdevdev.domain.service.techArticle.subscription.MemberSubscriptionService; +import com.dreamypatisiel.devdevdev.exception.MemberException; +import com.dreamypatisiel.devdevdev.exception.NotFoundException; +import com.dreamypatisiel.devdevdev.exception.SubscriptionException; +import com.dreamypatisiel.devdevdev.global.security.oauth2.model.SocialMemberDto; +import com.dreamypatisiel.devdevdev.global.security.oauth2.model.UserPrincipal; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.CompanyDetailResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscriableCompanyResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscriptionResponse; +import jakarta.persistence.EntityManager; +import java.util.List; +import org.assertj.core.groups.Tuple; +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.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class MemberSubscriptionServiceTest { + + @Autowired + EntityManager em; + @Autowired + MemberSubscriptionService memberSubscriptionService; + @Autowired + CompanyRepository companyRepository; + @Autowired + MemberRepository memberRepository; + @Autowired + SubscriptionRepository subscriptionRepository; + @Autowired + TechArticleRepository techArticleRepository; + + String userId = "dreamy5patisiel"; + String name = "꿈빛파티시엘"; + String nickname = "행복한 꿈빛파티시엘"; + String email = "dreamy5patisiel@kakao.com"; + String password = "password"; + String socialType = SocialType.KAKAO.name(); + String role = Role.ROLE_USER.name(); + + @Test + @DisplayName("회원이 기업을 구독한다.") + void subscribe() { + // given + // 회원 생성 + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 기업 생성 + Company company = createCompany("teuida"); + companyRepository.save(company); + + // when + SubscriptionResponse subscriptionResponse = memberSubscriptionService.subscribe(company.getId(), + authentication); + + // then + assertThat(subscriptionResponse).isNotNull(); + + Subscription findSubscription = subscriptionRepository.findById(subscriptionResponse.getId()).get(); + assertAll( + () -> assertThat(findSubscription.getMember().getId()).isEqualTo(member.getId()), + () -> assertThat(findSubscription.getCompany().getId()).isEqualTo(company.getId()) + ); + } + + @Test + @DisplayName("회원이 기업을 구독할 때 회원이 존재하지 않으면 예외가 발생한다.") + void subscribeMemberException() { + // given + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 기업 생성 + Company company = createCompany("teuida"); + companyRepository.save(company); + + // when // then + assertThatThrownBy(() -> memberSubscriptionService.subscribe(company.getId(), authentication)) + .isInstanceOf(MemberException.class) + .hasMessage(MemberExceptionMessage.INVALID_MEMBER_NOT_FOUND_MESSAGE); + } + + @Test + @DisplayName("회원이 기업을 구독할 때 이미 구독한 상태이면 예외가 발생한다.") + void subscribeSubscriptionException() { + // given + // 회원 생성 + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 기업 생성 + Company company = createCompany("teuida"); + companyRepository.save(company); + + // 구독 생성 + Subscription subscription = createSubscription(member, company); + subscriptionRepository.save(subscription); + + // when // then + assertThatThrownBy(() -> memberSubscriptionService.subscribe(company.getId(), authentication)) + .isInstanceOf(SubscriptionException.class) + .hasMessage(SubscriptionExceptionMessage.ALREADY_SUBSCRIBED_COMPANY_MESSAGE); + } + + @Test + @DisplayName("회원이 기업을 구독할 때 기업이 존재하지 않으면 예외가 발생한다.") + void subscribeNotFoundException() { + // given + // 회원 생성 + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // when // then + assertThatThrownBy(() -> memberSubscriptionService.subscribe(0L, authentication)) + .isInstanceOf(NotFoundException.class) + .hasMessage(CompanyExceptionMessage.NOT_FOUND_COMPANY_MESSAGE); + } + + @Test + @DisplayName("회원이 기업 구독을 취소한다.") + void unsubscribe() { + // given + // 회원 생성 + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 기업 생성 + Company company = createCompany("teuida"); + companyRepository.save(company); + + // 구독 생성 + Subscription subscription = createSubscription(member, company); + subscriptionRepository.save(subscription); + + // when // then + assertThatCode(() -> memberSubscriptionService.unsubscribe(company.getId(), authentication)) + .doesNotThrowAnyException(); + + em.flush(); + em.clear(); + + Subscription findSubscription = subscriptionRepository.findById(subscription.getId()) + .orElse(null); + assertThat(findSubscription).isNull(); + } + + @Test + @DisplayName("회원이 기업 구독을 취소할 때 회원이 존재하지 않으면 예외가 발생한다.") + void unsubscribeMemberException() { + // given + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 기업 생성 + Company company = createCompany("teuida"); + companyRepository.save(company); + + // when // then + assertThatThrownBy(() -> memberSubscriptionService.unsubscribe(company.getId(), authentication)) + .isInstanceOf(MemberException.class) + .hasMessage(MemberExceptionMessage.INVALID_MEMBER_NOT_FOUND_MESSAGE); + } + + @Test + @DisplayName("회원이 기업 구독을 취소할 때 구독 이력이 존재하지 않으면 예외가 발생한다.") + void unsubscribeNotFoundException() { + // given + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 기업 생성 + Company company = createCompany("teuida"); + companyRepository.save(company); + + // when // then + assertThatThrownBy(() -> memberSubscriptionService.unsubscribe(company.getId(), authentication)) + .isInstanceOf(NotFoundException.class) + .hasMessage(SubscriptionExceptionMessage.NOT_FOUND_SUBSCRIPTION_MESSAGE); + } + + @Test + @DisplayName("회원이 구독 가능한 기업 목록을 커서 방식으로 조회한다.") + void getSubscribableCompany() { + // given + // 회원 생성 + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 회사 생성 + Company company1 = createCompany("teuida1", "https://www.teuida1.net"); + Company company2 = createCompany("teuida2", "https://www.teuida2.net"); + Company company3 = createCompany("teuida3", "https://www.teuida3.net"); + Company company4 = createCompany("teuida4", "https://www.teuida4.net"); + Company company5 = createCompany("teuida5", "https://www.teuida5.net"); + companyRepository.saveAll(List.of(company1, company2, company3, company4, company5)); + + // 구독 생성 + Subscription subscription1 = createSubscription(member, company1); + Subscription subscription2 = createSubscription(member, company2); + subscriptionRepository.saveAll(List.of(subscription1, subscription2)); + + Pageable pageable = PageRequest.of(0, 2); + + // when + Slice subscribableCompany1 = memberSubscriptionService.getSubscribableCompany( + pageable, null, authentication); + // then + assertThat(subscribableCompany1).hasSize(2) + .extracting("companyId", "companyImageUrl", "isSubscribed") + .containsExactly( + Tuple.tuple(company5.getId(), company5.getOfficialImageUrl().getUrl(), false), + Tuple.tuple(company4.getId(), company4.getOfficialImageUrl().getUrl(), false) + ); + + // when + Slice subscribableCompany2 = memberSubscriptionService.getSubscribableCompany( + pageable, company4.getId(), authentication); + // then + assertThat(subscribableCompany2).hasSize(2) + .extracting("companyId", "companyImageUrl", "isSubscribed") + .containsExactly( + Tuple.tuple(company3.getId(), company3.getOfficialImageUrl().getUrl(), false), + Tuple.tuple(company2.getId(), company2.getOfficialImageUrl().getUrl(), true) + ); + + // when + Slice subscribableCompany3 = memberSubscriptionService.getSubscribableCompany( + pageable, company2.getId(), authentication); + + // then + assertThat(subscribableCompany3).hasSize(1) + .extracting("companyId", "companyImageUrl", "isSubscribed") + .containsExactly( + Tuple.tuple(company1.getId(), company1.getOfficialImageUrl().getUrl(), true) + ); + } + + @Test + @DisplayName("회원이 구독하지 않은 구독 가능한 기업 상세 정보를 조회한다.") + void getCompanyDetailNotSubscribe() { + // given + // 회원 생성 + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 기업 생성 + Company company = createCompany("트이다", "교육", "트이다는..", "https://www.teuida.net/iamge.png", + "https://www.teuida.net/career"); + companyRepository.save(company); + + // 기술 블로그 생성 + TechArticle techArticle1 = createTechArticle(company); + TechArticle techArticle2 = createTechArticle(company); + TechArticle techArticle3 = createTechArticle(company); + techArticleRepository.saveAll(List.of(techArticle1, techArticle2, techArticle3)); + + // when + CompanyDetailResponse companyDetail = memberSubscriptionService.getCompanyDetail(company.getId(), + authentication); + + // then + assertAll( + () -> assertThat(companyDetail.getCompanyId()).isEqualTo(company.getId()), + () -> assertThat(companyDetail.getCompanyName()).isEqualTo(company.getName().getCompanyName()), + () -> assertThat(companyDetail.getIndustry()).isEqualTo(company.getIndustry()), + () -> assertThat(companyDetail.getCompanyDescription()).isEqualTo(company.getDescription()), + () -> assertThat(companyDetail.getCompanyCareerUrl()).isEqualTo(company.getCareerUrl().getUrl()), + () -> assertThat(companyDetail.getCompanyOfficialImageUrl()).isEqualTo( + company.getOfficialImageUrl().getUrl()), + () -> assertThat(companyDetail.getTechArticleTotalCount()).isEqualTo(3L), + () -> assertThat(companyDetail.getIsSubscribed()).isFalse() + ); + } + + @Test + @DisplayName("회원이 이미 구독한 구독 가능한 기업 상세 정보를 조회한다.") + void getCompanyDetail() { + // given + // 회원 생성 + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 기업 생성 + Company company = createCompany("트이다", "교육", "트이다는..", "https://www.teuida.net/iamge.png", + "https://www.teuida.net/career"); + companyRepository.save(company); + + // 구독 생성 + Subscription subscription = createSubscription(member, company); + subscriptionRepository.save(subscription); + + // 기술 블로그 생성 + TechArticle techArticle1 = createTechArticle(company); + TechArticle techArticle2 = createTechArticle(company); + TechArticle techArticle3 = createTechArticle(company); + techArticleRepository.saveAll(List.of(techArticle1, techArticle2, techArticle3)); + + // when + CompanyDetailResponse companyDetail = memberSubscriptionService.getCompanyDetail(company.getId(), + authentication); + + // then + assertAll( + () -> assertThat(companyDetail.getCompanyId()).isEqualTo(company.getId()), + () -> assertThat(companyDetail.getCompanyName()).isEqualTo(company.getName().getCompanyName()), + () -> assertThat(companyDetail.getIndustry()).isEqualTo(company.getIndustry()), + () -> assertThat(companyDetail.getCompanyDescription()).isEqualTo(company.getDescription()), + () -> assertThat(companyDetail.getCompanyCareerUrl()).isEqualTo(company.getCareerUrl().getUrl()), + () -> assertThat(companyDetail.getCompanyOfficialImageUrl()).isEqualTo( + company.getOfficialImageUrl().getUrl()), + () -> assertThat(companyDetail.getTechArticleTotalCount()).isEqualTo(3L), + () -> assertThat(companyDetail.getIsSubscribed()).isTrue() + ); + } + + @Test + @DisplayName("회원이 구독 가능한 기업 상세 정보를 조회할 때 기업이 존재하지 않으면 예외가 발생한다.") + void getCompanyDetailNotFoundCompany() { + // given + // 회원 생성 + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // when // then + assertThatThrownBy(() -> memberSubscriptionService.getCompanyDetail(0L, authentication)) + .isInstanceOf(NotFoundException.class) + .hasMessage(CompanyExceptionMessage.NOT_FOUND_COMPANY_MESSAGE); + + } + + private static TechArticle createTechArticle(Company company) { + return TechArticle.builder() + .company(company) + .build(); + } + + private static Company createCompany(String companyName, String industry, String description, + String officialImageUrl, String careerUrl) { + return Company.builder() + .name(new CompanyName(companyName)) + .industry(industry) + .description(description) + .officialImageUrl(new Url(officialImageUrl)) + .careerUrl(new Url(careerUrl)) + .build(); + } + + private static Company createCompany(String companyName, String officialImageUrl) { + return Company.builder() + .name(new CompanyName(companyName)) + .officialImageUrl(new Url(officialImageUrl)) + .build(); + } + + private static Subscription createSubscription(Member member, Company company) { + return Subscription.builder() + .member(member) + .company(company) + .build(); + } + + private SocialMemberDto createSocialDto(String userId, String name, String nickName, String password, String email, + String socialType, String role) { + return SocialMemberDto.builder() + .userId(userId) + .name(name) + .nickname(nickName) + .password(password) + .email(email) + .socialType(SocialType.valueOf(socialType)) + .role(Role.valueOf(role)) + .build(); + } + + private static Company createCompany(String companyName) { + return Company.builder() + .name(new CompanyName(companyName)) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/MemberTechCommentServiceTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/MemberTechCommentServiceTest.java index 3862d28e..6b2ed657 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/MemberTechCommentServiceTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/MemberTechCommentServiceTest.java @@ -119,7 +119,8 @@ void registerTechComment() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -158,7 +159,8 @@ void registerTechComment() { @DisplayName("회원이 기술블로그 댓글을 작성할 때 존재하지 않는 기술블로그에 댓글을 작성하면 예외가 발생한다.") void registerTechCommentNotFoundTechArticleException() { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -220,7 +222,8 @@ void modifyTechComment() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -297,7 +300,8 @@ void modifyTechCommentNotFoundTechArticleCommentException() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -320,7 +324,8 @@ void modifyTechCommentNotFoundTechArticleCommentException() { @DisplayName("회원이 기술블로그 댓글을 수정할 때, 이미 삭제된 댓글이라면 예외가 발생한다.") void modifyTechCommentAlreadyDeletedException() { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", @@ -372,7 +377,8 @@ void deleteTechComment() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -407,7 +413,8 @@ void deleteTechComment() { @DisplayName("회원이 댓글을 삭제할 때, 이미 삭제된 댓글이라면 예외가 발생한다.") void deleteTechCommentAlreadyDeletedException() { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", @@ -446,7 +453,8 @@ void deleteTechCommentAlreadyDeletedException() { @DisplayName("회원이 댓글을 삭제할 때, 댓글이 존재하지 않으면 예외가 발생한다.") void deleteTechCommentNotFoundException() { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", @@ -492,7 +500,8 @@ void deleteTechCommentAdmin() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -542,7 +551,8 @@ void deleteTechCommentNotByMemberException() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -596,7 +606,8 @@ void registerRepliedTechComment() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -655,7 +666,8 @@ void registerRepliedTechCommentToRepliedTechComment() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -719,7 +731,8 @@ void registerRepliedTechCommentNotFoundTechCommentException() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -756,7 +769,8 @@ void registerRepliedTechCommentDeletedTechCommentException() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -825,7 +839,8 @@ void getTechCommentsSortByOLDEST() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -1121,7 +1136,8 @@ void getTechCommentsSortByLATEST() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -1292,7 +1308,8 @@ void getTechCommentsSortByMostCommented() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -1588,7 +1605,8 @@ void getTechCommentsSortByMostRecommended() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -1738,7 +1756,8 @@ void getTechCommentsByCursor() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -1890,7 +1909,8 @@ void recommendTechComment() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -1927,7 +1947,8 @@ void recommendTechCommentCancel() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -1967,7 +1988,8 @@ void recommendTechCommentNotFoundTechCommentException() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -2001,7 +2023,8 @@ void recommendTechCommentDeletedTechCommentException() { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -2090,7 +2113,8 @@ void findTechBestComments() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // 회사 생성 - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); // 기술 블로그 생성 @@ -2239,7 +2263,8 @@ void findTechBestCommentsExcludeLessThanOneRecommend() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // 회사 생성 - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); // 기술 블로그 생성 @@ -2403,7 +2428,7 @@ private static Company createCompany(String companyName, String officialImageUrl .name(new CompanyName(companyName)) .officialUrl(new Url(officialUrl)) .careerUrl(new Url(careerUrl)) - .officialImageUrl(officialImageUrl) + .officialImageUrl(new Url(officialImageUrl)) .build(); } } diff --git a/src/test/java/com/dreamypatisiel/devdevdev/elastic/domain/service/ElasticsearchSupportTest.java b/src/test/java/com/dreamypatisiel/devdevdev/elastic/domain/service/ElasticsearchSupportTest.java index ca54f330..e3554bbd 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/elastic/domain/service/ElasticsearchSupportTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/elastic/domain/service/ElasticsearchSupportTest.java @@ -31,7 +31,8 @@ public class ElasticsearchSupportTest { static void setup(@Autowired TechArticleRepository techArticleRepository, @Autowired CompanyRepository companyRepository, @Autowired ElasticTechArticleRepository elasticTechArticleRepository) { - company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + company = createCompany("꿈빛 파티시엘", "https://example.net/image.png", "https://example.com", + "https://example.com"); companyRepository.save(company); // 엘라스틱 기술블로그 데이터를 최신순->오래된순, 조회수많은순->적은순, 댓글많은순->적은순의 순서로 생성한다. @@ -60,9 +61,11 @@ static void setup(@Autowired TechArticleRepository techArticleRepository, @AfterAll static void tearDown(@Autowired TechArticleRepository techArticleRepository, - @Autowired ElasticTechArticleRepository elasticTechArticleRepository) { + @Autowired ElasticTechArticleRepository elasticTechArticleRepository, + @Autowired CompanyRepository companyRepository) { elasticTechArticleRepository.deleteAll(); techArticleRepository.deleteAllInBatch(); + companyRepository.deleteAllInBatch(); } private static ElasticTechArticle createElasticTechArticle(String id, String title, LocalDate regDate, @@ -95,7 +98,7 @@ private static Company createCompany(String companyName, String officialImageUrl .name(new CompanyName(companyName)) .officialUrl(new Url(officialUrl)) .careerUrl(new Url(careerUrl)) - .officialImageUrl(officialImageUrl) + .officialImageUrl(new Url(officialImageUrl)) .build(); } } diff --git a/src/test/java/com/dreamypatisiel/devdevdev/global/utils/AuthenticationMemberUtilsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/global/utils/AuthenticationMemberUtilsTest.java index dd48da23..7e56639b 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/global/utils/AuthenticationMemberUtilsTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/global/utils/AuthenticationMemberUtilsTest.java @@ -1,18 +1,23 @@ package com.dreamypatisiel.devdevdev.global.utils; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; - import com.dreamypatisiel.devdevdev.domain.entity.enums.SocialType; import com.dreamypatisiel.devdevdev.exception.UserPrincipalException; import com.dreamypatisiel.devdevdev.global.security.oauth2.model.UserPrincipal; +import static com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils.ANONYMOUS_USER; +import static com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils.INVALID_METHODS_CALL_MESSAGE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.when; class AuthenticationMemberUtilsTest { @@ -100,4 +105,30 @@ void isAnonymousTrue() { assertThat(anonymous).isFalse(); assertThat(anonymousByAuthentication).isFalse(); } + + @Test + @DisplayName("익명 사용자이면 예외가 발생하지 않는다.") + void validateAnonymousMethodCall() { + // given + Authentication authentication = mock(Authentication.class); + when(authentication.getPrincipal()).thenReturn(ANONYMOUS_USER); + + // when // then + assertThatCode(() -> AuthenticationMemberUtils.validateAnonymousMethodCall(authentication)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(strings = {"anonymous-user", "anonymousMember", "anonymous-member", "anonymous"}) + @DisplayName("익명 사용자이면 예외가 발생하지 않는다.") + void validateAnonymousMethodCallException(String principle) { + // given + Authentication authentication = mock(Authentication.class); + when(authentication.getPrincipal()).thenReturn(principle); + + // when // then + assertThatCode(() -> AuthenticationMemberUtils.validateAnonymousMethodCall(authentication)) + .isInstanceOf(IllegalStateException.class) + .hasMessage(INVALID_METHODS_CALL_MESSAGE); + } } \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/scheduler/AsyncHeartbeatSenderTest.java b/src/test/java/com/dreamypatisiel/devdevdev/scheduler/AsyncHeartbeatSenderTest.java new file mode 100644 index 00000000..0b92e771 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/scheduler/AsyncHeartbeatSenderTest.java @@ -0,0 +1,54 @@ +package com.dreamypatisiel.devdevdev.scheduler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.dreamypatisiel.devdevdev.global.common.TimeProvider; +import java.io.IOException; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter.SseEventBuilder; + +@ExtendWith(MockitoExtension.class) +class AsyncHeartbeatSenderTest { + + @Mock + private TimeProvider timeProvider; + + @InjectMocks + private AsyncHeartbeatSender asyncHeartbeatSender; + + @Test + @DisplayName("성공적으로 heartbeat을 전송한다.") + void sendHeartbeat() throws IOException { + // given + SseEmitter sseEmitter = mock(SseEmitter.class); // 여기 수정 + LocalDateTime now = LocalDateTime.of(2025, 1, 1, 0, 0, 0); + + given(timeProvider.getLocalDateTimeNow()).willReturn(now); + + // when + asyncHeartbeatSender.sendHeartbeat(sseEmitter); + + // then + // SseEventBuilder 타입의 인자를 가로채기 위한 캡처 설정 + ArgumentCaptor captor = ArgumentCaptor.forClass(SseEventBuilder.class); + + // send()에 넘긴 builder를 캡처 + verify(sseEmitter, times(1)).send(captor.capture()); + + // builder를 다시 꺼냄 + SseEmitter.SseEventBuilder captorValueEvent = captor.getValue(); + assertThat(captorValueEvent).isNotNull(); + } +} \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/scheduler/SseEmitterHeartbeatSchedulerTest.java b/src/test/java/com/dreamypatisiel/devdevdev/scheduler/SseEmitterHeartbeatSchedulerTest.java new file mode 100644 index 00000000..b359eb5d --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/scheduler/SseEmitterHeartbeatSchedulerTest.java @@ -0,0 +1,51 @@ +package com.dreamypatisiel.devdevdev.scheduler; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.dreamypatisiel.devdevdev.domain.repository.SseEmitterRepository; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +class SseEmitterHeartbeatSchedulerTest { + + @Mock + SseEmitterRepository sseEmitterRepository; + + @Mock + AsyncHeartbeatSender asyncHeartbeatSender; + + @InjectMocks + SseEmitterHeartbeatScheduler sseEmitterHeartbeatScheduler; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + @DisplayName("모든 SseEmitter에 대해 heartbeat을 전송한다.") + void scheduleHeartbeat() { + // given + SseEmitter sseEmitter1 = new SseEmitter(); + SseEmitter sseEmitter2 = new SseEmitter(); + List sseEmitters = Arrays.asList(sseEmitter1, sseEmitter2); + + given(sseEmitterRepository.findAll()).willReturn(sseEmitters); + + // when + sseEmitterHeartbeatScheduler.scheduleHeartbeat(); + + // then + verify(asyncHeartbeatSender, times(1)).sendHeartbeat(sseEmitter1); + verify(asyncHeartbeatSender, times(1)).sendHeartbeat(sseEmitter2); + } +} \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/controller/MockMvcCharacterEncodingCustomizer.java b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/MockMvcCharacterEncodingCustomizer.java new file mode 100644 index 00000000..a0952ad1 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/MockMvcCharacterEncodingCustomizer.java @@ -0,0 +1,15 @@ +package com.dreamypatisiel.devdevdev.web.controller; + +import java.nio.charset.StandardCharsets; +import org.springframework.boot.test.autoconfigure.web.servlet.MockMvcBuilderCustomizer; +import org.springframework.stereotype.Component; +import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder; + +@Component +public class MockMvcCharacterEncodingCustomizer implements MockMvcBuilderCustomizer { + + @Override + public void customize(ConfigurableMockMvcBuilder builder) { + builder.alwaysDo(result -> result.getResponse().setCharacterEncoding(StandardCharsets.UTF_8.name())); + } +} diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/controller/MyPageControllerTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/MyPageControllerTest.java index 9eab99c2..8e813e2d 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/controller/MyPageControllerTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/MyPageControllerTest.java @@ -1,53 +1,20 @@ package com.dreamypatisiel.devdevdev.web.controller; -import static com.dreamypatisiel.devdevdev.domain.exception.MemberExceptionMessage.INVALID_MEMBER_NOT_FOUND_MESSAGE; -import static com.dreamypatisiel.devdevdev.domain.exception.MemberExceptionMessage.MEMBER_INCOMPLETE_SURVEY_MESSAGE; -import static com.dreamypatisiel.devdevdev.global.constant.SecurityConstant.AUTHORIZATION_HEADER; -import static com.dreamypatisiel.devdevdev.global.security.jwt.model.JwtCookieConstant.DEVDEVDEV_LOGIN_STATUS; -import static com.dreamypatisiel.devdevdev.global.security.jwt.model.JwtCookieConstant.DEVDEVDEV_REFRESH_TOKEN; -import static com.dreamypatisiel.devdevdev.web.dto.response.ResultType.SUCCESS; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.dreamypatisiel.devdevdev.domain.entity.Bookmark; -import com.dreamypatisiel.devdevdev.domain.entity.Company; -import com.dreamypatisiel.devdevdev.domain.entity.Member; -import com.dreamypatisiel.devdevdev.domain.entity.Pick; -import com.dreamypatisiel.devdevdev.domain.entity.PickOption; -import com.dreamypatisiel.devdevdev.domain.entity.PickVote; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyAnswer; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyQuestion; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyQuestionOption; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyVersion; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyVersionQuestionMapper; -import com.dreamypatisiel.devdevdev.domain.entity.TechArticle; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.CompanyName; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.PickOptionContents; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.Url; +import com.dreamypatisiel.devdevdev.domain.entity.*; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.*; import com.dreamypatisiel.devdevdev.domain.entity.enums.ContentStatus; import com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType; import com.dreamypatisiel.devdevdev.domain.entity.enums.Role; import com.dreamypatisiel.devdevdev.domain.entity.enums.SocialType; -import com.dreamypatisiel.devdevdev.domain.repository.techArticle.BookmarkRepository; import com.dreamypatisiel.devdevdev.domain.repository.CompanyRepository; import com.dreamypatisiel.devdevdev.domain.repository.member.MemberRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickOptionRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickVoteRepository; -import com.dreamypatisiel.devdevdev.domain.repository.survey.SurveyAnswerRepository; -import com.dreamypatisiel.devdevdev.domain.repository.survey.SurveyQuestionOptionRepository; -import com.dreamypatisiel.devdevdev.domain.repository.survey.SurveyQuestionRepository; -import com.dreamypatisiel.devdevdev.domain.repository.survey.SurveyVersionQuestionMapperRepository; -import com.dreamypatisiel.devdevdev.domain.repository.survey.SurveyVersionRepository; +import com.dreamypatisiel.devdevdev.domain.repository.survey.*; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.BookmarkRepository; import com.dreamypatisiel.devdevdev.domain.repository.techArticle.BookmarkSort; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.SubscriptionRepository; import com.dreamypatisiel.devdevdev.domain.repository.techArticle.TechArticleRepository; import com.dreamypatisiel.devdevdev.elastic.domain.document.ElasticTechArticle; import com.dreamypatisiel.devdevdev.elastic.domain.repository.ElasticTechArticleRepository; @@ -61,14 +28,6 @@ import com.dreamypatisiel.devdevdev.web.dto.response.ResultType; import jakarta.persistence.EntityManager; import jakarta.servlet.http.Cookie; -import java.nio.charset.StandardCharsets; -import java.time.LocalDate; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.Random; -import java.util.concurrent.ThreadLocalRandom; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; @@ -87,6 +46,28 @@ import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.test.web.servlet.ResultActions; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + +import static com.dreamypatisiel.devdevdev.domain.exception.MemberExceptionMessage.INVALID_MEMBER_NOT_FOUND_MESSAGE; +import static com.dreamypatisiel.devdevdev.domain.exception.MemberExceptionMessage.MEMBER_INCOMPLETE_SURVEY_MESSAGE; +import static com.dreamypatisiel.devdevdev.global.constant.SecurityConstant.AUTHORIZATION_HEADER; +import static com.dreamypatisiel.devdevdev.global.security.jwt.model.JwtCookieConstant.DEVDEVDEV_LOGIN_STATUS; +import static com.dreamypatisiel.devdevdev.global.security.jwt.model.JwtCookieConstant.DEVDEVDEV_REFRESH_TOKEN; +import static com.dreamypatisiel.devdevdev.web.dto.response.ResultType.SUCCESS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + class MyPageControllerTest extends SupportControllerTest { private static final int TEST_ARTICLES_COUNT = 20; @@ -117,6 +98,10 @@ class MyPageControllerTest extends SupportControllerTest { @Autowired SurveyQuestionOptionRepository surveyQuestionOptionRepository; @Autowired + CompanyRepository companyRepository; + @Autowired + SubscriptionRepository subscriptionRepository; + @Autowired TimeProvider timeProvider; @Autowired EntityManager em; @@ -125,7 +110,8 @@ class MyPageControllerTest extends SupportControllerTest { static void setup(@Autowired TechArticleRepository techArticleRepository, @Autowired CompanyRepository companyRepository, @Autowired ElasticTechArticleRepository elasticTechArticleRepository) { - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); // 엘라스틱 기술블로그 데이터를 최신순->오래된순, 조회수많은순->적은순, 댓글많은순->적은순의 순서로 생성한다. @@ -153,9 +139,11 @@ static void setup(@Autowired TechArticleRepository techArticleRepository, @AfterAll static void tearDown(@Autowired ElasticTechArticleRepository elasticTechArticleRepository, - @Autowired TechArticleRepository techArticleRepository) { + @Autowired TechArticleRepository techArticleRepository, + @Autowired CompanyRepository companyRepository) { elasticTechArticleRepository.deleteAll(); techArticleRepository.deleteAllInBatch(); + companyRepository.deleteAllInBatch(); } @Test @@ -892,9 +880,21 @@ private static Company createCompany(String companyName, String officialImageUrl String careerUrl) { return Company.builder() .name(new CompanyName(companyName)) - .officialImageUrl(officialImageUrl) + .officialImageUrl(new Url(officialImageUrl)) .careerUrl(new Url(careerUrl)) .officialUrl(new Url(officialUrl)) .build(); } + + private static Company createCompany(String companyName, String officialUrl, String careerUrl, + String imageUrl, String description, String industry) { + return Company.builder() + .name(new CompanyName(companyName)) + .careerUrl(new Url(careerUrl)) + .officialUrl(new Url(officialUrl)) + .officialImageUrl(new Url(imageUrl)) + .description(description) + .industry(industry) + .build(); + } } \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/controller/MyPageControllerUsedMockServiceTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/MyPageControllerUsedMockServiceTest.java index 71704320..17b4e70c 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/controller/MyPageControllerUsedMockServiceTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/MyPageControllerUsedMockServiceTest.java @@ -3,6 +3,7 @@ import static com.dreamypatisiel.devdevdev.web.dto.response.ResultType.SUCCESS; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.when; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -23,6 +24,8 @@ import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.List; + +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscribedCompanyResponse; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -188,6 +191,57 @@ void getMyWrittenCommentsPickCommentIdBindException() throws Exception { .andExpect(jsonPath("$.errorCode").value(HttpStatus.BAD_REQUEST.value())); } + + @Test + @DisplayName("회원이 커서 방식으로 다음페이지의 자신이 구독한 기업 목록을 조회하여 응답을 생성한다.") + void findMySubscribedCompaniesByCursor() throws Exception { + // given + Pageable pageable = PageRequest.of(0, 1); + long cursorCompanyId = 999L; + SubscribedCompanyResponse subscribedCompanyResponse = new SubscribedCompanyResponse( + 1L, "Toss", "https://image.net/image.png", true); + List content = List.of(subscribedCompanyResponse); + SliceCustom response = new SliceCustom<>(content, pageable, 1L); + + when(memberService.findMySubscribedCompanies(any(), any(), any())).thenReturn(response); + + // when + mockMvc.perform(get(DEFAULT_PATH_V1 + "/mypage/subscriptions/companies") + .queryParam("size", String.valueOf(pageable.getPageSize())) + .queryParam("companyId", Long.toString(cursorCompanyId)) + .contentType(MediaType.APPLICATION_JSON) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content.[0].companyId").isNumber()) + .andExpect(jsonPath("$.data.content.[0].companyName").isString()) + .andExpect(jsonPath("$.data.content.[0].companyImageUrl").isString()) + .andExpect(jsonPath("$.data.content.[0].isSubscribed").isBoolean()) + .andExpect(jsonPath("$.data.pageable").isNotEmpty()) + .andExpect(jsonPath("$.data.pageable.pageNumber").isNumber()) + .andExpect(jsonPath("$.data.pageable.pageSize").isNumber()) + .andExpect(jsonPath("$.data.pageable.sort").isNotEmpty()) + .andExpect(jsonPath("$.data.pageable.sort.empty").isBoolean()) + .andExpect(jsonPath("$.data.pageable.sort.sorted").isBoolean()) + .andExpect(jsonPath("$.data.pageable.sort.unsorted").isBoolean()) + .andExpect(jsonPath("$.data.pageable.offset").isNumber()) + .andExpect(jsonPath("$.data.pageable.paged").isBoolean()) + .andExpect(jsonPath("$.data.pageable.unpaged").isBoolean()) + .andExpect(jsonPath("$.data.first").isBoolean()) + .andExpect(jsonPath("$.data.last").isBoolean()) + .andExpect(jsonPath("$.data.size").isNumber()) + .andExpect(jsonPath("$.data.number").isNumber()) + .andExpect(jsonPath("$.data.sort").isNotEmpty()) + .andExpect(jsonPath("$.data.sort.empty").isBoolean()) + .andExpect(jsonPath("$.data.sort.sorted").isBoolean()) + .andExpect(jsonPath("$.data.sort.unsorted").isBoolean()) + .andExpect(jsonPath("$.data.numberOfElements").isNumber()) + .andExpect(jsonPath("$.data.empty").isBoolean()); + } + private static MyWrittenCommentResponse createMyWrittenCommentResponse(String uniqueCommentId, Long postId, String postTitle, Long commentId, diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/controller/notification/NotificationControllerTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/notification/NotificationControllerTest.java new file mode 100644 index 00000000..7759f1c8 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/notification/NotificationControllerTest.java @@ -0,0 +1,401 @@ +package com.dreamypatisiel.devdevdev.web.controller.notification; + +import com.dreamypatisiel.devdevdev.domain.entity.enums.NotificationType; +import com.dreamypatisiel.devdevdev.domain.exception.NotificationExceptionMessage; +import com.dreamypatisiel.devdevdev.domain.service.ApiKeyService; +import com.dreamypatisiel.devdevdev.domain.service.notification.NotificationService; +import com.dreamypatisiel.devdevdev.exception.NotFoundException; +import com.dreamypatisiel.devdevdev.global.constant.SecurityConstant; +import com.dreamypatisiel.devdevdev.redis.pub.NotificationPublisher; +import com.dreamypatisiel.devdevdev.redis.sub.NotificationMessageDto; +import com.dreamypatisiel.devdevdev.web.controller.SupportControllerTest; +import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; +import com.dreamypatisiel.devdevdev.web.dto.request.publish.PublishTechArticle; +import com.dreamypatisiel.devdevdev.web.dto.request.publish.PublishTechArticleRequest; +import com.dreamypatisiel.devdevdev.web.dto.response.ResultType; +import com.dreamypatisiel.devdevdev.web.dto.response.notification.*; +import com.dreamypatisiel.devdevdev.web.dto.response.techArticle.CompanyResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.techArticle.TechArticleMainResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static io.lettuce.core.BitFieldArgs.OverflowType.FAIL; +import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class NotificationControllerTest extends SupportControllerTest { + + @MockBean + NotificationService notificationService; + + @MockBean + NotificationPublisher notificationPublisher; + @MockBean + ApiKeyService apiKeyService; + + @Test + @DisplayName("회원이 단건 알림을 읽으면 isRead가 true로 변경된 응답을 받는다.") + void readNotification() throws Exception { + // given + Long notificationId = 1L; + given(notificationService.readNotification(anyLong(), any())) + .willReturn(new NotificationReadResponse(notificationId, true)); + + // when // then + mockMvc.perform(patch(DEFAULT_PATH_V1 + "/notifications/{notificationId}/read", notificationId) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultType").value(ResultType.SUCCESS.name())) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andExpect(jsonPath("$.data.id").value(notificationId)) + .andExpect(jsonPath("$.data.isRead").value(true)); + } + + @Test + @DisplayName("회원이 자신의 알림이 아닌 알림을 조회하면 예외가 발생한다.") + void readNotificationNotOwnerException() throws Exception { + // given + Long notificationId = 1L; + given(notificationService.readNotification(anyLong(), any())) + .willThrow(new NotFoundException(NotificationExceptionMessage.NOT_FOUND_NOTIFICATION_MESSAGE)); + + // when // then + mockMvc.perform(patch(DEFAULT_PATH_V1 + "/notifications/{notificationId}/read", notificationId) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.resultType").value(ResultType.FAIL.name())) + .andExpect(jsonPath("$.message").value(NotificationExceptionMessage.NOT_FOUND_NOTIFICATION_MESSAGE)); + } + + @Test + @DisplayName("회원이 모든 알림을 읽는다.") + void readAllNotifications() throws Exception { + // given + // 별도의 응답이 없으므로 void 메서드 호출만 mock + doNothing().when(notificationService).readAllNotifications(any()); + + // when // then + mockMvc.perform(patch(DEFAULT_PATH_V1 + "/notifications/read-all") + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultType").value(ResultType.SUCCESS.name())) + .andExpect(jsonPath("$.data").doesNotExist()); + + // 호출 여부 확인 + verify(notificationService, times(1)).readAllNotifications(any()); + } + + @Test + @DisplayName("회원이 알림 팝업창을 조회한다.") + void getNotificationPopup() throws Exception { + // given + PageRequest pageable = PageRequest.of(0, 1); + List response = List.of( + new NotificationPopupNewArticleResponse(1L, "기술블로그 타이틀", LocalDateTime.now(), false, + "기업명", 1L)); + given(notificationService.getNotificationPopup(any(), any())) + .willReturn(new SliceCustom<>(response, pageable, false, 1L)); + + // when // then + mockMvc.perform(get(DEFAULT_PATH_V1 + "/notifications/popup") + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .queryParam("size", String.valueOf(pageable.getPageSize())) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultType").value(ResultType.SUCCESS.name())) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content.[0].id").isNumber()) + .andExpect(jsonPath("$.data.content.[0].type").isString()) + .andExpect(jsonPath("$.data.content.[0].title").isString()) + .andExpect(jsonPath("$.data.content.[0].isRead").isBoolean()) + .andExpect(jsonPath("$.data.content.[0].createdAt").isString()) + .andExpect(jsonPath("$.data.content.[0].companyName").isString()) + .andExpect(jsonPath("$.data.content.[0].techArticleId").isNumber()) + .andExpect(jsonPath("$.data.pageable").isNotEmpty()) + .andExpect(jsonPath("$.data.pageable.pageNumber").isNumber()) + .andExpect(jsonPath("$.data.pageable.pageSize").isNumber()) + .andExpect(jsonPath("$.data.pageable.sort").isNotEmpty()) + .andExpect(jsonPath("$.data.pageable.sort.empty").isBoolean()) + .andExpect(jsonPath("$.data.pageable.sort.sorted").isBoolean()) + .andExpect(jsonPath("$.data.pageable.sort.unsorted").isBoolean()) + .andExpect(jsonPath("$.data.pageable.offset").isNumber()) + .andExpect(jsonPath("$.data.pageable.paged").isBoolean()) + .andExpect(jsonPath("$.data.pageable.unpaged").isBoolean()) + .andExpect(jsonPath("$.data.totalElements").value(1)) + .andExpect(jsonPath("$.data.first").isBoolean()) + .andExpect(jsonPath("$.data.last").isBoolean()) + .andExpect(jsonPath("$.data.size").isNumber()) + .andExpect(jsonPath("$.data.number").isNumber()) + .andExpect(jsonPath("$.data.sort").isNotEmpty()) + .andExpect(jsonPath("$.data.sort.empty").isBoolean()) + .andExpect(jsonPath("$.data.sort.sorted").isBoolean()) + .andExpect(jsonPath("$.data.sort.unsorted").isBoolean()) + .andExpect(jsonPath("$.data.numberOfElements").isNumber()) + .andExpect(jsonPath("$.data.empty").isBoolean()); + + // 호출 여부 확인 + verify(notificationService, times(1)).getNotificationPopup(any(), any()); + } + + @Test + @DisplayName("회원이 알림 페이지를 무한스크롤링으로 조회한다.") + void getNotifications() throws Exception { + // given + PageRequest pageable = PageRequest.of(0, 1); + TechArticleMainResponse techArticleMainResponse = createTechArticleMainResponse( + 1L, "elasticId", "http://thumbnailUrl.com", false, + "http://techArticleUrl.com", "기술블로그 타이틀", "기술블로그 내용", + 1L, "기업명", "http://careerUrl.com","http://officialImage.com", LocalDate.now(), "작성자", + 0L, 0L, 0L, false, null + ); + List response = List.of( + new NotificationNewArticleResponse(1L, LocalDateTime.now(), false, techArticleMainResponse)); + given(notificationService.getNotifications(any(), anyLong(), any())) + .willReturn(new SliceCustom<>(response, pageable, true, 1L)); + + // when // then + mockMvc.perform(get(DEFAULT_PATH_V1 + "/notifications/page") + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .queryParam("size", String.valueOf(pageable.getPageSize())) + .queryParam("notificationId", String.valueOf(2L)) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultType").value(ResultType.SUCCESS.name())) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content.[0].notificationId").isNumber()) + .andExpect(jsonPath("$.data.content.[0].type").isString()) + .andExpect(jsonPath("$.data.content.[0].createdAt").isString()) + .andExpect(jsonPath("$.data.content.[0].isRead").isBoolean()) + .andExpect(jsonPath("$.data.content.[0].techArticle").isNotEmpty()) + .andExpect(jsonPath("$.data.content.[0].techArticle.id").isNumber()) + .andExpect(jsonPath("$.data.content.[0].techArticle.elasticId").isString()) + .andExpect(jsonPath("$.data.content.[0].techArticle.thumbnailUrl").isString()) + .andExpect(jsonPath("$.data.content.[0].techArticle.isLogoImage").isBoolean()) + .andExpect(jsonPath("$.data.content.[0].techArticle.techArticleUrl").isString()) + .andExpect(jsonPath("$.data.content.[0].techArticle.title").isString()) + .andExpect(jsonPath("$.data.content.[0].techArticle.contents").isString()) + .andExpect(jsonPath("$.data.content.[0].techArticle.regDate").isString()) + .andExpect(jsonPath("$.data.content.[0].techArticle.author").isString()) + .andExpect(jsonPath("$.data.content.[0].techArticle.viewTotalCount").isNumber()) + .andExpect(jsonPath("$.data.content.[0].techArticle.recommendTotalCount").isNumber()) + .andExpect(jsonPath("$.data.content.[0].techArticle.commentTotalCount").isNumber()) + .andExpect(jsonPath("$.data.content.[0].techArticle.popularScore").isNumber()) + .andExpect(jsonPath("$.data.content.[0].techArticle.isBookmarked").isBoolean()) + .andExpect(jsonPath("$.data.content.[0].techArticle.company").isNotEmpty()) + .andExpect(jsonPath("$.data.content.[0].techArticle.company.id").isNumber()) + .andExpect(jsonPath("$.data.content.[0].techArticle.company.name").isString()) + .andExpect(jsonPath("$.data.content.[0].techArticle.company.careerUrl").isString()) + .andExpect(jsonPath("$.data.pageable").isNotEmpty()) + .andExpect(jsonPath("$.data.pageable.pageNumber").isNumber()) + .andExpect(jsonPath("$.data.pageable.pageSize").isNumber()) + .andExpect(jsonPath("$.data.pageable.sort").isNotEmpty()) + .andExpect(jsonPath("$.data.pageable.sort.empty").isBoolean()) + .andExpect(jsonPath("$.data.pageable.sort.sorted").isBoolean()) + .andExpect(jsonPath("$.data.pageable.sort.unsorted").isBoolean()) + .andExpect(jsonPath("$.data.pageable.offset").isNumber()) + .andExpect(jsonPath("$.data.pageable.paged").isBoolean()) + .andExpect(jsonPath("$.data.pageable.unpaged").isBoolean()) + .andExpect(jsonPath("$.data.totalElements").value(1)) + .andExpect(jsonPath("$.data.first").isBoolean()) + .andExpect(jsonPath("$.data.last").isBoolean()) + .andExpect(jsonPath("$.data.size").isNumber()) + .andExpect(jsonPath("$.data.number").isNumber()) + .andExpect(jsonPath("$.data.sort").isNotEmpty()) + .andExpect(jsonPath("$.data.sort.empty").isBoolean()) + .andExpect(jsonPath("$.data.sort.sorted").isBoolean()) + .andExpect(jsonPath("$.data.sort.unsorted").isBoolean()) + .andExpect(jsonPath("$.data.numberOfElements").isNumber()) + .andExpect(jsonPath("$.data.empty").isBoolean()); + + // 호출 여부 확인 + verify(notificationService, times(1)).getNotifications(any(), anyLong(), any()); + } + + private TechArticleMainResponse createTechArticleMainResponse(Long id, String elasticId, String thumbnailUrl, Boolean isLogoImage, + String techArticleUrl, String title, String contents, + Long companyId, String companyName, String careerUrl, String officialImageUrl, + LocalDate regDate, String author, long recommendCount, + long commentCount, long viewCount, Boolean isBookmarked, Float score) { + return TechArticleMainResponse.builder() + .id(id) + .elasticId(elasticId) + .thumbnailUrl(thumbnailUrl) + .isLogoImage(isLogoImage) + .techArticleUrl(techArticleUrl) + .title(title) + .contents(contents) + .company(CompanyResponse.of(companyId, companyName, careerUrl, officialImageUrl)) + .regDate(regDate) + .author(author) + .viewTotalCount(viewCount) + .recommendTotalCount(recommendCount) + .commentTotalCount(commentCount) + .popularScore(0L) + .isBookmarked(isBookmarked) + .score(score) + .build(); + } + + @Test + @DisplayName("회원이 알림 개수를 조회하면 회원이 아직 읽지 않은 알림의 총 개수가 반환된다.") + void getUnreadNotificationCount() throws Exception { + // given + Long unreadNotificationCount = 12L; + given(notificationService.getUnreadNotificationCount(any())) + .willReturn(unreadNotificationCount); + + // when // then + mockMvc.perform(get(DEFAULT_PATH_V1 + "/notifications/unread-count") + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultType").value(ResultType.SUCCESS.name())) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andExpect(jsonPath("$.data").isNumber()); + + // 호출 여부 확인 + verify(notificationService, times(1)).getUnreadNotificationCount(any()); + } + + /** + * SseEmitter는 내부적으로 HttpServletResponse.getOutputStream() 또는 getWriter()를 직접 사용해서 데이터를 보냄 그래서 + * MockMvc.perform().characterEncoding("UTF-8")이 무시되는 거고, ISO-8859-1로 처리됨 + */ + @Test + @DisplayName("회원이 실시간 알림을 수신한다.") + void notification() throws Exception { + // given + SseEmitter sseEmitter = new SseEmitter(); + sseEmitter.send( + SseEmitter.event() + .data(new NotificationMessageDto("트이다에서 새로운 기슬블로그 105개가 올라왔어요!", + LocalDateTime.of(2025, 4, 6, 0, 0, 0))) + ); + + given(notificationService.addClientAndSendNotification(any())).willReturn(sseEmitter); + + // when // then + mockMvc.perform(get("/devdevdev/api/v1/notifications") + .contentType(MediaType.TEXT_EVENT_STREAM_VALUE) + .characterEncoding(StandardCharsets.UTF_8) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM_VALUE)) + .andExpect(content().string(containsString("data"))) + .andExpect(content().string(containsString("message"))) + .andExpect(content().string(containsString("createdAt"))); + + sseEmitter.complete(); + } + + @Test + @DisplayName("알림을 생성한다.") + void publishNotifications() throws Exception { + // given + PublishTechArticleRequest request = new PublishTechArticleRequest( + 1L, + List.of(new PublishTechArticle(1L), + new PublishTechArticle(2L)) + ); + + doNothing().when(apiKeyService).validateApiKey(any(), any()); + + // when // then + mockMvc.perform(post("/devdevdev/api/v1/notifications/{channel}", NotificationType.SUBSCRIPTION) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .header("service-name", "test-service") + .header("api-key", "test-key") + .content(om.writeValueAsBytes(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultType").value(ResultType.SUCCESS.name())); + } + + @Test + @DisplayName("알림을 생성할 때 잘못된 채널을 입력하면 예외가 발생한다.") + void publishNotificationsException() throws Exception { + // given + PublishTechArticleRequest request = new PublishTechArticleRequest( + 1L, + List.of(new PublishTechArticle(1L), + new PublishTechArticle(2L)) + ); + + doNothing().when(apiKeyService).validateApiKey(any(), any()); + + // when // then + mockMvc.perform(post("/devdevdev/api/v1/notifications/{channel}", "INVALID_CHANNEL") + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .header("service-name", "test-service") + .header("api-key", "test-key") + .content(om.writeValueAsBytes(request))) + .andDo(print()) + .andExpect(status().is4xxClientError()) + .andExpect(jsonPath("$.resultType").value(FAIL.name())) + .andExpect(jsonPath("$.message").isString()) + .andExpect(jsonPath("$.errorCode").isNumber()); + } + + @Test + @DisplayName("알림을 생성할 때 API Key가 없으면 예외가 발생한다.") + void publishNotificationsNotFoundException() throws Exception { + // given + PublishTechArticleRequest request = new PublishTechArticleRequest( + 1L, + List.of(new PublishTechArticle(1L), + new PublishTechArticle(2L)) + ); + + doThrow(new NotFoundException("not found")).when(apiKeyService).validateApiKey(any(), any()); + + // when // then + mockMvc.perform(post("/devdevdev/api/v1/notifications/{channel}", NotificationType.SUBSCRIPTION) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .header("service-name", "test-service") + .header("api-key", "test-key") + .content(om.writeValueAsBytes(request))) + .andDo(print()) + .andExpect(status().is4xxClientError()) + .andExpect(jsonPath("$.resultType").value(FAIL.name())) + .andExpect(jsonPath("$.message").isString()) + .andExpect(jsonPath("$.errorCode").isNumber()); + } +} \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/controller/subscription/SubscriptionControllerTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/subscription/SubscriptionControllerTest.java new file mode 100644 index 00000000..aa28a7a0 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/subscription/SubscriptionControllerTest.java @@ -0,0 +1,164 @@ +package com.dreamypatisiel.devdevdev.web.controller.subscription; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.dreamypatisiel.devdevdev.domain.service.techArticle.subscription.MemberSubscriptionService; +import com.dreamypatisiel.devdevdev.global.constant.SecurityConstant; +import com.dreamypatisiel.devdevdev.web.controller.SupportControllerTest; +import com.dreamypatisiel.devdevdev.web.dto.request.subscription.SubscribeCompanyRequest; +import com.dreamypatisiel.devdevdev.web.dto.response.ResultType; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.CompanyDetailResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscriableCompanyResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscriptionResponse; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.SliceImpl; +import org.springframework.http.MediaType; + +class SubscriptionControllerTest extends SupportControllerTest { + + @MockBean + MemberSubscriptionService memberSubscriptionService; + + @Test + @DisplayName("회원이 기업을 구독한다.") + void subscribe() throws Exception { + // given + given(memberSubscriptionService.subscribe(anyLong(), any())).willReturn(new SubscriptionResponse(1L)); + + // when // then + mockMvc.perform(post(DEFAULT_PATH_V1 + "/subscriptions") + .contentType(MediaType.APPLICATION_JSON) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .content(om.writeValueAsBytes(new SubscribeCompanyRequest(1L))) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultType").value(ResultType.SUCCESS.name())) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andExpect(jsonPath("$.data.id").isNumber()); + } + + @Test + @DisplayName("회원이 기업을 구독 취소한다.") + void unsubscribe() throws Exception { + // given // when + doNothing().when(memberSubscriptionService).unsubscribe(anyLong(), any()); + + // then + mockMvc.perform(delete(DEFAULT_PATH_V1 + "/subscriptions") + .contentType(MediaType.APPLICATION_JSON) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .content(om.writeValueAsBytes(new SubscribeCompanyRequest(1L))) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultType").value(ResultType.SUCCESS.name())); + + // 호출 여부 확인 + verify(memberSubscriptionService, times(1)).unsubscribe(anyLong(), any()); + } + + @Test + @DisplayName("구독 가능한 기업 목록을 조회한다.") + void getSubscriptions() throws Exception { + // given + SubscriableCompanyResponse response = new SubscriableCompanyResponse(1L, "트이다", + "https://www.teuida.net/public/src/img/teuida_logo.png", true); + given(memberSubscriptionService.getSubscribableCompany(any(), anyLong(), any())) + .willReturn(new SliceImpl<>(List.of(response), PageRequest.of(0, 20), false)); + + // when // then + mockMvc.perform(get(DEFAULT_PATH_V1 + "/subscriptions/companies") + .queryParam("size", "10") + .queryParam("companyId", "105") + .contentType(MediaType.APPLICATION_JSON) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content.[0].companyId").isNumber()) + .andExpect(jsonPath("$.data.content.[0].companyName").isString()) + .andExpect(jsonPath("$.data.content.[0].companyImageUrl").isString()) + .andExpect(jsonPath("$.data.content.[0].isSubscribed").isBoolean()) + .andExpect(jsonPath("$.data.pageable").isNotEmpty()) + .andExpect(jsonPath("$.data.pageable.pageNumber").isNumber()) + .andExpect(jsonPath("$.data.pageable.pageSize").isNumber()) + .andExpect(jsonPath("$.data.pageable.sort").isNotEmpty()) + .andExpect(jsonPath("$.data.pageable.sort.empty").isBoolean()) + .andExpect(jsonPath("$.data.pageable.sort.sorted").isBoolean()) + .andExpect(jsonPath("$.data.pageable.sort.unsorted").isBoolean()) + .andExpect(jsonPath("$.data.pageable.offset").isNumber()) + .andExpect(jsonPath("$.data.pageable.paged").isBoolean()) + .andExpect(jsonPath("$.data.pageable.unpaged").isBoolean()) + .andExpect(jsonPath("$.data.first").isBoolean()) + .andExpect(jsonPath("$.data.last").isBoolean()) + .andExpect(jsonPath("$.data.size").isNumber()) + .andExpect(jsonPath("$.data.number").isNumber()) + .andExpect(jsonPath("$.data.sort").isNotEmpty()) + .andExpect(jsonPath("$.data.sort.empty").isBoolean()) + .andExpect(jsonPath("$.data.sort.sorted").isBoolean()) + .andExpect(jsonPath("$.data.sort.unsorted").isBoolean()) + .andExpect(jsonPath("$.data.numberOfElements").isNumber()) + .andExpect(jsonPath("$.data.empty").isBoolean()); + } + + @Test + @DisplayName("구독 가능한 기업 상세 정보를 조회한다.") + void getCompanyDetails() throws Exception { + // given + CompanyDetailResponse response = CompanyDetailResponse.builder() + .companyId(1L) + .companyName("Teuida") + .industry("교육") + .companyCareerUrl("https://www.wanted.co.kr/company/5908") + .companyOfficialImageUrl("https://www.teuida.net/public/src/img/teuida_logo.png") + .companyDescription("“외국인의 한국어가 트이다”\n" + + "영어 공부 오래 했지만 영어로 말할 때 주저하게 되죠? 외국인도 마찬가지예요. 한국어 말할 때 주저하게 돼요.\n" + + "트이다는 화면 속 한국인과 가상대화를 하는 경험을 통해 외국인이 한국어 회화에 자신감을 가질 수 있도록 도움을 주는 스타트업이에요.\n" + + "전 세계 130만 명 이상의 사용자가 웹드라마 속 배우랑 가상대화를 하면서 한국어를 배우고 한국어 말하기에 자신감을 향상하고 있어요.\n" + + "우리는 외국어를 학습하기보다 여행을 갔을 때, 외국인 친구를 사귀었을 때 자신이 하고 싶은 말을 할 수 있도록 재미있는 콘텐츠와 서비스를 제공하기 위해 노력하고 있어요. 모든 사람들이 자신의 생각, 의견을 어떤 언어로든 말할 수 있는 용기를 심어주고 싶어요.") + .techArticleTotalCount(3980L) + .isSubscribed(true) + .build(); + + given(memberSubscriptionService.getCompanyDetail(anyLong(), any())).willReturn(response); + + // when // then + mockMvc.perform(get(DEFAULT_PATH_V1 + "/subscriptions/companies/{companyId}", 1L) + .queryParam("size", "10") + .queryParam("companyId", "105") + .contentType(MediaType.APPLICATION_JSON) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultType").value(ResultType.SUCCESS.name())) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andExpect(jsonPath("$.data.companyId").isNumber()) + .andExpect(jsonPath("$.data.companyName").isString()) + .andExpect(jsonPath("$.data.industry").isString()) + .andExpect(jsonPath("$.data.companyCareerUrl").isString()) + .andExpect(jsonPath("$.data.companyOfficialImageUrl").isString()) + .andExpect(jsonPath("$.data.companyDescription").isString()) + .andExpect(jsonPath("$.data.techArticleTotalCount").isNumber()) + .andExpect(jsonPath("$.data.isSubscribed").isBoolean()); + } +} \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/controller/techArticle/TechArticleCommentControllerTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/techArticle/TechArticleCommentControllerTest.java index 8f6d3feb..ec4acb50 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/controller/techArticle/TechArticleCommentControllerTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/techArticle/TechArticleCommentControllerTest.java @@ -1,16 +1,5 @@ package com.dreamypatisiel.devdevdev.web.controller.techArticle; -import static com.dreamypatisiel.devdevdev.domain.exception.MemberExceptionMessage.INVALID_MEMBER_NOT_FOUND_MESSAGE; -import static com.dreamypatisiel.devdevdev.domain.exception.TechArticleExceptionMessage.NOT_FOUND_TECH_ARTICLE_MESSAGE; -import static com.dreamypatisiel.devdevdev.web.dto.response.ResultType.SUCCESS; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - import com.dreamypatisiel.devdevdev.domain.entity.Company; import com.dreamypatisiel.devdevdev.domain.entity.Member; import com.dreamypatisiel.devdevdev.domain.entity.TechArticle; @@ -23,6 +12,8 @@ import com.dreamypatisiel.devdevdev.domain.entity.embedded.Url; import com.dreamypatisiel.devdevdev.domain.entity.enums.Role; import com.dreamypatisiel.devdevdev.domain.entity.enums.SocialType; +import static com.dreamypatisiel.devdevdev.domain.exception.MemberExceptionMessage.INVALID_MEMBER_NOT_FOUND_MESSAGE; +import static com.dreamypatisiel.devdevdev.domain.exception.TechArticleExceptionMessage.NOT_FOUND_TECH_ARTICLE_MESSAGE; import com.dreamypatisiel.devdevdev.domain.repository.CompanyRepository; import com.dreamypatisiel.devdevdev.domain.repository.member.MemberRepository; import com.dreamypatisiel.devdevdev.domain.repository.techArticle.TechArticleRepository; @@ -37,6 +28,7 @@ import com.dreamypatisiel.devdevdev.web.dto.request.techArticle.ModifyTechCommentRequest; import com.dreamypatisiel.devdevdev.web.dto.request.techArticle.RegisterTechCommentRequest; import com.dreamypatisiel.devdevdev.web.dto.response.ResultType; +import static com.dreamypatisiel.devdevdev.web.dto.response.ResultType.SUCCESS; import jakarta.persistence.EntityManager; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; @@ -54,6 +46,13 @@ import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class TechArticleCommentControllerTest extends SupportControllerTest { @@ -76,7 +75,8 @@ class TechArticleCommentControllerTest extends SupportControllerTest { @DisplayName("익명 사용자는 기술블로그 댓글을 작성할 수 없다.") void registerTechCommentByAnonymous() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -105,7 +105,8 @@ void registerTechCommentByAnonymous() throws Exception { @DisplayName("회원은 기술블로그 댓글을 작성할 수 있다.") void registerTechComment() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -142,7 +143,8 @@ void registerTechComment() throws Exception { @DisplayName("회원이 기술블로그 댓글을 작성할 때 존재하지 않는 기술블로그라면 예외가 발생한다.") void registerTechCommentNotFoundTechArticleException() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -178,7 +180,8 @@ void registerTechCommentNotFoundTechArticleException() throws Exception { @DisplayName("회원이 기술블로그 댓글을 작성할 때 존재하지 않는 회원이라면 예외가 발생한다.") void registerTechCommentNotFoundMemberException() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -209,7 +212,8 @@ void registerTechCommentNotFoundMemberException() throws Exception { @DisplayName("회원이 기술블로그 댓글을 작성할 때 댓글 내용이 공백이라면 예외가 발생한다.") void registerTechCommentContentsIsNullException(String contents) throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -239,7 +243,8 @@ void registerTechCommentContentsIsNullException(String contents) throws Exceptio @DisplayName("회원은 기술블로그 댓글을 수정할 수 있다.") void modifyTechComment() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", @@ -280,7 +285,8 @@ void modifyTechComment() throws Exception { @DisplayName("회원이 기술블로그 댓글을 수정할 때 댓글 내용이 공백이라면 예외가 발생한다.") void modifyTechCommentContentsIsNullException(String contents) throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", @@ -319,7 +325,8 @@ void modifyTechCommentContentsIsNullException(String contents) throws Exception @DisplayName("회원이 기술블로그 댓글을 수정할 때 댓글이 존재하지 않으면 예외가 발생한다.") void modifyTechCommentNotFoundException() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", @@ -353,7 +360,8 @@ void modifyTechCommentNotFoundException() throws Exception { @DisplayName("회원이 기술블로그 댓글을 수정할 때, 이미 삭제된 댓글이라면 예외가 발생한다.") void modifyTechCommentAlreadyDeletedException() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", @@ -395,7 +403,8 @@ void modifyTechCommentAlreadyDeletedException() throws Exception { @DisplayName("회원은 본인이 작성한 기술블로그 댓글을 삭제할 수 있다.") void deleteTechComment() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", @@ -432,7 +441,8 @@ void deleteTechComment() throws Exception { @DisplayName("회원이 기술블로그 댓글을 삭제할 때 댓글이 존재하지 않으면 예외가 발생한다.") void deleteTechCommentNotFoundException() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", @@ -466,7 +476,8 @@ void deleteTechCommentNotFoundException() throws Exception { @DisplayName("회원은 기술블로그 댓글에 답글을 작성할 수 있다.") void registerRepliedTechComment() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -520,7 +531,8 @@ void registerRepliedTechCommentContentsIsNullException(String contents) throws E member.updateRefreshToken(refreshToken); memberRepository.save(member); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -569,7 +581,8 @@ void getTechComments() throws Exception { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -686,7 +699,8 @@ void recommendTechComment() throws Exception { member.updateRefreshToken(refreshToken); memberRepository.save(member); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -734,7 +748,8 @@ void getTechBestComments() throws Exception { memberRepository.saveAll(List.of(member, member1, member2, member3)); // 회사 생성 - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); // 기술 블로그 생성 @@ -821,7 +836,8 @@ void getTechBestCommentsAnonymous() throws Exception { memberRepository.saveAll(List.of(member1, member2, member3)); // 회사 생성 - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); // 기술 블로그 생성 @@ -908,7 +924,7 @@ private static Company createCompany(String companyName, String officialImageUrl String careerUrl) { return Company.builder() .name(new CompanyName(companyName)) - .officialImageUrl(officialImageUrl) + .officialImageUrl(new Url(officialImageUrl)) .careerUrl(new Url(careerUrl)) .officialUrl(new Url(officialUrl)) .build(); diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/controller/techArticle/TechArticleControllerTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/techArticle/TechArticleControllerTest.java index 28947a86..8fa978b6 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/controller/techArticle/TechArticleControllerTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/techArticle/TechArticleControllerTest.java @@ -70,7 +70,8 @@ class TechArticleControllerTest extends SupportControllerTest { static void setup(@Autowired TechArticleRepository techArticleRepository, @Autowired CompanyRepository companyRepository, @Autowired ElasticTechArticleRepository elasticTechArticleRepository) { - company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + company = createCompany("꿈빛 파티시엘", + "https://example.com/company.png", "https://example.com", "https://example.com"); companyRepository.save(company); // 엘라스틱 기술블로그 데이터를 최신순->오래된순, 조회수많은순->적은순, 댓글많은순->적은순의 순서로 생성한다. @@ -99,9 +100,11 @@ static void setup(@Autowired TechArticleRepository techArticleRepository, @AfterAll static void tearDown(@Autowired TechArticleRepository techArticleRepository, - @Autowired ElasticTechArticleRepository elasticTechArticleRepository) { + @Autowired ElasticTechArticleRepository elasticTechArticleRepository, + @Autowired CompanyRepository companyRepository) { elasticTechArticleRepository.deleteAll(); techArticleRepository.deleteAllInBatch(); + companyRepository.deleteAllInBatch(); } @Test @@ -690,7 +693,7 @@ private static Company createCompany(String companyName, String officialImageUrl String careerUrl) { return Company.builder() .name(new CompanyName(companyName)) - .officialImageUrl(officialImageUrl) + .officialImageUrl(new Url(officialImageUrl)) .careerUrl(new Url(careerUrl)) .officialUrl(new Url(officialUrl)) .build(); diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/MyPageControllerDocsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/MyPageControllerDocsTest.java index 2e8d4304..0473a739 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/MyPageControllerDocsTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/MyPageControllerDocsTest.java @@ -1,72 +1,20 @@ package com.dreamypatisiel.devdevdev.web.docs; -import static com.dreamypatisiel.devdevdev.domain.exception.MemberExceptionMessage.INVALID_MEMBER_NOT_FOUND_MESSAGE; -import static com.dreamypatisiel.devdevdev.domain.exception.MemberExceptionMessage.MEMBER_INCOMPLETE_SURVEY_MESSAGE; -import static com.dreamypatisiel.devdevdev.global.constant.SecurityConstant.AUTHORIZATION_HEADER; -import static com.dreamypatisiel.devdevdev.global.security.jwt.model.JwtCookieConstant.DEVDEVDEV_LOGIN_STATUS; -import static com.dreamypatisiel.devdevdev.global.security.jwt.model.JwtCookieConstant.DEVDEVDEV_REFRESH_TOKEN; -import static com.dreamypatisiel.devdevdev.web.docs.format.ApiDocsFormatGenerator.authenticationType; -import static com.dreamypatisiel.devdevdev.web.docs.format.ApiDocsFormatGenerator.bookmarkSortType; -import static com.dreamypatisiel.devdevdev.web.docs.format.ApiDocsFormatGenerator.contentStatusType; -import static com.dreamypatisiel.devdevdev.web.docs.format.ApiDocsFormatGenerator.stringOrNull; -import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; -import static org.springframework.restdocs.cookies.CookieDocumentation.responseCookies; -import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; -import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; -import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; -import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; -import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; -import static org.springframework.restdocs.payload.JsonFieldType.OBJECT; -import static org.springframework.restdocs.payload.JsonFieldType.STRING; -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.queryParameters; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.dreamypatisiel.devdevdev.domain.entity.Bookmark; -import com.dreamypatisiel.devdevdev.domain.entity.Company; -import com.dreamypatisiel.devdevdev.domain.entity.Member; -import com.dreamypatisiel.devdevdev.domain.entity.Pick; -import com.dreamypatisiel.devdevdev.domain.entity.PickOption; -import com.dreamypatisiel.devdevdev.domain.entity.PickVote; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyAnswer; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyQuestion; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyQuestionOption; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyVersion; -import com.dreamypatisiel.devdevdev.domain.entity.SurveyVersionQuestionMapper; -import com.dreamypatisiel.devdevdev.domain.entity.TechArticle; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.CompanyName; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.PickOptionContents; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; -import com.dreamypatisiel.devdevdev.domain.entity.embedded.Url; +import com.dreamypatisiel.devdevdev.domain.entity.*; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.*; import com.dreamypatisiel.devdevdev.domain.entity.enums.ContentStatus; import com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType; import com.dreamypatisiel.devdevdev.domain.entity.enums.Role; import com.dreamypatisiel.devdevdev.domain.entity.enums.SocialType; -import com.dreamypatisiel.devdevdev.domain.repository.techArticle.BookmarkRepository; import com.dreamypatisiel.devdevdev.domain.repository.CompanyRepository; import com.dreamypatisiel.devdevdev.domain.repository.member.MemberRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickOptionRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickVoteRepository; -import com.dreamypatisiel.devdevdev.domain.repository.survey.SurveyAnswerRepository; -import com.dreamypatisiel.devdevdev.domain.repository.survey.SurveyQuestionOptionRepository; -import com.dreamypatisiel.devdevdev.domain.repository.survey.SurveyQuestionRepository; -import com.dreamypatisiel.devdevdev.domain.repository.survey.SurveyVersionQuestionMapperRepository; -import com.dreamypatisiel.devdevdev.domain.repository.survey.SurveyVersionRepository; +import com.dreamypatisiel.devdevdev.domain.repository.survey.*; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.BookmarkRepository; import com.dreamypatisiel.devdevdev.domain.repository.techArticle.BookmarkSort; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.SubscriptionRepository; import com.dreamypatisiel.devdevdev.domain.repository.techArticle.TechArticleRepository; import com.dreamypatisiel.devdevdev.elastic.domain.document.ElasticTechArticle; import com.dreamypatisiel.devdevdev.elastic.domain.repository.ElasticTechArticleRepository; @@ -77,12 +25,6 @@ import com.dreamypatisiel.devdevdev.web.dto.request.member.RecordMemberExitSurveyQuestionOptionsRequest; import com.dreamypatisiel.devdevdev.web.dto.response.ResultType; import jakarta.persistence.EntityManager; -import java.nio.charset.StandardCharsets; -import java.time.LocalDate; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.ThreadLocalRandom; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; @@ -100,6 +42,34 @@ import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.test.web.servlet.ResultActions; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +import static com.dreamypatisiel.devdevdev.domain.exception.MemberExceptionMessage.INVALID_MEMBER_NOT_FOUND_MESSAGE; +import static com.dreamypatisiel.devdevdev.domain.exception.MemberExceptionMessage.MEMBER_INCOMPLETE_SURVEY_MESSAGE; +import static com.dreamypatisiel.devdevdev.global.constant.SecurityConstant.AUTHORIZATION_HEADER; +import static com.dreamypatisiel.devdevdev.global.security.jwt.model.JwtCookieConstant.DEVDEVDEV_LOGIN_STATUS; +import static com.dreamypatisiel.devdevdev.global.security.jwt.model.JwtCookieConstant.DEVDEVDEV_REFRESH_TOKEN; +import static com.dreamypatisiel.devdevdev.web.docs.format.ApiDocsFormatGenerator.*; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.responseCookies; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + public class MyPageControllerDocsTest extends SupportControllerDocsTest { private static final int TEST_ARTICLES_COUNT = 20; @@ -132,13 +102,16 @@ public class MyPageControllerDocsTest extends SupportControllerDocsTest { @Autowired SurveyQuestionOptionRepository surveyQuestionOptionRepository; @Autowired + SubscriptionRepository subscriptionRepository; + @Autowired EntityManager em; @BeforeAll static void setup(@Autowired TechArticleRepository techArticleRepository, @Autowired CompanyRepository companyRepository, @Autowired ElasticTechArticleRepository elasticTechArticleRepository) { - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); // 엘라스틱 기술블로그 데이터를 최신순->오래된순, 조회수많은순->적은순, 댓글많은순->적은순의 순서로 생성한다. @@ -166,9 +139,11 @@ static void setup(@Autowired TechArticleRepository techArticleRepository, @AfterAll static void tearDown(@Autowired TechArticleRepository techArticleRepository, - @Autowired ElasticTechArticleRepository elasticTechArticleRepository) { + @Autowired ElasticTechArticleRepository elasticTechArticleRepository, + @Autowired CompanyRepository companyRepository) { elasticTechArticleRepository.deleteAll(); techArticleRepository.deleteAllInBatch(); + companyRepository.deleteAllInBatch(); } @Test @@ -285,7 +260,7 @@ void getBookmarkedTechArticles() throws Exception { } @Test - @DisplayName("회원이 기술블로그 북마크 목록을 조회할 때 회원이 없으면 예외가 발생한다.") + @DisplayName("회원이 자신이 구독한 기업 목록을 조회할 때 회원이 없으면 예외가 발생한다.") void getBookmarkedTechArticlesNotFoundMemberException() throws Exception { // given SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", @@ -873,6 +848,41 @@ void recordMemberExitSurveyOptionIdNotNullException() throws Exception { )); } + @Test + @DisplayName("회원이 기술블로그 북마크 목록을 조회할 때 회원이 없으면 예외가 발생한다.") + void findMySubscribedCompaniesNotFoundMemberException() throws Exception { + // given + SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", + "꿈빛파티시엘", "1234", email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + member.updateRefreshToken(refreshToken); + + Pageable pageable = PageRequest.of(0, 1); + + // when // then + ResultActions actions = mockMvc.perform(get(DEFAULT_PATH_V1 + "/mypage/subscriptions/companies") + .queryParam("size", String.valueOf(pageable.getPageSize())) + .queryParam("companyId", "3") + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.resultType").value(ResultType.FAIL.name())) + .andExpect(jsonPath("$.message").value(INVALID_MEMBER_NOT_FOUND_MESSAGE)) + .andExpect(jsonPath("$.errorCode").value(HttpStatus.NOT_FOUND.value())); + + // Docs + actions.andDo(document("subscribed-companies-not-found-member-exception", + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("resultType").type(JsonFieldType.STRING).description("응답 결과"), + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지"), + fieldWithPath("errorCode").type(JsonFieldType.NUMBER).description("에러 코드") + ) + )); + } + private Long pickSetup(Member member, ContentStatus contentStatus, Title pickTitle, Title firstPickOptionTitle, PickOptionContents firstPickOptionContents, Title secondPickOptinTitle, PickOptionContents secondPickOptionContents) { @@ -989,9 +999,21 @@ private static Company createCompany(String companyName, String officialImageUrl String careerUrl) { return Company.builder() .name(new CompanyName(companyName)) - .officialImageUrl(officialImageUrl) + .officialImageUrl(new Url(officialImageUrl)) + .careerUrl(new Url(careerUrl)) + .officialUrl(new Url(officialUrl)) + .build(); + } + + private static Company createCompany(String companyName, String officialUrl, String careerUrl, + String imageUrl, String description, String industry) { + return Company.builder() + .name(new CompanyName(companyName)) .careerUrl(new Url(careerUrl)) .officialUrl(new Url(officialUrl)) + .officialImageUrl(new Url(imageUrl)) + .description(description) + .industry(industry) .build(); } } diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/MyPageControllerDocsUsedMockServiceTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/MyPageControllerDocsUsedMockServiceTest.java index 381ab133..13fdc0f0 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/MyPageControllerDocsUsedMockServiceTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/MyPageControllerDocsUsedMockServiceTest.java @@ -8,6 +8,7 @@ import static com.dreamypatisiel.devdevdev.web.docs.format.ApiDocsFormatGenerator.uniqueCommentIdType; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.when; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; @@ -16,6 +17,7 @@ import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.JsonFieldType.*; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; @@ -36,6 +38,8 @@ import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.List; + +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscribedCompanyResponse; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -278,6 +282,78 @@ void getMyWrittenCommentsPickCommentIdBindException() throws Exception { )); } + @Test + @DisplayName("회원이 커서 방식으로 다음페이지의 자신이 구독한 기업 목록을 조회하여 응답을 생성한다.") + void findMySubscribedCompaniesByCursor() throws Exception { + // given + Pageable pageable = PageRequest.of(0, 1); + long cursorCompanyId = 999L; + SubscribedCompanyResponse subscribedCompanyResponse = new SubscribedCompanyResponse( + 1L, "Toss", "https://image.net/image.png", true); + List content = List.of(subscribedCompanyResponse); + SliceCustom response = new SliceCustom<>(content, pageable, 1L); + + when(memberService.findMySubscribedCompanies(any(), any(), any())).thenReturn(response); + + // when + ResultActions actions = mockMvc.perform(get(DEFAULT_PATH_V1 + "/mypage/subscriptions/companies") + .queryParam("size", String.valueOf(pageable.getPageSize())) + .queryParam("companyId", Long.toString(cursorCompanyId)) + .contentType(MediaType.APPLICATION_JSON) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()); + + actions.andDo(document("subscribed-companies", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(SecurityConstant.AUTHORIZATION_HEADER).description("Bearer 엑세스 토큰") + ), + queryParameters( + parameterWithName("size").optional().description("조회되는 데이터 수"), + parameterWithName("companyId").optional().description("커서(마지막 기업 아이디)") + ), + responseFields( + fieldWithPath("resultType").type(STRING).description("응답 결과"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + + fieldWithPath("data.content").type(ARRAY).description("구독한 기업 목록 메인 배열"), + fieldWithPath("data.content[].companyId").type(NUMBER).description("기업 아이디"), + fieldWithPath("data.content[].companyName").type(STRING).description("기업 이름"), + fieldWithPath("data.content[].companyImageUrl").type(STRING).description("기업 로고 이미지 url"), + fieldWithPath("data.content[].isSubscribed").type(JsonFieldType.BOOLEAN).description("회원의 구독 여부"), + + fieldWithPath("data.pageable").type(OBJECT).description("픽픽픽 메인 페이지네이션 정보"), + fieldWithPath("data.pageable.pageNumber").type(NUMBER).description("페이지 번호"), + fieldWithPath("data.pageable.pageSize").type(NUMBER).description("페이지 사이즈"), + + fieldWithPath("data.pageable.sort").type(OBJECT).description("정렬 정보"), + fieldWithPath("data.pageable.sort.empty").type(BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("data.pageable.sort.sorted").type(BOOLEAN).description("정렬 여부"), + fieldWithPath("data.pageable.sort.unsorted").type(BOOLEAN).description("비정렬 여부"), + + fieldWithPath("data.pageable.offset").type(NUMBER).description("페이지 오프셋 (페이지 크기 * 페이지 번호)"), + fieldWithPath("data.pageable.paged").type(BOOLEAN).description("페이지 정보 포함 여부"), + fieldWithPath("data.pageable.unpaged").type(BOOLEAN).description("페이지 정보 비포함 여부"), + + fieldWithPath("data.first").type(BOOLEAN).description("현재 페이지가 첫 페이지 여부"), + fieldWithPath("data.last").type(BOOLEAN).description("현재 페이지가 마지막 페이지 여부"), + fieldWithPath("data.size").type(NUMBER).description("페이지 크기"), + fieldWithPath("data.number").type(NUMBER).description("현재 페이지"), + + fieldWithPath("data.sort").type(OBJECT).description("정렬 정보"), + fieldWithPath("data.sort.empty").type(BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("data.sort.sorted").type(BOOLEAN).description("정렬 상태 여부"), + fieldWithPath("data.sort.unsorted").type(BOOLEAN).description("비정렬 상태 여부"), + fieldWithPath("data.numberOfElements").type(NUMBER).description("현재 페이지 데이터 수"), + fieldWithPath("data.totalElements").type(NUMBER).description("전체 데이터 수"), + fieldWithPath("data.empty").type(BOOLEAN).description("현재 빈 페이지 여부") + ) + )); + } + private static MyWrittenCommentResponse createMyWrittenCommentResponse(String uniqueCommentId, Long postId, String postTitle, Long commentId, diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/NotificationControllerDocsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/NotificationControllerDocsTest.java new file mode 100644 index 00000000..64bf1939 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/NotificationControllerDocsTest.java @@ -0,0 +1,534 @@ +package com.dreamypatisiel.devdevdev.web.docs; + +import com.dreamypatisiel.devdevdev.domain.entity.enums.NotificationType; +import com.dreamypatisiel.devdevdev.domain.entity.enums.Role; +import com.dreamypatisiel.devdevdev.domain.exception.NotificationExceptionMessage; +import com.dreamypatisiel.devdevdev.domain.service.ApiKeyService; +import com.dreamypatisiel.devdevdev.domain.service.notification.NotificationService; +import com.dreamypatisiel.devdevdev.exception.NotFoundException; +import com.dreamypatisiel.devdevdev.global.constant.SecurityConstant; +import com.dreamypatisiel.devdevdev.global.security.jwt.model.Token; +import com.dreamypatisiel.devdevdev.redis.sub.NotificationMessageDto; +import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; +import com.dreamypatisiel.devdevdev.web.dto.request.publish.PublishTechArticle; +import com.dreamypatisiel.devdevdev.web.dto.request.publish.PublishTechArticleRequest; +import com.dreamypatisiel.devdevdev.web.dto.response.ResultType; +import com.dreamypatisiel.devdevdev.web.dto.response.notification.*; +import com.dreamypatisiel.devdevdev.web.dto.response.techArticle.CompanyResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.techArticle.TechArticleMainResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static com.dreamypatisiel.devdevdev.global.constant.SecurityConstant.AUTHORIZATION_HEADER; +import static com.dreamypatisiel.devdevdev.web.docs.custom.CustomPreprocessors.modifyResponseBody; +import static com.dreamypatisiel.devdevdev.web.docs.format.ApiDocsFormatGenerator.notificationType; +import static com.dreamypatisiel.devdevdev.web.docs.format.ApiDocsFormatGenerator.resultType; +import static io.lettuce.core.BitFieldArgs.OverflowType.FAIL; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.times; +import static org.springframework.restdocs.headers.HeaderDocumentation.*; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class NotificationControllerDocsTest extends SupportControllerDocsTest { + + @MockBean + NotificationService notificationService; + @MockBean + ApiKeyService apiKeyService; + + @Test + @DisplayName("회원이 단건 알림을 읽으면 isRead가 true로 변경된 응답을 받는다.") + void readNotification() throws Exception { + // given + Long notificationId = 1L; + given(notificationService.readNotification(anyLong(), any())) + .willReturn(new NotificationReadResponse(notificationId, true)); + + // when + ResultActions actions = mockMvc.perform( + patch(DEFAULT_PATH_V1 + "/notifications/{notificationId}/read", notificationId) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()); + + // docs + actions.andDo(document("read-notification", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("Bearer 엑세스 토큰") + ), + pathParameters( + parameterWithName("notificationId").description("알림 아이디") + ), + responseFields( + fieldWithPath("resultType").type(STRING).description("응답 결과"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(NUMBER).description("알림 아이디"), + fieldWithPath("data.isRead").type(BOOLEAN).description("알림 읽음 여부") + ) + )); + } + + @Test + @DisplayName("회원이 자신의 알림이 아닌 알림을 조회하면 예외가 발생한다.") + void readNotificationNotOwnerException() throws Exception { + // given + Long notificationId = 2L; + given(notificationService.readNotification(anyLong(), any())) + .willThrow(new NotFoundException(NotificationExceptionMessage.NOT_FOUND_NOTIFICATION_MESSAGE)); + + // when + ResultActions actions = mockMvc.perform( + patch(DEFAULT_PATH_V1 + "/notifications/{notificationId}/read", notificationId) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isNotFound()); + + // docs + actions.andDo(document("read-notification-not-found", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("Bearer 엑세스 토큰") + ), + pathParameters( + parameterWithName("notificationId").description("알림 아이디") + ), + exceptionResponseFields() + )); + } + + @Test + @DisplayName("회원이 모든 알림을 읽는다.") + void readAllNotifications() throws Exception { + // given + doNothing().when(notificationService).readAllNotifications(any()); + + // when // then + ResultActions actions = mockMvc.perform(patch(DEFAULT_PATH_V1 + "/notifications/read-all") + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()); + + // docs + actions.andDo(document("read-all-notifications", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("Bearer 엑세스 토큰") + ), + responseFields( + fieldWithPath("resultType").type(STRING).description("응답 결과") + ) + )); + } + + + @Test + @DisplayName("회원이 알림 팝업창을 조회한다.") + void getNotificationPopup() throws Exception { + // given + PageRequest pageable = PageRequest.of(0, 1); + List response = List.of( + new NotificationPopupNewArticleResponse(1L, "기술블로그 타이틀", LocalDateTime.now(), false, + "기업명", 1L)); + given(notificationService.getNotificationPopup(any(), any())) + .willReturn(new SliceCustom<>(response, pageable, false, 1L)); + + // when + ResultActions actions = mockMvc.perform(get(DEFAULT_PATH_V1 + "/notifications/popup") + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .queryParam("size", String.valueOf(pageable.getPageSize())) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()); + + // docs + actions.andDo(document("get-notification-popup", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("Bearer 엑세스 토큰") + ), + queryParameters( + parameterWithName("size").description("페이지 크기") + ), + responseFields( + fieldWithPath("resultType").type(STRING).description("응답 결과"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.content").type(ARRAY).description("알림 팝업 리스트"), + fieldWithPath("data.content[].id").type(NUMBER).description("알림 ID"), + fieldWithPath("data.content[].type").type(STRING).description("알림 타입").attributes(notificationType()), + fieldWithPath("data.content[].title").type(STRING).description("알림 제목"), + fieldWithPath("data.content[].isRead").type(BOOLEAN).description("회원의 읽음 여부"), + fieldWithPath("data.content[].createdAt").type(STRING).description("알림 생성 일시"), + fieldWithPath("data.content[].companyName").type(STRING).description("기업 이름"), + fieldWithPath("data.content[].techArticleId").type(NUMBER).description("기술블로그 id"), + fieldWithPath("data.pageable").type(OBJECT).description("페이지 정보"), + fieldWithPath("data.pageable.pageNumber").type(NUMBER).description("페이지 번호"), + fieldWithPath("data.pageable.pageSize").type(NUMBER).description("페이지 크기"), + fieldWithPath("data.pageable.sort").type(OBJECT).description("정렬 정보"), + fieldWithPath("data.pageable.sort.empty").type(BOOLEAN).description("정렬 여부 - 비어있는지"), + fieldWithPath("data.pageable.sort.sorted").type(BOOLEAN).description("정렬 여부 - 정렬됨"), + fieldWithPath("data.pageable.sort.unsorted").type(BOOLEAN).description("정렬 여부 - 정렬되지 않음"), + fieldWithPath("data.pageable.offset").type(NUMBER).description("데이터 시작 위치"), + fieldWithPath("data.pageable.paged").type(BOOLEAN).description("페이징 적용 여부"), + fieldWithPath("data.pageable.unpaged").type(BOOLEAN).description("페이징 미적용 여부"), + fieldWithPath("data.totalElements").type(NUMBER).description("회원이 읽지 않은 알림 총 개수"), + fieldWithPath("data.first").type(BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("data.last").type(BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("data.size").type(NUMBER).description("페이지 크기"), + fieldWithPath("data.number").type(NUMBER).description("현재 페이지 번호"), + fieldWithPath("data.sort").type(OBJECT).description("정렬 정보"), + fieldWithPath("data.sort.empty").type(BOOLEAN).description("정렬 정보 - 비어있는지"), + fieldWithPath("data.sort.sorted").type(BOOLEAN).description("정렬 정보 - 정렬됨"), + fieldWithPath("data.sort.unsorted").type(BOOLEAN).description("정렬 정보 - 정렬되지 않음"), + fieldWithPath("data.numberOfElements").type(NUMBER).description("현재 페이지 요소 수"), + fieldWithPath("data.empty").type(BOOLEAN).description("비어있는 페이지인지 여부") + + ) + )); + } + + @Test + @DisplayName("회원이 알림 개수를 조회하면 회원이 아직 읽지 않은 알림의 총 개수가 반환된다.") + void getUnreadNotificationCount() throws Exception { + // given + Long unreadNotificationCount = 12L; + given(notificationService.getUnreadNotificationCount(any())) + .willReturn(unreadNotificationCount); + + // when // then + ResultActions actions = mockMvc.perform(get(DEFAULT_PATH_V1 + "/notifications/unread-count") + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultType").value(ResultType.SUCCESS.name())) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andExpect(jsonPath("$.data").isNumber()); + + // docs + actions.andDo(document("get-notification-unread-count", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("Bearer 엑세스 토큰") + ), + responseFields( + fieldWithPath("resultType").type(STRING).description("응답 결과"), + fieldWithPath("data").type(NUMBER).description("응답 데이터(읽지 않은 알림 개수)") + ) + )); + } + + /** + * SseEmitter는 내부적으로 HttpServletResponse.getOutputStream() 또는 getWriter()를 직접 사용해서 데이터를 보냄 그래서 + * MockMvc.perform().characterEncoding("UTF-8")이 무시되는 거고, ISO-8859-1로 처리됨 + */ + @Test + @DisplayName("회원이 실시간 알림을 수신한다.") + void notification() throws Exception { + // given + SseEmitter sseEmitter = new SseEmitter(); + sseEmitter.send( + SseEmitter.event() + .data(new NotificationMessageDto("트이다에서 새로운 기슬블로그 105개가 올라왔어요!", + LocalDateTime.of(2025, 4, 6, 0, 0, 0))) + ); + + given(notificationService.addClientAndSendNotification(any())).willReturn(sseEmitter); + + // when // then + ResultActions actions = mockMvc.perform(get("/devdevdev/api/v1/notifications") + .contentType(MediaType.TEXT_EVENT_STREAM_VALUE) + .characterEncoding(StandardCharsets.UTF_8) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM_VALUE)); + + // docs + actions.andDo(document("notifications", + preprocessRequest(prettyPrint()), + preprocessResponse(modifyResponseBody()), + requestHeaders( + headerWithName(SecurityConstant.AUTHORIZATION_HEADER).description("Bearer Token"), + headerWithName("Content-Type").description(MediaType.TEXT_EVENT_STREAM_VALUE) + ), + responseHeaders( + headerWithName("Content-Type").description(MediaType.TEXT_EVENT_STREAM_VALUE) + ), + responseBody() + )); + + sseEmitter.complete(); + } + + @Test + @DisplayName("알림을 생성한다.") + void publishNotifications() throws Exception { + // given + PublishTechArticleRequest request = new PublishTechArticleRequest( + 1L, + List.of(new PublishTechArticle(1L), + new PublishTechArticle(2L)) + ); + + // 어드민 토큰 생성 + Token token = tokenService.generateTokenBy(email, socialType, Role.ROLE_ADMIN.name()); + accessToken = token.getAccessToken(); + + // when // then + ResultActions actions = mockMvc.perform( + post("/devdevdev/api/v1/notifications/{channel}", NotificationType.SUBSCRIPTION) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .header("service-name", "test-service") + .header("api-key", "test-key") + .content(om.writeValueAsBytes(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultType").value(ResultType.SUCCESS.name())); + + // docs + actions.andDo(document("publish-notifications", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("service-name").description("서비스 이름"), + headerWithName("api-key").description("api key") + ), + pathParameters( + parameterWithName("channel").description("알림 채널").attributes(notificationType()) + ), + requestFields( + fieldWithPath("companyId").type(NUMBER).description("회사 아이디"), + fieldWithPath("techArticles").type(ARRAY).description("기술블로그 배열"), + fieldWithPath("techArticles.[].id").type(NUMBER).description("기술블로그 아이디") + ), + responseFields( + fieldWithPath("resultType").type(STRING).description("응답 결과").attributes(resultType()) + ) + )); + } + + @Test + @DisplayName("회원이 알림 페이지를 무한스크롤링으로 조회한다.") + void getNotifications() throws Exception { + // given + PageRequest pageable = PageRequest.of(0, 1); + TechArticleMainResponse techArticleMainResponse = createTechArticleMainResponse( + 1L, "elasticId", "http://thumbnailUrl.com", false, + "http://techArticleUrl.com", "기술블로그 타이틀", "기술블로그 내용", + 1L, "기업명", "http://careerUrl.com", "http://officialImage.com", LocalDate.now(), "작성자", + 0L, 0L, 0L, false, null + ); + + List response = List.of( + new NotificationNewArticleResponse(1L, LocalDateTime.now(), false, techArticleMainResponse) + ); + given(notificationService.getNotifications(any(), anyLong(), any())) + .willReturn(new SliceCustom<>(response, pageable, true, 1L)); + + // when + ResultActions actions = mockMvc.perform(get(DEFAULT_PATH_V1 + "/notifications/page") + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .queryParam("size", String.valueOf(pageable.getPageSize())) + .queryParam("notificationId", String.valueOf(2L)) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()); + + // docs + actions.andDo(document("get-notifications", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("Bearer 엑세스 토큰") + ), + queryParameters( + parameterWithName("notificationId").description("마지막 알림 ID (커서)"), + parameterWithName("size").description("페이지 크기") + ), + responseFields( + fieldWithPath("resultType").type(STRING).description("응답 결과"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.content").type(ARRAY).description("알림 목록"), + fieldWithPath("data.content[].notificationId").type(NUMBER).description("알림 ID"), + fieldWithPath("data.content[].type").type(STRING).description("알림 타입").attributes(notificationType()), + fieldWithPath("data.content[].createdAt").type(STRING).description("알림 생성 일시"), + fieldWithPath("data.content[].isRead").type(BOOLEAN).description("회원의 알림 읽음 여부"), + fieldWithPath("data.content[].techArticle").type(OBJECT).description("기술블로그 정보"), + fieldWithPath("data.content[].techArticle.id").type(NUMBER).description("기술블로그 ID"), + fieldWithPath("data.content[].techArticle.elasticId").type(STRING).description("엘라스틱서치 ID"), + fieldWithPath("data.content[].techArticle.thumbnailUrl").type(STRING).description("썸네일 URL"), + fieldWithPath("data.content[].techArticle.isLogoImage").type(BOOLEAN).description("로고 이미지 여부"), + fieldWithPath("data.content[].techArticle.techArticleUrl").type(STRING).description("기술블로그 URL"), + fieldWithPath("data.content[].techArticle.title").type(STRING).description("기술블로그 제목"), + fieldWithPath("data.content[].techArticle.contents").type(STRING).description("기술블로그 내용"), + fieldWithPath("data.content[].techArticle.regDate").type(STRING).description("작성일"), + fieldWithPath("data.content[].techArticle.author").type(STRING).description("작성자"), + fieldWithPath("data.content[].techArticle.company").type(OBJECT).description("기업 정보"), + fieldWithPath("data.content[].techArticle.company.id").type(NUMBER).description("기업 ID"), + fieldWithPath("data.content[].techArticle.company.name").type(STRING).description("기업명"), + fieldWithPath("data.content[].techArticle.company.careerUrl").type(STRING).description("기업 채용공고 URL"), + fieldWithPath("data.content[].techArticle.company.officialImageUrl").type(STRING).description("기업 채용공고 URL"), + fieldWithPath("data.content[].techArticle.viewTotalCount").type(NUMBER).description("조회 수"), + fieldWithPath("data.content[].techArticle.recommendTotalCount").type(NUMBER).description("추천 수"), + fieldWithPath("data.content[].techArticle.commentTotalCount").type(NUMBER).description("댓글 수"), + fieldWithPath("data.content[].techArticle.popularScore").type(NUMBER).description("인기 점수"), + fieldWithPath("data.content[].techArticle.isBookmarked").type(BOOLEAN).description("북마크 여부"), + fieldWithPath("data.content[].techArticle.score").type(NULL).description("정확도 점수(null)"), + fieldWithPath("data.pageable").type(OBJECT).description("페이지 정보"), + fieldWithPath("data.pageable.pageNumber").type(NUMBER).description("페이지 번호"), + fieldWithPath("data.pageable.pageSize").type(NUMBER).description("페이지 크기"), + fieldWithPath("data.pageable.sort").type(OBJECT).description("정렬 정보"), + fieldWithPath("data.pageable.sort.empty").type(BOOLEAN).description("정렬 정보 - 비어있는지"), + fieldWithPath("data.pageable.sort.sorted").type(BOOLEAN).description("정렬 정보 - 정렬됨"), + fieldWithPath("data.pageable.sort.unsorted").type(BOOLEAN).description("정렬 정보 - 정렬되지 않음"), + fieldWithPath("data.pageable.offset").type(NUMBER).description("데이터 시작 위치"), + fieldWithPath("data.pageable.paged").type(BOOLEAN).description("페이징 적용 여부"), + fieldWithPath("data.pageable.unpaged").type(BOOLEAN).description("페이징 미적용 여부"), + fieldWithPath("data.totalElements").type(NUMBER).description("전체 요소 수"), + fieldWithPath("data.first").type(BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("data.last").type(BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("data.size").type(NUMBER).description("요청한 페이지 크기"), + fieldWithPath("data.number").type(NUMBER).description("현재 페이지 번호"), + fieldWithPath("data.sort").type(OBJECT).description("정렬 정보"), + fieldWithPath("data.sort.empty").type(BOOLEAN).description("정렬 정보 - 비어있는지"), + fieldWithPath("data.sort.sorted").type(BOOLEAN).description("정렬 정보 - 정렬됨"), + fieldWithPath("data.sort.unsorted").type(BOOLEAN).description("정렬 정보 - 정렬되지 않음"), + fieldWithPath("data.numberOfElements").type(NUMBER).description("현재 페이지 요소 수"), + fieldWithPath("data.empty").type(BOOLEAN).description("비어있는 페이지인지 여부") + ) + )); + } + + @Test + @DisplayName("알림을 생성할 때 잘못된 채널을 입력하면 예외가 발생한다.") + void publishNotificationsException() throws Exception { + // given + PublishTechArticleRequest request = new PublishTechArticleRequest( + 1L, + List.of(new PublishTechArticle(1L), + new PublishTechArticle(2L)) + ); + + // 어드민 토큰 생성 + Token token = tokenService.generateTokenBy(email, socialType, Role.ROLE_ADMIN.name()); + accessToken = token.getAccessToken(); + + // when // then + ResultActions actions = mockMvc.perform(post("/devdevdev/api/v1/notifications/{channel}", "INVALID_CHANNEL") + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .header("service-name", "test-service") + .header("api-key", "test-key") + .content(om.writeValueAsBytes(request))) + .andDo(print()) + .andExpect(status().is4xxClientError()) + .andExpect(jsonPath("$.resultType").value(FAIL.name())) + .andExpect(jsonPath("$.message").isString()) + .andExpect(jsonPath("$.errorCode").isNumber()); + + // docs + actions.andDo(document("publish-notifications-exception", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("service-name").description("서비스 이름"), + headerWithName("api-key").description("api key") + ), + requestFields( + fieldWithPath("companyId").type(NUMBER).description("회사 아이디"), + fieldWithPath("techArticles").type(ARRAY).description("기술블로그 배열"), + fieldWithPath("techArticles.[].id").type(NUMBER).description("기술블로그 아이디") + ), + exceptionResponseFields() + )); + } + + @Test + @DisplayName("알림을 생성할 때 API Key가 없으면 예외가 발생한다.") + void publishNotificationsNotFoundException() throws Exception { + // given + PublishTechArticleRequest request = new PublishTechArticleRequest( + 1L, + List.of(new PublishTechArticle(1L), + new PublishTechArticle(2L)) + ); + + doThrow(new NotFoundException("not found")).when(apiKeyService).validateApiKey(any(), any()); + + // when // then + mockMvc.perform( + MockMvcRequestBuilders.post("/devdevdev/api/v1/notifications/{channel}", NotificationType.SUBSCRIPTION) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .header("service-name", "test-service") + .header("api-key", "test-key") + .content(om.writeValueAsBytes(request))) + .andDo(print()) + .andExpect(status().is4xxClientError()) + .andExpect(jsonPath("$.resultType").value(FAIL.name())) + .andExpect(jsonPath("$.message").isString()) + .andExpect(jsonPath("$.errorCode").isNumber()); + } + + private TechArticleMainResponse createTechArticleMainResponse(Long id, String elasticId, String thumbnailUrl, Boolean isLogoImage, + String techArticleUrl, String title, String contents, + Long companyId, String companyName, String careerUrl, String officialImageUrl, + LocalDate regDate, String author, long recommendCount, + long commentCount, long viewCount, Boolean isBookmarked, Float score) { + return TechArticleMainResponse.builder() + .id(id) + .elasticId(elasticId) + .thumbnailUrl(thumbnailUrl) + .isLogoImage(isLogoImage) + .techArticleUrl(techArticleUrl) + .title(title) + .contents(contents) + .company(CompanyResponse.of(companyId, companyName, careerUrl, officialImageUrl)) + .regDate(regDate) + .author(author) + .viewTotalCount(viewCount) + .recommendTotalCount(recommendCount) + .commentTotalCount(commentCount) + .popularScore(0L) + .isBookmarked(isBookmarked) + .score(score) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/SubscriptionControllerDocsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/SubscriptionControllerDocsTest.java new file mode 100644 index 00000000..f452458a --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/SubscriptionControllerDocsTest.java @@ -0,0 +1,370 @@ +package com.dreamypatisiel.devdevdev.web.docs; + +import static com.dreamypatisiel.devdevdev.global.constant.SecurityConstant.AUTHORIZATION_HEADER; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.OBJECT; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +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.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.dreamypatisiel.devdevdev.domain.exception.CompanyExceptionMessage; +import com.dreamypatisiel.devdevdev.domain.exception.SubscriptionExceptionMessage; +import com.dreamypatisiel.devdevdev.domain.service.techArticle.subscription.MemberSubscriptionService; +import com.dreamypatisiel.devdevdev.exception.NotFoundException; +import com.dreamypatisiel.devdevdev.global.constant.SecurityConstant; +import com.dreamypatisiel.devdevdev.web.dto.request.subscription.SubscribeCompanyRequest; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.CompanyDetailResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscriableCompanyResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.subscription.SubscriptionResponse; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.SliceImpl; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +class SubscriptionControllerDocsTest extends SupportControllerDocsTest { + + @MockBean + MemberSubscriptionService memberSubscriptionService; + + @Test + @DisplayName("회원이 기업을 구독한다.") + void subscribe() throws Exception { + // given + given(memberSubscriptionService.subscribe(anyLong(), any())).willReturn(new SubscriptionResponse(1L)); + + // when // then + ResultActions actions = mockMvc.perform(post(DEFAULT_PATH_V1 + "/subscriptions") + .contentType(MediaType.APPLICATION_JSON) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .content(om.writeValueAsBytes(new SubscribeCompanyRequest(1L))) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()); + + // docs + actions.andDo(document("subscribe-company", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("Bearer 엑세스 토큰") + ), + requestFields( + fieldWithPath("companyId").description("기업 아이디") + ), + responseFields( + fieldWithPath("resultType").type(STRING).description("응답 결과"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(NUMBER).description("구독 아이디") + ) + )); + } + + @Test + @DisplayName("회원이 기업을 구독할 때 기업이 존재하지 않으면 예외가 발생한다.") + void subscribeNotFoundException() throws Exception { + // given + given(memberSubscriptionService.subscribe(anyLong(), any())).willThrow( + new NotFoundException(CompanyExceptionMessage.NOT_FOUND_COMPANY_MESSAGE)); + + // when // then + ResultActions actions = mockMvc.perform(post(DEFAULT_PATH_V1 + "/subscriptions") + .contentType(MediaType.APPLICATION_JSON) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .content(om.writeValueAsBytes(new SubscribeCompanyRequest(1L))) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().is4xxClientError()); + + // docs + actions.andDo(document("subscribe-company-not-found-company", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("Bearer 엑세스 토큰") + ), + requestFields( + fieldWithPath("companyId").description("기업 아이디") + ), + exceptionResponseFields() + )); + } + + @Test + @DisplayName("회원이 기업을 구독 취소한다.") + void unsubscribe() throws Exception { + // given // when + doNothing().when(memberSubscriptionService).unsubscribe(anyLong(), any()); + + // then + ResultActions actions = mockMvc.perform(delete(DEFAULT_PATH_V1 + "/subscriptions") + .contentType(MediaType.APPLICATION_JSON) + .header(AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .content(om.writeValueAsBytes(new SubscribeCompanyRequest(1L))) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()); + + // docs + actions.andDo(document("unsubscribe-company", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("Bearer 엑세스 토큰") + ), + requestFields( + fieldWithPath("companyId").description("기업 아이디") + ), + responseFields( + fieldWithPath("resultType").type(STRING).description("응답 결과") + ) + )); + } + + @ParameterizedTest + @NullSource + @DisplayName("회원이 기업을 구독 취소한다.") + void unsubscribeBindException(Long companyId) throws Exception { + // given // when + doNothing().when(memberSubscriptionService).unsubscribe(anyLong(), any()); + + // then + ResultActions actions = mockMvc.perform(delete(DEFAULT_PATH_V1 + "/subscriptions") + .contentType(MediaType.APPLICATION_JSON) + .header(AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .content(om.writeValueAsBytes(new SubscribeCompanyRequest(companyId))) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isBadRequest()); + + // docs + actions.andDo(document("unsubscribe-company-bind-exception", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("Bearer 엑세스 토큰") + ), + requestFields( + fieldWithPath("companyId").description("기업 아이디") + ), + exceptionResponseFields() + )); + } + + @Test + @DisplayName("회원이 기업을 구독 취소할 때 구독 이력이 없으면 예외가 발생한다.") + void unsubscribeSubscriptionException() throws Exception { + // given // when + doThrow(new NotFoundException(SubscriptionExceptionMessage.NOT_FOUND_SUBSCRIPTION_MESSAGE)) + .when(memberSubscriptionService).unsubscribe(anyLong(), any()); + + // then + ResultActions actions = mockMvc.perform(delete(DEFAULT_PATH_V1 + "/subscriptions") + .contentType(MediaType.APPLICATION_JSON) + .header(AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .content(om.writeValueAsBytes(new SubscribeCompanyRequest(1L))) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().is4xxClientError()); + + // docs + actions.andDo(document("unsubscribe-company-not-found-subscription", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("Bearer 엑세스 토큰") + ), + requestFields( + fieldWithPath("companyId").description("기업 아이디") + ), + exceptionResponseFields() + )); + } + + @Test + @DisplayName("구독 가능한 기업 목록을 조회한다.") + void getSubscriptions() throws Exception { + // given + SubscriableCompanyResponse response = new SubscriableCompanyResponse(1L, "트이다", + "https://www.teuida.net/public/src/img/teuida_logo.png", true); + given(memberSubscriptionService.getSubscribableCompany(any(), anyLong(), any())) + .willReturn(new SliceImpl<>(List.of(response), PageRequest.of(0, 20), false)); + + // when // then + ResultActions actions = mockMvc.perform( + get(DEFAULT_PATH_V1 + "/subscriptions/companies") + .queryParam("size", "10") + .queryParam("companyId", "105") + .contentType(MediaType.APPLICATION_JSON) + .header(AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()); + + // docs + actions.andDo(document("subscribable-companies", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("Bearer 엑세스 토큰") + ), + queryParameters( + parameterWithName("size").optional().description("조회되는 데이터 수"), + parameterWithName("companyId").optional().description("기업 아이디") + ), + responseFields( + fieldWithPath("resultType").type(STRING).description("응답 결과"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + + fieldWithPath("data.content").type(ARRAY).description("구독 가능한 기업 목록 메인 배열"), + fieldWithPath("data.content[].companyId").type(NUMBER).description("기업 아이디"), + fieldWithPath("data.content[].companyName").type(STRING).description("기업명"), + fieldWithPath("data.content[].companyImageUrl").type(STRING).description("기업 로고 이미지 url"), + fieldWithPath("data.content[].isSubscribed").type(JsonFieldType.BOOLEAN) + .description("회원의 구독 여부"), + + fieldWithPath("data.pageable").type(OBJECT).description("픽픽픽 메인 페이지네이션 정보"), + fieldWithPath("data.pageable.pageNumber").type(NUMBER).description("페이지 번호"), + fieldWithPath("data.pageable.pageSize").type(NUMBER).description("페이지 사이즈"), + + fieldWithPath("data.pageable.sort").type(OBJECT).description("정렬 정보"), + fieldWithPath("data.pageable.sort.empty").type(BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("data.pageable.sort.sorted").type(BOOLEAN).description("정렬 여부"), + fieldWithPath("data.pageable.sort.unsorted").type(BOOLEAN).description("비정렬 여부"), + + fieldWithPath("data.pageable.offset").type(NUMBER).description("페이지 오프셋 (페이지 크기 * 페이지 번호)"), + fieldWithPath("data.pageable.paged").type(BOOLEAN).description("페이지 정보 포함 여부"), + fieldWithPath("data.pageable.unpaged").type(BOOLEAN).description("페이지 정보 비포함 여부"), + + fieldWithPath("data.first").type(BOOLEAN).description("현재 페이지가 첫 페이지 여부"), + fieldWithPath("data.last").type(BOOLEAN).description("현재 페이지가 마지막 페이지 여부"), + fieldWithPath("data.size").type(NUMBER).description("페이지 크기"), + fieldWithPath("data.number").type(NUMBER).description("현재 페이지"), + + fieldWithPath("data.sort").type(OBJECT).description("정렬 정보"), + fieldWithPath("data.sort.empty").type(BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("data.sort.sorted").type(BOOLEAN).description("정렬 상태 여부"), + fieldWithPath("data.sort.unsorted").type(BOOLEAN).description("비정렬 상태 여부"), + fieldWithPath("data.numberOfElements").type(NUMBER).description("현재 페이지 데이터 수"), + fieldWithPath("data.empty").type(BOOLEAN).description("현재 빈 페이지 여부") + ) + )); + } + + @Test + @DisplayName("구독 가능한 기업 상세 정보를 조회한다.") + void getCompanyDetails() throws Exception { + // given + CompanyDetailResponse response = CompanyDetailResponse.builder() + .companyId(1L) + .companyName("Teuida") + .industry("교육") + .companyCareerUrl("https://www.wanted.co.kr/company/5908") + .companyOfficialImageUrl("https://www.teuida.net/public/src/img/teuida_logo.png") + .companyDescription("“외국인의 한국어가 트이다”\n" + + "영어 공부 오래 했지만 영어로 말할 때 주저하게 되죠? 외국인도 마찬가지예요. 한국어 말할 때 주저하게 돼요.\n" + + "트이다는 화면 속 한국인과 가상대화를 하는 경험을 통해 외국인이 한국어 회화에 자신감을 가질 수 있도록 도움을 주는 스타트업이에요.\n" + + "전 세계 130만 명 이상의 사용자가 웹드라마 속 배우랑 가상대화를 하면서 한국어를 배우고 한국어 말하기에 자신감을 향상하고 있어요.\n" + + "우리는 외국어를 학습하기보다 여행을 갔을 때, 외국인 친구를 사귀었을 때 자신이 하고 싶은 말을 할 수 있도록 재미있는 콘텐츠와 서비스를 제공하기 위해 노력하고 있어요. 모든 사람들이 자신의 생각, 의견을 어떤 언어로든 말할 수 있는 용기를 심어주고 싶어요.") + .techArticleTotalCount(3980L) + .isSubscribed(true) + .build(); + + given(memberSubscriptionService.getCompanyDetail(anyLong(), any())).willReturn(response); + + // when // then + ResultActions actions = mockMvc.perform( + get(DEFAULT_PATH_V1 + "/subscriptions/companies/{companyId}", 1L) + .queryParam("size", "10") + .queryParam("companyId", "105") + .contentType(MediaType.APPLICATION_JSON) + .header(AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()); + + // docs + actions.andDo(document("subscribable-company-detail", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("Bearer 엑세스 토큰") + ), + pathParameters( + parameterWithName("companyId").description("기업 아이디") + ), + responseFields( + fieldWithPath("resultType").type(STRING).description("응답 결과"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.companyId").type(NUMBER).description("기업 아이디"), + fieldWithPath("data.companyName").type(STRING).description("기업명"), + fieldWithPath("data.industry").type(STRING).description("산업군"), + fieldWithPath("data.companyCareerUrl").type(STRING).description("기업 채용 url"), + fieldWithPath("data.companyOfficialImageUrl").type(STRING).description("기업 로고 url"), + fieldWithPath("data.companyDescription").type(STRING).description("기업 설명"), + fieldWithPath("data.techArticleTotalCount").type(NUMBER).description("기술 블로그 총 갯수"), + fieldWithPath("data.isSubscribed").type(BOOLEAN).description("구독 여부") + ) + )); + } + + @Test + @DisplayName("구독 가능한 기업 상세 정보를 조회할 때 기업이 존재하지 않으면 예외가 발생한다.") + void getCompanyDetailsException() throws Exception { + // given + given(memberSubscriptionService.getCompanyDetail(anyLong(), any())) + .willThrow(new NotFoundException(CompanyExceptionMessage.NOT_FOUND_COMPANY_MESSAGE)); + + // when // then + ResultActions actions = mockMvc.perform(get(DEFAULT_PATH_V1 + "/subscriptions/companies/{companyId}", 2L) + .queryParam("size", "10") + .queryParam("companyId", "105") + .contentType(MediaType.APPLICATION_JSON) + .header(AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().is4xxClientError()); + + // docs + actions.andDo(document("subscribable-company-detail-not-found-exception", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("Bearer 엑세스 토큰") + ), + pathParameters( + parameterWithName("companyId").description("기업 아이디") + ), + exceptionResponseFields() + )); + } +} \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/SupportControllerDocsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/SupportControllerDocsTest.java index ef3b088d..6457378d 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/SupportControllerDocsTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/SupportControllerDocsTest.java @@ -1,5 +1,6 @@ package com.dreamypatisiel.devdevdev.web.docs; +import static com.dreamypatisiel.devdevdev.web.docs.format.ApiDocsFormatGenerator.resultType; import static org.mockito.Mockito.when; import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; import static org.springframework.restdocs.payload.JsonFieldType.STRING; @@ -94,7 +95,7 @@ void tearDown() { protected ResponseFieldsSnippet exceptionResponseFields() { return responseFields( - fieldWithPath("resultType").type(STRING).description("응답 결과"), + fieldWithPath("resultType").type(STRING).description("응답 결과").attributes(resultType()), fieldWithPath("message").type(STRING).description("에러 메시지"), fieldWithPath("errorCode").type(NUMBER).description("에러 코드") ); diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/TechArticleCommentControllerDocsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/TechArticleCommentControllerDocsTest.java index 2bd0ea02..afa5ff38 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/TechArticleCommentControllerDocsTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/TechArticleCommentControllerDocsTest.java @@ -98,7 +98,8 @@ public class TechArticleCommentControllerDocsTest extends SupportControllerDocsT @DisplayName("익명 사용자는 기술블로그 댓글을 작성할 수 없다.") void registerTechCommentByAnonymous() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -140,7 +141,8 @@ void registerTechCommentByAnonymous() throws Exception { @DisplayName("회원은 기술블로그 댓글을 작성할 수 있다.") void registerTechComment() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -198,7 +200,8 @@ void registerTechComment() throws Exception { @DisplayName("회원이 기술블로그 댓글을 작성할 때 존재하지 않는 기술블로그라면 예외가 발생한다.") void registerTechCommentNotFoundTechArticleException() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -249,7 +252,8 @@ void registerTechCommentNotFoundTechArticleException() throws Exception { @DisplayName("회원이 기술블로그 댓글을 작성할 때 존재하지 않는 회원이라면 예외가 발생한다.") void registerTechCommentNotFoundMemberException() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -296,7 +300,8 @@ void registerTechCommentNotFoundMemberException() throws Exception { @DisplayName("회원이 기술블로그 댓글을 작성할 때 댓글 내용이 공백이라면 예외가 발생한다.") void registerTechCommentContentsIsNullException(String contents) throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -338,7 +343,8 @@ void registerTechCommentContentsIsNullException(String contents) throws Exceptio @DisplayName("회원은 기술블로그 댓글을 수정할 수 있다.") void modifyTechComment() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", @@ -401,7 +407,8 @@ void modifyTechComment() throws Exception { @DisplayName("회원이 기술블로그 댓글을 수정할 때 댓글 내용이 공백이라면 예외가 발생한다.") void modifyTechCommentContentsIsNullException(String contents) throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", @@ -456,7 +463,8 @@ void modifyTechCommentContentsIsNullException(String contents) throws Exception @DisplayName("회원이 기술블로그 댓글을 수정할 때 댓글이 존재하지 않으면 예외가 발생한다.") void modifyTechCommentNotFoundException() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", @@ -506,7 +514,8 @@ void modifyTechCommentNotFoundException() throws Exception { @DisplayName("회원은 본인이 작성한 기술블로그 댓글을 삭제할 수 있다.") void deleteTechComment() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", @@ -562,7 +571,8 @@ void deleteTechComment() throws Exception { @DisplayName("회원이 기술블로그 댓글을 삭제할 때 댓글이 존재하지 않으면 예외가 발생한다.") void deleteTechCommentNotFoundException() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", @@ -612,7 +622,8 @@ void deleteTechCommentNotFoundException() throws Exception { @DisplayName("회원은 기술블로그 댓글에 답글을 작성할 수 있다.") void registerTechReply() throws Exception { // given - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -688,7 +699,8 @@ void registerTechReplyContentsIsNullException(String contents) throws Exception member.updateRefreshToken(refreshToken); memberRepository.save(member); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -753,7 +765,8 @@ void getTechComments() throws Exception { userPrincipal.getSocialType().name())); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -980,7 +993,8 @@ void recommendTechComment() throws Exception { member.updateRefreshToken(refreshToken); memberRepository.save(member); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -1037,7 +1051,8 @@ void recommendTechCommentNotFoundTechComment() throws Exception { member.updateRefreshToken(refreshToken); memberRepository.save(member); - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -1095,7 +1110,8 @@ void getTechBestComments() throws Exception { memberRepository.saveAll(List.of(member1, member2, member3)); // 회사 생성 - Company company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); // 기술 블로그 생성 @@ -1231,7 +1247,7 @@ private static Company createCompany(String companyName, String officialImageUrl String careerUrl) { return Company.builder() .name(new CompanyName(companyName)) - .officialImageUrl(officialImageUrl) + .officialImageUrl(new Url(officialImageUrl)) .careerUrl(new Url(careerUrl)) .officialUrl(new Url(officialUrl)) .build(); diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/TechArticleControllerDocsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/TechArticleControllerDocsTest.java index eb6a0ad1..e41092db 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/TechArticleControllerDocsTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/TechArticleControllerDocsTest.java @@ -32,9 +32,9 @@ import com.dreamypatisiel.devdevdev.domain.entity.embedded.Url; import com.dreamypatisiel.devdevdev.domain.entity.enums.Role; import com.dreamypatisiel.devdevdev.domain.entity.enums.SocialType; -import com.dreamypatisiel.devdevdev.domain.repository.techArticle.BookmarkRepository; import com.dreamypatisiel.devdevdev.domain.repository.CompanyRepository; import com.dreamypatisiel.devdevdev.domain.repository.member.MemberRepository; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.BookmarkRepository; import com.dreamypatisiel.devdevdev.domain.repository.techArticle.TechArticleRepository; import com.dreamypatisiel.devdevdev.domain.repository.techArticle.TechArticleSort; import com.dreamypatisiel.devdevdev.elastic.domain.document.ElasticTechArticle; @@ -84,7 +84,8 @@ public class TechArticleControllerDocsTest extends SupportControllerDocsTest { static void setup(@Autowired TechArticleRepository techArticleRepository, @Autowired CompanyRepository companyRepository, @Autowired ElasticTechArticleRepository elasticTechArticleRepository) { - company = createCompany("꿈빛 파티시엘", "https://example.png", "https://example.com", "https://example.com"); + company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://example.com", + "https://example.com"); companyRepository.save(company); // 엘라스틱 기술블로그 데이터를 최신순->오래된순, 조회수많은순->적은순, 댓글많은순->적은순의 순서로 생성한다. @@ -113,9 +114,11 @@ static void setup(@Autowired TechArticleRepository techArticleRepository, @AfterAll static void tearDown(@Autowired TechArticleRepository techArticleRepository, - @Autowired ElasticTechArticleRepository elasticTechArticleRepository) { + @Autowired ElasticTechArticleRepository elasticTechArticleRepository, + @Autowired CompanyRepository companyRepository) { elasticTechArticleRepository.deleteAll(); techArticleRepository.deleteAllInBatch(); + companyRepository.deleteAllInBatch(); } @Test @@ -662,7 +665,7 @@ private static Company createCompany(String companyName, String officialImageUrl String careerUrl) { return Company.builder() .name(new CompanyName(companyName)) - .officialImageUrl(officialImageUrl) + .officialImageUrl(new Url(officialImageUrl)) .careerUrl(new Url(careerUrl)) .officialUrl(new Url(officialUrl)) .build(); diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/TokenControllerDocsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/TokenControllerDocsTest.java index e2423d72..109fc9ba 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/TokenControllerDocsTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/TokenControllerDocsTest.java @@ -194,6 +194,7 @@ void createAdminToken() throws Exception { // when // then Member findMember = memberRepository.findMemberByEmailAndSocialTypeAndIsDeletedIsFalse(new Email(adminEmail), SocialType.valueOf(socialType)).get(); + ResultActions actions = mockMvc.perform(get("/devdevdev/api/v1/token/test/admin") .contentType(MediaType.APPLICATION_JSON)) .andDo(print()) diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/custom/CustomPreprocessors.java b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/custom/CustomPreprocessors.java new file mode 100644 index 00000000..ad46686b --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/custom/CustomPreprocessors.java @@ -0,0 +1,92 @@ +package com.dreamypatisiel.devdevdev.web.docs.custom; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.restdocs.operation.OperationRequest; +import org.springframework.restdocs.operation.OperationResponse; +import org.springframework.restdocs.operation.ResponseCookie; +import org.springframework.restdocs.operation.preprocess.OperationPreprocessor; + +public class CustomPreprocessors { + + public static OperationPreprocessor modifyResponseBody() { + return new OperationPreprocessor() { + + private final ObjectMapper objectMapper = new ObjectMapper() + .disable(SerializationFeature.INDENT_OUTPUT); // 한 줄 JSON + + @Override + public OperationRequest preprocess(OperationRequest request) { + return request; + } + + @Override + public OperationResponse preprocess(OperationResponse response) { + try { + String original = response.getContentAsString(); + + // data: 로 시작하는 줄만 추출 + List formattedLines = Arrays.stream(original.split("\n")) + .filter(line -> line.startsWith("data:")) + .map(line -> { + String json = line.replaceFirst("data:", "").trim(); + try { + Object parsed = objectMapper.readValue(json, Object.class); + String oneLineJson = objectMapper.writeValueAsString(parsed); + + // 보기 좋게 : 앞에 공백 추가 + oneLineJson = oneLineJson.replaceAll("\":\"", "\" : \"") + .replaceAll("\":", "\" : ") + .replaceAll(",\"", ", \""); + + return "data: " + oneLineJson; + } catch (Exception e) { + return line; // 실패하면 원본 유지 + } + }) + .collect(Collectors.toList()); + + String formatted = String.join("\n", formattedLines); + byte[] contentBytes = formatted.getBytes(StandardCharsets.UTF_8); + + return new OperationResponse() { + @Override + public HttpStatusCode getStatus() { + return response.getStatus(); + } + + @Override + public HttpHeaders getHeaders() { + return response.getHeaders(); + } + + @Override + public byte[] getContent() { + return contentBytes; + } + + @Override + public String getContentAsString() { + return formatted; + } + + @Override + public Collection getCookies() { + return response.getCookies(); + } + }; + + } catch (Exception e) { + throw new RuntimeException("SSE 응답 포매팅 실패", e); + } + } + }; + } +} diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/format/ApiDocsFormatGenerator.java b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/format/ApiDocsFormatGenerator.java index d5563235..d751cba3 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/format/ApiDocsFormatGenerator.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/format/ApiDocsFormatGenerator.java @@ -3,6 +3,7 @@ import static org.springframework.restdocs.snippet.Attributes.key; import com.dreamypatisiel.devdevdev.domain.entity.enums.ContentStatus; +import com.dreamypatisiel.devdevdev.domain.entity.enums.NotificationType; import com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickCommentSort; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; @@ -12,6 +13,7 @@ import com.dreamypatisiel.devdevdev.domain.service.pick.MemberPickService; import com.dreamypatisiel.devdevdev.web.dto.request.comment.MyWrittenCommentFilter; import com.dreamypatisiel.devdevdev.web.dto.request.common.BlamePathType; +import com.dreamypatisiel.devdevdev.web.dto.response.ResultType; import java.util.Arrays; import java.util.stream.Collectors; import org.springframework.restdocs.snippet.Attributes; @@ -121,4 +123,20 @@ static Attributes.Attribute myWrittenCommentSort() { return key(FORMAT).value(blamePathType); } + + static Attributes.Attribute notificationType() { + String notificationType = Arrays.stream(NotificationType.values()) + .map(Enum::name) + .collect(Collectors.joining(COMMA)); + + return key(FORMAT).value(notificationType); + } + + static Attributes.Attribute resultType() { + String resultType = Arrays.stream(ResultType.values()) + .map(Enum::name) + .collect(Collectors.joining(COMMA)); + + return key(FORMAT).value(resultType); + } } diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/dto/response/techArticle/TechArticleMainResponseTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/dto/response/techArticle/TechArticleMainResponseTest.java index 4652fd79..ae8b1691 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/dto/response/techArticle/TechArticleMainResponseTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/dto/response/techArticle/TechArticleMainResponseTest.java @@ -1,9 +1,5 @@ package com.dreamypatisiel.devdevdev.web.dto.response.techArticle; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - import com.dreamypatisiel.devdevdev.domain.entity.Company; import com.dreamypatisiel.devdevdev.domain.entity.TechArticle; import com.dreamypatisiel.devdevdev.domain.entity.embedded.CompanyName; @@ -12,6 +8,9 @@ import com.dreamypatisiel.devdevdev.domain.entity.embedded.Url; import com.dreamypatisiel.devdevdev.elastic.domain.document.ElasticTechArticle; import java.time.LocalDate; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -21,7 +20,7 @@ class TechArticleMainResponseTest { @DisplayName("ElasticTechArticle의 썸네일 이미지가 있다면 썸네일 이미지로 설정되어야 한다.") public void setThumbnailImageWhenPresent() { // given - Company company = createCompany("꿈빛 파티시엘", "https://officialImageUrl.png", + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://officialUrl.com", "https://careerUrl.com"); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -29,7 +28,7 @@ public void setThumbnailImageWhenPresent() { new Count(1L), new Count(1L), new Count(1L), null, company); ElasticTechArticle elasticTechArticle = createElasticTechArticle("elasticId", "타이틀", LocalDate.now(), - "내용", "http://example.com/", "설명", "http://thumbnailImage.com", "작성자", + "내용", "http://example.com/", "설명", "http://thumbnailImage.com/image.png", "작성자", company.getName().getCompanyName(), company.getId(), 0L, 0L, 0L, 0L); CompanyResponse companyResponse = CompanyResponse.from(company); @@ -39,7 +38,7 @@ public void setThumbnailImageWhenPresent() { .of(techArticle, elasticTechArticle, companyResponse); // then - assertEquals("http://thumbnailImage.com", techArticleMainResponse.getThumbnailUrl()); + assertEquals("http://thumbnailImage.com/image.png", techArticleMainResponse.getThumbnailUrl()); assertFalse(techArticleMainResponse.getIsLogoImage()); } @@ -47,7 +46,7 @@ public void setThumbnailImageWhenPresent() { @DisplayName("ElasticTechArticle의 썸네일 이미지가 없다면 회사 로고 이미지로 대체하고, isLogoImage가 true로 설정되어야 한다.") public void setLogoImageWhenThumbnailIsAbsent() { // given - Company company = createCompany("꿈빛 파티시엘", "https://officialImageUrl.png", + Company company = createCompany("꿈빛 파티시엘", "https://example.com/company.png", "https://officialUrl.com", "https://careerUrl.com"); TechArticle techArticle = TechArticle.createTechArticle(new Title("기술블로그 제목"), new Url("https://example.com"), @@ -65,7 +64,7 @@ public void setLogoImageWhenThumbnailIsAbsent() { .of(techArticle, elasticTechArticle, companyResponse); // then - assertEquals(company.getOfficialImageUrl(), techArticleMainResponse.getThumbnailUrl()); + assertEquals(company.getOfficialImageUrl().getUrl(), techArticleMainResponse.getThumbnailUrl()); assertTrue(techArticleMainResponse.getIsLogoImage()); } @@ -75,7 +74,7 @@ private static Company createCompany(String companyName, String officialImageUrl .name(new CompanyName(companyName)) .officialUrl(new Url(officialUrl)) .careerUrl(new Url(careerUrl)) - .officialImageUrl(officialImageUrl) + .officialImageUrl(new Url(officialImageUrl)) .build(); } diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtilsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtilsTest.java index f3ba9247..6a4d122e 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtilsTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtilsTest.java @@ -12,9 +12,23 @@ import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; class PickResponseUtilsTest { + @ParameterizedTest + @CsvSource(value = {"alsdudr97@naver.com:als******", "merooongg@naver.com:mer******", + "dreamy5patisiel@gmail.com:dre************", "mmj9908@naver.com:mmj****"}, delimiter = ':') + @DisplayName("이메일 도메인을 제거하고 아이디를 마스킹 처리한다.") + void sliceAndMaskEmail(String email, String expected) { + // given // when + String result = CommonResponseUtil.sliceAndMaskEmail(email); + + // then + assertThat(result).isEqualTo(expected); + } + @Test @DisplayName("투표한 회원인지 확인한다.") void isVotedMember() { diff --git a/src/test/resources/application-jwt-test.yml b/src/test/resources/application-jwt-test.yml index a19ad5b2..b50cf160 100644 --- a/src/test/resources/application-jwt-test.yml +++ b/src/test/resources/application-jwt-test.yml @@ -1,4 +1,8 @@ jwt: secret: devdevdevtestdevdevdevtestdevdevdevtestdevdevdevtest redirectUri: - path: /home \ No newline at end of file + path: /home + + expire: + access: 1800000 # 30분 + refresh: 604800000 # 7일 \ No newline at end of file