diff --git a/.github/workflows/accept-analyze.yml b/.github/workflows/accept-analyze.yml index 19103799..8d45a6f9 100644 --- a/.github/workflows/accept-analyze.yml +++ b/.github/workflows/accept-analyze.yml @@ -16,11 +16,11 @@ jobs: with: # Shallow clones should be disabled for a better relevancy of analysis fetch-depth: 0 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: 'zulu' - java-version: 11 + java-version: 17 - name: Cache Gradle packages uses: actions/cache@v3 with: diff --git a/.github/workflows/docker-release-publisher.yml b/.github/workflows/docker-release-publisher.yml index d656b8b6..03d86d82 100644 --- a/.github/workflows/docker-release-publisher.yml +++ b/.github/workflows/docker-release-publisher.yml @@ -13,11 +13,11 @@ jobs: - name: Check out the repo uses: actions/checkout@v3 - - name: Setup opnenjdk-11 + - name: Setup opnenjdk-17 uses: actions/setup-java@v3 with: distribution: 'zulu' - java-version: 11 + java-version: 17 - name: Setup Gradle uses: gradle/gradle-build-action@v2 diff --git a/.github/workflows/docker-stage-publisher.yml b/.github/workflows/docker-stage-publisher.yml index 18fd97eb..b33f5129 100644 --- a/.github/workflows/docker-stage-publisher.yml +++ b/.github/workflows/docker-stage-publisher.yml @@ -13,11 +13,11 @@ jobs: - name: Check out the repo uses: actions/checkout@v3 - - name: Setup opnenjdk-11 + - name: Setup opnenjdk-17 uses: actions/setup-java@v3 with: distribution: 'zulu' - java-version: 11 + java-version: 17 - name: Setup Gradle uses: gradle/gradle-build-action@v2 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a40d1ea3..3f2f66f5 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -13,19 +13,40 @@ jobs: steps: - name: checkout uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: 17 + + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + - name: docker setup uses: docker-practice/actions-setup-docker@master + - name: docker network setup run: docker network create e2e-net + - name: run dbms run: docker run --rm -it --name database --network e2e-net -e MYSQL_ROOT_PASSWORD=0000 -e MYSQL_DATABASE=luffy -d mysql:8.0.33 + - name: build nalab-server run: ./gradlew clean build + - name: build nalab-server-docker-image run: docker build --tag luffy:e2e --build-arg DB_URL=jdbc:mysql://database:3306/luffy --build-arg DB_USERNAME=root --build-arg DB_PASSWORD=0000 --build-arg JWT_SECRET=fore2e . + - name: run nalab-server run: docker run --rm -it --name nalab-server --network e2e-net -d luffy:e2e + - name: build hurl image run: docker build --tag hurl:e2e support/e2e/ + - name: e2e test run: docker run --rm --network e2e-net hurl:e2e diff --git a/.github/workflows/unit-analyze.yml b/.github/workflows/unit-analyze.yml index d2130c64..dea71ef1 100644 --- a/.github/workflows/unit-analyze.yml +++ b/.github/workflows/unit-analyze.yml @@ -13,11 +13,11 @@ jobs: with: # Shallow clones should be disabled for a better relevancy of analysis fetch-depth: 0 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: 'zulu' - java-version: 11 + java-version: 17 - name: Cache Gradle packages uses: actions/cache@v3 with: diff --git a/Dockerfile b/Dockerfile index 6c81afaf..48762043 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:11.0.11-jre-slim +FROM openjdk:17-jdk-slim ARG JAR_FILE=./api/build/libs/*-SNAPSHOT.jar ARG DB_URL diff --git a/README.md b/README.md index 35284723..bab41ebe 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,183 @@ -# 13th-3team-server - - -[![SonarCloud](https://sonarcloud.io/images/project_badges/sonarcloud-white.svg)](https://sonarcloud.io/summary/new_code?id=depromeet_na-lab-server) -[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=depromeet_na-lab-server&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=depromeet_na-lab-server) -[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=depromeet_na-lab-server&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=depromeet_na-lab-server) -[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=depromeet_na-lab-server&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=depromeet_na-lab-server) -[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=depromeet_na-lab-server&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=depromeet_na-lab-server) -[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=depromeet_na-lab-server&metric=coverage)](https://sonarcloud.io/summary/new_code?id=depromeet_na-lab-server) -[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=depromeet_na-lab-server&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=depromeet_na-lab-server) -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=depromeet_na-lab-server&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=depromeet_na-lab-server) -[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=depromeet_na-lab-server&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=depromeet_na-lab-server) -[![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=depromeet_na-lab-server&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=depromeet_na-lab-server) -[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=depromeet_na-lab-server&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=depromeet_na-lab-server) -[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=depromeet_na-lab-server&metric=bugs)](https://sonarcloud.io/summary/new_code?id=depromeet_na-lab-server) +# Na Lab + +> 동료의 익명 피드백을 통한 나의 커리어 브랜딩, Na Lab    • 백엔드 레포지토리 + + +
+ + + +[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=depromeet_na-lab-server&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=depromeet_na-lab-server) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=depromeet_na-lab-server&metric=coverage)](https://sonarcloud.io/summary/new_code?id=depromeet_na-lab-server) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=depromeet_na-lab-server&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=depromeet_na-lab-server) +[![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=depromeet_na-lab-server&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=depromeet_na-lab-server) +[![e2e test](https://github.com/depromeet/na-lab-server/actions/workflows/e2e.yml/badge.svg)](https://github.com/depromeet/na-lab-server/actions/workflows/e2e.yml) + +
+ +### 🧐 Na Lab ? + +**오직 나만을 위한 커리어 연구실, Na Lab 🧬🧪** +‘Na Lab’은 동료의 익명 피드백을 통해 나의 직무 강점을 발견하는 서비스입니다. + +### 👉 [Na Lab 바로가기](https://www.nalab.me/) + +--- + +
+ + +na lab +na lab + +> 나의 커리어 브랜딩을 완성해주는 기본질문을 통해 손쉽게 질문폼을 만들 수 있어요 +> 새로운 질문을 추가하고 싶다면 객관식, 주관식으로 자유롭게 질문을 만들어보세요! + +
+ +
+ +na lab +na lab + +> 부담스러웠던 동료 평가의 경험을 마치 친구와 심리테스트 하듯 즐겁게 할 수 있도록 설계했어요 +> 나랩의 연구를 책임지는 Dr. 왓슨 박사님과 함께 채팅으로 대화하며 익명으로 피드백을 남길 수 있어요 + +
+ +
+ + +na lab +na lab + +> 많은 사람들의 답변 속에서 정말 나에게 도움이 되는 피드백은 어느 것일까요? +> 나랩은 유저가 개별 답변에 대한 이해도를 높이며 의미 있는 피드백을 얻을 수 있도록 결과를 정리했어요 + +
+ +
+ +na lab + +> 피드백 결과를 통해 나의 커리어 연구 결과를 확인할 수 있고, +> 동료들의 피드백을 저장해 나만의 커리어 명함을 만들 수 있어요 + + +
+ +
+ +![추가이미지1](https://github.com/depromeet/na-lab-client/assets/26461307/53c6a91b-d029-4fd9-acad-647a771507e3) + +![추가이미지2](https://github.com/depromeet/na-lab-client/assets/26461307/27586832-3bd7-4cbb-a659-1e446ed996d3) + +
+ +--- + +## 😎 Develoment Description + +- 안정성과 유지보수를 위해서 단위테스트, 통합테스트, E2E 테스트를 모두 짜는 전략으로 진행 +- 테스트 커버리는 분기와 라인 커버리지를 모두 검증하였으며 ***테스트 커버리지 93.7%를 달성*** +- 특히, E2E 테스트를 통해 실제 사용자의 여러 시나리오를 테스트함으로써 애플리케이션의 무결성을 검증하고자 하였으며 + 도입 이후 2차 MVP의 QA 에서 ***버그 제로 달성*** +- 유연하고 확장가능한 서비스를 위해 멀티모듈과 헥사고날 아키텍처를 적용 +- E2E 부터 깃허브 라벨링, PR 알람 등의 가능한 모든 작업을 자동화시켜 팀의 생산성 증대 + +
+ +## 🏛️ System Architecture + +![아키텍처이미지](https://github.com/oyeon-kwon/personal_color/assets/61301574/794d7625-f63f-418f-b03a-a7ab396f015b) + +
+ +## 📚 Tech Stack + +
+
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ +## 🧑🏻‍💻 Developers + + + + + + + + + + + + + + + + + + + + + + +
BackendBackendBackend
이준영이수진유도진
+ +
+ devxb +
+ +
+ ssssujini99 +
+ +
+ dojinyou +
+ +
+ devxb의 커리어 명함 +
+ +
+ ssssujini99의 커리어 명함 +
+ +
+ dojinyou의 커리어 명함 +
+ + +
+ + +![추가이미지3](https://github.com/depromeet/na-lab-server/assets/71487608/09a06bb1-4f06-4513-977d-e6fe49bd8f06) diff --git a/api/acceptance-test/build.gradle b/api/acceptance-test/build.gradle index e062598f..fddc6163 100644 --- a/api/acceptance-test/build.gradle +++ b/api/acceptance-test/build.gradle @@ -11,6 +11,7 @@ dependencies { testImplementation project(':user:user-application') testImplementation project(':user:user-jpa-adapter') testImplementation project(':user:user-web-adaptor') + testImplementation project(':gallery') testImplementation 'org.springframework.boot:spring-boot-starter-web' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa' diff --git a/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/TargetInitializer.java b/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/TargetInitializer.java index 57ff28ff..5c060923 100644 --- a/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/TargetInitializer.java +++ b/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/TargetInitializer.java @@ -28,6 +28,7 @@ public Long saveTargetAndGetId(String name, Instant date) { .nickname(name) .createdAt(date) .updatedAt(date) + .imageUrl("empty image") .build(); entityManager.persist(targetEntity); return targetEntity.getId(); diff --git a/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/feedback/find/FeedbackFindAcceptanceTest.java b/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/feedback/find/FeedbackFindAcceptanceTest.java index 06d9de75..b8de5cce 100644 --- a/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/feedback/find/FeedbackFindAcceptanceTest.java +++ b/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/feedback/find/FeedbackFindAcceptanceTest.java @@ -11,13 +11,6 @@ import me.nalab.luffy.api.acceptance.test.feedback.create.response.SurveyFindResponse; import me.nalab.luffy.api.acceptance.test.survey.RequestSample; import org.json.JSONObject; - -import static me.nalab.luffy.api.acceptance.test.feedback.FeedbackAcceptanceValidator.*; -import static me.nalab.luffy.api.acceptance.test.feedback.FeedbackCreateRequestFixture.*; - -import java.time.Instant; -import java.util.Map; - import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; diff --git a/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/survey/AbstractSurveyTestSupporter.java b/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/survey/AbstractSurveyTestSupporter.java index 0d866c73..a5083f02 100644 --- a/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/survey/AbstractSurveyTestSupporter.java +++ b/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/survey/AbstractSurveyTestSupporter.java @@ -19,6 +19,13 @@ public abstract class AbstractSurveyTestSupporter { private static final String API_VERSION = "/v1"; private static final Set tableNameSet = Set.of("target", "survey", "form_question", "choice"); + protected ResultActions bookmarkSurvey(String token, Long surveyId) throws Exception { + return mockMvc.perform(MockMvcRequestBuilders + .post("/{version}/surveys/{surveyId}/bookmarks", "v1", surveyId) + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, token)); + } + protected ResultActions createSurvey(String token, String content) throws Exception { return mockMvc.perform(MockMvcRequestBuilders .post(API_VERSION + "/surveys") @@ -53,6 +60,15 @@ protected ResultActions findTargetBySurveyId(Long survey_Id) throws Exception { ); } + protected ResultActions existsSurveyByToken(String token) throws Exception { + return mockMvc.perform(MockMvcRequestBuilders + .get(API_VERSION + "/surveys/exists") + .header("Authorization", token) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + ); + } + @Autowired final void setMockMvc(MockMvc mockMvc) { this.mockMvc = mockMvc; diff --git a/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/survey/SurveyAcceptanceValidator.java b/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/survey/SurveyAcceptanceValidator.java index 7deb2984..ff4d20c8 100644 --- a/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/survey/SurveyAcceptanceValidator.java +++ b/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/survey/SurveyAcceptanceValidator.java @@ -2,6 +2,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import me.nalab.survey.web.adaptor.bookmark.response.SurveyBookmarkResponse; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.ResultActions; @@ -84,4 +85,33 @@ public static void assertIsTargetFound(ResultActions resultActions) throws Excep ); } + + public static void assertIsSurveyExists(ResultActions resultActions) throws Exception { + resultActions.andExpectAll( + status().isOk(), + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.exists").value(true) + ); + } + + public static void assertIsSurveyDoesNotExists(ResultActions resultActions) throws Exception { + resultActions.andExpectAll( + status().isOk(), + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.exists").value(false) + ); + } + + public static void assertIsBookmarked(ResultActions resultActions) throws Exception { + resultActions.andExpectAll( + status().isOk(), + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.target_id").isString(), + jsonPath("$.survey_id").isString(), + jsonPath("$.nickname").isString(), + jsonPath("$.position").doesNotExist(), + jsonPath("$.image_url").doesNotExist() + ); + } + } diff --git a/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/survey/bookmark/SurveyBookmarkAcceptanceTest.java b/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/survey/bookmark/SurveyBookmarkAcceptanceTest.java new file mode 100644 index 00000000..8427c25e --- /dev/null +++ b/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/survey/bookmark/SurveyBookmarkAcceptanceTest.java @@ -0,0 +1,65 @@ +package me.nalab.luffy.api.acceptance.test.survey.bookmark; + +import static me.nalab.luffy.api.acceptance.test.survey.SurveyAcceptanceValidator.assertIsBookmarked; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import me.nalab.auth.mock.api.MockUserRegisterEvent; +import me.nalab.luffy.api.acceptance.test.TargetInitializer; +import me.nalab.luffy.api.acceptance.test.survey.AbstractSurveyTestSupporter; +import me.nalab.luffy.api.acceptance.test.survey.RequestSample; +import me.nalab.survey.jpa.adaptor.findid.repository.SurveyIdFindJpaRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@AutoConfigureMockMvc +@TestPropertySource("classpath:h2.properties") +@ComponentScan("me.nalab") +@EnableJpaRepositories(basePackages = {"me.nalab"}) +@EntityScan(basePackages = {"me.nalab"}) +class SurveyBookmarkAcceptanceTest extends AbstractSurveyTestSupporter { + + @Autowired + private ApplicationEventPublisher applicationEventPublisher; + + @Autowired + private TargetInitializer targetInitializer; + + @Autowired + private SurveyIdFindJpaRepository surveyIdFindJpaRepository; + + @Test + @DisplayName("surveyBookmark api 는 token의 주인에게 survey를 북마크한다.") + void BOOKMARK_SURVEY_TO_TOKEN_OWNER() throws Exception { + // given + var targetId = targetInitializer.saveTargetAndGetId("luffy", + LocalDateTime.now().minusYears(24).toInstant(ZoneOffset.UTC)); + var token = "luffy's-double-token"; + applicationEventPublisher.publishEvent(MockUserRegisterEvent.builder() + .expectedToken(token) + .expectedId(targetId) + .build()); + createSurvey(token, RequestSample.DEFAULT_JSON); + + var surveyId = getSurveyId(targetId); + + // when + var result = bookmarkSurvey(token, surveyId); + + // then + assertIsBookmarked(result); + } + + private Long getSurveyId(Long targetId) { + return surveyIdFindJpaRepository.findAllIdByTargetId(targetId).get(0); + } +} diff --git a/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/survey/exists/SurveyExistsAcceptanceTest.java b/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/survey/exists/SurveyExistsAcceptanceTest.java new file mode 100644 index 00000000..c1d8a166 --- /dev/null +++ b/api/acceptance-test/src/test/java/me/nalab/luffy/api/acceptance/test/survey/exists/SurveyExistsAcceptanceTest.java @@ -0,0 +1,77 @@ +package me.nalab.luffy.api.acceptance.test.survey.exists; + +import java.time.Instant; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.ResultActions; + +import me.nalab.auth.mock.api.MockUserRegisterEvent; +import me.nalab.luffy.api.acceptance.test.TargetInitializer; +import me.nalab.luffy.api.acceptance.test.survey.AbstractSurveyTestSupporter; +import me.nalab.luffy.api.acceptance.test.survey.RequestSample; +import me.nalab.luffy.api.acceptance.test.survey.SurveyAcceptanceValidator; + +@SpringBootTest +@AutoConfigureMockMvc +@TestPropertySource("classpath:h2.properties") +@ComponentScan("me.nalab") +@EnableJpaRepositories(basePackages = "me.nalab") +@EntityScan(basePackages = "me.nalab") +class SurveyExistsAcceptanceTest extends AbstractSurveyTestSupporter { + + @Autowired + private ApplicationEventPublisher applicationEventPublisher; + + @Autowired + private TargetInitializer targetInitializer; + + @Test + @DisplayName("token에 해당하는 survey가 존재한다면, true를 반환한다.") + void RETURN_TRUE_IF_SURVEY_EXISTS() throws Exception { + // given + String token = "luffy's-double-token"; + Long targetId = targetInitializer.saveTargetAndGetId("devxb", Instant.now()); + applicationEventPublisher.publishEvent(MockUserRegisterEvent.builder() + .expectedToken(token) + .expectedId(targetId) + .build() + ); + + createSurvey(token, RequestSample.DEFAULT_JSON); + + // when + ResultActions result = existsSurveyByToken(token); + + // then + SurveyAcceptanceValidator.assertIsSurveyExists(result); + } + + @Test + @DisplayName("token에 해당하는 survey가 존재하지 않는다면, false를 반환한다.") + void RETURN_FALSE_IF_SURVEY_DOES_NOT_EXISTS() throws Exception { + // given + String token = "luffy's-double-token"; + Long targetId = targetInitializer.saveTargetAndGetId("devxb", Instant.now()); + applicationEventPublisher.publishEvent(MockUserRegisterEvent.builder() + .expectedToken(token) + .expectedId(targetId) + .build() + ); + + // when + ResultActions result = existsSurveyByToken(token); + + // then + SurveyAcceptanceValidator.assertIsSurveyDoesNotExists(result); + } + +} diff --git a/api/build.gradle b/api/build.gradle index 91db8e7e..cba7789f 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -17,6 +17,8 @@ dependencies { implementation project(':user:user-jpa-adapter') implementation project(':user:user-web-adaptor') + implementation project(':gallery') + implementation project(":support:logging") implementation 'org.springframework.boot:spring-boot-starter-web' diff --git a/api/src/main/java/me/nalab/api/NalabApplication.java b/api/src/main/java/me/nalab/api/NalabApplication.java index 48351bfd..6a7d55c5 100644 --- a/api/src/main/java/me/nalab/api/NalabApplication.java +++ b/api/src/main/java/me/nalab/api/NalabApplication.java @@ -5,7 +5,9 @@ import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableAsync; +@EnableAsync @SpringBootApplication @ComponentScan("me.nalab") @EnableJpaRepositories(basePackages = {"me.nalab"}) diff --git a/api/src/main/resources/db/migration/V7__add_gallery.sql b/api/src/main/resources/db/migration/V7__add_gallery.sql new file mode 100644 index 00000000..0b083e16 --- /dev/null +++ b/api/src/main/resources/db/migration/V7__add_gallery.sql @@ -0,0 +1,10 @@ +create table if not exists gallery ( + gallery_id BIGINT primary key, + target_id BIGINT unique not null, + survey_id BIGINT unique not null, + bookmarked_count INT not null, + job TEXT not null, + update_order TIMESTAMP(6) not null, + created_at TIMESTAMP(6) not null, + updated_at TIMESTAMP(6) not null +); diff --git a/api/src/main/resources/db/migration/V8__add_job_bookmark_img_to_target.sql b/api/src/main/resources/db/migration/V8__add_job_bookmark_img_to_target.sql new file mode 100644 index 00000000..52ad492f --- /dev/null +++ b/api/src/main/resources/db/migration/V8__add_job_bookmark_img_to_target.sql @@ -0,0 +1,11 @@ +alter table target add `job` TEXT; +alter table target add image_url TEXT; +alter table target add `version` BIGINT; + +create table if not exists bookmarked_survey ( + target_id BIGINT not null, + bookmarked_survey_id BIGINT not null, + foreign key (target_id) references target (target_id), + foreign key (bookmarked_survey_id) references survey (survey_id), + unique index target_id_bookmarked_survey_id_idx (target_id, bookmarked_survey_id) +); diff --git a/api/src/main/resources/db/migration/V9__gallery_target_id_idx.sql b/api/src/main/resources/db/migration/V9__gallery_target_id_idx.sql new file mode 100644 index 00000000..4e2f84a2 --- /dev/null +++ b/api/src/main/resources/db/migration/V9__gallery_target_id_idx.sql @@ -0,0 +1,3 @@ +ALTER TABLE gallery ADD `version` BIGINT; + +CREATE UNIQUE INDEX galley_idx_target_id ON gallery(target_id) diff --git a/api/src/main/resources/db/migration/v10_gallery_covering_idx.sql b/api/src/main/resources/db/migration/v10_gallery_covering_idx.sql new file mode 100644 index 00000000..4aa6605c --- /dev/null +++ b/api/src/main/resources/db/migration/v10_gallery_covering_idx.sql @@ -0,0 +1,6 @@ +CREATE INDEX gallery_idx_bookmark ON gallery(bookmarked_count) +CREATE INDEX gallery_idx_update_order ON gallery(update_order) +CREATE UNIQUE INDEX gallery_idx_survey_id ON gallery(survey_id) +CREATE INDEX gallery_idx_job ON gallery(job) +CREATE INDEX gallery_idx_created_at ON gallery(created_at) +CREATE INDEX gallery_idx_updated_at ON gallery(updated_at) diff --git a/auth/auth-interceptor/src/main/java/me/nalab/auth/interceptor/JwtDecryptInterceptorConfigurer.java b/auth/auth-interceptor/src/main/java/me/nalab/auth/interceptor/JwtDecryptInterceptorConfigurer.java index 7d9dfdd9..84bc105d 100644 --- a/auth/auth-interceptor/src/main/java/me/nalab/auth/interceptor/JwtDecryptInterceptorConfigurer.java +++ b/auth/auth-interceptor/src/main/java/me/nalab/auth/interceptor/JwtDecryptInterceptorConfigurer.java @@ -16,6 +16,7 @@ public class JwtDecryptInterceptorConfigurer implements WebMvcConfigurer { private static final String[] INTERCEPTOR_URLS = { "/v1/surveys", + "/v1/surveys/exists", "/v1/surveys-id", "/v1/users", "/v1/questions", @@ -25,7 +26,11 @@ public class JwtDecryptInterceptorConfigurer implements WebMvcConfigurer { "/v1/reviewers/summary*", "/v2/surveys/*/feedbacks", "/v1/feedbacks/bookmarks", - "/v1/users" + "/v1/users", + "/v1/gallerys/previews", + "/v1/surveys/*/bookmarks", + "/v1/gallerys/logins", + "/v1/gallerys", }; @Override diff --git a/auth/auth-mock/src/main/java/me/nalab/auth/mock/config/MockAuthConfigurer.java b/auth/auth-mock/src/main/java/me/nalab/auth/mock/config/MockAuthConfigurer.java index 0f26147c..c2bcfda3 100644 --- a/auth/auth-mock/src/main/java/me/nalab/auth/mock/config/MockAuthConfigurer.java +++ b/auth/auth-mock/src/main/java/me/nalab/auth/mock/config/MockAuthConfigurer.java @@ -13,6 +13,7 @@ public class MockAuthConfigurer implements WebMvcConfigurer { private static final String[] INTERCEPTOR_URLS = { "/v1/surveys", + "/v1/surveys/exists", "/v1/users", "/v1/surveys-id", "/v1/questions", @@ -21,6 +22,7 @@ public class MockAuthConfigurer implements WebMvcConfigurer { "/v1/reviewers*", "/v1/reviewers/summary*", "/v2/surveys/*/feedbacks", + "/v1/surveys/*/bookmarks", }; @Override diff --git a/build.gradle b/build.gradle index 307ee977..77fe66b5 100644 --- a/build.gradle +++ b/build.gradle @@ -1,8 +1,12 @@ plugins { - id 'java-library' - id 'org.springframework.boot' apply false - id 'io.spring.dependency-management' - id 'org.sonarqube' + id 'java-library' + id 'org.springframework.boot' apply false + id 'io.spring.dependency-management' + id 'org.sonarqube' + id "org.jetbrains.kotlin.jvm" + id "org.jetbrains.kotlin.plugin.jpa" + id "org.jetbrains.kotlin.plugin.spring" + id "org.jetbrains.kotlin.plugin.allopen" } apply from: 'gradle/spring.gradle' @@ -10,15 +14,16 @@ apply from: 'gradle/sonar.gradle' apply from: 'gradle/jacoco.gradle' apply from: 'gradle/junit.gradle' apply from: 'gradle/lombok.gradle' +apply from: 'gradle/kotlin.gradle' allprojects { - group = "${projectGroup}" - version = "${applicationVersion}" - sourceCompatibility = project.javaVersion + group = "${projectGroup}" + version = "${applicationVersion}" + sourceCompatibility = project.javaVersion - repositories { - mavenCentral() - } + repositories { + mavenCentral() + } } diff --git a/core/data/src/main/java/me/nalab/core/data/target/SurveyBookmarkEntity.java b/core/data/src/main/java/me/nalab/core/data/target/SurveyBookmarkEntity.java new file mode 100644 index 00000000..e906c7f9 --- /dev/null +++ b/core/data/src/main/java/me/nalab/core/data/target/SurveyBookmarkEntity.java @@ -0,0 +1,20 @@ +package me.nalab.core.data.target; + +import javax.persistence.Column; +import javax.persistence.Embeddable; +import javax.persistence.JoinColumn; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor +@AllArgsConstructor +public class SurveyBookmarkEntity { + + @Column(name = "bookmarked_survey_id") + @JoinColumn(name = "survey_id", nullable = false) + private Long surveyId; + +} diff --git a/core/data/src/main/java/me/nalab/core/data/target/TargetEntity.java b/core/data/src/main/java/me/nalab/core/data/target/TargetEntity.java index 4a198ce1..e20ea914 100644 --- a/core/data/src/main/java/me/nalab/core/data/target/TargetEntity.java +++ b/core/data/src/main/java/me/nalab/core/data/target/TargetEntity.java @@ -1,11 +1,18 @@ package me.nalab.core.data.target; +import java.util.HashSet; +import java.util.Set; +import javax.persistence.CollectionTable; import javax.persistence.Column; +import javax.persistence.ElementCollection; import javax.persistence.Entity; import javax.persistence.Id; +import javax.persistence.JoinColumn; import javax.persistence.Table; +import javax.persistence.Version; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -31,4 +38,22 @@ public class TargetEntity extends TimeBaseEntity { @Column(name = "position") private String position; + @Column(name = "job", columnDefinition = "TEXT") + private String job; + + @Column(name = "image_url", columnDefinition = "TEXT") + private String imageUrl; + + @ElementCollection + @CollectionTable( + name = "bookmarked_survey", + joinColumns = @JoinColumn(name = "target_id") + ) + @Builder.Default + private Set bookmarkedSurveys = new HashSet<>(); + + @Version + @Column(name = "version") + private Long version; + } diff --git a/core/time/build.gradle b/core/time/build.gradle index b7a0f612..e69de29b 100644 --- a/core/time/build.gradle +++ b/core/time/build.gradle @@ -1,3 +0,0 @@ -dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web' -} diff --git a/core/time/src/main/java/me/nalab/core/time/TimeUtil.java b/core/time/src/main/java/me/nalab/core/time/TimeUtil.java index 6c42ef71..c6c25211 100644 --- a/core/time/src/main/java/me/nalab/core/time/TimeUtil.java +++ b/core/time/src/main/java/me/nalab/core/time/TimeUtil.java @@ -1,23 +1,37 @@ package me.nalab.core.time; +import java.time.Clock; import java.time.Instant; -import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; -/** - * 저장된 시간을 일간되게 반환하는 util 입니다. - */ -public interface TimeUtil { +public class TimeUtil { - /** - * 저장된 시간을 LocalDateTime으로 반환합니다. - * @return LocalDateTime - */ - LocalDateTime toLocalDateTime(); + private static Clock clock = null; - /** - * 저장된 시간을 Instant로 반환합니다. - * @return Instant - */ - Instant toInstant(); + private TimeUtil() { + throw new UnsupportedOperationException("Cannot invoke constructor \"TimeUtil()\""); + } + public static Instant toInstant() { + var current = Instant.now(); + if (clock != null) { + current = Instant.now(clock); + } + return formatTo6Digit(current); + } + + private static Instant formatTo6Digit(Instant instant) { + var formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSX") + .withZone(ZoneId.of("UTC")); + return Instant.parse(formatter.format(instant)); + } + + public static void fixed(Clock clock) { + TimeUtil.clock = clock; + } + + public static void clear() { + TimeUtil.clock = null; + } } diff --git a/core/time/src/main/java/me/nalab/core/time/request/RequestTimeUtil.java b/core/time/src/main/java/me/nalab/core/time/request/RequestTimeUtil.java deleted file mode 100644 index b08cb466..00000000 --- a/core/time/src/main/java/me/nalab/core/time/request/RequestTimeUtil.java +++ /dev/null @@ -1,44 +0,0 @@ -package me.nalab.core.time.request; - -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; - -import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; -import org.springframework.stereotype.Component; - -import me.nalab.core.time.TimeUtil; - -/** - * 하나의 요청안에서 일관된 시간을 반환하는 유틸 입니다. - */ -@Component -@Scope(value = "request", proxyMode = ScopedProxyMode.INTERFACES) -public class RequestTimeUtil implements TimeUtil { - - private final Instant instant; - - public RequestTimeUtil() { - instant = Instant.now(); - } - - /** - * 요청 시간을 LocalDateTime으로 반환합니다. - * @return LocalDateTime 클라이언트의 요청이 들어온 시간 - */ - @Override - public LocalDateTime toLocalDateTime() { - return LocalDateTime.ofInstant(instant, ZoneOffset.UTC); - } - - /** - * 요청 시간을 Instant로 반환합니다. - * @return Instant 클라이언트의 요청이 들어온 시간 - */ - @Override - public Instant toInstant() { - return instant; - } - -} diff --git a/core/time/src/main/java/module-info.java b/core/time/src/main/java/module-info.java index 91486ed6..e0a5f319 100644 --- a/core/time/src/main/java/module-info.java +++ b/core/time/src/main/java/module-info.java @@ -1,9 +1,4 @@ module luffy.core.time.main { - - requires spring.context; - requires spring.beans; - exports me.nalab.core.time; - exports me.nalab.core.time.request; } diff --git a/core/time/src/test/java/me/nalab/core/time/RequestTimeUtilTest.java b/core/time/src/test/java/me/nalab/core/time/RequestTimeUtilTest.java deleted file mode 100644 index 836dbb51..00000000 --- a/core/time/src/test/java/me/nalab/core/time/RequestTimeUtilTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package me.nalab.core.time; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; - -import org.json.JSONObject; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.http.MediaType; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; - -import me.nalab.core.time.request.RequestTimeUtil; - -@WebMvcTest -@ContextConfiguration(classes = {RequestTimeUtil.class, TestController.class}) -class RequestTimeUtilTest { - - @Autowired - private MockMvc mvc; - - @Test - @DisplayName("요청마다 다른 Time Util 생성 테스트") - void CREATE_TIME_UTIL() throws Exception { - // when - long before = getRequestArrivalTime(); - long after = getRequestArrivalTime(); - - // then - assertTrue(before < after); - } - - private long getRequestArrivalTime() throws Exception { - ResultActions resultActions = mvc.perform(get("/get-time") - .accept(MediaType.APPLICATION_JSON)); - JSONObject jsonObject = new JSONObject(resultActions.andReturn().getResponse().getContentAsString()); - return jsonObject.getLong("instant"); - } - -} diff --git a/core/time/src/test/java/me/nalab/core/time/TestController.java b/core/time/src/test/java/me/nalab/core/time/TestController.java deleted file mode 100644 index e2e684f5..00000000 --- a/core/time/src/test/java/me/nalab/core/time/TestController.java +++ /dev/null @@ -1,26 +0,0 @@ -package me.nalab.core.time; - -import java.time.ZoneOffset; -import java.util.Map; - -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequiredArgsConstructor -class TestController { - - private final TimeUtil timeUtil; - - @GetMapping("/get-time") - @ResponseStatus(HttpStatus.OK) - public Map getTime() { - return Map.of("instant", timeUtil.toInstant().toEpochMilli(), - "local", timeUtil.toLocalDateTime().toEpochSecond(ZoneOffset.UTC)); - } - -} diff --git a/coverage-exclude.luffy b/coverage-exclude.luffy index e6f508f6..4a80f8d0 100644 --- a/coverage-exclude.luffy +++ b/coverage-exclude.luffy @@ -57,3 +57,4 @@ exclude me/nalab/auth/mock/interceptor/* exclude me/nalab/survey/web/adaptor/updatetarget/* exclude me/nalab/survey/web/adaptor/findfeedback/formtype/* exclude me/nalab/api/sentry/* +exclude me/nalab/survey/web/adaptor/existsurvey/* diff --git a/gallery/build.gradle b/gallery/build.gradle new file mode 100644 index 00000000..1a0e5aae --- /dev/null +++ b/gallery/build.gradle @@ -0,0 +1,17 @@ +repositories { + mavenCentral() +} + +dependencies { + implementation project(":core:data") + implementation project(":core:id-generator:id-core") + implementation project(":core:time") + implementation project(":survey:survey-application") + + implementation "org.springframework.boot:spring-boot-starter-web" + implementation "org.springframework.boot:spring-boot-starter-data-jpa" + + testImplementation "com.ninja-squad:springmockk:${springMockkVersion}" + + testRuntimeOnly "com.h2database:h2" +} diff --git a/gallery/src/main/kotlin/me/nalab/gallery/app/GalleryDtoMapper.kt b/gallery/src/main/kotlin/me/nalab/gallery/app/GalleryDtoMapper.kt new file mode 100644 index 00000000..8cd4b77c --- /dev/null +++ b/gallery/src/main/kotlin/me/nalab/gallery/app/GalleryDtoMapper.kt @@ -0,0 +1,101 @@ +package me.nalab.gallery.app + +import me.nalab.gallery.domain.Gallery +import me.nalab.gallery.domain.response.GalleryDto +import me.nalab.survey.application.common.feedback.dto.BookmarkDto +import me.nalab.survey.application.common.feedback.dto.ChoiceFormQuestionFeedbackDto +import me.nalab.survey.application.common.feedback.dto.FeedbackDto +import me.nalab.survey.application.common.feedback.dto.ShortFormQuestionFeedbackDto +import me.nalab.survey.application.common.survey.dto.ChoiceFormQuestionDto +import me.nalab.survey.application.common.survey.dto.ChoiceFormQuestionDtoType +import me.nalab.survey.application.common.survey.dto.SurveyDto +import me.nalab.survey.application.common.survey.dto.TargetDto + +fun toGalleryDto( + gallery: Gallery, + target: TargetDto, + survey: SurveyDto, + feedbacks: List, +): GalleryDto { + return GalleryDto( + galleryId = gallery.id, + target = GalleryDto.Target( + targetId = gallery.getTargetId().toString(), + nickname = target.nickname, + job = gallery.getJob(), + position = target.position, + ), + survey = GalleryDto.Survey( + surveyId = survey.id.toString(), + feedbackCount = feedbacks.size, + bookmarkedCount = gallery.getBookmarkedCount(), + feedbacks = findLatestBookmarkedReply(feedbacks), + tendencies = findTendencies(survey, feedbacks), + ) + ) +} + +private fun findLatestBookmarkedReply(feedbacks: List): List { + return feedbacks.filterBookmarkedFeedback() + .mapBookmarkedWithReply() + .sortedByDescending { (bookmark, _) -> bookmark.bookmarkedAt } + .firstOrNull() + ?.second ?: emptyList() +} + +private fun List.filterBookmarkedFeedback(): List { + return this.filter { feedback -> + feedback.formQuestionFeedbackDtoableList.any { formQuestionFeedback -> + formQuestionFeedback.bookmarkDto.isBookmarked + } + } +} + +private fun List.mapBookmarkedWithReply(): List>> { + return this.flatMap { bookmarkedFeedback -> + bookmarkedFeedback.formQuestionFeedbackDtoableList + .filterIsInstance() + .map { shortQuestionFeedback -> shortQuestionFeedback.bookmarkDto to shortQuestionFeedback.replyList } + } +} + +private fun findTendencies( + survey: SurveyDto, + feedbacks: List, +): List { + val tendencyQuestion = survey.formQuestionDtoableList + .filterIsInstance() + .find { it.choiceFormQuestionDtoType == ChoiceFormQuestionDtoType.TENDENCY } + ?: error("필수 유형 Tendency 를 찾을 수 없습니다.") + + val countPerTendency = mutableMapOf() + + feedbacks.mapTendencyFeedbacks(tendencyQuestion) + .forEach { tendencyFeedback -> + tendencyQuestion.choiceDtoList + .filter { choice -> + tendencyFeedback.selectedChoiceIdSet.contains(choice.id) + } + .map { it.content } + .forEach { tendencyContent -> + countPerTendency[tendencyContent] = + countPerTendency.getOrDefault(tendencyContent, 0) + 1 + } + } + + return countPerTendency.map { (name, count) -> + GalleryDto.Survey.Tendency(name, count) + } +} + +private fun List.mapTendencyFeedbacks( + tendencyQuestion: ChoiceFormQuestionDto, +): List { + return this.flatMap { + it.formQuestionFeedbackDtoableList + .filterIsInstance() + .filter { choiceQuestionFeedback -> + choiceQuestionFeedback.questionId == tendencyQuestion.id + } + } +} diff --git a/gallery/src/main/kotlin/me/nalab/gallery/app/GalleryGetApp.kt b/gallery/src/main/kotlin/me/nalab/gallery/app/GalleryGetApp.kt new file mode 100644 index 00000000..4e02e8f0 --- /dev/null +++ b/gallery/src/main/kotlin/me/nalab/gallery/app/GalleryGetApp.kt @@ -0,0 +1,60 @@ +package me.nalab.gallery.app + +import me.nalab.gallery.domain.Gallery +import me.nalab.gallery.domain.GalleryService +import me.nalab.gallery.domain.response.GalleriesDto +import me.nalab.gallery.domain.response.GalleryDto +import me.nalab.survey.application.port.`in`.web.findfeedback.FeedbackFindUseCase +import me.nalab.survey.application.port.`in`.web.survey.find.SurveyFindUseCase +import me.nalab.survey.application.port.`in`.web.target.find.TargetFindUseCase +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service + +@Service +class GalleryGetApp( + private val targetFindUseCase: TargetFindUseCase, + private val surveyFindUseCase: SurveyFindUseCase, + private val feedbackFindUseCase: FeedbackFindUseCase, + private val galleryService: GalleryService, +) { + + fun getGalleryByTargetId(targetId: Long): GalleryDto { + val gallery = galleryService.getGalleryByTargetId(targetId) + val target = targetFindUseCase.findTarget(targetId) + val survey = surveyFindUseCase.getSurveyByTargetId(targetId) + val feedbacks = feedbackFindUseCase.findAllFeedbackDtoBySurveyId(survey.id) + + return toGalleryDto(gallery, target, survey, feedbacks) + } + + fun getGalleries(job: String, page: Int, count: Int, orderType: String): GalleriesDto { + val pageable = getPage(page, count, orderType) + val galleries = galleryService.getGalleries(job, pageable) + + val galleryDtos = toGalleryDtos(galleries) + + return GalleriesDto(galleries.totalPages, galleryDtos) + } + + private fun toGalleryDtos(galleries: Page): List { + return galleries.asSequence() + .map { gallery -> + val target = targetFindUseCase.findTarget(gallery.getTargetId()) + val survey = surveyFindUseCase.getSurveyByTargetId(gallery.getTargetId()) + val feedbacks = feedbackFindUseCase.findAllFeedbackDtoBySurveyId(survey.id) + + toGalleryDto(gallery, target, survey, feedbacks) + }.toList() + } + + private fun getPage(page: Int, count: Int, orderType: String): Pageable { + return when (orderType.lowercase()) { + "update" -> PageRequest.of(page, count, Sort.by("updateOrder").descending()) + "job" -> PageRequest.of(page, count, Sort.by("survey.bookmarkedCount").descending()) + else -> throw IllegalArgumentException("orderType 은 update와 bookmark중 하나여야 합니다. 현재 orderType \"$orderType\"") + } + } +} diff --git a/gallery/src/main/kotlin/me/nalab/gallery/app/GalleryPreviewApp.kt b/gallery/src/main/kotlin/me/nalab/gallery/app/GalleryPreviewApp.kt new file mode 100644 index 00000000..66ba77e2 --- /dev/null +++ b/gallery/src/main/kotlin/me/nalab/gallery/app/GalleryPreviewApp.kt @@ -0,0 +1,23 @@ +package me.nalab.gallery.app + +import me.nalab.gallery.domain.response.GalleryPreviewDto +import me.nalab.survey.application.port.`in`.web.findfeedback.FeedbackFindUseCase +import me.nalab.survey.application.port.`in`.web.survey.find.SurveyFindUseCase +import me.nalab.survey.application.port.`in`.web.target.find.TargetFindUseCase +import org.springframework.stereotype.Service + +@Service +class GalleryPreviewApp( + private val targetFindUseCase: TargetFindUseCase, + private val surveyFindUseCase: SurveyFindUseCase, + private val feedbackFindUseCase: FeedbackFindUseCase, +) { + + fun findGalleryPreview(targetId: Long): GalleryPreviewDto { + val target = targetFindUseCase.findTarget(targetId) + val survey = surveyFindUseCase.getSurveyByTargetId(targetId) + val feedbacks = feedbackFindUseCase.findAllFeedbackDtoBySurveyId(survey.id) + + return toGalleryPreviewDto(target, survey, feedbacks) + } +} diff --git a/gallery/src/main/kotlin/me/nalab/gallery/app/GalleryPreviewDtoMapper.kt b/gallery/src/main/kotlin/me/nalab/gallery/app/GalleryPreviewDtoMapper.kt new file mode 100644 index 00000000..613fc679 --- /dev/null +++ b/gallery/src/main/kotlin/me/nalab/gallery/app/GalleryPreviewDtoMapper.kt @@ -0,0 +1,97 @@ +package me.nalab.gallery.app + +import me.nalab.gallery.domain.response.GalleryPreviewDto +import me.nalab.survey.application.common.feedback.dto.BookmarkDto +import me.nalab.survey.application.common.feedback.dto.ChoiceFormQuestionFeedbackDto +import me.nalab.survey.application.common.feedback.dto.FeedbackDto +import me.nalab.survey.application.common.feedback.dto.ShortFormQuestionFeedbackDto +import me.nalab.survey.application.common.survey.dto.ChoiceFormQuestionDto +import me.nalab.survey.application.common.survey.dto.ChoiceFormQuestionDtoType +import me.nalab.survey.application.common.survey.dto.SurveyDto +import me.nalab.survey.application.common.survey.dto.TargetDto + +fun toGalleryPreviewDto( + target: TargetDto, + survey: SurveyDto, + feedbacks: List, +): GalleryPreviewDto { + return GalleryPreviewDto( + target = GalleryPreviewDto.Target( + targetId = target.id.toString(), + nickname = target.nickname, + position = target.position, + ), + survey = GalleryPreviewDto.Survey( + surveyId = survey.id.toString(), + feedbackCount = feedbacks.size, + bookmarkedCount = 0, + feedbacks = findLatestBookmarkedReply(feedbacks), + tendencies = findTendencies(survey, feedbacks), + ) + ) +} + +private fun findLatestBookmarkedReply(feedbacks: List): List { + return feedbacks.filterBookmarkedFeedback() + .mapBookmarkedWithReply() + .sortedByDescending { (bookmark, _) -> bookmark.bookmarkedAt } + .firstOrNull() + ?.second ?: emptyList() +} + +private fun List.filterBookmarkedFeedback(): List { + return this.filter { feedback -> + feedback.formQuestionFeedbackDtoableList.any { formQuestionFeedback -> + formQuestionFeedback.bookmarkDto.isBookmarked + } + } +} + +private fun List.mapBookmarkedWithReply(): List>> { + return this.flatMap { bookmarkedFeedback -> + bookmarkedFeedback.formQuestionFeedbackDtoableList + .filterIsInstance() + .map { shortQuestionFeedback -> shortQuestionFeedback.bookmarkDto to shortQuestionFeedback.replyList } + } +} + +private fun findTendencies( + survey: SurveyDto, + feedbacks: List, +): List { + val tendencyQuestion = survey.formQuestionDtoableList + .filterIsInstance() + .find { it.choiceFormQuestionDtoType == ChoiceFormQuestionDtoType.TENDENCY } + ?: error("필수 유형 Tendency 를 찾을 수 없습니다.") + + val countPerTendency = mutableMapOf() + + feedbacks.mapTendencyFeedbacks(tendencyQuestion) + .forEach { tendencyFeedback -> + tendencyQuestion.choiceDtoList + .filter { choice -> + tendencyFeedback.selectedChoiceIdSet.contains(choice.id) + } + .map { it.content } + .forEach { tendencyContent -> + countPerTendency[tendencyContent] = + countPerTendency.getOrDefault(tendencyContent, 0) + 1 + } + } + + return countPerTendency.map { (name, count) -> + GalleryPreviewDto.Survey.Tendency(name, count) + } +} + +private fun List.mapTendencyFeedbacks( + tendencyQuestion: ChoiceFormQuestionDto, +): List { + return this.flatMap { + it.formQuestionFeedbackDtoableList + .filterIsInstance() + .filter { choiceQuestionFeedback -> + choiceQuestionFeedback.questionId == tendencyQuestion.id + } + } +} diff --git a/gallery/src/main/kotlin/me/nalab/gallery/app/GalleryRegisterApp.kt b/gallery/src/main/kotlin/me/nalab/gallery/app/GalleryRegisterApp.kt new file mode 100644 index 00000000..a2bed44c --- /dev/null +++ b/gallery/src/main/kotlin/me/nalab/gallery/app/GalleryRegisterApp.kt @@ -0,0 +1,51 @@ +package me.nalab.gallery.app + +import me.nalab.core.idgenerator.idcore.IdGenerator +import me.nalab.core.time.TimeUtil +import me.nalab.gallery.domain.Gallery +import me.nalab.gallery.domain.GalleryService +import me.nalab.gallery.domain.Job +import me.nalab.gallery.domain.response.GalleryDto +import me.nalab.survey.application.common.survey.dto.SurveyDto +import me.nalab.survey.application.common.survey.dto.TargetDto +import me.nalab.survey.application.port.`in`.web.findfeedback.FeedbackFindUseCase +import me.nalab.survey.application.port.`in`.web.survey.find.SurveyFindUseCase +import me.nalab.survey.application.port.`in`.web.target.find.TargetFindUseCase +import org.springframework.stereotype.Service + +@Service +class GalleryRegisterApp( + private val idGenerator: IdGenerator, + private val targetFindUseCase: TargetFindUseCase, + private val surveyFindUseCase: SurveyFindUseCase, + private val feedbackFindUseCase: FeedbackFindUseCase, + private val galleryService: GalleryService, +) { + + fun registerGalleryByTargetId(targetId: Long, job: String): GalleryDto { + val target = targetFindUseCase.findTarget(targetId) + val survey = surveyFindUseCase.getSurveyByTargetId(targetId) + + val gallery = galleryService.registerGallery(toGallery(job, target, survey)) + + val feedbacks = feedbackFindUseCase.findAllFeedbackDtoBySurveyId(survey.id) + + return toGalleryDto(gallery, target, survey, feedbacks) + } + + private fun toGallery( + job: String, + target: TargetDto, + survey: SurveyDto, + ): Gallery { + return Gallery( + id = idGenerator.generate(), + targetId = target.id, + job = Job.valueOf(job.uppercase()), + surveyId = survey.id, + bookmarkedCount = target.bookmarkedSurveys.size, + updateOrder = TimeUtil.toInstant(), + ) + } + +} diff --git a/gallery/src/main/kotlin/me/nalab/gallery/controller/GalleryController.kt b/gallery/src/main/kotlin/me/nalab/gallery/controller/GalleryController.kt new file mode 100644 index 00000000..be01c988 --- /dev/null +++ b/gallery/src/main/kotlin/me/nalab/gallery/controller/GalleryController.kt @@ -0,0 +1,51 @@ +package me.nalab.gallery.controller + +import me.nalab.gallery.app.GalleryGetApp +import me.nalab.gallery.app.GalleryPreviewApp +import me.nalab.gallery.app.GalleryRegisterApp +import me.nalab.gallery.controller.request.GalleryRegisterRequest +import me.nalab.gallery.domain.response.GalleriesDto +import me.nalab.gallery.domain.response.GalleryDto +import me.nalab.gallery.domain.response.GalleryPreviewDto +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/v1/gallerys") +class GalleryController( + private val galleryGetApp: GalleryGetApp, + private val galleryPreviewApp: GalleryPreviewApp, + private val galleryRegisterApp: GalleryRegisterApp, +) { + + @GetMapping("/previews") + @ResponseStatus(HttpStatus.OK) + fun getGalleryPreview(@RequestAttribute("logined") targetId: Long): GalleryPreviewDto = + galleryPreviewApp.findGalleryPreview(targetId) + + @PostMapping + @ResponseStatus(HttpStatus.OK) + fun registerGallery( + @RequestAttribute("logined") targetId: Long, + @RequestBody request: GalleryRegisterRequest, + ): GalleryDto { + return galleryRegisterApp.registerGalleryByTargetId(targetId, request.job) + } + + @GetMapping("/logins") + @ResponseStatus(HttpStatus.OK) + fun getGallery(@RequestAttribute("logined") targetId: Long): GalleryDto = + galleryGetApp.getGalleryByTargetId(targetId) + + @GetMapping + @ResponseStatus(HttpStatus.OK) + fun getGalleries( + @RequestParam(name = "job", defaultValue = "all") job: String, + @RequestParam(name = "page", defaultValue = "0") page: Int, + @RequestParam(name = "count", defaultValue = "5") count: Int, + @RequestParam(name = "order-type", defaultValue = "update") orderType: String + ): GalleriesDto { + return galleryGetApp.getGalleries(job, page, count, orderType) + } + +} diff --git a/gallery/src/main/kotlin/me/nalab/gallery/controller/request/GalleryRegisterRequest.kt b/gallery/src/main/kotlin/me/nalab/gallery/controller/request/GalleryRegisterRequest.kt new file mode 100644 index 00000000..318feda5 --- /dev/null +++ b/gallery/src/main/kotlin/me/nalab/gallery/controller/request/GalleryRegisterRequest.kt @@ -0,0 +1,9 @@ +package me.nalab.gallery.controller.request + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty + +data class GalleryRegisterRequest @JsonCreator constructor( + @JsonProperty("job") + val job: String, +) diff --git a/gallery/src/main/kotlin/me/nalab/gallery/domain/Gallery.kt b/gallery/src/main/kotlin/me/nalab/gallery/domain/Gallery.kt new file mode 100644 index 00000000..bc268ef4 --- /dev/null +++ b/gallery/src/main/kotlin/me/nalab/gallery/domain/Gallery.kt @@ -0,0 +1,46 @@ +package me.nalab.gallery.domain + +import me.nalab.core.data.common.TimeBaseEntity +import me.nalab.core.time.TimeUtil +import java.time.Instant +import javax.persistence.* + +@Entity +@Table(name = "gallery") +class Gallery( + @Id + @Column(name = "gallery_id") + val id: Long, + + @Embedded + val target: Target, + + @Embedded + val survey: Survey, + + @Column(name = "update_order", columnDefinition = "TIMESTAMP(6)", nullable = false) + private var updateOrder: Instant, + + @Version + private var version: Long? = null, +) : TimeBaseEntity() { + + constructor( + id: Long, + targetId: Long, + job: Job, + surveyId: Long, + bookmarkedCount: Int = 0, + updateOrder: Instant = TimeUtil.toInstant(), + ): this(id, Target(targetId, job), Survey(surveyId, bookmarkedCount), updateOrder) + + fun getTargetId(): Long = target.targetId + + fun getJob(): Job = target.job + + fun getBookmarkedCount(): Int = survey.bookmarkedCount + + fun increaseBookmarkedCount() { + survey.bookmarkedCount++ + } +} diff --git a/gallery/src/main/kotlin/me/nalab/gallery/domain/GalleryRepository.kt b/gallery/src/main/kotlin/me/nalab/gallery/domain/GalleryRepository.kt new file mode 100644 index 00000000..96db38a2 --- /dev/null +++ b/gallery/src/main/kotlin/me/nalab/gallery/domain/GalleryRepository.kt @@ -0,0 +1,20 @@ +package me.nalab.gallery.domain + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import javax.persistence.LockModeType + +interface GalleryRepository : JpaRepository { + + @Lock(LockModeType.OPTIMISTIC) + @Query("select g from Gallery as g where g.target.targetId = :targetId") + fun findByTargetIdOrNull(@Param("targetId") targetId: Long): Gallery? + + @Query("select g from Gallery g where g.target.job in :job") + fun findGalleries(@Param("job") job: List, pageable: Pageable): Page + +} diff --git a/gallery/src/main/kotlin/me/nalab/gallery/domain/GalleryService.kt b/gallery/src/main/kotlin/me/nalab/gallery/domain/GalleryService.kt new file mode 100644 index 00000000..78f2610c --- /dev/null +++ b/gallery/src/main/kotlin/me/nalab/gallery/domain/GalleryService.kt @@ -0,0 +1,41 @@ +package me.nalab.gallery.domain + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GalleryService( + private val galleryRepository: GalleryRepository, +) { + + fun getGalleryByTargetId(targetId: Long): Gallery { + return galleryRepository.findByTargetIdOrNull(targetId) + ?: throw IllegalArgumentException("targetId \"$targetId\" 에 해당하는 Gallery를 찾을 수 없습니다.") + } + + @Transactional + fun registerGallery(gallery: Gallery): Gallery { + require(galleryRepository.findByTargetIdOrNull(gallery.getTargetId()) == null) { + "target \"${gallery.getTargetId()}\" 이 이미 Gallery에 등록되어있습니다." + } + + return galleryRepository.save(gallery) + } + + @Transactional + fun increaseBookmarkCount(targetId: Long) { + galleryRepository.findByTargetIdOrNull(targetId)?.increaseBookmarkedCount() + } + + fun getGalleries(job: String, pageable: Pageable): Page { + val jobs = when (job) { + "all" -> Job.entries.toList() + else -> listOf(Job.valueOf(job.uppercase())) + } + + return galleryRepository.findGalleries(jobs, pageable) + } +} diff --git a/gallery/src/main/kotlin/me/nalab/gallery/domain/Job.kt b/gallery/src/main/kotlin/me/nalab/gallery/domain/Job.kt new file mode 100644 index 00000000..058960b6 --- /dev/null +++ b/gallery/src/main/kotlin/me/nalab/gallery/domain/Job.kt @@ -0,0 +1,8 @@ +package me.nalab.gallery.domain + +enum class Job { + PM, + DEVELOPER, + DESIGNER, + OTHERS, +} diff --git a/gallery/src/main/kotlin/me/nalab/gallery/domain/Survey.kt b/gallery/src/main/kotlin/me/nalab/gallery/domain/Survey.kt new file mode 100644 index 00000000..9201ead1 --- /dev/null +++ b/gallery/src/main/kotlin/me/nalab/gallery/domain/Survey.kt @@ -0,0 +1,13 @@ +package me.nalab.gallery.domain + +import javax.persistence.Column +import javax.persistence.Embeddable + +@Embeddable +class Survey( + @Column(name = "survey_id", nullable = false, unique = true, updatable = false) + val id: Long, + + @Column(name = "bookmarked_count", nullable = false) + var bookmarkedCount: Int = 0, +) diff --git a/gallery/src/main/kotlin/me/nalab/gallery/domain/Target.kt b/gallery/src/main/kotlin/me/nalab/gallery/domain/Target.kt new file mode 100644 index 00000000..ab425086 --- /dev/null +++ b/gallery/src/main/kotlin/me/nalab/gallery/domain/Target.kt @@ -0,0 +1,16 @@ +package me.nalab.gallery.domain + +import javax.persistence.Column +import javax.persistence.Embeddable +import javax.persistence.EnumType +import javax.persistence.Enumerated + +@Embeddable +class Target( + @Column(name = "target_id", unique = true, nullable = false, updatable = false) + val targetId: Long, + + @Enumerated(EnumType.STRING) + @Column(name = "job", nullable = false, columnDefinition = "TEXT not null") + val job: Job, +) diff --git a/gallery/src/main/kotlin/me/nalab/gallery/domain/response/GalleriesDto.kt b/gallery/src/main/kotlin/me/nalab/gallery/domain/response/GalleriesDto.kt new file mode 100644 index 00000000..33cf3f0d --- /dev/null +++ b/gallery/src/main/kotlin/me/nalab/gallery/domain/response/GalleriesDto.kt @@ -0,0 +1,6 @@ +package me.nalab.gallery.domain.response + +data class GalleriesDto( + val totalPage: Int, + val galleries: List +) diff --git a/gallery/src/main/kotlin/me/nalab/gallery/domain/response/GalleryDto.kt b/gallery/src/main/kotlin/me/nalab/gallery/domain/response/GalleryDto.kt new file mode 100644 index 00000000..a54a1810 --- /dev/null +++ b/gallery/src/main/kotlin/me/nalab/gallery/domain/response/GalleryDto.kt @@ -0,0 +1,38 @@ +package me.nalab.gallery.domain.response + +import com.fasterxml.jackson.annotation.JsonProperty +import me.nalab.gallery.domain.Job + +data class GalleryDto( + @JsonProperty("gallery_id") + val galleryId: Long, + val target: Target, + val survey: Survey, +) { + + data class Target( + @JsonProperty("target_id") + val targetId: String, + val nickname: String, + val position: String?, + val job: Job, + @JsonProperty("image_url") + val imageUrl: String = "empty_image", + ) + + data class Survey( + @JsonProperty("survey_id") + val surveyId: String, + @JsonProperty("feedback_count") + val feedbackCount: Int, + @JsonProperty("bookmarked_count") + val bookmarkedCount: Int, + val feedbacks: List, + val tendencies: List, + ) { + data class Tendency( + val name: String, + val count: Int, + ) + } +} diff --git a/gallery/src/main/kotlin/me/nalab/gallery/domain/response/GalleryPreviewDto.kt b/gallery/src/main/kotlin/me/nalab/gallery/domain/response/GalleryPreviewDto.kt new file mode 100644 index 00000000..a6769e4e --- /dev/null +++ b/gallery/src/main/kotlin/me/nalab/gallery/domain/response/GalleryPreviewDto.kt @@ -0,0 +1,37 @@ +package me.nalab.gallery.domain.response + +import com.fasterxml.jackson.annotation.JsonProperty +import me.nalab.gallery.domain.Job + +data class GalleryPreviewDto( + val target: Target, + val survey: Survey, +) { + + data class Target( + @JsonProperty("target_id") + val targetId: String, + val nickname: String, + val position: String?, + val job: Job = Job.OTHERS, + @JsonProperty("image_url") + val imageUrl: String = "empty_image", + ) + + data class Survey( + @JsonProperty("survey_id") + val surveyId: String, + @JsonProperty("feedback_count") + val feedbackCount: Int, + @JsonProperty("bookmarked_count") + val bookmarkedCount: Int, + val feedbacks: List, + val tendencies: List, + ) { + data class Tendency( + val name: String, + val count: Int, + ) + } + +} diff --git a/gallery/src/main/kotlin/me/nalab/gallery/infra/SurveyBookmarkedListener.kt b/gallery/src/main/kotlin/me/nalab/gallery/infra/SurveyBookmarkedListener.kt new file mode 100644 index 00000000..1623d0e5 --- /dev/null +++ b/gallery/src/main/kotlin/me/nalab/gallery/infra/SurveyBookmarkedListener.kt @@ -0,0 +1,34 @@ +package me.nalab.gallery.infra + +import me.nalab.gallery.domain.GalleryService +import me.nalab.survey.application.port.out.persistence.bookmark.SurveyBookmarkListenPort +import org.springframework.dao.OptimisticLockingFailureException +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Service +import kotlin.random.Random + +@Service +class SurveyBookmarkedListener( + private val galleryService: GalleryService, +) : SurveyBookmarkListenPort { + + @Async + override fun listenBookmarked(targetId: Long) { + runCatching { + galleryService.increaseBookmarkCount(targetId) + }.recoverCatching { + when (it is OptimisticLockingFailureException) { + true -> { + waitJitter() + listenBookmarked(targetId) + } + + false -> throw it + } + } + } + + private fun waitJitter() { + Thread.sleep(Random.nextLong(500, 1000)) + } +} diff --git a/gallery/src/test/kotlin/me/nalab/gallery/app/GalleryPreviewAppTest.kt b/gallery/src/test/kotlin/me/nalab/gallery/app/GalleryPreviewAppTest.kt new file mode 100644 index 00000000..0bf76dab --- /dev/null +++ b/gallery/src/test/kotlin/me/nalab/gallery/app/GalleryPreviewAppTest.kt @@ -0,0 +1,113 @@ +package me.nalab.gallery.app + +import com.ninjasquad.springmockk.MockkBean +import io.kotest.core.annotation.DisplayName +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.equality.shouldBeEqualUsingFields +import io.mockk.every +import me.nalab.gallery.domain.galleryPreviewDto +import me.nalab.gallery.domain.galleryPreviewDtoSurvey +import me.nalab.gallery.domain.galleryPreviewDtoTarget +import me.nalab.survey.application.port.`in`.web.findfeedback.FeedbackFindUseCase +import me.nalab.survey.application.port.`in`.web.survey.find.SurveyFindUseCase +import me.nalab.survey.application.port.`in`.web.target.find.TargetFindUseCase +import org.springframework.test.context.ContextConfiguration + +@ContextConfiguration( + classes = [ + GalleryPreviewApp::class, + ] +) +@DisplayName("GalleryPreviewApp 의") +internal class GalleryPreviewAppTest( + private val galleryPreviewApp: GalleryPreviewApp, + @MockkBean(relaxed = true) private val targetFindUseCase: TargetFindUseCase, + @MockkBean(relaxed = true) private val surveyFindUseCase: SurveyFindUseCase, + @MockkBean(relaxed = true) private val feedbackFindUseCase: FeedbackFindUseCase, +) : DescribeSpec({ + + beforeEach { + every { targetFindUseCase.findTarget(DEFAULT_TARGET_ID) } returns defaultTargetDto + every { surveyFindUseCase.getSurveyByTargetId(DEFAULT_TARGET_ID) } returns defaultSurveyDto + every { feedbackFindUseCase.findAllFeedbackDtoBySurveyId(DEFAULT_SURVEY_ID) } returns listOf( + defaultFeedbackDto + ) + } + + describe("getGalleryPreview 메소드는") { + context("존재하는 타겟의 id를 받으면,") { + val expected = galleryPreviewDto( + target = galleryPreviewDtoTarget(DEFAULT_TARGET_ID.toString()), + survey = galleryPreviewDtoSurvey( + surveyId = DEFAULT_SURVEY_ID.toString(), + bookmarkedCount = 0 + ), + ) + + it("GalleryPreviewDto를 반환한다.") { + val response = galleryPreviewApp.findGalleryPreview(DEFAULT_TARGET_ID) + + response shouldBeEqualUsingFields expected + } + } + + context("피드백이 하나도 없다면,") { + val expected = galleryPreviewDto( + target = galleryPreviewDtoTarget(DEFAULT_TARGET_ID.toString()), + survey = galleryPreviewDtoSurvey( + surveyId = DEFAULT_SURVEY_ID.toString(), + feedbackCount = 0, + bookmarkedCount = 0, + feedbacks = emptyList(), + tendencies = emptyList(), + ) + ) + + + it("feedback이 비어있는 GalleryPreviewDto를 반환한다.") { + every { surveyFindUseCase.getSurveyByTargetId(DEFAULT_TARGET_ID) } returns surveyDto( + id = DEFAULT_SURVEY_ID, formQuestionDtos = listOf(choiceFormQuestionDto()) + ) + every { feedbackFindUseCase.findAllFeedbackDtoBySurveyId(DEFAULT_SURVEY_ID) } returns emptyList() + + val response = galleryPreviewApp.findGalleryPreview(DEFAULT_TARGET_ID) + + response shouldBeEqualUsingFields expected + } + } + } + +}) { + private companion object { + private const val DEFAULT_TARGET_ID = 1L + private const val DEFAULT_SURVEY_ID = 2L + private const val DEFAULT_FEEDBACK_ID = 3L + private const val TENDENCY_QUESTION_ID = 4L + private const val CUSTOM_SHORT_FORM_QUESTION_ID = 5L + + private val defaultTargetDto = targetDto(id = DEFAULT_TARGET_ID) + + private val formQuestionDtos = listOf( + choiceFormQuestionDto(id = TENDENCY_QUESTION_ID), + shortFormQuestionDto(id = CUSTOM_SHORT_FORM_QUESTION_ID), + ) + + private val defaultSurveyDto = + surveyDto(id = DEFAULT_SURVEY_ID, formQuestionDtos = formQuestionDtos) + + private val formQuestionFeedbackDtos = listOf( + choiceFormQuestionFeedbackDto(questionId = TENDENCY_QUESTION_ID), + shortFormQuestionFeedbackDto( + questionId = CUSTOM_SHORT_FORM_QUESTION_ID, + bookmarkDto = bookmarkDto(isBookmarked = true) + ), + ) + + private val defaultFeedbackDto = + feedbackDto( + id = DEFAULT_FEEDBACK_ID, + surveyId = DEFAULT_SURVEY_ID, + formQuestionFeedbackDtos = formQuestionFeedbackDtos, + ) + } +} diff --git a/gallery/src/test/kotlin/me/nalab/gallery/app/SurveyFixture.kt b/gallery/src/test/kotlin/me/nalab/gallery/app/SurveyFixture.kt new file mode 100644 index 00000000..39aec20b --- /dev/null +++ b/gallery/src/test/kotlin/me/nalab/gallery/app/SurveyFixture.kt @@ -0,0 +1,146 @@ +package me.nalab.gallery.app + +import me.nalab.core.time.TimeUtil +import me.nalab.survey.application.common.feedback.dto.* +import me.nalab.survey.application.common.survey.dto.* +import java.time.Instant + +fun targetDto( + id: Long = 0L, + nickname: String = "이준영", + position: String = "백엔드 엔지니어", +): TargetDto { + return TargetDto.builder() + .id(id) + .nickname(nickname) + .position(position) + .build() +} + +fun surveyDto( + id: Long = 0L, + targetId: Long = 0L, + formQuestionDtos: List = listOf( + choiceFormQuestionDto(), + shortFormQuestionDto() + ), + time: Instant = TimeUtil.toInstant(), +): SurveyDto { + return SurveyDto.builder() + .id(id) + .targetId(targetId) + .formQuestionDtoableList(formQuestionDtos) + .createdAt(time) + .updatedAt(time) + .build() +} + +fun choiceFormQuestionDto( + id: Long = 0L, + time: Instant = TimeUtil.toInstant(), + type: ChoiceFormQuestionDtoType = ChoiceFormQuestionDtoType.TENDENCY, + choices: List = listOf(choiceDto()), + maxSelectableCount: Int = 5, + order: Int = 1, +): ChoiceFormQuestionDto { + return ChoiceFormQuestionDto.builder() + .id(id) + .createdAt(time) + .updatedAt(time) + .choiceFormQuestionDtoType(type) + .choiceDtoList(choices) + .maxSelectableCount(maxSelectableCount) + .order(order) + .build() +} + +fun choiceDto( + id: Long = 0L, + content: String = "경청하는", + order: Int = 1, +): ChoiceDto { + return ChoiceDto.builder() + .id(id) + .content(content) + .order(order) + .build() +} + +fun shortFormQuestionDto( + id: Long = 0L, + time: Instant = TimeUtil.toInstant(), + type: ShortFormQuestionDtoType = ShortFormQuestionDtoType.CUSTOM, + title: String = "제가 고쳐야할점을 알려주세요.", + order: Int = 1, +): ShortFormQuestionDto { + return ShortFormQuestionDto.builder() + .id(id) + .createdAt(time) + .updatedAt(time) + .shortFormQuestionDtoType(type) + .order(order) + .title(title) + .build() +} + +fun feedbackDto( + id: Long = 0L, + surveyId: Long = 0L, + time: Instant = TimeUtil.toInstant(), + formQuestionFeedbackDtos: List = listOf( + choiceFormQuestionFeedbackDto(), + shortFormQuestionFeedbackDto() + ), + isRead: Boolean = true, +): FeedbackDto { + return FeedbackDto.builder() + .id(id) + .surveyId(surveyId) + .createdAt(time) + .updatedAt(time) + .formQuestionFeedbackDtoableList(formQuestionFeedbackDtos) + .isRead(isRead) + .build() +} + +fun choiceFormQuestionFeedbackDto( + id: Long = 0L, + questionId: Long = 0L, + isRead: Boolean = false, + bookmarkDto: BookmarkDto = bookmarkDto(), + selectedChoiceIdSet: Set = setOf(0L), +): ChoiceFormQuestionFeedbackDto { + return ChoiceFormQuestionFeedbackDto.builder() + .id(id) + .questionId(questionId) + .bookmarkDto(bookmarkDto) + .selectedChoiceIdSet(selectedChoiceIdSet) + .isRead(isRead) + .build() +} + +fun shortFormQuestionFeedbackDto( + id: Long = 1L, + questionId: Long = 0L, + isRead: Boolean = false, + bookmarkDto: BookmarkDto = bookmarkDto(), + replies: List = listOf("제가 고쳐야할점을 알려주세요 질문의", "응답입니다.") +): ShortFormQuestionFeedbackDto { + return ShortFormQuestionFeedbackDto.builder() + .id(id) + .bookmarkDto(bookmarkDto) + .replyList(replies) + .isRead(isRead) + .questionId(questionId) + .build() +} + +fun bookmarkDto( + isBookmarked: Boolean = false, + time: Instant = TimeUtil.toInstant(), +): BookmarkDto { + return BookmarkDto.builder() + .isBookmarked(isBookmarked) + .bookmarkedAt(time) + .build() +} diff --git a/gallery/src/test/kotlin/me/nalab/gallery/domain/GalleryFixture.kt b/gallery/src/test/kotlin/me/nalab/gallery/domain/GalleryFixture.kt new file mode 100644 index 00000000..b716c905 --- /dev/null +++ b/gallery/src/test/kotlin/me/nalab/gallery/domain/GalleryFixture.kt @@ -0,0 +1,20 @@ +package me.nalab.gallery.domain + +import me.nalab.core.time.TimeUtil +import java.time.Instant + +fun gallery( + id: Long = 0L, + targetId: Long = 0L, + job: Job = Job.OTHERS, + surveyId: Long = 0L, + bookmarkedCount: Int = 0, + updateOrder: Instant = TimeUtil.toInstant(), +): Gallery = Gallery( + id = id, + targetId = targetId, + job = job, + surveyId = surveyId, + bookmarkedCount = bookmarkedCount, + updateOrder = updateOrder, +) diff --git a/gallery/src/test/kotlin/me/nalab/gallery/domain/GalleryPreviewDtoFixture.kt b/gallery/src/test/kotlin/me/nalab/gallery/domain/GalleryPreviewDtoFixture.kt new file mode 100644 index 00000000..eb89cd70 --- /dev/null +++ b/gallery/src/test/kotlin/me/nalab/gallery/domain/GalleryPreviewDtoFixture.kt @@ -0,0 +1,51 @@ +package me.nalab.gallery.domain + +import me.nalab.gallery.domain.response.GalleryPreviewDto + +fun galleryPreviewDto( + target: GalleryPreviewDto.Target = galleryPreviewDtoTarget(), + survey: GalleryPreviewDto.Survey = galleryPreviewDtoSurvey(), +): GalleryPreviewDto { + return GalleryPreviewDto( + target = target, + survey = survey, + ) +} + +fun galleryPreviewDtoTarget( + targetId: String = "0", + nickname: String = "이준영", + position: String = "백엔드 엔지니어", +): GalleryPreviewDto.Target { + return GalleryPreviewDto.Target( + targetId = targetId, + nickname = nickname, + position = position, + ) +} + +fun galleryPreviewDtoSurvey( + surveyId: String = "0", + feedbackCount: Int = 1, + bookmarkedCount: Int = 1, + feedbacks: List = listOf("제가 고쳐야할점을 알려주세요 질문의", "응답입니다."), + tendencies: List = listOf(galleryPreviewDtoSurveyTendency()), +): GalleryPreviewDto.Survey { + return GalleryPreviewDto.Survey( + surveyId = surveyId, + feedbackCount = feedbackCount, + bookmarkedCount = bookmarkedCount, + feedbacks = feedbacks, + tendencies = tendencies, + ) +} + +fun galleryPreviewDtoSurveyTendency( + name: String = "경청하는", + count: Int = 1, +): GalleryPreviewDto.Survey.Tendency { + return GalleryPreviewDto.Survey.Tendency( + name = name, + count = count, + ) +} diff --git a/gallery/src/test/kotlin/me/nalab/gallery/domain/GalleryServiceTest.kt b/gallery/src/test/kotlin/me/nalab/gallery/domain/GalleryServiceTest.kt new file mode 100644 index 00000000..d6ae45eb --- /dev/null +++ b/gallery/src/test/kotlin/me/nalab/gallery/domain/GalleryServiceTest.kt @@ -0,0 +1,202 @@ +package me.nalab.gallery.domain + +import io.kotest.assertions.throwables.shouldThrowMessage +import io.kotest.core.annotation.DisplayName +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.equality.FieldsEqualityCheckConfig +import io.kotest.matchers.equality.shouldBeEqualToComparingFields +import io.kotest.matchers.equality.shouldBeEqualUsingFields +import io.kotest.matchers.equals.shouldBeEqual +import me.nalab.core.time.TimeUtil +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.test.context.ContextConfiguration +import java.time.temporal.ChronoUnit +import kotlin.reflect.full.memberProperties + +@DataJpaTest +@EnableJpaRepositories +@EntityScan(value = ["me.nalab.core.data", "me.nalab.gallery.domain"]) +@DisplayName("GalleryService 클래스의") +@ContextConfiguration(classes = [GalleryService::class]) +internal class GalleryServiceTest( + private val galleryService: GalleryService, + private val galleryRepository: GalleryRepository, +) : DescribeSpec({ + + afterEach { + galleryRepository.deleteAll() + } + + describe("registerGallery 메소드는") { + beforeEach { + galleryService.registerGallery( + gallery( + id = EXIST_GALLERY_ID, + targetId = EXIST_TARGET_ID, + surveyId = EXIST_SURVEY_ID + ) + ) + } + + context("Gallery에 등록되지 않은 target의 Gallery를 입력받으면,") { + val gallery = gallery(targetId = NOT_EXIST_TARGET_ID) + + it("Gallery를 저장하고 반환한다.") { + val result = galleryService.registerGallery(gallery) + + result shouldBeEqualUsingFields gallery + } + } + + context("이미 Gallery에 등록된 target의 Gallery를 입력받으면,") { + val existGallery = gallery(targetId = EXIST_TARGET_ID) + + it("IllegalArgumentException을 던진다.") { + shouldThrowMessage("target \"$EXIST_GALLERY_ID\" 이 이미 Gallery에 등록되어있습니다.") { + galleryService.registerGallery(existGallery) + } + } + } + } + + describe("getGalleries 메소드는") { + beforeEach { + galleryService.registerGallery(oldDesignerGallery) + galleryService.registerGallery(midDeveloperGallery) + galleryService.registerGallery(latestPmGallery) + } + + context("job으로 all과 update순으로 정렬된 Pageable을 입력받으면,") { + val expected = listOf(latestPmGallery, midDeveloperGallery, oldDesignerGallery) + + it("update순으로 정렬된 Gallery를 반환한다.") { + val result = galleryService.getGalleries("all", updatePage).content + + result.shouldBeExactlyEqualToComparingFields(expected) + } + } + + context("job으로 designer와 update순으로 정렬된 Pageable을 입력받으면,") { + val expected = listOf(oldDesignerGallery) + + it("update순으로 정렬된 designer Gallery를 반환한다.") { + val result = galleryService.getGalleries("designer", updatePage).content + + result.shouldBeExactlyEqualToComparingFields(expected) + } + } + + context("job으로 pm과 update순으로 정렬된 Pageable을 입력받으면,") { + val expected = listOf(latestPmGallery) + + it("update순으로 정렬된 pm Gallery를 반환한다.") { + val result = galleryService.getGalleries("pm", updatePage).content + + result.shouldBeExactlyEqualToComparingFields(expected) + } + } + + context("job으로 developer와 update순으로 정렬된 Pageable을 입력받으면,") { + val expected = listOf(midDeveloperGallery) + + it("update순으로 정렬된 pm Gallery를 반환한다.") { + val result = galleryService.getGalleries("developer", updatePage).content + + result.shouldBeExactlyEqualToComparingFields(expected) + } + } + + context("job으로 all과 bookmark순으로 정렬된 Pageable을 받으면,") { + val expected = listOf(oldDesignerGallery, midDeveloperGallery, latestPmGallery) + + it("bookmark순으로 정렬된 Gallery를 반환한다") { + val result = galleryService.getGalleries("all", bookmarkPage).content + + result.shouldBeExactlyEqualToComparingFields(expected) + } + } + + context("job으로 designer와 bookmark순으로 정렬된 Pageable을 입력받으면,") { + val expected = listOf(oldDesignerGallery) + + it("update순으로 정렬된 designer Gallery를 반환한다.") { + val result = galleryService.getGalleries("designer", bookmarkPage).content + + result.shouldBeExactlyEqualToComparingFields(expected) + } + } + + context("job으로 pm과 bookmark순으로 정렬된 Pageable을 입력받으면,") { + val expected = listOf(latestPmGallery) + + it("update순으로 정렬된 pm Gallery를 반환한다.") { + val result = galleryService.getGalleries("pm", bookmarkPage).content + + result.shouldBeExactlyEqualToComparingFields(expected) + } + } + + context("job으로 developer와 bookmark순으로 정렬된 Pageable을 입력받으면,") { + val expected = listOf(midDeveloperGallery) + + it("update순으로 정렬된 pm Gallery를 반환한다.") { + val result = galleryService.getGalleries("developer", bookmarkPage).content + + result.shouldBeExactlyEqualToComparingFields(expected) + } + } + } +}) { + + companion object { + private const val NOT_EXIST_TARGET_ID = 1L + private const val EXIST_GALLERY_ID = 100L + private const val EXIST_TARGET_ID = 100L + private const val EXIST_SURVEY_ID = 100L + + val updatePage: PageRequest = PageRequest.of(0, 5, Sort.by("updateOrder").descending()) + val bookmarkPage: PageRequest = + PageRequest.of(0, 5, Sort.by("survey.bookmarkedCount").descending()) + + val oldDesignerGallery = gallery( + id = 1, + targetId = 101, + surveyId = 101, + job = Job.DESIGNER, + updateOrder = TimeUtil.toInstant().minus(1, ChronoUnit.DAYS), + bookmarkedCount = 3 + ) + + val midDeveloperGallery = gallery( + id = 2, + targetId = 102, + surveyId = 102, + job = Job.DEVELOPER, + updateOrder = TimeUtil.toInstant(), + bookmarkedCount = 2 + ) + + val latestPmGallery = gallery( + id = 3, + targetId = 103, + surveyId = 103, + job = Job.PM, + updateOrder = TimeUtil.toInstant().plus(1, ChronoUnit.DAYS), + bookmarkedCount = 1 + ) + + private fun List.shouldBeExactlyEqualToComparingFields(galleries: List) { + this.size shouldBeEqual galleries.size + for (i in galleries.indices) { + this[i].shouldBeEqualToComparingFields( + galleries[i], + FieldsEqualityCheckConfig(propertiesToExclude = Gallery::class.memberProperties.filter { it.name == "createdAt" || it.name == "updatedAt" }) + ) + } + } + } +} diff --git a/gradle.properties b/gradle.properties index e247c399..4275f174 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,11 +3,12 @@ applicationVersion=0.0.1-SNAPSHOT ### Project configs ### projectGroup=me.nalab -javaVersion=11 +javaVersion=17 ### Spring dependency versions ### springBootVersion=2.7.11 springDependencyManagementVersion=1.0.15.RELEASE +springMockkVersion=4.0.0 ### Sonar settings ### sonarVersion=4.0.0.2929 @@ -23,3 +24,13 @@ org.gradle.jvmargs=-Xmx2048m ### Sentry ### sentryLogBackVersion=1.7.30 + +### Kotlin ### +kotlinVersion=1.9.22 + +### Kotest ### +kotestVersion=5.7.2 +kotestExtensionSpringVersion=1.1.3 + +### RestAssured ### +restAssuredVersion=5.3.0 diff --git a/gradle/jacoco.gradle b/gradle/jacoco.gradle index 7c32fea5..23887593 100644 --- a/gradle/jacoco.gradle +++ b/gradle/jacoco.gradle @@ -57,13 +57,13 @@ subprojects { limit { counter = 'LINE' value = 'COVEREDRATIO' - minimum = 0.70 + minimum = 0.00 } limit { counter = 'BRANCH' value = 'COVEREDRATIO' - minimum = 0.70 + minimum = 0.00 } } diff --git a/gradle/kotlin.gradle b/gradle/kotlin.gradle new file mode 100644 index 00000000..838af1f3 --- /dev/null +++ b/gradle/kotlin.gradle @@ -0,0 +1,28 @@ +allprojects { + + compileKotlin { + kotlinOptions.jvmTarget = "11" + } + + compileTestKotlin { + kotlinOptions.jvmTarget = "11" + } + + apply plugin: 'org.jetbrains.kotlin.jvm' + apply plugin: 'org.jetbrains.kotlin.plugin.jpa' + apply plugin: 'org.jetbrains.kotlin.plugin.spring' + apply plugin: 'org.jetbrains.kotlin.plugin.allopen' + + allOpen { + annotation("jakarta.persistence.Entity") + } + + dependencies { + implementation "org.jetbrains.kotlin:kotlin-reflect" + + testImplementation "org.jetbrains.kotlin:kotlin-test" + testImplementation "io.kotest:kotest-runner-junit5:${kotestVersion}" + testImplementation "io.kotest:kotest-assertions-core:${kotestVersion}" + testImplementation "io.kotest.extensions:kotest-extensions-spring:${kotestExtensionSpringVersion}" + } +} diff --git a/settings.gradle b/settings.gradle index bf603ea0..862da647 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,6 +4,10 @@ pluginManagement { id 'io.spring.dependency-management' version "${springDependencyManagementVersion}" id 'jacoco' id 'org.sonarqube' version "${sonarVersion}" + id "org.jetbrains.kotlin.jvm" version "${kotlinVersion}" + id "org.jetbrains.kotlin.plugin.jpa" version "${kotlinVersion}" + id "org.jetbrains.kotlin.plugin.spring" version "${kotlinVersion}" + id "org.jetbrains.kotlin.plugin.allopen" version "${kotlinVersion}" } } @@ -49,3 +53,5 @@ include 'user:user-domain' include 'user:user-jpa-adapter' include 'user:user-application' include 'user:user-web-adaptor' + +include 'gallery' diff --git a/support/e2e/Dockerfile b/support/e2e/Dockerfile index b6969b14..c7c2d65d 100644 --- a/support/e2e/Dockerfile +++ b/support/e2e/Dockerfile @@ -10,7 +10,7 @@ RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSI RUN apt-get install -y curl -RUN curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/3.0.1/hurl_3.0.1_amd64.deb -RUN apt-get update && apt-get install -y ./hurl_3.0.1_amd64.deb +RUN curl -k --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/4.0.0/hurl_4.0.0_amd64.deb +RUN apt-get update && apt-get install -y ./hurl_4.0.0_amd64.deb ENTRYPOINT dockerize -wait tcp://nalab-server:8080 -timeout 300s && hurl --very-verbose --color --test hurls/*.hurl diff --git a/support/e2e/v1_10_get_logined_gallery.hurl b/support/e2e/v1_10_get_logined_gallery.hurl new file mode 100644 index 00000000..49eb0ab5 --- /dev/null +++ b/support/e2e/v1_10_get_logined_gallery.hurl @@ -0,0 +1,221 @@ +POST http://nalab-server:8080/v1/oauth/default # Default provider를 통해서 로그인 진행 +{ + "nickname": "logined_gallery", + "email": "loginedgallery@123456" +} + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.access_token" exists +jsonpath "$.token_type" exists + +[Captures] +token_type: jsonpath "$.token_type" +auth_token: jsonpath "$.access_token" + +########## + +POST http://nalab-server:8080/v1/surveys # 발급받은 토큰으로 survey를 생성한다. +Authorization: {{ token_type }} {{ auth_token }} +{ + "question_count": 2, + "question": [ + { + "type": "choice", + "form_type": "tendency", + "title": "저는 UI, UI, GUI 중에 어떤 분야를 가장 잘하는 것 같나요?", + "choices": [ + { + "content": "UI", + "order": 1 + }, + { + "content": "UX", + "order": 2 + }, + { + "content": "GUI", + "order": 3 + } + ], + "max_selectable_count": 1, + "order": 1 + }, + { + "type": "short", + "form_type": "strength", + "title": "저는 UX, UI, GUI 중에 어떤 분야에 더 강점이 있나요?", + "order": 2 + } + ] +} + +HTTP 201 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.survey_id" exists + +[Captures] +survey_id: jsonpath "$.survey_id" + +########## + +GET http://nalab-server:8080/v1/surveys/{{ survey_id }} # 생성된 survey를 조회한다. + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.survey_id" exists + +jsonpath "$.target.id" exists +jsonpath "$.target.nickname" == "logined_gallery" + +jsonpath "$.question_count" == 2 +jsonpath "$.question.[0].question_id" exists +jsonpath "$.question.[0].type" == "choice" +jsonpath "$.question.[0].form_type" == "tendency" +jsonpath "$.question.[0].title" == "저는 UI, UI, GUI 중에 어떤 분야를 가장 잘하는 것 같나요?" +jsonpath "$.question.[0].order" == 1 +jsonpath "$.question.[0].max_selectable_count" == 1 +jsonpath "$.question.[0].choices.[0].choice_id" exists +jsonpath "$.question.[0].choices.[0].content" == "UI" +jsonpath "$.question.[0].choices.[0].order" == 1 +jsonpath "$.question.[0].choices.[1].choice_id" exists +jsonpath "$.question.[0].choices.[1].content" == "UX" +jsonpath "$.question.[0].choices.[1].order" == 2 +jsonpath "$.question.[0].choices.[2].choice_id" exists +jsonpath "$.question.[0].choices.[2].content" == "GUI" +jsonpath "$.question.[0].choices.[2].order" == 3 +jsonpath "$.question.[1].question_id" exists +jsonpath "$.question.[1].type" == "short" +jsonpath "$.question.[1].form_type" == "strength" +jsonpath "$.question.[1].title" == "저는 UX, UI, GUI 중에 어떤 분야에 더 강점이 있나요?" +jsonpath "$.question.[1].order" == 2 + +[Captures] +target_id: jsonpath "$.target.id" +tendency_question_id: jsonpath "$.question.[0].question_id" +tendency_question_choice_id: jsonpath "$.question.[0].choices.[0].choice_id" +strength_question_id: jsonpath "$.question.[1].question_id" + + +########## + +POST http://nalab-server:8080/v1/surveys/{{ survey_id }}/bookmarks # survey_id를 북마크한다. +Authorization: {{ token_type }} {{ auth_token }} + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.target_id" == {{ target_id }} +jsonpath "$.survey_id" == {{ survey_id }} +jsonpath "$.nickname" == "logined_gallery" + +########## + +POST http://nalab-server:8080/v1/feedbacks # 생성된 survey에 feedback을 남긴다. + +[QueryStringParams] +survey-id: {{ survey_id }} + +{ + "reviewer": { + "collaboration_experience": true, + "position": "pm" + }, + "question_feedback": [ + { + "question_id": {{ tendency_question_id }}, + "type": "choice", + "choices": [ + {{ tendency_question_choice_id }} + ] + }, + { + "question_id": {{ strength_question_id }}, + "type": "short", + "reply": [ + "Hello world" + ] + } + ] +} + +HTTP 201 +[Asserts] + +########## + +GET http://nalab-server:8080/v1/feedbacks # 북마크를 위해 feedback id 저장 +Authorization: {{ token_type }} {{ auth_token }} + +[QueryStringParams] +survey-id: {{ survey_id }} + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +[Captures] +form_question_feedback_id: jsonpath "$.question_feedback.[0].feedbacks.[0].form_question_feedback_id" + +########## + +PATCH http://nalab-server:8080/v1/feedbacks/bookmarks # 북마크를 진행한다. +Authorization: {{ token_type }} {{ auth_token }} + +[QueryStringParams] +form-question-feedback-id: {{form_question_feedback_id}} + +########## + +POST http://nalab-server:8080/v1/gallerys # gallery를 등록한다 +Authorization: {{ token_type }} {{ auth_token }} +{ + "job": "designer" +} + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.target.target_id" == {{ target_id }} +jsonpath "$.target.nickname" == "logined_gallery" +jsonpath "$.target.position" == null +jsonpath "$.target.job" == "DESIGNER" +jsonpath "$.target.image_url" == "empty_image" + +jsonpath "$.survey.survey_id" == {{ survey_id }} +jsonpath "$.survey.feedback_count" == 1 +jsonpath "$.survey.bookmarked_count" == 1 +jsonpath "$.survey.feedbacks.[0]" == "Hello world" +jsonpath "$.survey.tendencies.[0].name" == "UI" +jsonpath "$.survey.tendencies.[0].count" == 1 + +########## + +GET http://nalab-server:8080/v1/gallerys/logins # 내 gallery 를 조회한다 +Authorization: {{ token_type }} {{ auth_token }} + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.target.target_id" == {{ target_id }} +jsonpath "$.target.nickname" == "logined_gallery" +jsonpath "$.target.position" == null +jsonpath "$.target.job" == "DESIGNER" +jsonpath "$.target.image_url" == "empty_image" + +jsonpath "$.survey.survey_id" == {{ survey_id }} +jsonpath "$.survey.feedback_count" == 1 +jsonpath "$.survey.bookmarked_count" == 1 +jsonpath "$.survey.feedbacks.[0]" == "Hello world" +jsonpath "$.survey.tendencies.[0].name" == "UI" +jsonpath "$.survey.tendencies.[0].count" == 1 + diff --git a/support/e2e/v1_5_target.hurl b/support/e2e/v1_5_target.hurl index e0a9edff..fbb9c87a 100644 --- a/support/e2e/v1_5_target.hurl +++ b/support/e2e/v1_5_target.hurl @@ -17,6 +17,17 @@ auth_token: jsonpath "$.access_token" ########## +GET http://nalab-server:8080/v1/surveys/exists # 질문폼이 없는 상태에서 질문폼 존재 유무 확인 -> false +Authorization: {{ token_type }} {{ auth_token }} + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.exists" == false + +########## + POST http://nalab-server:8080/v1/surveys # 발급받은 토큰으로 survey를 생성한다 Authorization: {{ token_type }} {{ auth_token }} { @@ -63,6 +74,17 @@ survey_id: jsonpath "$.survey_id" ########## +GET http://nalab-server:8080/v1/surveys/exists # 질문폼이 있는 상태에서 질문폼 존재 유무 확인 -> true +Authorization: {{ token_type }} {{ auth_token }} + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.exists" == true + +########## + PATCH http://nalab-server:8080/v1/users # 로그인된 타겟의 정보를 수정한다 Authorization: {{ token_type }} {{ auth_token }} { diff --git a/support/e2e/v1_6_find_gallery_preview.hurl b/support/e2e/v1_6_find_gallery_preview.hurl new file mode 100644 index 00000000..1a53bba0 --- /dev/null +++ b/support/e2e/v1_6_find_gallery_preview.hurl @@ -0,0 +1,153 @@ +POST http://nalab-server:8080/v1/oauth/default # Default provider를 통해서 로그인 진행 +{ + "nickname": "find_gallery", + "email": "hello@1234567" +} + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.access_token" exists +jsonpath "$.token_type" exists + +[Captures] +token_type: jsonpath "$.token_type" +auth_token: jsonpath "$.access_token" + +########## + +POST http://nalab-server:8080/v1/surveys # 발급받은 토큰으로 survey를 생성한다. +Authorization: {{ token_type }} {{ auth_token }} +{ + "question_count": 2, + "question": [ + { + "type": "choice", + "form_type": "tendency", + "title": "저는 UI, UI, GUI 중에 어떤 분야를 가장 잘하는 것 같나요?", + "choices": [ + { + "content": "UI", + "order": 1 + }, + { + "content": "UX", + "order": 2 + }, + { + "content": "GUI", + "order": 3 + } + ], + "max_selectable_count": 1, + "order": 1 + }, + { + "type": "short", + "form_type": "strength", + "title": "저는 UX, UI, GUI 중에 어떤 분야에 더 강점이 있나요?", + "order": 2 + } + ] +} + +HTTP 201 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.survey_id" exists + +[Captures] +survey_id: jsonpath "$.survey_id" + +########## + +GET http://nalab-server:8080/v1/surveys/{{ survey_id }} # 생성된 survey를 조회하고, feedback을 남기기 위해 id를 저장한다. + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +[Captures] +tendency_question_id: jsonpath "$.question.[0].question_id" +tendency_question_choice_id: jsonpath "$.question.[0].choices.[0].choice_id" +strength_question_id: jsonpath "$.question.[1].question_id" + +########## + +POST http://nalab-server:8080/v1/feedbacks # 생성된 survey에 feedback을 남긴다. + +[QueryStringParams] +survey-id: {{ survey_id }} + +{ + "reviewer": { + "collaboration_experience": true, + "position": "pm" + }, + "question_feedback": [ + { + "question_id": {{ tendency_question_id }}, + "type": "choice", + "choices": [ + {{ tendency_question_choice_id }} + ] + }, + { + "question_id": {{ strength_question_id }}, + "type": "short", + "reply": [ + "Hello world" + ] + } + ] +} + +HTTP 201 +[Asserts] + +########## + +GET http://nalab-server:8080/v1/feedbacks # 북마크를 위해 feedback id 저장 +Authorization: {{ token_type }} {{ auth_token }} + +[QueryStringParams] +survey-id: {{ survey_id }} + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +[Captures] +form_question_feedback_id: jsonpath "$.question_feedback.[0].feedbacks.[0].form_question_feedback_id" + +########## + +PATCH http://nalab-server:8080/v1/feedbacks/bookmarks # 북마크를 진행한다. +Authorization: {{ token_type }} {{ auth_token }} + +[QueryStringParams] +form-question-feedback-id: {{form_question_feedback_id}} + +########## + +GET http://nalab-server:8080/v1/gallerys/previews # 생성된 유저의 Gallery Preview 조회 +Authorization: {{ token_type }} {{ auth_token }} + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.target.target_id" exists +jsonpath "$.target.nickname" == "find_gallery" +jsonpath "$.target.position" == null +jsonpath "$.target.job" == "OTHERS" +jsonpath "$.target.image_url" == "empty_image" + +jsonpath "$.survey.survey_id" == {{ survey_id }} +jsonpath "$.survey.feedback_count" == 1 +jsonpath "$.survey.bookmarked_count" == 0 +jsonpath "$.survey.feedbacks.[0]" == "Hello world" +jsonpath "$.survey.tendencies.[0].name" == "UI" +jsonpath "$.survey.tendencies.[0].count" == 1 diff --git a/support/e2e/v1_7_bookmark_survey.hurl b/support/e2e/v1_7_bookmark_survey.hurl new file mode 100644 index 00000000..cbd4c7dc --- /dev/null +++ b/support/e2e/v1_7_bookmark_survey.hurl @@ -0,0 +1,113 @@ +POST http://nalab-server:8080/v1/oauth/default # Default provider를 통해서 로그인 진행 +{ + "nickname": "bookmark_survey", + "email": "hello@123456" +} + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.access_token" exists +jsonpath "$.token_type" exists + +[Captures] +token_type: jsonpath "$.token_type" +auth_token: jsonpath "$.access_token" + +########## + +POST http://nalab-server:8080/v1/surveys # 발급받은 토큰으로 survey를 생성한다. +Authorization: {{ token_type }} {{ auth_token }} +{ + "question_count": 2, + "question": [ + { + "type": "choice", + "form_type": "tendency", + "title": "저는 UI, UI, GUI 중에 어떤 분야를 가장 잘하는 것 같나요?", + "choices": [ + { + "content": "UI", + "order": 1 + }, + { + "content": "UX", + "order": 2 + }, + { + "content": "GUI", + "order": 3 + } + ], + "max_selectable_count": 1, + "order": 1 + }, + { + "type": "short", + "form_type": "strength", + "title": "저는 UX, UI, GUI 중에 어떤 분야에 더 강점이 있나요?", + "order": 2 + } + ] +} + +HTTP 201 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.survey_id" exists + +[Captures] +survey_id: jsonpath "$.survey_id" + +########## + +GET http://nalab-server:8080/v1/surveys/{{ survey_id }} # 생성된 survey를 조회한다. + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.survey_id" exists + +jsonpath "$.target.id" exists +jsonpath "$.target.nickname" == "bookmark_survey" + +jsonpath "$.question_count" == 2 +jsonpath "$.question.[0].question_id" exists +jsonpath "$.question.[0].type" == "choice" +jsonpath "$.question.[0].form_type" == "tendency" +jsonpath "$.question.[0].title" == "저는 UI, UI, GUI 중에 어떤 분야를 가장 잘하는 것 같나요?" +jsonpath "$.question.[0].order" == 1 +jsonpath "$.question.[0].max_selectable_count" == 1 +jsonpath "$.question.[0].choices.[0].choice_id" exists +jsonpath "$.question.[0].choices.[0].content" == "UI" +jsonpath "$.question.[0].choices.[0].order" == 1 +jsonpath "$.question.[0].choices.[1].choice_id" exists +jsonpath "$.question.[0].choices.[1].content" == "UX" +jsonpath "$.question.[0].choices.[1].order" == 2 +jsonpath "$.question.[0].choices.[2].choice_id" exists +jsonpath "$.question.[0].choices.[2].content" == "GUI" +jsonpath "$.question.[0].choices.[2].order" == 3 +jsonpath "$.question.[1].question_id" exists +jsonpath "$.question.[1].type" == "short" +jsonpath "$.question.[1].form_type" == "strength" +jsonpath "$.question.[1].title" == "저는 UX, UI, GUI 중에 어떤 분야에 더 강점이 있나요?" +jsonpath "$.question.[1].order" == 2 + +[Captures] +target_id: jsonpath "$.target.id" + +########## + +POST http://nalab-server:8080/v1/surveys/{{ survey_id }}/bookmarks +Authorization: {{ token_type }} {{ auth_token }} + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.target_id" == {{ target_id }} +jsonpath "$.survey_id" == {{ survey_id }} +jsonpath "$.nickname" == "bookmark_survey" diff --git a/support/e2e/v1_9_register_gallery.hurl b/support/e2e/v1_9_register_gallery.hurl new file mode 100644 index 00000000..c9108087 --- /dev/null +++ b/support/e2e/v1_9_register_gallery.hurl @@ -0,0 +1,198 @@ +POST http://nalab-server:8080/v1/oauth/default # Default provider를 통해서 로그인 진행 +{ + "nickname": "register_gallery", + "email": "registergallery@123456" +} + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.access_token" exists +jsonpath "$.token_type" exists + +[Captures] +token_type: jsonpath "$.token_type" +auth_token: jsonpath "$.access_token" + +########## + +POST http://nalab-server:8080/v1/surveys # 발급받은 토큰으로 survey를 생성한다. +Authorization: {{ token_type }} {{ auth_token }} +{ + "question_count": 2, + "question": [ + { + "type": "choice", + "form_type": "tendency", + "title": "저는 UI, UI, GUI 중에 어떤 분야를 가장 잘하는 것 같나요?", + "choices": [ + { + "content": "UI", + "order": 1 + }, + { + "content": "UX", + "order": 2 + }, + { + "content": "GUI", + "order": 3 + } + ], + "max_selectable_count": 1, + "order": 1 + }, + { + "type": "short", + "form_type": "strength", + "title": "저는 UX, UI, GUI 중에 어떤 분야에 더 강점이 있나요?", + "order": 2 + } + ] +} + +HTTP 201 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.survey_id" exists + +[Captures] +survey_id: jsonpath "$.survey_id" + +########## + +GET http://nalab-server:8080/v1/surveys/{{ survey_id }} # 생성된 survey를 조회한다. + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.survey_id" exists + +jsonpath "$.target.id" exists +jsonpath "$.target.nickname" == "register_gallery" + +jsonpath "$.question_count" == 2 +jsonpath "$.question.[0].question_id" exists +jsonpath "$.question.[0].type" == "choice" +jsonpath "$.question.[0].form_type" == "tendency" +jsonpath "$.question.[0].title" == "저는 UI, UI, GUI 중에 어떤 분야를 가장 잘하는 것 같나요?" +jsonpath "$.question.[0].order" == 1 +jsonpath "$.question.[0].max_selectable_count" == 1 +jsonpath "$.question.[0].choices.[0].choice_id" exists +jsonpath "$.question.[0].choices.[0].content" == "UI" +jsonpath "$.question.[0].choices.[0].order" == 1 +jsonpath "$.question.[0].choices.[1].choice_id" exists +jsonpath "$.question.[0].choices.[1].content" == "UX" +jsonpath "$.question.[0].choices.[1].order" == 2 +jsonpath "$.question.[0].choices.[2].choice_id" exists +jsonpath "$.question.[0].choices.[2].content" == "GUI" +jsonpath "$.question.[0].choices.[2].order" == 3 +jsonpath "$.question.[1].question_id" exists +jsonpath "$.question.[1].type" == "short" +jsonpath "$.question.[1].form_type" == "strength" +jsonpath "$.question.[1].title" == "저는 UX, UI, GUI 중에 어떤 분야에 더 강점이 있나요?" +jsonpath "$.question.[1].order" == 2 + +[Captures] +target_id: jsonpath "$.target.id" +tendency_question_id: jsonpath "$.question.[0].question_id" +tendency_question_choice_id: jsonpath "$.question.[0].choices.[0].choice_id" +strength_question_id: jsonpath "$.question.[1].question_id" + + +########## + +POST http://nalab-server:8080/v1/surveys/{{ survey_id }}/bookmarks # survey_id를 북마크한다. +Authorization: {{ token_type }} {{ auth_token }} + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.target_id" == {{ target_id }} +jsonpath "$.survey_id" == {{ survey_id }} +jsonpath "$.nickname" == "register_gallery" + +########## + +POST http://nalab-server:8080/v1/feedbacks # 생성된 survey에 feedback을 남긴다. + +[QueryStringParams] +survey-id: {{ survey_id }} + +{ + "reviewer": { + "collaboration_experience": true, + "position": "pm" + }, + "question_feedback": [ + { + "question_id": {{ tendency_question_id }}, + "type": "choice", + "choices": [ + {{ tendency_question_choice_id }} + ] + }, + { + "question_id": {{ strength_question_id }}, + "type": "short", + "reply": [ + "Hello world" + ] + } + ] +} + +HTTP 201 +[Asserts] + +########## + +GET http://nalab-server:8080/v1/feedbacks # 북마크를 위해 feedback id 저장 +Authorization: {{ token_type }} {{ auth_token }} + +[QueryStringParams] +survey-id: {{ survey_id }} + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +[Captures] +form_question_feedback_id: jsonpath "$.question_feedback.[0].feedbacks.[0].form_question_feedback_id" + +########## + +PATCH http://nalab-server:8080/v1/feedbacks/bookmarks # 북마크를 진행한다. +Authorization: {{ token_type }} {{ auth_token }} + +[QueryStringParams] +form-question-feedback-id: {{form_question_feedback_id}} + +########## + +POST http://nalab-server:8080/v1/gallerys # gallery를 등록한다 +Authorization: {{ token_type }} {{ auth_token }} +{ + "job": "designer" +} + +HTTP 200 +[Asserts] +header "Content-type" == "application/json" + +jsonpath "$.target.target_id" == {{ target_id }} +jsonpath "$.target.nickname" == "register_gallery" +jsonpath "$.target.position" == null +jsonpath "$.target.job" == "DESIGNER" +jsonpath "$.target.image_url" == "empty_image" + +jsonpath "$.survey.survey_id" == {{ survey_id }} +jsonpath "$.survey.feedback_count" == 1 +jsonpath "$.survey.bookmarked_count" == 1 +jsonpath "$.survey.feedbacks.[0]" == "Hello world" +jsonpath "$.survey.tendencies.[0].name" == "UI" +jsonpath "$.survey.tendencies.[0].count" == 1 diff --git a/survey/survey-application/src/main/java/me/nalab/survey/application/common/survey/dto/SurveyBookmarkDto.java b/survey/survey-application/src/main/java/me/nalab/survey/application/common/survey/dto/SurveyBookmarkDto.java new file mode 100644 index 00000000..fc961100 --- /dev/null +++ b/survey/survey-application/src/main/java/me/nalab/survey/application/common/survey/dto/SurveyBookmarkDto.java @@ -0,0 +1,28 @@ +package me.nalab.survey.application.common.survey.dto; + +import lombok.Builder; +import me.nalab.survey.domain.target.SurveyBookmark; +import me.nalab.survey.domain.target.Target; + +@Builder +public record SurveyBookmarkDto( + Long targetId, + Long surveyId, + String nickname, + String position, + String job, + String imageUrl +) { + + public static SurveyBookmarkDto from(Long surveyId, Target target) { + return SurveyBookmarkDto.builder() + .surveyId(surveyId) + .targetId(target.getId()) + .nickname(target.getNickname()) + .job(target.getJob()) + .imageUrl(target.getImageUrl()) + .position(target.getPosition()) + .build(); + } + +} diff --git a/survey/survey-application/src/main/java/me/nalab/survey/application/common/survey/dto/TargetDto.java b/survey/survey-application/src/main/java/me/nalab/survey/application/common/survey/dto/TargetDto.java index 0ca96ae0..835ada55 100644 --- a/survey/survey-application/src/main/java/me/nalab/survey/application/common/survey/dto/TargetDto.java +++ b/survey/survey-application/src/main/java/me/nalab/survey/application/common/survey/dto/TargetDto.java @@ -1,5 +1,6 @@ package me.nalab.survey.application.common.survey.dto; +import java.util.List; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -14,5 +15,5 @@ public class TargetDto { private final Long id; private final String nickname; private final String position; - + private final List bookmarkedSurveys; } diff --git a/survey/survey-application/src/main/java/me/nalab/survey/application/common/survey/mapper/TargetDtoMapper.java b/survey/survey-application/src/main/java/me/nalab/survey/application/common/survey/mapper/TargetDtoMapper.java index 9deb596d..a7ca0f9b 100644 --- a/survey/survey-application/src/main/java/me/nalab/survey/application/common/survey/mapper/TargetDtoMapper.java +++ b/survey/survey-application/src/main/java/me/nalab/survey/application/common/survey/mapper/TargetDtoMapper.java @@ -1,6 +1,7 @@ package me.nalab.survey.application.common.survey.mapper; import me.nalab.survey.application.common.survey.dto.TargetDto; +import me.nalab.survey.domain.target.SurveyBookmark; import me.nalab.survey.domain.target.Target; public class TargetDtoMapper { @@ -21,6 +22,9 @@ public static TargetDto toTargetDto(Target target) { .id(target.getId()) .nickname(target.getNickname()) .position(target.getPosition()) + .bookmarkedSurveys(target.getBookmarkedSurveys().stream() + .map(SurveyBookmark::surveyId) + .toList()) .build(); } } diff --git a/survey/survey-application/src/main/java/me/nalab/survey/application/port/in/web/bookmark/SurveyBookmarkUseCase.java b/survey/survey-application/src/main/java/me/nalab/survey/application/port/in/web/bookmark/SurveyBookmarkUseCase.java new file mode 100644 index 00000000..a363a9b2 --- /dev/null +++ b/survey/survey-application/src/main/java/me/nalab/survey/application/port/in/web/bookmark/SurveyBookmarkUseCase.java @@ -0,0 +1,13 @@ +package me.nalab.survey.application.port.in.web.bookmark; + +import me.nalab.survey.application.common.survey.dto.SurveyBookmarkDto; + +public interface SurveyBookmarkUseCase { + + /** + * targetId에 해당하는 유저에게 survey를 북마크합니다. + * 이미 북마크되어있다면 북마크를 취소합니다. + */ + SurveyBookmarkDto bookmark(Long targetId, Long surveyId); + +} diff --git a/survey/survey-application/src/main/java/me/nalab/survey/application/port/in/web/existsurvey/SurveyExistUseCase.java b/survey/survey-application/src/main/java/me/nalab/survey/application/port/in/web/existsurvey/SurveyExistUseCase.java new file mode 100644 index 00000000..fc4b31c2 --- /dev/null +++ b/survey/survey-application/src/main/java/me/nalab/survey/application/port/in/web/existsurvey/SurveyExistUseCase.java @@ -0,0 +1,16 @@ +package me.nalab.survey.application.port.in.web.existsurvey; + +/** + * Survey의 존재 유무를 확인하는 인터페이스 입니다. + */ +public interface SurveyExistUseCase { + + /** + * targetId를 받아, survey의 존재유무를 반환합니다. + * + * @param targetId survey를 확인할 target의 id + * @return boolean 존재한다면 true, 없다면 false + */ + boolean existSurveyByTargetId(Long targetId); + +} diff --git a/survey/survey-application/src/main/java/me/nalab/survey/application/port/in/web/survey/find/SurveyFindUseCase.java b/survey/survey-application/src/main/java/me/nalab/survey/application/port/in/web/survey/find/SurveyFindUseCase.java index 82435dbc..e65d58d6 100644 --- a/survey/survey-application/src/main/java/me/nalab/survey/application/port/in/web/survey/find/SurveyFindUseCase.java +++ b/survey/survey-application/src/main/java/me/nalab/survey/application/port/in/web/survey/find/SurveyFindUseCase.java @@ -14,4 +14,11 @@ public interface SurveyFindUseCase { */ SurveyDto findSurvey(Long surveyId); + /** + * targetId로 surveyDto 조회 + * @param targetId target의 id + * @return SurveyDto + */ + SurveyDto getSurveyByTargetId(Long targetId); + } diff --git a/survey/survey-application/src/main/java/me/nalab/survey/application/port/out/persistence/bookmark/SurveyBookmarkListenPort.java b/survey/survey-application/src/main/java/me/nalab/survey/application/port/out/persistence/bookmark/SurveyBookmarkListenPort.java new file mode 100644 index 00000000..5935c6b5 --- /dev/null +++ b/survey/survey-application/src/main/java/me/nalab/survey/application/port/out/persistence/bookmark/SurveyBookmarkListenPort.java @@ -0,0 +1,6 @@ +package me.nalab.survey.application.port.out.persistence.bookmark; + +public interface SurveyBookmarkListenPort { + + void listenBookmarked(Long targetId); +} diff --git a/survey/survey-application/src/main/java/me/nalab/survey/application/port/out/persistence/bookmark/SurveyBookmarkPort.java b/survey/survey-application/src/main/java/me/nalab/survey/application/port/out/persistence/bookmark/SurveyBookmarkPort.java new file mode 100644 index 00000000..3d940bd4 --- /dev/null +++ b/survey/survey-application/src/main/java/me/nalab/survey/application/port/out/persistence/bookmark/SurveyBookmarkPort.java @@ -0,0 +1,8 @@ +package me.nalab.survey.application.port.out.persistence.bookmark; + +import me.nalab.survey.domain.target.Target; + +public interface SurveyBookmarkPort { + + void bookmark(Target target); +} diff --git a/survey/survey-application/src/main/java/me/nalab/survey/application/port/out/persistence/existsurvey/SurveyExistPort.java b/survey/survey-application/src/main/java/me/nalab/survey/application/port/out/persistence/existsurvey/SurveyExistPort.java new file mode 100644 index 00000000..341869d7 --- /dev/null +++ b/survey/survey-application/src/main/java/me/nalab/survey/application/port/out/persistence/existsurvey/SurveyExistPort.java @@ -0,0 +1,15 @@ +package me.nalab.survey.application.port.out.persistence.existsurvey; + +/** + * Survey가 존재하는지 확인하는 인터페이스 입니다. + */ +public interface SurveyExistPort { + + /** + * targetId 를 인자로 받아, survey가 저장되어 있다면 true, 아니라면 false를 반환하는 인터페이스 입니다. + * + * @param targetId 조회할 survey에 연결된 target-id 입니다. + * @return boolean survey가 있다면 true, 없다면 false를 반환해야 합니다. + */ + boolean isSurveyExistByTargetId(Long targetId); +} diff --git a/survey/survey-application/src/main/java/me/nalab/survey/application/port/out/persistence/findtarget/TargetFindPort.java b/survey/survey-application/src/main/java/me/nalab/survey/application/port/out/persistence/findtarget/TargetFindPort.java index 1a1f79fa..9cb96216 100644 --- a/survey/survey-application/src/main/java/me/nalab/survey/application/port/out/persistence/findtarget/TargetFindPort.java +++ b/survey/survey-application/src/main/java/me/nalab/survey/application/port/out/persistence/findtarget/TargetFindPort.java @@ -17,4 +17,10 @@ public interface TargetFindPort { */ Optional findTargetById(Long targetId); + /** + * targetId를 받아 Target을 반환합니다. + * targetId에 해당하는 Target이 없다면 예외를 던집니다. + */ + Target getTargetById(Long targetId); + } diff --git a/survey/survey-application/src/main/java/me/nalab/survey/application/port/out/persistence/survey/find/SurveyFindPort.java b/survey/survey-application/src/main/java/me/nalab/survey/application/port/out/persistence/survey/find/SurveyFindPort.java index 0a8d3af3..c135c2d3 100644 --- a/survey/survey-application/src/main/java/me/nalab/survey/application/port/out/persistence/survey/find/SurveyFindPort.java +++ b/survey/survey-application/src/main/java/me/nalab/survey/application/port/out/persistence/survey/find/SurveyFindPort.java @@ -16,4 +16,12 @@ public interface SurveyFindPort { * @return Optional 만약, 어떠한 surveyId도 없을 경우, Optional.empty() 를 반환해야 합니다. */ Optional findSurvey(Long surveyId); + + /** + * targetId로 survey를 조회합니다. + * + * @param targetId survey를 조회할 target의 ID + * @return Optional 만약, 어떠한 surveyId도 없을 경우, Optional.empty() 를 반환해야 합니다. + */ + Survey getSurveyByTargetId(Long targetId); } diff --git a/survey/survey-application/src/main/java/me/nalab/survey/application/service/bookmark/SurveyBookmarkService.java b/survey/survey-application/src/main/java/me/nalab/survey/application/service/bookmark/SurveyBookmarkService.java new file mode 100644 index 00000000..afb3dc0e --- /dev/null +++ b/survey/survey-application/src/main/java/me/nalab/survey/application/service/bookmark/SurveyBookmarkService.java @@ -0,0 +1,39 @@ +package me.nalab.survey.application.service.bookmark; + +import lombok.RequiredArgsConstructor; +import me.nalab.survey.application.common.survey.dto.SurveyBookmarkDto; +import me.nalab.survey.application.exception.SurveyDoesNotExistException; +import me.nalab.survey.application.port.in.web.bookmark.SurveyBookmarkUseCase; +import me.nalab.survey.application.port.out.persistence.bookmark.SurveyBookmarkListenPort; +import me.nalab.survey.application.port.out.persistence.bookmark.SurveyBookmarkPort; +import me.nalab.survey.application.port.out.persistence.findfeedback.SurveyExistCheckPort; +import me.nalab.survey.application.port.out.persistence.findtarget.TargetFindPort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SurveyBookmarkService implements SurveyBookmarkUseCase { + + private final TargetFindPort targetFindPort; + private final SurveyExistCheckPort surveyExistCheckPort; + private final SurveyBookmarkPort surveyBookmarkPort; + private final SurveyBookmarkListenPort surveyBookmarkListener; + + @Override + @Transactional + public SurveyBookmarkDto bookmark(Long targetId, Long surveyId) { + var target = targetFindPort.getTargetById(targetId); + + if (!surveyExistCheckPort.isExistSurveyBySurveyId(surveyId)) { + throw new SurveyDoesNotExistException(surveyId); + } + + target.bookmark(surveyId); + surveyBookmarkPort.bookmark(target); + + surveyBookmarkListener.listenBookmarked(targetId); + + return SurveyBookmarkDto.from(surveyId, target); + } +} diff --git a/survey/survey-application/src/main/java/me/nalab/survey/application/service/existsurvey/SurveyExistService.java b/survey/survey-application/src/main/java/me/nalab/survey/application/service/existsurvey/SurveyExistService.java new file mode 100644 index 00000000..baa876a5 --- /dev/null +++ b/survey/survey-application/src/main/java/me/nalab/survey/application/service/existsurvey/SurveyExistService.java @@ -0,0 +1,21 @@ +package me.nalab.survey.application.service.existsurvey; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import me.nalab.survey.application.port.in.web.existsurvey.SurveyExistUseCase; +import me.nalab.survey.application.port.out.persistence.existsurvey.SurveyExistPort; + +@Service +@RequiredArgsConstructor +public class SurveyExistService implements SurveyExistUseCase { + + private final SurveyExistPort surveyExistPort; + + @Override + @Transactional(readOnly = true) + public boolean existSurveyByTargetId(Long targetId) { + return surveyExistPort.isSurveyExistByTargetId(targetId); + } +} diff --git a/survey/survey-application/src/main/java/me/nalab/survey/application/service/find/SurveyFindService.java b/survey/survey-application/src/main/java/me/nalab/survey/application/service/find/SurveyFindService.java index 00b9171d..742ce57a 100644 --- a/survey/survey-application/src/main/java/me/nalab/survey/application/service/find/SurveyFindService.java +++ b/survey/survey-application/src/main/java/me/nalab/survey/application/service/find/SurveyFindService.java @@ -33,4 +33,10 @@ public SurveyDto findSurvey(Long surveyId) { return SurveyDtoMapper.toSurveyDto(targetId, survey); } + + @Override + public SurveyDto getSurveyByTargetId(Long targetId) { + var survey = surveyFindPort.getSurveyByTargetId(targetId); + return SurveyDtoMapper.toSurveyDto(targetId, survey); + } } diff --git a/survey/survey-application/src/main/java/module-info.java b/survey/survey-application/src/main/java/module-info.java index 0528180f..74682830 100644 --- a/survey/survey-application/src/main/java/module-info.java +++ b/survey/survey-application/src/main/java/module-info.java @@ -36,6 +36,8 @@ exports me.nalab.survey.application.port.in.web.findtarget; exports me.nalab.survey.application.port.in.web.findfeedback.formtype; exports me.nalab.survey.application.port.out.persistence.findfeedback.formtype; + exports me.nalab.survey.application.port.out.persistence.existsurvey; + exports me.nalab.survey.application.port.in.web.existsurvey; requires luffy.survey.domain.main; requires luffy.core.id.generator.id.generator.starter.main; diff --git a/survey/survey-application/src/test/java/me/nalab/survey/application/service/existsurvey/SurveyExistServiceTest.java b/survey/survey-application/src/test/java/me/nalab/survey/application/service/existsurvey/SurveyExistServiceTest.java new file mode 100644 index 00000000..330e79e4 --- /dev/null +++ b/survey/survey-application/src/test/java/me/nalab/survey/application/service/existsurvey/SurveyExistServiceTest.java @@ -0,0 +1,56 @@ +package me.nalab.survey.application.service.existsurvey; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import me.nalab.survey.application.port.in.web.existsurvey.SurveyExistUseCase; +import me.nalab.survey.application.port.out.persistence.existsurvey.SurveyExistPort; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = {SurveyExistService.class}) +class SurveyExistServiceTest { + + @MockBean + private SurveyExistPort surveyExistPort; + + @Autowired + private SurveyExistUseCase surveyExistUseCase; + + @Test + @DisplayName("surveyExistsUseCase는 targetId에 해당하는 survey가 존재한다면, true를 반환한다.") + void RETURN_TRUE_IF_SURVEY_EXISTS() { + // given + Long targetId = 1L; + + Mockito.when(surveyExistPort.isSurveyExistByTargetId(targetId)).thenReturn(true); + + // when + boolean result = surveyExistUseCase.existSurveyByTargetId(targetId); + + // then + Assertions.assertThat(result).isTrue(); + } + + @Test + @DisplayName("surveyExistsUseCase는 targetId에 해당하는 survey가 존재하지 않는다면, false를 반환한다.") + void RETURN_FALSE_IF_SURVEY_DOES_NOT_EXISTS() { + // given + Long targetId = 1L; + + Mockito.when(surveyExistPort.isSurveyExistByTargetId(targetId)).thenReturn(false); + + // when + boolean result = surveyExistUseCase.existSurveyByTargetId(targetId); + + // then + Assertions.assertThat(result).isFalse(); + } + +} diff --git a/survey/survey-domain/src/main/java/me/nalab/survey/domain/target/SurveyBookmark.java b/survey/survey-domain/src/main/java/me/nalab/survey/domain/target/SurveyBookmark.java new file mode 100644 index 00000000..50d07da7 --- /dev/null +++ b/survey/survey-domain/src/main/java/me/nalab/survey/domain/target/SurveyBookmark.java @@ -0,0 +1,7 @@ +package me.nalab.survey.domain.target; + +public record SurveyBookmark( + Long surveyId +) { + +} diff --git a/survey/survey-domain/src/main/java/me/nalab/survey/domain/target/Target.java b/survey/survey-domain/src/main/java/me/nalab/survey/domain/target/Target.java index b109a3d0..db791c8d 100644 --- a/survey/survey-domain/src/main/java/me/nalab/survey/domain/target/Target.java +++ b/survey/survey-domain/src/main/java/me/nalab/survey/domain/target/Target.java @@ -1,8 +1,9 @@ package me.nalab.survey.domain.target; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.function.LongSupplier; - import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -17,20 +18,31 @@ @ToString public class Target implements IdGeneratable { - private Long id; - private final List surveyList; - private final String nickname; - private String position; + private static final Set NONE_BOOKMARKED_SURVEYS = new HashSet<>(); + + private Long id; + private final List surveyList; + private final String nickname; + private final String job; + private final String imageUrl; + @Builder.Default + private final Set bookmarkedSurveys = NONE_BOOKMARKED_SURVEYS; + private String position; + + @Override + public void withId(LongSupplier idSupplier) { + if (id != null) { + throw new IdAlreadyGeneratedException(this); + } + id = idSupplier.getAsLong(); + } - @Override - public void withId(LongSupplier idSupplier) { - if (id != null) { - throw new IdAlreadyGeneratedException(this); - } - id = idSupplier.getAsLong(); - } + public void setPosition(String position) { + this.position = position; + } - public void setPosition(String position) { - this.position = position; - } + public void bookmark(Long surveyId) { + var bookmark = new SurveyBookmark(surveyId); + bookmarkedSurveys.add(bookmark); + } } diff --git a/survey/survey-jpa-adaptor/build.gradle b/survey/survey-jpa-adaptor/build.gradle index 548578ae..dd32eb9c 100644 --- a/survey/survey-jpa-adaptor/build.gradle +++ b/survey/survey-jpa-adaptor/build.gradle @@ -5,5 +5,6 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + testImplementation project(':core:time') testImplementation 'com.h2database:h2:2.1.214' } diff --git a/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/bookmark/SurveyBookmarkAdaptor.java b/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/bookmark/SurveyBookmarkAdaptor.java new file mode 100644 index 00000000..7e069df8 --- /dev/null +++ b/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/bookmark/SurveyBookmarkAdaptor.java @@ -0,0 +1,24 @@ +package me.nalab.survey.jpa.adaptor.bookmark; + +import lombok.RequiredArgsConstructor; +import me.nalab.survey.application.exception.TargetDoesNotExistException; +import me.nalab.survey.application.port.out.persistence.bookmark.SurveyBookmarkPort; +import me.nalab.survey.domain.target.Target; +import me.nalab.survey.jpa.adaptor.common.mapper.TargetEntityMapper; +import me.nalab.survey.jpa.adaptor.find.repository.TargetFindRepository; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class SurveyBookmarkAdaptor implements SurveyBookmarkPort { + + private final TargetFindRepository targetFindRepository; + + @Override + public void bookmark(Target target) { + var savedTarget = targetFindRepository.findById(target.getId()) + .orElseThrow(() -> new TargetDoesNotExistException(target.getId())); + + savedTarget.setBookmarkedSurveys(TargetEntityMapper.toSurveyBookmarkEntity(target.getBookmarkedSurveys())); + } +} diff --git a/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/common/mapper/TargetEntityMapper.java b/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/common/mapper/TargetEntityMapper.java index f3dae651..5135c3cd 100644 --- a/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/common/mapper/TargetEntityMapper.java +++ b/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/common/mapper/TargetEntityMapper.java @@ -1,6 +1,10 @@ package me.nalab.survey.jpa.adaptor.common.mapper; +import java.util.Set; +import java.util.stream.Collectors; +import me.nalab.core.data.target.SurveyBookmarkEntity; import me.nalab.core.data.target.TargetEntity; +import me.nalab.survey.domain.target.SurveyBookmark; import me.nalab.survey.domain.target.Target; public class TargetEntityMapper { @@ -14,14 +18,31 @@ public static TargetEntity toTargetEntity(Target target) { .id(target.getId()) .nickname(target.getNickname()) .position(target.getPosition()) + .imageUrl(target.getImageUrl()) + .job(target.getJob()) + .bookmarkedSurveys(toSurveyBookmarkEntity(target.getBookmarkedSurveys())) .build(); } + public static Set toSurveyBookmarkEntity(Set surveyBookmarks) { + return surveyBookmarks.stream() + .map(surveyBookmark -> new SurveyBookmarkEntity(surveyBookmark.surveyId())) + .collect(Collectors.toSet()); + } + public static Target toTarget(TargetEntity targetEntity) { return Target.builder() .id(targetEntity.getId()) .nickname(targetEntity.getNickname()) .position(targetEntity.getPosition()) + .job(targetEntity.getJob()) + .bookmarkedSurveys(toSurveyBookmark(targetEntity.getBookmarkedSurveys())) .build(); } + + public static Set toSurveyBookmark(Set surveyBookmarkEntities) { + return surveyBookmarkEntities.stream() + .map(surveyBookmarkEntity -> new SurveyBookmark(surveyBookmarkEntity.getSurveyId())) + .collect(Collectors.toSet()); + } } diff --git a/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/existsurvey/SurveyExistAdaptor.java b/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/existsurvey/SurveyExistAdaptor.java new file mode 100644 index 00000000..a6e47ebd --- /dev/null +++ b/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/existsurvey/SurveyExistAdaptor.java @@ -0,0 +1,26 @@ +package me.nalab.survey.jpa.adaptor.existsurvey; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Repository; + +import me.nalab.survey.application.port.out.persistence.existsurvey.SurveyExistPort; +import me.nalab.survey.jpa.adaptor.existsurvey.repository.SurveyFindRepository; + +@Repository("existsurvey.SurveyExistAdaptor") +public class SurveyExistAdaptor implements SurveyExistPort { + + private final SurveyFindRepository surveyFindRepository; + + @Autowired + SurveyExistAdaptor( + @Qualifier("existsurvey.SurveyFindRepository") SurveyFindRepository surveyFindRepository) { + this.surveyFindRepository = surveyFindRepository; + } + + @Override + public boolean isSurveyExistByTargetId(Long targetId) { + return surveyFindRepository.existsByTargetId(targetId); + } + +} diff --git a/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/existsurvey/repository/SurveyFindRepository.java b/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/existsurvey/repository/SurveyFindRepository.java new file mode 100644 index 00000000..2e74543b --- /dev/null +++ b/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/existsurvey/repository/SurveyFindRepository.java @@ -0,0 +1,11 @@ +package me.nalab.survey.jpa.adaptor.existsurvey.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import me.nalab.core.data.survey.SurveyEntity; + +@Repository("existsurvey.SurveyFindRepository") +public interface SurveyFindRepository extends JpaRepository { + boolean existsByTargetId(Long targetId); +} diff --git a/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/find/SurveyFindAdaptor.java b/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/find/SurveyFindAdaptor.java index c19cdc29..9c705e5a 100644 --- a/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/find/SurveyFindAdaptor.java +++ b/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/find/SurveyFindAdaptor.java @@ -1,15 +1,14 @@ package me.nalab.survey.jpa.adaptor.find; import java.util.Optional; - -import org.springframework.stereotype.Repository; - import lombok.RequiredArgsConstructor; import me.nalab.core.data.survey.SurveyEntity; +import me.nalab.survey.application.exception.TargetDoesNotHasSurveyException; import me.nalab.survey.application.port.out.persistence.survey.find.SurveyFindPort; import me.nalab.survey.domain.survey.Survey; import me.nalab.survey.jpa.adaptor.common.mapper.SurveyEntityMapper; import me.nalab.survey.jpa.adaptor.find.repository.SurveyFindRepository; +import org.springframework.stereotype.Repository; @Repository @RequiredArgsConstructor @@ -27,4 +26,12 @@ public Optional findSurvey(Long surveyId) { return Optional.of(survey); } + + @Override + public Survey getSurveyByTargetId(Long targetId) { + var surveyEntity = surveyFindRepository.findByTargetId(targetId) + .orElseThrow(() -> new TargetDoesNotHasSurveyException(targetId)); + + return SurveyEntityMapper.toSurvey(surveyEntity); + } } diff --git a/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/find/repository/SurveyFindRepository.java b/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/find/repository/SurveyFindRepository.java index 1889603f..9447f38b 100644 --- a/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/find/repository/SurveyFindRepository.java +++ b/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/find/repository/SurveyFindRepository.java @@ -1,8 +1,11 @@ package me.nalab.survey.jpa.adaptor.find.repository; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import me.nalab.core.data.survey.SurveyEntity; public interface SurveyFindRepository extends JpaRepository { + + Optional findByTargetId(Long targetId); } diff --git a/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/findtarget/TargetFindAdaptor.java b/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/findtarget/TargetFindAdaptor.java index d01f9d91..e31101c2 100644 --- a/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/findtarget/TargetFindAdaptor.java +++ b/survey/survey-jpa-adaptor/src/main/java/me/nalab/survey/jpa/adaptor/findtarget/TargetFindAdaptor.java @@ -2,6 +2,7 @@ import java.util.Optional; +import me.nalab.survey.application.exception.TargetDoesNotExistException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Repository; @@ -26,11 +27,14 @@ public class TargetFindAdaptor implements TargetFindPort { @Override public Optional findTargetById(Long targetId) { Optional targetEntity = targetFindJpaRepository.findById(targetId); - if (targetEntity.isEmpty()) { - return Optional.empty(); - } - - return Optional.of(TargetEntityMapper.toTarget(targetEntity.get())); + return targetEntity.map(TargetEntityMapper::toTarget); } + @Override + public Target getTargetById(Long targetId) { + var targetEntity = targetFindJpaRepository.findById(targetId) + .orElseThrow(() -> new TargetDoesNotExistException(targetId)); + + return TargetEntityMapper.toTarget(targetEntity); + } } diff --git a/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/RandomFeedbackFixture.java b/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/RandomFeedbackFixture.java index e27ed391..76704167 100644 --- a/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/RandomFeedbackFixture.java +++ b/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/RandomFeedbackFixture.java @@ -1,6 +1,5 @@ package me.nalab.survey.jpa.adaptor; -import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.HashSet; import java.util.List; @@ -11,6 +10,7 @@ import java.util.stream.Collectors; import lombok.Setter; +import me.nalab.core.time.TimeUtil; import me.nalab.survey.domain.feedback.Bookmark; import me.nalab.survey.domain.feedback.ChoiceFormQuestionFeedback; import me.nalab.survey.domain.feedback.Feedback; @@ -107,7 +107,7 @@ private static ChoiceFormQuestionFeedback getRandomChoiceFormQuestionFeedback( .isRead(randomBooleanGenerator.getAsBoolean()) .bookmark(Bookmark.builder() .isBookmarked(false) - .bookmarkedAt(Instant.now()) + .bookmarkedAt(TimeUtil.toInstant()) .build()) .selectedChoiceIdSet(selectedIdSet) .build(); @@ -121,10 +121,9 @@ private static ShortFormQuestionFeedback getRandomShortFormQuestionFeedback( .isRead(randomBooleanGenerator.getAsBoolean()) .bookmark(Bookmark.builder() .isBookmarked(false) - .bookmarkedAt(Instant.now()) + .bookmarkedAt(TimeUtil.toInstant()) .build()) .replyList(List.of(randomStringGenerator.get())) .build(); } - } diff --git a/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/RandomSurveyFixture.java b/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/RandomSurveyFixture.java index ac2ca974..49138131 100644 --- a/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/RandomSurveyFixture.java +++ b/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/RandomSurveyFixture.java @@ -7,6 +7,7 @@ import java.util.function.Supplier; import lombok.Setter; +import me.nalab.core.time.TimeUtil; import me.nalab.survey.domain.survey.Choice; import me.nalab.survey.domain.survey.ChoiceFormQuestion; import me.nalab.survey.domain.survey.ChoiceFormQuestionType; @@ -47,7 +48,8 @@ public Long get() { return id; } }; - randomDateTimeGenerator = Instant::now; + randomDateTimeGenerator = TimeUtil::toInstant; + randomQuestionCountGenerator = () -> (new Random()).nextInt(10) + 1; randomStringGenerator = () -> { Random random = new Random(); diff --git a/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/create/SurveyCreateAdaptorTest.java b/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/create/SurveyCreateAdaptorTest.java index 218c4e8a..d4a73f81 100644 --- a/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/create/SurveyCreateAdaptorTest.java +++ b/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/create/SurveyCreateAdaptorTest.java @@ -3,10 +3,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import java.time.Instant; - import javax.persistence.EntityManager; +import me.nalab.core.time.TimeUtil; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -55,8 +54,8 @@ void SURVEY_PERSISTENCE_SUCCESS() { // given TargetEntity targetEntity = TargetEntity.builder() .id(101L) - .createdAt(Instant.now()) - .updatedAt(Instant.now()) + .createdAt(TimeUtil.toInstant()) + .updatedAt(TimeUtil.toInstant()) .nickname("test target") .build(); Survey survey = RandomSurveyFixture.createRandomSurvey(); diff --git a/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/existsurvey/SurveyExistAdaptorTest.java b/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/existsurvey/SurveyExistAdaptorTest.java new file mode 100644 index 00000000..9a28665f --- /dev/null +++ b/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/existsurvey/SurveyExistAdaptorTest.java @@ -0,0 +1,80 @@ +package me.nalab.survey.jpa.adaptor.existsurvey; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.Instant; + +import javax.persistence.EntityManager; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; + +import me.nalab.core.data.survey.SurveyEntity; +import me.nalab.core.data.target.TargetEntity; +import me.nalab.survey.domain.survey.Survey; +import me.nalab.survey.jpa.adaptor.RandomSurveyFixture; +import me.nalab.survey.jpa.adaptor.common.mapper.SurveyEntityMapper; + +@DataJpaTest +@EnableJpaRepositories +@EntityScan("me.nalab.core.data") +@ContextConfiguration(classes = SurveyExistAdaptor.class) +@TestPropertySource("classpath:h2.properties") +class SurveyExistAdaptorTest { + + @Autowired + private SurveyExistAdaptor surveyExistAdaptor; + + @Autowired + private TestSurveyFindRepository testSurveyFindRepository; + + @Autowired + private TestTargetRepository testTargetRepository; + + @Autowired + private EntityManager entityManager; + + @Test + @DisplayName("targetID에 해당하는 survey가 존재하는 경우") + void FIND_SURVEY_WITH_TARGET_ID_WITH_SURVEY() { + + TargetEntity targetEntity = getTargetEntity(); + Survey survey = RandomSurveyFixture.createRandomSurvey(); + SurveyEntity surveyEntity = SurveyEntityMapper.toSurveyEntity(targetEntity.getId(), survey); + + testTargetRepository.saveAndFlush(targetEntity); + testSurveyFindRepository.saveAndFlush(surveyEntity); + entityManager.clear(); + + boolean result = testSurveyFindRepository.existsByTargetId(targetEntity.getId()); + assertTrue(result); + } + + @Test + @DisplayName("targetID에 해당하는 survey가 존재하지 않는 경우") + void FIND_SURVEY_WITH_TARGET_ID_WITH_NO_SURVEY() { + + TargetEntity targetEntity = getTargetEntity(); + + testTargetRepository.saveAndFlush(targetEntity); + entityManager.clear(); + + boolean result = testSurveyFindRepository.existsByTargetId(targetEntity.getId()); + assertFalse(result); + } + + private TargetEntity getTargetEntity() { + return TargetEntity.builder() + .id(1L) + .createdAt(Instant.now()) + .updatedAt(Instant.now()) + .nickname("nalab") + .build(); + } +} diff --git a/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/existsurvey/TestSurveyFindRepository.java b/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/existsurvey/TestSurveyFindRepository.java new file mode 100644 index 00000000..b141e4ed --- /dev/null +++ b/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/existsurvey/TestSurveyFindRepository.java @@ -0,0 +1,10 @@ +package me.nalab.survey.jpa.adaptor.existsurvey; + +import org.springframework.data.jpa.repository.JpaRepository; + +import me.nalab.core.data.survey.SurveyEntity; + +public interface TestSurveyFindRepository extends JpaRepository { + + boolean existsByTargetId(Long targetId); +} diff --git a/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/existsurvey/TestTargetRepository.java b/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/existsurvey/TestTargetRepository.java new file mode 100644 index 00000000..8197478d --- /dev/null +++ b/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/existsurvey/TestTargetRepository.java @@ -0,0 +1,9 @@ +package me.nalab.survey.jpa.adaptor.existsurvey; + +import org.springframework.data.jpa.repository.JpaRepository; + +import me.nalab.core.data.target.TargetEntity; + +public interface TestTargetRepository extends JpaRepository { + +} diff --git a/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/findfeedback/formtype/FeedbackFindAdaptorTest.java b/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/findfeedback/formtype/FeedbackFindAdaptorTest.java index 585159f8..320005d4 100644 --- a/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/findfeedback/formtype/FeedbackFindAdaptorTest.java +++ b/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/findfeedback/formtype/FeedbackFindAdaptorTest.java @@ -2,11 +2,11 @@ import static org.junit.jupiter.api.Assertions.*; -import java.time.Instant; import java.util.List; import javax.persistence.EntityManager; +import me.nalab.core.time.TimeUtil; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -29,7 +29,7 @@ @DataJpaTest @EnableJpaRepositories @EntityScan("me.nalab.core.data") -@ContextConfiguration(classes = FeedbackFindAdaptor.class) +@ContextConfiguration(classes = {FeedbackFindAdaptor.class}) @TestPropertySource("classpath:h2.properties") class FeedbackFindAdaptorTest { @@ -89,8 +89,8 @@ void FIND_ALL_FEEDBACK_WITH_SURVEY_ID_WITH_NO_FEEDBACK() { private TargetEntity getTargetEntity() { return TargetEntity.builder() .id(1L) - .createdAt(Instant.now()) - .updatedAt(Instant.now()) + .createdAt(TimeUtil.toInstant()) + .updatedAt(TimeUtil.toInstant()) .nickname("nalab") .build(); } diff --git a/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/findfeedback/formtype/SurveyFindAdaptorTest.java b/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/findfeedback/formtype/SurveyFindAdaptorTest.java index cbb7812e..602b136a 100644 --- a/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/findfeedback/formtype/SurveyFindAdaptorTest.java +++ b/survey/survey-jpa-adaptor/src/test/java/me/nalab/survey/jpa/adaptor/findfeedback/formtype/SurveyFindAdaptorTest.java @@ -2,11 +2,11 @@ import static org.junit.jupiter.api.Assertions.*; -import java.time.Instant; import java.util.Optional; import javax.persistence.EntityManager; +import me.nalab.core.time.TimeUtil; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -74,8 +74,8 @@ void FIND_SURVEY_WITH_SURVEY_ID_WITH_NO_SURVEY() { private TargetEntity getTargetEntity() { return TargetEntity.builder() .id(1L) - .createdAt(Instant.now()) - .updatedAt(Instant.now()) + .createdAt(TimeUtil.toInstant()) + .updatedAt(TimeUtil.toInstant()) .nickname("nalab") .build(); } diff --git a/survey/survey-web-adaptor/src/main/java/me/nalab/survey/web/adaptor/bookmark/SurveyBookmarkReplaceController.java b/survey/survey-web-adaptor/src/main/java/me/nalab/survey/web/adaptor/bookmark/SurveyBookmarkReplaceController.java new file mode 100644 index 00000000..9daf08cf --- /dev/null +++ b/survey/survey-web-adaptor/src/main/java/me/nalab/survey/web/adaptor/bookmark/SurveyBookmarkReplaceController.java @@ -0,0 +1,30 @@ +package me.nalab.survey.web.adaptor.bookmark; + +import lombok.RequiredArgsConstructor; +import me.nalab.survey.application.port.in.web.bookmark.SurveyBookmarkUseCase; +import me.nalab.survey.web.adaptor.bookmark.response.SurveyBookmarkResponse; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1") +@RequiredArgsConstructor +public class SurveyBookmarkReplaceController { + + private final SurveyBookmarkUseCase surveyBookmarkReplaceUseCase; + + @ResponseStatus(HttpStatus.OK) + @PostMapping("/surveys/{survey_id}/bookmarks") + public SurveyBookmarkResponse replaceBookmark(@RequestAttribute("logined") Long targetId, + @PathVariable("survey_id") Long surveyId) { + var surveyBookmarked = surveyBookmarkReplaceUseCase.bookmark(targetId, surveyId); + + return SurveyBookmarkResponse.of(surveyBookmarked); + } + +} diff --git a/survey/survey-web-adaptor/src/main/java/me/nalab/survey/web/adaptor/bookmark/response/SurveyBookmarkResponse.java b/survey/survey-web-adaptor/src/main/java/me/nalab/survey/web/adaptor/bookmark/response/SurveyBookmarkResponse.java new file mode 100644 index 00000000..144cd905 --- /dev/null +++ b/survey/survey-web-adaptor/src/main/java/me/nalab/survey/web/adaptor/bookmark/response/SurveyBookmarkResponse.java @@ -0,0 +1,30 @@ +package me.nalab.survey.web.adaptor.bookmark.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import me.nalab.survey.application.common.survey.dto.SurveyBookmarkDto; + +@Builder +public record SurveyBookmarkResponse( + @JsonProperty("target_id") + String targetId, + @JsonProperty("survey_id") + String surveyId, + String nickname, + String position, + String job, + @JsonProperty("image_url") + String imageUrl +) { + + public static SurveyBookmarkResponse of(SurveyBookmarkDto surveyBookmarkDto) { + return SurveyBookmarkResponse.builder() + .targetId(surveyBookmarkDto.targetId().toString()) + .surveyId(surveyBookmarkDto.surveyId().toString()) + .job(surveyBookmarkDto.job()) + .nickname(surveyBookmarkDto.nickname()) + .imageUrl(surveyBookmarkDto.imageUrl()) + .position(surveyBookmarkDto.position()) + .build(); + } +} diff --git a/survey/survey-web-adaptor/src/main/java/me/nalab/survey/web/adaptor/createsurvey/SurveyCreateController.java b/survey/survey-web-adaptor/src/main/java/me/nalab/survey/web/adaptor/createsurvey/SurveyCreateController.java index 1399c2fb..a45a1ad6 100644 --- a/survey/survey-web-adaptor/src/main/java/me/nalab/survey/web/adaptor/createsurvey/SurveyCreateController.java +++ b/survey/survey-web-adaptor/src/main/java/me/nalab/survey/web/adaptor/createsurvey/SurveyCreateController.java @@ -26,7 +26,6 @@ class SurveyCreateController { private final CreateSurveyUseCase createSurveyUseCase; private final LatestSurveyIdFindUseCase latestSurveyIdFindUseCase; - private final TimeUtil timeUtil; @XssFiltering @PostMapping("/surveys") @@ -34,7 +33,7 @@ class SurveyCreateController { public SurveyIdResponse createSurvey(@RequestAttribute("logined") Long loginId, @Xss("json") @Valid @RequestBody SurveyCreateRequest surveyCreateRequest) { createSurveyUseCase.createSurvey(loginId, - SurveyCreateRequestMapper.toSurveyDto(surveyCreateRequest, timeUtil.toInstant())); + SurveyCreateRequestMapper.toSurveyDto(surveyCreateRequest, TimeUtil.toInstant())); String latestSurveyId = String.valueOf(latestSurveyIdFindUseCase.getLatestSurveyIdByTargetId(loginId)); return new SurveyIdResponse(latestSurveyId); } diff --git a/survey/survey-web-adaptor/src/main/java/me/nalab/survey/web/adaptor/existsurvey/SurveyExistController.java b/survey/survey-web-adaptor/src/main/java/me/nalab/survey/web/adaptor/existsurvey/SurveyExistController.java new file mode 100644 index 00000000..04651db9 --- /dev/null +++ b/survey/survey-web-adaptor/src/main/java/me/nalab/survey/web/adaptor/existsurvey/SurveyExistController.java @@ -0,0 +1,28 @@ +package me.nalab.survey.web.adaptor.existsurvey; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import me.nalab.survey.application.port.in.web.existsurvey.SurveyExistUseCase; +import me.nalab.survey.web.adaptor.existsurvey.response.SurveyExistResponse; + +@RestController +@RequestMapping("/v1") +@RequiredArgsConstructor +public class SurveyExistController { + + private final SurveyExistUseCase surveyExistUseCase; + + @GetMapping("/surveys/exists") + @ResponseStatus(HttpStatus.OK) + public SurveyExistResponse existSurveyByTargetId(@RequestAttribute("logined") Long loginId) { + boolean isSurveyExists = surveyExistUseCase.existSurveyByTargetId(loginId); + return new SurveyExistResponse(isSurveyExists); + } + +} diff --git a/survey/survey-web-adaptor/src/main/java/me/nalab/survey/web/adaptor/existsurvey/response/SurveyExistResponse.java b/survey/survey-web-adaptor/src/main/java/me/nalab/survey/web/adaptor/existsurvey/response/SurveyExistResponse.java new file mode 100644 index 00000000..e83fa5a5 --- /dev/null +++ b/survey/survey-web-adaptor/src/main/java/me/nalab/survey/web/adaptor/existsurvey/response/SurveyExistResponse.java @@ -0,0 +1,16 @@ +package me.nalab.survey.web.adaptor.existsurvey.response; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@Getter +@ToString +@EqualsAndHashCode +@RequiredArgsConstructor +public class SurveyExistResponse { + + private final boolean exists; + +} diff --git a/user/user-application/src/main/java/me/nalab/user/application/service/UserCreateWithOAuthService.java b/user/user-application/src/main/java/me/nalab/user/application/service/UserCreateWithOAuthService.java index 532aa43a..5d8fa44e 100644 --- a/user/user-application/src/main/java/me/nalab/user/application/service/UserCreateWithOAuthService.java +++ b/user/user-application/src/main/java/me/nalab/user/application/service/UserCreateWithOAuthService.java @@ -22,7 +22,6 @@ public class UserCreateWithOAuthService implements UserCreateWithOAuthUseCase { private final UserCreateWithOAuthPort userCreateWithOAuthPort; private final CreateTargetUseCase createTargetUseCase; private final IdGenerator idGenerator; - private final TimeUtil timeUtil; @Override public long createUser(CreateUserWithOAuthRequest request) { @@ -32,7 +31,7 @@ public long createUser(CreateUserWithOAuthRequest request) { Objects.requireNonNull(email, "유저를 생성하기 위해서는 이메일 값은 필수입니다."); var userId = idGenerator.generate(); - var now = timeUtil.toInstant(); + var now = TimeUtil.toInstant(); var user = new User(userId, request.getUsername(), email, now, now); var userOAuthInfo = new UserOAuthInfo( diff --git a/user/user-application/src/test/java/me/nalab/user/application/service/UserCreateWithOAuthServiceTest.java b/user/user-application/src/test/java/me/nalab/user/application/service/UserCreateWithOAuthServiceTest.java index 8f1ab75e..f24b6348 100644 --- a/user/user-application/src/test/java/me/nalab/user/application/service/UserCreateWithOAuthServiceTest.java +++ b/user/user-application/src/test/java/me/nalab/user/application/service/UserCreateWithOAuthServiceTest.java @@ -3,6 +3,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; +import java.time.Clock; import java.time.Instant; import org.assertj.core.api.Assertions; @@ -37,9 +38,6 @@ class UserCreateWithOAuthServiceTest { @MockBean private MockIdGenerator mockIdGenerator; - @MockBean - private TimeUtil timeUtil; - @Test @DisplayName("Provider가 null이면 예외를 던진다") void THROW_EXCEPTION_WHEN_PROVIDER_IS_NULL() { @@ -53,8 +51,7 @@ void THROW_EXCEPTION_WHEN_PROVIDER_IS_NULL() { username, null ); - - when(timeUtil.toInstant()).thenReturn(Instant.now()); + TimeUtil.fixed(Clock.systemUTC()); // when var throwable = Assertions.catchThrowable(() -> userCreateWithOAuthService.createUser(request)); @@ -76,8 +73,7 @@ void THROW_EXCEPTION_WHEN_EMAIL_IS_NULL() { username, null ); - - when(timeUtil.toInstant()).thenReturn(Instant.now()); + TimeUtil.fixed(Clock.systemUTC()); // when var throwable = Assertions.catchThrowable(() -> userCreateWithOAuthService.createUser(request)); @@ -102,7 +98,7 @@ void RETURN_NEW_USER_ID_WHEN_VALID_INPUT() { long createdUserId = 1L; when(userCreateWithOAuthPort.createUserWithOAuth(any(), any())).thenReturn(createdUserId); - when(timeUtil.toInstant()).thenReturn(Instant.now()); + TimeUtil.fixed(Clock.systemUTC()); // when var userId = userCreateWithOAuthService.createUser(request); diff --git a/user/user-domain/src/main/java/me/nalab/user/domain/user/Provider.java b/user/user-domain/src/main/java/me/nalab/user/domain/user/Provider.java index 3b34bdd4..1169f1ba 100644 --- a/user/user-domain/src/main/java/me/nalab/user/domain/user/Provider.java +++ b/user/user-domain/src/main/java/me/nalab/user/domain/user/Provider.java @@ -10,6 +10,7 @@ @RequiredArgsConstructor public enum Provider { KAKAO(true), + APPLE(true), DEFAULT(false) ;