diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 0000000..663ba77 --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,126 @@ +name: Deploy Prod Backend + +concurrency: + group: be-prod + cancel-in-progress: true # Deploy 작업은 한번에 하나만 실행하도록 제한 + +on: + push: + tags: + - 'v*.*.*-be' + +jobs: + build-and-push: + runs-on: ubuntu-latest + + outputs: + version: ${{ steps.get_version.outputs.VERSION }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # 태그 검증을 위해 전체 Git 히스토리 필요 + + - name: tag가 be/prod branch에 있는지 검증 + run: | + git fetch origin be/prod + if git merge-base --is-ancestor ${{ github.sha }} origin/be/prod; then + echo "이 태그는 be/prod branch에 포함되어 있습니다. 배포를 진행합니다." + else + echo "Error: 이 tag는 be/prod branch에 없습니다." + exit 1 + fi + + - name: Git tag에서 버전 정보 추출 + id: get_version + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + + - name: Docker Hub 로그인 + run: echo "${{ secrets.DOCKER_ACCESS_TOKEN }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin + + - name: QEMU 설정 + uses: docker/setup-qemu-action@v3 + + - name: Docker Buildx 설정 + uses: docker/setup-buildx-action@v3 + + - name: Docker Image Build & Push + uses: docker/build-push-action@v6 + with: + context: ./ + file: ./Dockerfile + push: true + platforms: linux/arm64 + tags: | + ${{ secrets.PROD_IMAGE_NAME }}:${{ steps.get_version.outputs.VERSION }} + ${{ secrets.PROD_IMAGE_NAME }}:latest + + deploy: + runs-on: [ self-hosted, recycle-study-server-prod ] + needs: build-and-push + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: .env file 생성 + run: | + cat < .env + DOCKER_IMAGE=${{ secrets.PROD_IMAGE_NAME }} + DOCKER_TAG=${{ needs.build-and-push.outputs.version }} + ${{ secrets.ENV_PROD }} + EOF + + - name: log directory 준비 + run: | + sudo mkdir -p /app/log + sudo chown -R 1001:1001 /app/log + + - name: 배포 시작 + run: | + docker compose -f docker-compose.prod.yaml pull + docker compose -f docker-compose.prod.yaml up -d + + - name: 신규 deploy에 대한 health check + run: | + echo "==========================================" + echo "Health Check 시작" + echo "==========================================" + + echo "애플리케이션 초기화 대기 중... (10초)" + sleep 10 + + # 최대 12번 시도 (총 60초) + for i in {1..12}; do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/actuator/health || echo "000") + + if [ "$STATUS" -eq 200 ]; then + echo "==========================================" + echo "배포 성공!" + echo "==========================================" + docker ps --filter "name=recycle-study-server" + exit 0 + fi + + echo "[$i/12] 헬스체크 재시도... (응답 코드: $STATUS)" + sleep 5 + done + + # Health Check 실패 시 + echo "==========================================" + echo "Health Check 실패" + echo "==========================================" + echo "컨테이너 상태:" + docker ps -a --filter "name=recycle-study-server" + echo "" + echo "컨테이너 로그:" + docker logs recycle-study-server --tail 100 || true + + exit 1 + + - name: Docker resource 정리 + if: always() + run: | + docker image prune -a -f + docker builder prune -a -f diff --git a/.github/workflows/test_coverage.yml b/.github/workflows/test_coverage.yml new file mode 100644 index 0000000..d7da34a --- /dev/null +++ b/.github/workflows/test_coverage.yml @@ -0,0 +1,67 @@ +name: Test Coverage Validation + +on: + pull_request: + types: + [ opened, synchronize, reopened ] + branches: + - 'be/**' + +jobs: + Build-And-Test: + runs-on: ubuntu-latest + + steps: + - name: Github Repository 파일 불러오기 + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Docker + uses: docker/setup-buildx-action@v3 + + - name: JDK 버전 21 설치 + uses: actions/setup-java@v5 + with: + distribution: corretto + java-version: 21 + + - name: Gradle Wrapper 실행 권한 부여 + run: chmod +x ./gradlew + + - name: 전체 빌드 및 테스트 + run: ./gradlew --info test + + - name: JaCoCo 커버리지 리포트 생성 + id: jacoco_report + uses: madrapps/jacoco-report@v1.7.2 + with: + paths: ${{ github.workspace }}/build/reports/jacoco/test/jacocoTestReport.xml + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 70 + min-coverage-changed-files: 70 + title: "🧪 테스트 커버리지 리포트" + update-comment: true + + - name: Close PR, if build fail + if: ${{ failure() }} + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const pull_number = ${{ github.event.pull_request.number }} + const updated_title = `[VALIDATION FAIL] ${{ github.event.pull_request.title }}` + await github.rest.pulls.createReview({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pull_number, + body: '빌드 또는 테스트 커버리지 검증에 실패했습니다.', + event: 'REQUEST_CHANGES' + }) + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pull_number, + title: updated_title, + state: 'closed' + }) diff --git a/.gitignore b/.gitignore index 527cb86..bf2fc93 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### .env ### .env + +# Generated Swagger UI +src/main/resources/static/docs/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ea2cc0d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# Build stage +FROM amazoncorretto:21-alpine3.19-jdk AS builder + +WORKDIR /app + +COPY gradlew . +COPY gradle gradle +COPY build.gradle . +COPY settings.gradle . +COPY src src + +RUN chmod +x ./gradlew +RUN ./gradlew bootJar --no-daemon + +# Runtime stage +FROM amazoncorretto:21-alpine3.19 + +WORKDIR /app + +RUN apk add --no-cache curl tzdata && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +RUN addgroup -g 1001 appgroup && adduser -u 1001 -G appgroup -D appuser +RUN mkdir -p /app/log && chown -R appuser:appgroup /app + +COPY --from=builder --chown=appuser:appgroup /app/build/libs/*.jar app.jar + +USER appuser + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..6f8bce6 --- /dev/null +++ b/build.gradle @@ -0,0 +1,125 @@ +plugins { + id 'java' + id 'jacoco' + id 'org.springframework.boot' version '3.5.9' + id 'io.spring.dependency-management' version '1.1.7' + id 'com.epages.restdocs-api-spec' version '0.19.4' + id 'org.hidetake.swagger.generator' version '2.19.2' +} + +group = 'com.recyclestudy' +version = '0.0.1-SNAPSHOT' +description = 'Periodic Review Assistant' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + runtimeOnly 'com.mysql:mysql-connector-j' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-mysql' + + // monitoring + implementation 'org.springframework.boot:spring-boot-starter-actuator' + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.rest-assured:rest-assured' + testRuntimeOnly 'com.h2database:h2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // api docs + testImplementation 'org.springframework.restdocs:spring-restdocs-restassured' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + testImplementation 'com.epages:restdocs-api-spec-restassured:0.19.4' + implementation 'com.epages:restdocs-api-spec-mockmvc:0.19.4' + + swaggerUI 'org.webjars:swagger-ui:5.30.3' +} + +tasks.named('test') { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacoco { + toolVersion = "0.8.14" +} + +jacocoTestReport { + reports { + html.required = true + xml.required = true + csv.required = true + } + + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: [ + "**/*Application*", + "**/common/**", + "**/exception/**", + "**/request/**", + "**/response/**", + "**/input/**", + "**/output/**", + "**/dto/**" + ]) + })) + } + dependsOn test +} + +openapi3 { + servers = [ + { url = "http://localhost:8080" } + ] + title = "Recycle-Study Server API Document" + description = "응답 테스트 검증이 완료된 명세서입니다." + version = "0.0.1" + format = "yaml" +} + +swaggerSources { + sample { + setInputFile(file("${project.buildDir}/api-spec/openapi3.yaml")) + } +} + +tasks.withType(GenerateSwaggerUI).configureEach { + dependsOn 'openapi3' +} + +tasks.register('copySwaggerDocument', Copy) { + dependsOn generateSwaggerUI + + from file("build/swagger-ui-sample/") + into file("src/main/resources/static/docs") +} + +jar { + enabled = false +} + +bootJar { + dependsOn copySwaggerDocument +} diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml new file mode 100644 index 0000000..4bec73e --- /dev/null +++ b/docker-compose.prod.yaml @@ -0,0 +1,11 @@ +services: + app: + container_name: recycle-study-server + image: ${DOCKER_IMAGE}:${DOCKER_TAG} + restart: unless-stopped + ports: + - "8080:8080" + env_file: + - .env + volumes: + - /app/log:/app/log diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..892f0cd --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,34 @@ +services: + app: + container_name: recycle-study-server + build: . + restart: unless-stopped + ports: + - "8080:8080" + env_file: + - .env + depends_on: + mysql: + condition: service_healthy + + mysql: + container_name: recycle-study-mysql + image: mysql:8.4 + volumes: + - mysql_volume:/var/lib/mysql + restart: unless-stopped + ports: + - "33306:3306" + environment: + - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD} + - MYSQL_DATABASE=${DB_DATABASE} + - MYSQL_USER=${DB_USERNAME} + - MYSQL_PASSWORD=${DB_PASSWORD} + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + mysql_volume: diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..d284d11 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'recyclestudy' diff --git a/src/main/java/com/recyclestudy/RecyclestudyApplication.java b/src/main/java/com/recyclestudy/RecyclestudyApplication.java new file mode 100644 index 0000000..89662b6 --- /dev/null +++ b/src/main/java/com/recyclestudy/RecyclestudyApplication.java @@ -0,0 +1,17 @@ +package com.recyclestudy; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableAsync +@EnableScheduling +@SpringBootApplication +public class RecyclestudyApplication { + + public static void main(String[] args) { + SpringApplication.run(RecyclestudyApplication.class, args); + } + +} diff --git a/src/main/java/com/recyclestudy/common/BaseEntity.java b/src/main/java/com/recyclestudy/common/BaseEntity.java new file mode 100644 index 0000000..82e243a --- /dev/null +++ b/src/main/java/com/recyclestudy/common/BaseEntity.java @@ -0,0 +1,57 @@ +package com.recyclestudy.common; + +import jakarta.persistence.Column; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.Hibernate; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +@MappedSuperclass +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + protected Long id; + + @Column(name = "created_at", nullable = false) + @CreationTimestamp + protected LocalDateTime createdAt; + + @Column(name = "modified_at") + @UpdateTimestamp + protected LocalDateTime modifiedAt; + + @Override + public final boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null) { + return false; + } + + if (Hibernate.getClass(this) != Hibernate.getClass(o)) { + return false; + } + final BaseEntity that = (BaseEntity) o; + return id != null && id.equals(that.id); + } + + @Override + public int hashCode() { + if (id == null) { + throw new IllegalStateException("hashCode() called on entity without ID"); + } + return Objects.hash(id); + } +} diff --git a/src/main/java/com/recyclestudy/common/NullValidator.java b/src/main/java/com/recyclestudy/common/NullValidator.java new file mode 100644 index 0000000..e914489 --- /dev/null +++ b/src/main/java/com/recyclestudy/common/NullValidator.java @@ -0,0 +1,35 @@ +package com.recyclestudy.common; + +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class NullValidator { + + public static ValidatorBuilder builder() { + return new ValidatorBuilder(); + } + + public static class ValidatorBuilder { + + private final List elements = new ArrayList<>(); + + public ValidatorBuilder add(final String name, final Object target) { + this.elements.add(new ValidationElement(name, target)); + return this; + } + + public void validate() { + for (final ValidationElement element : this.elements) { + if (element.target() == null) { + throw new IllegalArgumentException("null이 될 수 없습니다: %s".formatted(element.name)); + } + } + } + + private record ValidationElement(String name, Object target) { + } + } +} diff --git a/src/main/java/com/recyclestudy/common/config/TimeConfig.java b/src/main/java/com/recyclestudy/common/config/TimeConfig.java new file mode 100644 index 0000000..a7b112f --- /dev/null +++ b/src/main/java/com/recyclestudy/common/config/TimeConfig.java @@ -0,0 +1,14 @@ +package com.recyclestudy.common.config; + +import java.time.Clock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class TimeConfig { + + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } +} diff --git a/src/main/java/com/recyclestudy/common/log/ApiLogFilter.java b/src/main/java/com/recyclestudy/common/log/ApiLogFilter.java new file mode 100644 index 0000000..03eade5 --- /dev/null +++ b/src/main/java/com/recyclestudy/common/log/ApiLogFilter.java @@ -0,0 +1,96 @@ +package com.recyclestudy.common.log; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Optional; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; + +import static com.recyclestudy.common.log.MDCKey.CLIENT_IP; +import static com.recyclestudy.common.log.MDCKey.HOST; +import static com.recyclestudy.common.log.MDCKey.HTTP_METHOD; +import static com.recyclestudy.common.log.MDCKey.QUERY_STRING; +import static com.recyclestudy.common.log.MDCKey.REQUEST_URI; +import static com.recyclestudy.common.log.MDCKey.TRACE_ID; +import static com.recyclestudy.common.log.MDCKey.USER_AGENT; + +@Slf4j +@Component +public class ApiLogFilter implements Filter { + + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + + @Override + public void doFilter( + final ServletRequest servletRequest, + final ServletResponse servletResponse, + final FilterChain filterChain + ) throws IOException, ServletException { + final HttpServletRequest request = (HttpServletRequest) servletRequest; + final HttpServletResponse response = (HttpServletResponse) servletResponse; + + final String traceId = Optional.ofNullable(request.getHeader(REQUEST_ID_HEADER)) + .filter(header -> !header.isBlank()) + .orElseGet(this::generateTraceId); + + populateMDC(traceId, request); + response.setHeader(REQUEST_ID_HEADER, traceId); + + final long startTime = System.currentTimeMillis(); + logRequest(request); + + int statusForLog = 200; + try { + filterChain.doFilter(servletRequest, servletResponse); + statusForLog = response.getStatus(); + } catch (final Exception ex) { + statusForLog = 500; + throw ex; + } finally { + logResponse(response, startTime, statusForLog); + MDC.clear(); + } + } + + private void logRequest(final HttpServletRequest request) { + final String uri = request.getRequestURI(); + final String method = request.getMethod(); + final String ip = request.getRemoteAddr(); + + final String queryString = request.getQueryString(); + final String userAgentHeader = request.getHeader("User-Agent"); + final String query = (queryString != null ? "?" + queryString : ""); + final String userAgent = (userAgentHeader != null ? userAgentHeader : "-"); + + log.info("[REQ] layer=filter | ip={} | method={} | uri={}{} | userAgent={}", ip, method, uri, query, userAgent); + } + + private void logResponse(final HttpServletResponse response, final long startTime, final int status) { + final long duration = System.currentTimeMillis() - startTime; + final String contentType = Optional.ofNullable(response.getContentType()).orElse("-"); + + log.info("[RES] layer=filter | status={} | duration={}ms | contentType={}", status, duration, contentType); + } + + private String generateTraceId() { + return UUID.randomUUID().toString().substring(0, 8); + } + + private void populateMDC(final String traceId, final HttpServletRequest request) { + MDC.put(TRACE_ID.getKey(), traceId); + MDC.put(HOST.getKey(), request.getHeader("host")); + MDC.put(HTTP_METHOD.getKey(), request.getMethod()); + MDC.put(REQUEST_URI.getKey(), request.getRequestURI()); + MDC.put(QUERY_STRING.getKey(), request.getQueryString()); + MDC.put(CLIENT_IP.getKey(), request.getRemoteAddr()); + MDC.put(USER_AGENT.getKey(), request.getHeader("User-Agent")); + } +} diff --git a/src/main/java/com/recyclestudy/common/log/ControllerLoggingAspect.java b/src/main/java/com/recyclestudy/common/log/ControllerLoggingAspect.java new file mode 100644 index 0000000..62d7e91 --- /dev/null +++ b/src/main/java/com/recyclestudy/common/log/ControllerLoggingAspect.java @@ -0,0 +1,88 @@ +package com.recyclestudy.common.log; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Aspect +@Component +@Slf4j +public class ControllerLoggingAspect { + + private static final int MAX_LOG_LENGTH = 500; + + @Pointcut("execution(* com.recyclestudy..controller..*(..))") + public void controllerMethods() { + } + + @Around("controllerMethods()") + public Object logController(final ProceedingJoinPoint joinPoint) throws Throwable { + final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + final String className = signature.getDeclaringType().getSimpleName(); + final String methodName = signature.getName(); + final Object[] args = joinPoint.getArgs(); + + final HttpServletRequest request = getCurrentHttpRequest(); + final String httpMethod = request != null ? request.getMethod() : "N/A"; + final String uri = request != null ? request.getRequestURI() : "N/A"; + + final long startTime = System.currentTimeMillis(); + logRequest(className, methodName, httpMethod, uri, args); + + final Object result = joinPoint.proceed(); + logResponse(className, methodName, httpMethod, uri, result, startTime); + + return result; + } + + private void logRequest( + final String className, + final String methodName, + final String httpMethod, + final String uri, + final Object[] args + ) { + log.info("[REQ] layer=controller | method={}.{} | httpMethod={} | uri={} | args={}", + className, methodName, httpMethod, uri, Arrays.toString(args)); + } + + private void logResponse( + final String className, + final String methodName, + final String httpMethod, + final String uri, + final Object result, + final long startTime + ) { + final long duration = System.currentTimeMillis() - startTime; + final String resultStr = formatResult(result); + + log.info("[RES] layer=controller | method={}.{} | httpMethod={} | uri={} | duration={}ms | result={}", + className, methodName, httpMethod, uri, duration, resultStr); + } + + private HttpServletRequest getCurrentHttpRequest() { + return Optional.ofNullable(RequestContextHolder.getRequestAttributes()) + .filter(ServletRequestAttributes.class::isInstance) + .map(ServletRequestAttributes.class::cast) + .map(ServletRequestAttributes::getRequest) + .orElse(null); + } + + private String formatResult(final Object result) { + if (result == null) { + return "null"; + } + final String resultStr = result.toString(); + return resultStr.length() <= MAX_LOG_LENGTH ? resultStr : resultStr.substring(0, MAX_LOG_LENGTH) + "..."; + } +} diff --git a/src/main/java/com/recyclestudy/common/log/MDCKey.java b/src/main/java/com/recyclestudy/common/log/MDCKey.java new file mode 100644 index 0000000..1410c9a --- /dev/null +++ b/src/main/java/com/recyclestudy/common/log/MDCKey.java @@ -0,0 +1,20 @@ +package com.recyclestudy.common.log; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MDCKey { + + TRACE_ID("traceId"), + HOST("host"), + HTTP_METHOD("httpMethod"), + REQUEST_URI("requestUri"), + QUERY_STRING("queryString"), + CLIENT_IP("clientIp"), + USER_AGENT("userAgent"); + + private final String key; +} + diff --git a/src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java b/src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java new file mode 100644 index 0000000..531de08 --- /dev/null +++ b/src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java @@ -0,0 +1,44 @@ +package com.recyclestudy.email; + +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.member.domain.Email; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DeviceAuthEmailSender { + + private final EmailSender emailSender; + private final TemplateEngine templateEngine; + + @Value("${auth.base-url}") + private String baseUrl; + + @Async + public void sendDeviceAuthMail(final Email email, final DeviceIdentifier deviceIdentifier) { + final String authUrl = createAuthUrl(email, deviceIdentifier); + final String message = createMessage(authUrl); + + emailSender.send(email, "[Recycle Study] 디바이스 인증을 완료해주세요.", message); + + log.info("[AUTH_MAIL_SENT] 인증 메일 발송 성공: {}", email); + } + + private String createAuthUrl(final Email email, final DeviceIdentifier deviceIdentifier) { + return String.format("%s/api/v1/device/auth?email=%s&identifier=%s", + baseUrl, email.getValue(), deviceIdentifier.getValue()); + } + + private String createMessage(final String authUrl) { + final Context context = new Context(); + context.setVariable("authUrl", authUrl); + return templateEngine.process("auth_email", context); + } +} diff --git a/src/main/java/com/recyclestudy/email/EmailSender.java b/src/main/java/com/recyclestudy/email/EmailSender.java new file mode 100644 index 0000000..0b141a3 --- /dev/null +++ b/src/main/java/com/recyclestudy/email/EmailSender.java @@ -0,0 +1,38 @@ +package com.recyclestudy.email; + +import com.recyclestudy.exception.EmailSendException; +import com.recyclestudy.member.domain.Email; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class EmailSender { + + private final JavaMailSender javaMailSender; + + public void send(final Email targetEmail, final String subject, final String content) { + try { + final MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + final MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, false, "UTF-8"); + + helper.setTo(targetEmail.getValue()); + helper.setSubject(subject); + helper.setText(content, true); + + javaMailSender.send(mimeMessage); + + log.info("[MAIL_SENT] 메일 발송 성공: email={}", targetEmail.toMaskedValue()); + + } catch (MessagingException e) { + log.error("[MAIL_SEND_FAILED] 메일 발송 실패: email={}", targetEmail, e); + throw new EmailSendException("메일 전송 중 오류가 발생했습니다.", e); + } + } +} diff --git a/src/main/java/com/recyclestudy/email/ReviewEmailSender.java b/src/main/java/com/recyclestudy/email/ReviewEmailSender.java new file mode 100644 index 0000000..048c663 --- /dev/null +++ b/src/main/java/com/recyclestudy/email/ReviewEmailSender.java @@ -0,0 +1,81 @@ +package com.recyclestudy.email; + +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.review.domain.NotificationStatus; +import com.recyclestudy.review.domain.ReviewURL; +import com.recyclestudy.review.service.NotificationHistoryService; +import com.recyclestudy.review.service.ReviewCycleService; +import com.recyclestudy.review.service.input.ReviewSendInput; +import com.recyclestudy.review.service.output.ReviewSendOutput; +import com.recyclestudy.review.service.output.ReviewSendOutput.ReviewSendElement; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReviewEmailSender { + + private final EmailSender emailSender; + private final TemplateEngine templateEngine; + private final ReviewCycleService reviewCycleService; + private final NotificationHistoryService notificationHistoryService; + private final Clock clock; + + @Scheduled(cron = "0 0 8 * * *", zone = "Asia/Seoul") + public void sendReviewMail() { + + final LocalDate targetDate = LocalDate.now(clock); + final LocalTime targetTime = LocalTime.of(8, 0); + + final ReviewSendOutput targetReviewCycle = reviewCycleService.findTargetReviewCycle( + ReviewSendInput.from(targetDate, targetTime)); + + final List elements = targetReviewCycle.elements(); + log.info("[REVIEW_MAIL_SENT] 복습 메일 발송 시작: date={}, time={}, size={}", targetDate, targetTime, elements.size()); + + int successCount = 0; + int failCount = 0; + + for (final ReviewSendElement element : elements) { + final String message = createMessage(element.targetUrls()); + final Email targetEmail = element.email(); + + final boolean success = sendToTargetEmail(targetEmail, message); + + if (success) { + notificationHistoryService.saveAll(element.reviewCycleIds(), NotificationStatus.SENT); + successCount++; + } else { + notificationHistoryService.saveAll(element.reviewCycleIds(), NotificationStatus.FAILED); + failCount++; + } + } + + log.info("[REVIEW_MAIL_SENT] 복습 메일 발송 처리 완료: success={}, fail={}", successCount, failCount); + } + + private boolean sendToTargetEmail(final Email targetEmail, final String message) { + try { + emailSender.send(targetEmail, "[Recycle Study] 오늘의 복습 목록이 도착했습니다", message); + return true; + } catch (final Exception e) { + log.error("[REVIEW_MAIL_SEND_FAILED] 복습 메일 발송 실패: email={}", targetEmail.getValue(), e); + return false; + } + } + + private String createMessage(final List targetUrls) { + final Context context = new Context(); + context.setVariable("targetUrls", targetUrls); + return templateEngine.process("review_email", context); + } +} diff --git a/src/main/java/com/recyclestudy/exception/BadRequestException.java b/src/main/java/com/recyclestudy/exception/BadRequestException.java new file mode 100644 index 0000000..078aa4c --- /dev/null +++ b/src/main/java/com/recyclestudy/exception/BadRequestException.java @@ -0,0 +1,7 @@ +package com.recyclestudy.exception; + +public class BadRequestException extends RuntimeException { + public BadRequestException(String message) { + super(message); + } +} diff --git a/src/main/java/com/recyclestudy/exception/DeviceActivationExpiredException.java b/src/main/java/com/recyclestudy/exception/DeviceActivationExpiredException.java new file mode 100644 index 0000000..4c9a37c --- /dev/null +++ b/src/main/java/com/recyclestudy/exception/DeviceActivationExpiredException.java @@ -0,0 +1,7 @@ +package com.recyclestudy.exception; + +public class DeviceActivationExpiredException extends UnauthorizedException { + public DeviceActivationExpiredException(String message) { + super(message); + } +} diff --git a/src/main/java/com/recyclestudy/exception/EmailSendException.java b/src/main/java/com/recyclestudy/exception/EmailSendException.java new file mode 100644 index 0000000..07ccbe3 --- /dev/null +++ b/src/main/java/com/recyclestudy/exception/EmailSendException.java @@ -0,0 +1,12 @@ +package com.recyclestudy.exception; + +public class EmailSendException extends RuntimeException { + + public EmailSendException(final String message) { + super(message); + } + + public EmailSendException(String message, final Exception e) { + super(message); + } +} diff --git a/src/main/java/com/recyclestudy/exception/GlobalControllerAdvice.java b/src/main/java/com/recyclestudy/exception/GlobalControllerAdvice.java new file mode 100644 index 0000000..fef6437 --- /dev/null +++ b/src/main/java/com/recyclestudy/exception/GlobalControllerAdvice.java @@ -0,0 +1,64 @@ +package com.recyclestudy.exception; + +import com.recyclestudy.exception.dto.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalControllerAdvice { + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity handleNotFound(final NotFoundException e) { + log.warn("[NOT_FOUND] {}", e.getMessage()); + final ErrorResponse response = ErrorResponse.from(e.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); + } + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity handleBadRequest(final BadRequestException e) { + log.warn("[BAD_REQUEST] {}", e.getMessage()); + final ErrorResponse response = ErrorResponse.from(e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(DeviceActivationExpiredException.class) + public ResponseEntity handleDeviceActivationExpired(final DeviceActivationExpiredException e) { + log.warn("[DEVICE_ACTIVATION_EXPIRED] {}", e.getMessage()); + final ErrorResponse response = ErrorResponse.from(e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity handleUnauthorized(final UnauthorizedException e) { + log.warn("[UNAUTHORIZED] {}", e.getMessage()); + final ErrorResponse response = ErrorResponse.from(e.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(final IllegalArgumentException e) { + log.warn("[ILLEGAL_ARGUMENT] {}", e.getMessage()); + final ErrorResponse response = ErrorResponse.from(e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingServletRequestParameter( + final MissingServletRequestParameterException e) { + log.warn("[MISSING_PARAMETER] {}", e.getMessage()); + final ErrorResponse response = ErrorResponse.from(e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(final Exception e) { + log.error("[INTERNAL_ERROR] 예기치 못한 에러 발생", e); + final ErrorResponse response = ErrorResponse.from("예기치 못한 에러가 발생했습니다"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } +} diff --git a/src/main/java/com/recyclestudy/exception/NotFoundException.java b/src/main/java/com/recyclestudy/exception/NotFoundException.java new file mode 100644 index 0000000..68b5478 --- /dev/null +++ b/src/main/java/com/recyclestudy/exception/NotFoundException.java @@ -0,0 +1,7 @@ +package com.recyclestudy.exception; + +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/recyclestudy/exception/UnauthorizedException.java b/src/main/java/com/recyclestudy/exception/UnauthorizedException.java new file mode 100644 index 0000000..9833d50 --- /dev/null +++ b/src/main/java/com/recyclestudy/exception/UnauthorizedException.java @@ -0,0 +1,7 @@ +package com.recyclestudy.exception; + +public class UnauthorizedException extends RuntimeException { + public UnauthorizedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/recyclestudy/exception/dto/ErrorResponse.java b/src/main/java/com/recyclestudy/exception/dto/ErrorResponse.java new file mode 100644 index 0000000..f3d3efa --- /dev/null +++ b/src/main/java/com/recyclestudy/exception/dto/ErrorResponse.java @@ -0,0 +1,8 @@ +package com.recyclestudy.exception.dto; + +public record ErrorResponse(String message) { + + public static ErrorResponse from(final String message) { + return new ErrorResponse(message); + } +} diff --git a/src/main/java/com/recyclestudy/member/controller/DeviceController.java b/src/main/java/com/recyclestudy/member/controller/DeviceController.java new file mode 100644 index 0000000..400b329 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/controller/DeviceController.java @@ -0,0 +1,42 @@ +package com.recyclestudy.member.controller; + +import com.recyclestudy.member.controller.request.DeviceDeleteRequest; +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.member.service.MemberService; +import com.recyclestudy.member.service.input.DeviceDeleteInput; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; + +@Controller +@RequestMapping("/api/v1/device") +@RequiredArgsConstructor +public class DeviceController { + + private final MemberService memberService; + + @GetMapping("/auth") + public String authenticateDevice( + @RequestParam("email") String email, + @RequestParam("identifier") String deviceIdentifier + ) { + memberService.authenticateDevice(Email.from(email), DeviceIdentifier.from(deviceIdentifier)); + return "auth_success"; + } + + @DeleteMapping + @ResponseBody + public ResponseEntity deleteDevice(@RequestBody final DeviceDeleteRequest request) { + final DeviceDeleteInput input = DeviceDeleteInput.from(request.email(), request.deviceIdentifier(), + request.targetDeviceIdentifier()); + memberService.deleteDevice(input); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/recyclestudy/member/controller/MemberController.java b/src/main/java/com/recyclestudy/member/controller/MemberController.java new file mode 100644 index 0000000..a805f51 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/controller/MemberController.java @@ -0,0 +1,51 @@ +package com.recyclestudy.member.controller; + +import com.recyclestudy.email.DeviceAuthEmailSender; +import com.recyclestudy.member.controller.request.MemberSaveRequest; +import com.recyclestudy.member.controller.response.MemberFindResponse; +import com.recyclestudy.member.controller.response.MemberSaveResponse; +import com.recyclestudy.member.service.MemberService; +import com.recyclestudy.member.service.input.MemberFindInput; +import com.recyclestudy.member.service.input.MemberSaveInput; +import com.recyclestudy.member.service.output.MemberFindOutput; +import com.recyclestudy.member.service.output.MemberSaveOutput; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/members") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + private final DeviceAuthEmailSender deviceAuthEmailSender; + + @PostMapping + public ResponseEntity saveMember(@RequestBody final MemberSaveRequest request) { + final MemberSaveInput input = request.toInput(); + final MemberSaveOutput output = memberService.saveDevice(input); + + deviceAuthEmailSender.sendDeviceAuthMail(output.email(), output.identifier()); + + final MemberSaveResponse response = MemberSaveResponse.from(output); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping + public ResponseEntity findAllMemberDevices( + @RequestParam(name = "email") final String email, + @RequestParam(name = "identifier") final String identifier + ) { + final MemberFindInput input = MemberFindInput.from(email, identifier); + final MemberFindOutput output = memberService.findAllMemberDevices(input); + final MemberFindResponse response = MemberFindResponse.from(output); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/recyclestudy/member/controller/request/DeviceDeleteRequest.java b/src/main/java/com/recyclestudy/member/controller/request/DeviceDeleteRequest.java new file mode 100644 index 0000000..86bfa82 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/controller/request/DeviceDeleteRequest.java @@ -0,0 +1,4 @@ +package com.recyclestudy.member.controller.request; + +public record DeviceDeleteRequest(String email, String deviceIdentifier, String targetDeviceIdentifier) { +} diff --git a/src/main/java/com/recyclestudy/member/controller/request/MemberSaveRequest.java b/src/main/java/com/recyclestudy/member/controller/request/MemberSaveRequest.java new file mode 100644 index 0000000..ee02bfc --- /dev/null +++ b/src/main/java/com/recyclestudy/member/controller/request/MemberSaveRequest.java @@ -0,0 +1,10 @@ +package com.recyclestudy.member.controller.request; + +import com.recyclestudy.member.service.input.MemberSaveInput; + +public record MemberSaveRequest(String email) { + + public MemberSaveInput toInput() { + return MemberSaveInput.from(this.email); + } +} diff --git a/src/main/java/com/recyclestudy/member/controller/response/MemberFindResponse.java b/src/main/java/com/recyclestudy/member/controller/response/MemberFindResponse.java new file mode 100644 index 0000000..4845dce --- /dev/null +++ b/src/main/java/com/recyclestudy/member/controller/response/MemberFindResponse.java @@ -0,0 +1,19 @@ +package com.recyclestudy.member.controller.response; + +import com.recyclestudy.member.service.output.MemberFindOutput; +import java.time.LocalDateTime; +import java.util.List; + +public record MemberFindResponse(String email, List devices) { + + public static MemberFindResponse from(final MemberFindOutput output) { + final List memberFindElements = output.elements().stream() + .map(outputElement -> new MemberFindElement(outputElement.identifier().getValue(), + outputElement.createdAt())) + .toList(); + return new MemberFindResponse(output.email().getValue(), memberFindElements); + } + + private record MemberFindElement(String identifier, LocalDateTime createdAt) { + } +} diff --git a/src/main/java/com/recyclestudy/member/controller/response/MemberSaveResponse.java b/src/main/java/com/recyclestudy/member/controller/response/MemberSaveResponse.java new file mode 100644 index 0000000..7d7645f --- /dev/null +++ b/src/main/java/com/recyclestudy/member/controller/response/MemberSaveResponse.java @@ -0,0 +1,10 @@ +package com.recyclestudy.member.controller.response; + +import com.recyclestudy.member.service.output.MemberSaveOutput; + +public record MemberSaveResponse(String email, String identifier) { + + public static MemberSaveResponse from(final MemberSaveOutput output) { + return new MemberSaveResponse(output.email().getValue(), output.identifier().getValue()); + } +} diff --git a/src/main/java/com/recyclestudy/member/domain/ActivationExpiredDateTime.java b/src/main/java/com/recyclestudy/member/domain/ActivationExpiredDateTime.java new file mode 100644 index 0000000..08b1028 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/domain/ActivationExpiredDateTime.java @@ -0,0 +1,45 @@ +package com.recyclestudy.member.domain; + +import com.recyclestudy.common.NullValidator; +import com.recyclestudy.exception.DeviceActivationExpiredException; +import jakarta.persistence.Embeddable; +import java.time.Duration; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.FieldNameConstants; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@FieldNameConstants(level = AccessLevel.PRIVATE) +@Getter +@ToString +@EqualsAndHashCode +public class ActivationExpiredDateTime { + + private static final Duration EXPIRE_TIME_RATE = Duration.ofMinutes(5); + + private LocalDateTime value; + + public static ActivationExpiredDateTime create(final LocalDateTime currentTime) { + validateNotNull(currentTime); + return new ActivationExpiredDateTime(currentTime.plusMinutes(EXPIRE_TIME_RATE.toMinutes())); + } + + private static void validateNotNull(final LocalDateTime currentTime) { + NullValidator.builder() + .add(Fields.value, currentTime) + .validate(); + } + + public void checkExpired(final LocalDateTime currentTime) { + if (currentTime.isAfter(value)) { + throw new DeviceActivationExpiredException("인증 유효 시간이 만료되었습니다."); + } + } +} diff --git a/src/main/java/com/recyclestudy/member/domain/Device.java b/src/main/java/com/recyclestudy/member/domain/Device.java new file mode 100644 index 0000000..6749bd5 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/domain/Device.java @@ -0,0 +1,68 @@ +package com.recyclestudy.member.domain; + +import com.recyclestudy.common.BaseEntity; +import com.recyclestudy.common.NullValidator; +import com.recyclestudy.exception.BadRequestException; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.FieldNameConstants; + +@Entity +@Table(name = "device") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@FieldNameConstants(level = AccessLevel.PRIVATE) +@Getter +public class Device extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "identifier", nullable = false, unique = true)) + private DeviceIdentifier identifier; + + @Column(name = "is_active", nullable = false) + private boolean isActive; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "activation_expires_at", nullable = false)) + private ActivationExpiredDateTime activationExpiresAt; + + public static Device withoutId( + final Member member, + final DeviceIdentifier deviceIdentifier, + final boolean isActive, + final ActivationExpiredDateTime activationExpiresAt + ) { + NullValidator.builder() + .add(Fields.member, member) + .add(Fields.identifier, deviceIdentifier) + .add(Fields.activationExpiresAt, activationExpiresAt) + .validate(); + return new Device(member, deviceIdentifier, isActive, activationExpiresAt); + } + + public void activate(final LocalDateTime currentTime) { + activationExpiresAt.checkExpired(currentTime); + this.isActive = true; + } + + public void verifyOwner(final Email email) { + if (!member.hasEmail(email)) { + throw new BadRequestException("디바이스 소유자가 아닙니다."); + } + } +} diff --git a/src/main/java/com/recyclestudy/member/domain/DeviceIdentifier.java b/src/main/java/com/recyclestudy/member/domain/DeviceIdentifier.java new file mode 100644 index 0000000..8e6e488 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/domain/DeviceIdentifier.java @@ -0,0 +1,39 @@ +package com.recyclestudy.member.domain; + +import com.recyclestudy.common.NullValidator; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.FieldNameConstants; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@FieldNameConstants(level = AccessLevel.PRIVATE) +@Getter +@ToString +@EqualsAndHashCode +public class DeviceIdentifier { + + private String value; + + public static DeviceIdentifier from(final String value) { + validateNotNull(value); + return new DeviceIdentifier(value); + } + + public static DeviceIdentifier create() { + final String value = IdentifierCreator.create(); + return new DeviceIdentifier(value); + } + + private static void validateNotNull(final String value) { + NullValidator.builder() + .add(Fields.value, value) + .validate(); + } +} diff --git a/src/main/java/com/recyclestudy/member/domain/Email.java b/src/main/java/com/recyclestudy/member/domain/Email.java new file mode 100644 index 0000000..3333414 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/domain/Email.java @@ -0,0 +1,65 @@ +package com.recyclestudy.member.domain; + +import com.recyclestudy.common.NullValidator; +import jakarta.persistence.Embeddable; +import java.util.regex.Pattern; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.FieldNameConstants; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@FieldNameConstants(level = AccessLevel.PRIVATE) +@Getter +@ToString +@EqualsAndHashCode +public class Email { + + private static final String EMAIL_FORMAT = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$"; + private static final Pattern EMAIL_PATTERN = Pattern.compile(EMAIL_FORMAT); + + private String value; + + public static Email from(final String value) { + validateNotNull(value); + validateEmailFormat(value); + return new Email(value); + } + + private static void validateNotNull(final String value) { + NullValidator.builder() + .add(Fields.value, value) + .validate(); + } + + private static void validateEmailFormat(final String emailValue) { + if (!EMAIL_PATTERN.matcher(emailValue).matches()) { + throw new IllegalArgumentException("유효하지 않은 이메일 형식입니다."); + } + } + + public String toMaskedValue() { + final String[] split = value.split("@"); + final String maskedLocalPart = maskLocalPart(split[0]); + return maskedLocalPart + "@" + split[1]; + } + + private String maskLocalPart(final String localPart) { + int length = localPart.length(); + + if (length <= 2) { + return localPart.charAt(0) + "*"; + } + + int visibleLength = Math.min(3, length / 2); + String visiblePart = localPart.substring(0, visibleLength); + String maskedPart = "*".repeat(length - visibleLength); + + return visiblePart + maskedPart; + } +} diff --git a/src/main/java/com/recyclestudy/member/domain/IdentifierCreator.java b/src/main/java/com/recyclestudy/member/domain/IdentifierCreator.java new file mode 100644 index 0000000..6208c80 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/domain/IdentifierCreator.java @@ -0,0 +1,13 @@ +package com.recyclestudy.member.domain; + +import java.util.UUID; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class IdentifierCreator { + + public static String create() { + return UUID.randomUUID().toString(); + } +} diff --git a/src/main/java/com/recyclestudy/member/domain/Member.java b/src/main/java/com/recyclestudy/member/domain/Member.java new file mode 100644 index 0000000..b994ca1 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/domain/Member.java @@ -0,0 +1,42 @@ +package com.recyclestudy.member.domain; + +import com.recyclestudy.common.BaseEntity; +import com.recyclestudy.common.NullValidator; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.FieldNameConstants; + +@Entity +@Table(name = "member") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@FieldNameConstants(level = AccessLevel.PRIVATE) +@Getter +public class Member extends BaseEntity { + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "email", nullable = false, unique = true)) + private Email email; + + public static Member withoutId(final Email email) { + validateNotNull(email); + return new Member(email); + } + + private static void validateNotNull(final Email email) { + NullValidator.builder() + .add(Fields.email, email) + .validate(); + } + + public boolean hasEmail(final Email email) { + return this.email.equals(email); + } +} diff --git a/src/main/java/com/recyclestudy/member/repository/DeviceRepository.java b/src/main/java/com/recyclestudy/member/repository/DeviceRepository.java new file mode 100644 index 0000000..74f49fd --- /dev/null +++ b/src/main/java/com/recyclestudy/member/repository/DeviceRepository.java @@ -0,0 +1,25 @@ +package com.recyclestudy.member.repository; + +import com.recyclestudy.member.domain.Device; +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.member.domain.Email; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface DeviceRepository extends JpaRepository { + + @Query(""" + SELECT d + FROM Device d + JOIN FETCH d.member m + WHERE m.email = :email + """) + List findAllByMemberEmail(@Param("email") Email email); + + Optional findByIdentifier(DeviceIdentifier deviceIdentifier); + + void deleteByIdentifier(DeviceIdentifier identifier); +} diff --git a/src/main/java/com/recyclestudy/member/repository/MemberRepository.java b/src/main/java/com/recyclestudy/member/repository/MemberRepository.java new file mode 100644 index 0000000..5531bd4 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/repository/MemberRepository.java @@ -0,0 +1,13 @@ +package com.recyclestudy.member.repository; + +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.member.domain.Member; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { + + Optional findByEmail(Email email); + + boolean existsByEmail(Email email); +} diff --git a/src/main/java/com/recyclestudy/member/service/MemberService.java b/src/main/java/com/recyclestudy/member/service/MemberService.java new file mode 100644 index 0000000..2d202d7 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/service/MemberService.java @@ -0,0 +1,116 @@ +package com.recyclestudy.member.service; + +import com.recyclestudy.exception.BadRequestException; +import com.recyclestudy.exception.NotFoundException; +import com.recyclestudy.exception.UnauthorizedException; +import com.recyclestudy.member.domain.ActivationExpiredDateTime; +import com.recyclestudy.member.domain.Device; +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.member.domain.Member; +import com.recyclestudy.member.repository.DeviceRepository; +import com.recyclestudy.member.repository.MemberRepository; +import com.recyclestudy.member.service.input.DeviceDeleteInput; +import com.recyclestudy.member.service.input.MemberFindInput; +import com.recyclestudy.member.service.input.MemberSaveInput; +import com.recyclestudy.member.service.output.MemberFindOutput; +import com.recyclestudy.member.service.output.MemberSaveOutput; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MemberService { + + private final MemberRepository memberRepository; + private final DeviceRepository deviceRepository; + private final Clock clock; + + @Transactional + public MemberSaveOutput saveDevice(final MemberSaveInput input) { + final Member member = saveNewMember(input.email()); + final DeviceIdentifier deviceIdentifier = DeviceIdentifier.create(); + + final ActivationExpiredDateTime activationExpiredDateTime + = ActivationExpiredDateTime.create(LocalDateTime.now(clock)); + + final Device notSavedDevice = Device.withoutId(member, deviceIdentifier, false, activationExpiredDateTime); + final Device device = deviceRepository.save(notSavedDevice); + log.info("[DEVICE_SAVED] 디바이스 저장 성공: {}", device.getIdentifier()); + + return MemberSaveOutput.from(device); + } + + @Transactional(readOnly = true) + public MemberFindOutput findAllMemberDevices(final MemberFindInput input) { + checkExistedMember(input.email()); + checkActiveDevice(input.deviceIdentifier()); + + final List devices = deviceRepository.findAllByMemberEmail(input.email()); + return MemberFindOutput.of(input.email(), devices); + } + + @Transactional + public void authenticateDevice(final Email email, final DeviceIdentifier deviceIdentifier) { + checkExistedMember(email); + + final Device device = deviceRepository.findByIdentifier(deviceIdentifier) + .orElseThrow(() -> new NotFoundException("존재하지 않는 디바이스 아이디입니다: %s" + .formatted(deviceIdentifier.getValue()))); + + if (device.isActive()) { + throw new BadRequestException("이미 인증되었습니다"); + } + + device.verifyOwner(email); + device.activate(LocalDateTime.now(clock)); + log.info("[DEVICE_AUTH] 디바이스 인증 성공: email={}, device={}", email.toMaskedValue(), deviceIdentifier); + } + + @Transactional + public void deleteDevice(final DeviceDeleteInput input) { + final Device device = deviceRepository.findByIdentifier(input.deviceIdentifier()) + .orElseThrow(() -> new UnauthorizedException("유효하지 않은 디바이스 아이디입니다: %s" + .formatted(input.deviceIdentifier().getValue()))); + device.verifyOwner(input.email()); + deviceRepository.deleteByIdentifier(input.targetDeviceIdentifier()); + log.info("[DEVICE_DELETED] 디바이스 삭제 성공: {}", input.targetDeviceIdentifier()); + } + + private Member saveNewMember(final Email email) { + final Optional memberOptional = memberRepository.findByEmail(email); + + if (memberOptional.isPresent()) { + return memberOptional.get(); + } + + log.info("[MEMBER_REGISTER_START] 신규 유저 이메일 등록 시작: {}", email.toMaskedValue()); + final Member notSavedMember = Member.withoutId(email); + final Member savedMember = memberRepository.save(notSavedMember); + log.info("[MEMBER_SAVED] 신규 유저 이메일 등록 성공: {}", email.toMaskedValue()); + return savedMember; + } + + private void checkExistedMember(final Email email) { + if (!memberRepository.existsByEmail(email)) { + throw new NotFoundException("존재하지 않는 멤버입니다: %s".formatted(email.getValue())); + } + } + + private void checkActiveDevice(final DeviceIdentifier deviceIdentifier) { + final Device device = deviceRepository.findByIdentifier(deviceIdentifier) + .orElseThrow(() -> new NotFoundException("존재하지 않는 디바이스 아이디입니다: %s" + .formatted(deviceIdentifier.getValue()))); + + if (!device.isActive()) { + throw new UnauthorizedException("인증되지 않은 디바이스입니다"); + } + } +} diff --git a/src/main/java/com/recyclestudy/member/service/input/DeviceDeleteInput.java b/src/main/java/com/recyclestudy/member/service/input/DeviceDeleteInput.java new file mode 100644 index 0000000..0c27aa7 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/service/input/DeviceDeleteInput.java @@ -0,0 +1,16 @@ +package com.recyclestudy.member.service.input; + +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.member.domain.Email; + +public record DeviceDeleteInput(Email email, DeviceIdentifier deviceIdentifier, + DeviceIdentifier targetDeviceIdentifier) { + + public static DeviceDeleteInput from(final String emailValue, final String identifier, + final String targetIdentifier) { + final Email email = Email.from(emailValue); + final DeviceIdentifier deviceIdentifier = DeviceIdentifier.from(identifier); + final DeviceIdentifier targetDeviceIdentifier = DeviceIdentifier.from(targetIdentifier); + return new DeviceDeleteInput(email, deviceIdentifier, targetDeviceIdentifier); + } +} diff --git a/src/main/java/com/recyclestudy/member/service/input/MemberFindInput.java b/src/main/java/com/recyclestudy/member/service/input/MemberFindInput.java new file mode 100644 index 0000000..321e28b --- /dev/null +++ b/src/main/java/com/recyclestudy/member/service/input/MemberFindInput.java @@ -0,0 +1,13 @@ +package com.recyclestudy.member.service.input; + +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.member.domain.Email; + +public record MemberFindInput(Email email, DeviceIdentifier deviceIdentifier) { + + public static MemberFindInput from(final String emailValue, final String identifier) { + final Email email = Email.from(emailValue); + final DeviceIdentifier deviceIdentifier = DeviceIdentifier.from(identifier); + return new MemberFindInput(email, deviceIdentifier); + } +} diff --git a/src/main/java/com/recyclestudy/member/service/input/MemberSaveInput.java b/src/main/java/com/recyclestudy/member/service/input/MemberSaveInput.java new file mode 100644 index 0000000..156c0a6 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/service/input/MemberSaveInput.java @@ -0,0 +1,11 @@ +package com.recyclestudy.member.service.input; + +import com.recyclestudy.member.domain.Email; + +public record MemberSaveInput(Email email) { + + public static MemberSaveInput from(final String email) { + final Email value = Email.from(email); + return new MemberSaveInput(value); + } +} diff --git a/src/main/java/com/recyclestudy/member/service/output/MemberFindOutput.java b/src/main/java/com/recyclestudy/member/service/output/MemberFindOutput.java new file mode 100644 index 0000000..9523629 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/service/output/MemberFindOutput.java @@ -0,0 +1,20 @@ +package com.recyclestudy.member.service.output; + +import com.recyclestudy.member.domain.Device; +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.member.domain.Email; +import java.time.LocalDateTime; +import java.util.List; + +public record MemberFindOutput(Email email, List elements) { + + public static MemberFindOutput of(final Email email, final List devices) { + final List memberFindElements = devices.stream() + .map(device -> new MemberFindElement(device.getIdentifier(), device.getCreatedAt())) + .toList(); + return new MemberFindOutput(email, memberFindElements); + } + + public record MemberFindElement(DeviceIdentifier identifier, LocalDateTime createdAt) { + } +} diff --git a/src/main/java/com/recyclestudy/member/service/output/MemberSaveOutput.java b/src/main/java/com/recyclestudy/member/service/output/MemberSaveOutput.java new file mode 100644 index 0000000..09ce054 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/service/output/MemberSaveOutput.java @@ -0,0 +1,12 @@ +package com.recyclestudy.member.service.output; + +import com.recyclestudy.member.domain.Device; +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.member.domain.Email; + +public record MemberSaveOutput(Email email, DeviceIdentifier identifier) { + + public static MemberSaveOutput from(final Device device) { + return new MemberSaveOutput(device.getMember().getEmail(), device.getIdentifier()); + } +} diff --git a/src/main/java/com/recyclestudy/review/controller/ReviewController.java b/src/main/java/com/recyclestudy/review/controller/ReviewController.java new file mode 100644 index 0000000..bb4d5cd --- /dev/null +++ b/src/main/java/com/recyclestudy/review/controller/ReviewController.java @@ -0,0 +1,30 @@ +package com.recyclestudy.review.controller; + +import com.recyclestudy.review.controller.request.ReviewSaveRequest; +import com.recyclestudy.review.controller.response.ReviewSaveResponse; +import com.recyclestudy.review.service.ReviewService; +import com.recyclestudy.review.service.input.ReviewSaveInput; +import com.recyclestudy.review.service.output.ReviewSaveOutput; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/reviews") +@RequiredArgsConstructor +public class ReviewController { + + private final ReviewService reviewService; + + @PostMapping + public ResponseEntity saveReview(@RequestBody ReviewSaveRequest request) { + final ReviewSaveInput input = request.toInput(); + final ReviewSaveOutput output = reviewService.saveReview(input); + ReviewSaveResponse response = ReviewSaveResponse.of(output.url(), output.scheduledAts()); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } +} diff --git a/src/main/java/com/recyclestudy/review/controller/request/ReviewSaveRequest.java b/src/main/java/com/recyclestudy/review/controller/request/ReviewSaveRequest.java new file mode 100644 index 0000000..5d20579 --- /dev/null +++ b/src/main/java/com/recyclestudy/review/controller/request/ReviewSaveRequest.java @@ -0,0 +1,10 @@ +package com.recyclestudy.review.controller.request; + +import com.recyclestudy.review.service.input.ReviewSaveInput; + +public record ReviewSaveRequest(String identifier, String targetUrl) { + + public ReviewSaveInput toInput() { + return ReviewSaveInput.of(this.identifier, this.targetUrl); + } +} diff --git a/src/main/java/com/recyclestudy/review/controller/response/ReviewSaveResponse.java b/src/main/java/com/recyclestudy/review/controller/response/ReviewSaveResponse.java new file mode 100644 index 0000000..f46f791 --- /dev/null +++ b/src/main/java/com/recyclestudy/review/controller/response/ReviewSaveResponse.java @@ -0,0 +1,12 @@ +package com.recyclestudy.review.controller.response; + +import com.recyclestudy.review.domain.ReviewURL; +import java.time.LocalDateTime; +import java.util.List; + +public record ReviewSaveResponse(String url, List scheduledAts) { + + public static ReviewSaveResponse of(ReviewURL url, List scheduledAts) { + return new ReviewSaveResponse(url.getValue(), scheduledAts); + } +} diff --git a/src/main/java/com/recyclestudy/review/domain/NotificationHistory.java b/src/main/java/com/recyclestudy/review/domain/NotificationHistory.java new file mode 100644 index 0000000..500dc5d --- /dev/null +++ b/src/main/java/com/recyclestudy/review/domain/NotificationHistory.java @@ -0,0 +1,52 @@ +package com.recyclestudy.review.domain; + +import com.recyclestudy.common.BaseEntity; +import com.recyclestudy.common.NullValidator; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.FieldNameConstants; + +@Entity +@Table(name = "notification_history") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@FieldNameConstants(level = AccessLevel.PRIVATE) +@Getter +public class NotificationHistory extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_cycle_id", nullable = false) + private ReviewCycle reviewCycle; + + @Enumerated(value = EnumType.STRING) + @Column(name = "status", nullable = false) + private NotificationStatus status; + + public static NotificationHistory withoutId( + final ReviewCycle reviewCycle, + final NotificationStatus status + ) { + validateNotNull(reviewCycle, status); + return new NotificationHistory(reviewCycle, status); + } + + private static void validateNotNull( + final ReviewCycle reviewCycle, + final NotificationStatus status + ) { + NullValidator.builder() + .add(Fields.reviewCycle, reviewCycle) + .add(Fields.status, status) + .validate(); + } +} diff --git a/src/main/java/com/recyclestudy/review/domain/NotificationStatus.java b/src/main/java/com/recyclestudy/review/domain/NotificationStatus.java new file mode 100644 index 0000000..853a097 --- /dev/null +++ b/src/main/java/com/recyclestudy/review/domain/NotificationStatus.java @@ -0,0 +1,8 @@ +package com.recyclestudy.review.domain; + +public enum NotificationStatus { + PENDING, + SENT, + FAILED, + ; +} diff --git a/src/main/java/com/recyclestudy/review/domain/Review.java b/src/main/java/com/recyclestudy/review/domain/Review.java new file mode 100644 index 0000000..a21b72c --- /dev/null +++ b/src/main/java/com/recyclestudy/review/domain/Review.java @@ -0,0 +1,47 @@ +package com.recyclestudy.review.domain; + +import com.recyclestudy.common.BaseEntity; +import com.recyclestudy.common.NullValidator; +import com.recyclestudy.member.domain.Member; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.FieldNameConstants; + +@Entity +@Table(name = "review") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@FieldNameConstants(level = AccessLevel.PRIVATE) +@Getter +public class Review extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "url", nullable = false, columnDefinition = "TEXT")) + private ReviewURL url; + + public static Review withoutId(final Member member, final ReviewURL url) { + validateNotNull(member, url); + return new Review(member, url); + } + + private static void validateNotNull(final Member member, final ReviewURL url) { + NullValidator.builder() + .add(Fields.member, member) + .add(Fields.url, url) + .validate(); + } +} diff --git a/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java b/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java new file mode 100644 index 0000000..a320d83 --- /dev/null +++ b/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java @@ -0,0 +1,44 @@ +package com.recyclestudy.review.domain; + +import com.recyclestudy.common.BaseEntity; +import com.recyclestudy.common.NullValidator; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.FieldNameConstants; + +@Entity +@Table(name = "review_cycle") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@FieldNameConstants(level = AccessLevel.PRIVATE) +@Getter +public class ReviewCycle extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id", nullable = false) + private Review review; + + @Column(name = "scheduled_at", nullable = false) + private LocalDateTime scheduledAt; + + public static ReviewCycle withoutId(final Review review, final LocalDateTime scheduledAt) { + validateNotNull(review, scheduledAt); + return new ReviewCycle(review, scheduledAt); + } + + private static void validateNotNull(final Review review, final LocalDateTime scheduledAt) { + NullValidator.builder() + .add(Fields.review, review) + .add(Fields.scheduledAt, scheduledAt) + .validate(); + } +} diff --git a/src/main/java/com/recyclestudy/review/domain/ReviewCycleDuration.java b/src/main/java/com/recyclestudy/review/domain/ReviewCycleDuration.java new file mode 100644 index 0000000..a6ccba6 --- /dev/null +++ b/src/main/java/com/recyclestudy/review/domain/ReviewCycleDuration.java @@ -0,0 +1,34 @@ +package com.recyclestudy.review.domain; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Arrays; +import java.util.List; + +public enum ReviewCycleDuration { + DAY(Duration.ofDays(1)), + WEEK(Duration.ofDays(7)), + MONTH(Duration.ofDays(30)), + QUARTER(Duration.ofDays(90)), + HALF_YEAR(Duration.ofDays(180)), + ; + + private final Duration duration; + + ReviewCycleDuration(final Duration duration) { + this.duration = duration; + } + + public static List calculate(final LocalDate target, final LocalTime time) { + return Arrays.stream(ReviewCycleDuration.values()) + .map(cycle -> target.plusDays(cycle.duration.toDays()) + .atTime(time)) + .toList(); + } + + public static List calculate(final LocalDate target) { + return calculate(target, LocalTime.of(8, 0)); + } +} diff --git a/src/main/java/com/recyclestudy/review/domain/ReviewURL.java b/src/main/java/com/recyclestudy/review/domain/ReviewURL.java new file mode 100644 index 0000000..c562f21 --- /dev/null +++ b/src/main/java/com/recyclestudy/review/domain/ReviewURL.java @@ -0,0 +1,34 @@ +package com.recyclestudy.review.domain; + +import com.recyclestudy.common.NullValidator; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.FieldNameConstants; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@FieldNameConstants(level = AccessLevel.PRIVATE) +@Getter +@ToString +@EqualsAndHashCode +public class ReviewURL { + + private String value; + + public static ReviewURL from(final String value) { + validateNotNull(value); + return new ReviewURL(value); + } + + private static void validateNotNull(final String value) { + NullValidator.builder() + .add(Fields.value, value) + .validate(); + } +} diff --git a/src/main/java/com/recyclestudy/review/repository/NotificationHistoryRepository.java b/src/main/java/com/recyclestudy/review/repository/NotificationHistoryRepository.java new file mode 100644 index 0000000..fa496fd --- /dev/null +++ b/src/main/java/com/recyclestudy/review/repository/NotificationHistoryRepository.java @@ -0,0 +1,7 @@ +package com.recyclestudy.review.repository; + +import com.recyclestudy.review.domain.NotificationHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationHistoryRepository extends JpaRepository { +} diff --git a/src/main/java/com/recyclestudy/review/repository/ReviewCycleRepository.java b/src/main/java/com/recyclestudy/review/repository/ReviewCycleRepository.java new file mode 100644 index 0000000..3060ad0 --- /dev/null +++ b/src/main/java/com/recyclestudy/review/repository/ReviewCycleRepository.java @@ -0,0 +1,11 @@ +package com.recyclestudy.review.repository; + +import com.recyclestudy.review.domain.ReviewCycle; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewCycleRepository extends JpaRepository { + + List findAllByScheduledAt(LocalDateTime scheduledAt); +} diff --git a/src/main/java/com/recyclestudy/review/repository/ReviewRepository.java b/src/main/java/com/recyclestudy/review/repository/ReviewRepository.java new file mode 100644 index 0000000..ab7d889 --- /dev/null +++ b/src/main/java/com/recyclestudy/review/repository/ReviewRepository.java @@ -0,0 +1,7 @@ +package com.recyclestudy.review.repository; + +import com.recyclestudy.review.domain.Review; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewRepository extends JpaRepository { +} diff --git a/src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java b/src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java new file mode 100644 index 0000000..f7b458c --- /dev/null +++ b/src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java @@ -0,0 +1,33 @@ +package com.recyclestudy.review.service; + +import com.recyclestudy.review.domain.NotificationHistory; +import com.recyclestudy.review.domain.NotificationStatus; +import com.recyclestudy.review.domain.ReviewCycle; +import com.recyclestudy.review.repository.NotificationHistoryRepository; +import com.recyclestudy.review.repository.ReviewCycleRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class NotificationHistoryService { + + private final NotificationHistoryRepository notificationHistoryRepository; + private final ReviewCycleRepository reviewCycleRepository; + + @Transactional + public void saveAll(final List reviewCycleIds, final NotificationStatus status) { + final List reviewCycles = reviewCycleRepository.findAllById(reviewCycleIds); + + final List histories = reviewCycles.stream() + .map(cycle -> NotificationHistory.withoutId(cycle, status)) + .toList(); + + notificationHistoryRepository.saveAll(histories); + log.info("[NOTIFY_HIST_UPDATED] 알림 이력 상태 변경: status={}, count={}", status, histories.size()); + } +} diff --git a/src/main/java/com/recyclestudy/review/service/ReviewCycleService.java b/src/main/java/com/recyclestudy/review/service/ReviewCycleService.java new file mode 100644 index 0000000..9dc708d --- /dev/null +++ b/src/main/java/com/recyclestudy/review/service/ReviewCycleService.java @@ -0,0 +1,23 @@ +package com.recyclestudy.review.service; + +import com.recyclestudy.review.domain.ReviewCycle; +import com.recyclestudy.review.repository.ReviewCycleRepository; +import com.recyclestudy.review.service.input.ReviewSendInput; +import com.recyclestudy.review.service.output.ReviewSendOutput; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ReviewCycleService { + + private final ReviewCycleRepository reviewCycleRepository; + + @Transactional(readOnly = true) + public ReviewSendOutput findTargetReviewCycle(final ReviewSendInput input) { + final List targetCycle = reviewCycleRepository.findAllByScheduledAt(input.scheduledAt()); + return ReviewSendOutput.from(targetCycle); + } +} diff --git a/src/main/java/com/recyclestudy/review/service/ReviewService.java b/src/main/java/com/recyclestudy/review/service/ReviewService.java new file mode 100644 index 0000000..2b36188 --- /dev/null +++ b/src/main/java/com/recyclestudy/review/service/ReviewService.java @@ -0,0 +1,81 @@ +package com.recyclestudy.review.service; + +import com.recyclestudy.common.BaseEntity; +import com.recyclestudy.exception.UnauthorizedException; +import com.recyclestudy.member.domain.Device; +import com.recyclestudy.member.repository.DeviceRepository; +import com.recyclestudy.review.domain.NotificationHistory; +import com.recyclestudy.review.domain.NotificationStatus; +import com.recyclestudy.review.domain.Review; +import com.recyclestudy.review.domain.ReviewCycle; +import com.recyclestudy.review.domain.ReviewCycleDuration; +import com.recyclestudy.review.repository.NotificationHistoryRepository; +import com.recyclestudy.review.repository.ReviewCycleRepository; +import com.recyclestudy.review.repository.ReviewRepository; +import com.recyclestudy.review.service.input.ReviewSaveInput; +import com.recyclestudy.review.service.output.ReviewSaveOutput; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReviewService { + + private final ReviewRepository reviewRepository; + private final ReviewCycleRepository reviewCycleRepository; + private final DeviceRepository deviceRepository; + private final NotificationHistoryRepository notificationHistoryRepository; + private final Clock clock; + + @Transactional + public ReviewSaveOutput saveReview(final ReviewSaveInput input) { + final Device device = deviceRepository.findByIdentifier(input.identifier()) + .orElseThrow(() -> new UnauthorizedException("유효하지 않은 디바이스입니다")); + checkValidDevice(device); + + final Review review = Review.withoutId(device.getMember(), input.url()); + final Review savedReview = reviewRepository.save(review); + log.info("[REVIEW_SAVED] 복습 주제 저장 성공: reviewId={}", savedReview.getId()); + + final LocalDate current = LocalDate.now(clock); + final List scheduledAts = ReviewCycleDuration.calculate(current); + + final List reviewCycles = scheduledAts.stream() + .map(scheduledAt -> ReviewCycle.withoutId(savedReview, scheduledAt)) + .toList(); + + final List savedReviewCycles = reviewCycleRepository.saveAll(reviewCycles); + final List savedScheduledAts = savedReviewCycles.stream() + .map(ReviewCycle::getScheduledAt) + .toList(); + log.info("[REVIEW_CYCLE_SAVED] 복습 주기 저장 성공: reviewCycleId={}", + savedReviewCycles.stream().map(BaseEntity::getId).toList()); + + savePendingNotificationHistory(savedReviewCycles); + + return ReviewSaveOutput.of(savedReview.getUrl(), savedScheduledAts); + } + + private void checkValidDevice(final Device device) { + if (!device.isActive()) { + throw new UnauthorizedException("인증되지 않은 디바이스입니다"); + } + } + + private void savePendingNotificationHistory(final List savedReviewCycles) { + final List notificationHistories = savedReviewCycles.stream() + .map(reviewCycle -> NotificationHistory.withoutId(reviewCycle, NotificationStatus.PENDING)) + .toList(); + final List savedNotificationHistories + = notificationHistoryRepository.saveAll(notificationHistories); + log.info("[NOTIFY_HIST_SAVED] 전송 현황 등록 성공: status={}, notificationHistoryId={}", + NotificationStatus.PENDING, savedNotificationHistories.stream().map(BaseEntity::getId).toList()); + } +} diff --git a/src/main/java/com/recyclestudy/review/service/input/ReviewSaveInput.java b/src/main/java/com/recyclestudy/review/service/input/ReviewSaveInput.java new file mode 100644 index 0000000..4dff4b8 --- /dev/null +++ b/src/main/java/com/recyclestudy/review/service/input/ReviewSaveInput.java @@ -0,0 +1,13 @@ +package com.recyclestudy.review.service.input; + +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.review.domain.ReviewURL; + +public record ReviewSaveInput(DeviceIdentifier identifier, ReviewURL url) { + + public static ReviewSaveInput of(final String identifier, final String url) { + final DeviceIdentifier deviceIdentifier = DeviceIdentifier.from(identifier); + final ReviewURL reviewURL = ReviewURL.from(url); + return new ReviewSaveInput(deviceIdentifier, reviewURL); + } +} diff --git a/src/main/java/com/recyclestudy/review/service/input/ReviewSendInput.java b/src/main/java/com/recyclestudy/review/service/input/ReviewSendInput.java new file mode 100644 index 0000000..cee411c --- /dev/null +++ b/src/main/java/com/recyclestudy/review/service/input/ReviewSendInput.java @@ -0,0 +1,12 @@ +package com.recyclestudy.review.service.input; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +public record ReviewSendInput(LocalDateTime scheduledAt) { + + public static ReviewSendInput from(final LocalDate targetDate, final LocalTime targetTime) { + return new ReviewSendInput(LocalDateTime.of(targetDate, targetTime)); + } +} diff --git a/src/main/java/com/recyclestudy/review/service/output/ReviewSaveOutput.java b/src/main/java/com/recyclestudy/review/service/output/ReviewSaveOutput.java new file mode 100644 index 0000000..2beaadc --- /dev/null +++ b/src/main/java/com/recyclestudy/review/service/output/ReviewSaveOutput.java @@ -0,0 +1,12 @@ +package com.recyclestudy.review.service.output; + +import com.recyclestudy.review.domain.ReviewURL; +import java.time.LocalDateTime; +import java.util.List; + +public record ReviewSaveOutput(ReviewURL url, List scheduledAts) { + + public static ReviewSaveOutput of(ReviewURL url, List scheduledAts) { + return new ReviewSaveOutput(url, scheduledAts); + } +} diff --git a/src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java b/src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java new file mode 100644 index 0000000..df9b0c1 --- /dev/null +++ b/src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java @@ -0,0 +1,46 @@ +package com.recyclestudy.review.service.output; + +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.review.domain.ReviewCycle; +import com.recyclestudy.review.domain.ReviewURL; +import java.util.List; +import java.util.stream.Collectors; + +public record ReviewSendOutput(List elements) { + + public static ReviewSendOutput from(final List reviewCycles) { + final List elements = reviewCycles.stream() + .collect(Collectors.groupingBy( + cycle -> cycle.getReview().getMember().getEmail(), + Collectors.toUnmodifiableList() + )) + .entrySet().stream() + .map(entry -> { + final List cycles = entry.getValue(); + final List cycleIds = cycles.stream() + .map(ReviewCycle::getId) + .toList(); + final List urls = cycles.stream() + .map(cycle -> cycle.getReview().getUrl()) + .toList(); + return ReviewSendElement.of(entry.getKey(), cycleIds, urls); + }) + .toList(); + + return new ReviewSendOutput(elements); + } + + public record ReviewSendElement( + Email email, + List reviewCycleIds, + List targetUrls + ) { + public static ReviewSendElement of( + final Email email, + final List reviewCycleIds, + final List targetUrls + ) { + return new ReviewSendElement(email, reviewCycleIds, targetUrls); + } + } +} diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml new file mode 100644 index 0000000..0ffc710 --- /dev/null +++ b/src/main/resources/application-local.yaml @@ -0,0 +1,6 @@ +spring: + jpa: + properties: + hibernate: + format_sql: true + show_sql: true diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 0000000..a2fff2a --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,6 @@ +spring: + jpa: + properties: + hibernate: + format_sql: false + show_sql: false diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..189e82a --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,45 @@ +spring: + application: + name: recyclestudy + profiles: + active: local + + config: + import: + - optional:file:.env[.properties] + + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + flyway: + enabled: true + + jpa: + hibernate: + ddl-auto: validate + + open-in-view: false + + mail: + host: smtp.gmail.com + port: 587 + username: ${GMAIL_USERNAME} + password: ${GMAIL_PASSWORD} + properties: + mail: + smtp: + starttls: + enable: true + auth: true + +management: + endpoints: + web: + exposure: + include: prometheus, health + +auth: + base-url: ${BASE_URL} diff --git a/src/main/resources/db/migration/V20260102_1__init.sql b/src/main/resources/db/migration/V20260102_1__init.sql new file mode 100644 index 0000000..110a970 --- /dev/null +++ b/src/main/resources/db/migration/V20260102_1__init.sql @@ -0,0 +1,60 @@ +-- member +create table member +( + id bigint auto_increment primary key, + email varchar(255) not null, + created_at datetime(6) not null, + modified_at datetime(6) null, + constraint uk_member_email unique (email) +); + +-- device +create table device +( + id bigint auto_increment primary key, + identifier varchar(255) not null, + is_active bit not null, + activation_expires_at datetime(6) not null, + member_id bigint not null, + created_at datetime(6) not null, + modified_at datetime(6) null, + constraint uk_device_identifier unique (identifier), + constraint fk_device_member_id + foreign key (member_id) references member (id) +); + +-- review +create table review +( + id bigint auto_increment primary key, + url text not null, + member_id bigint not null, + created_at datetime(6) not null, + modified_at datetime(6) null, + constraint fk_review_member_id + foreign key (member_id) references member (id) +); + +-- review_cycle +create table review_cycle +( + id bigint auto_increment primary key, + review_id bigint not null, + scheduled_at datetime(6) not null, + created_at datetime(6) not null, + modified_at datetime(6) null, + constraint fk_review_cycle_review_id + foreign key (review_id) references review (id) +); + +-- notification_history +create table notification_history +( + id bigint auto_increment primary key, + review_cycle_id bigint not null, + created_at datetime(6) not null, + modified_at datetime(6) null, + status enum ('FAILED', 'PENDING', 'SENT') not null, + constraint fk_notification_history_review_cycle_id + foreign key (review_cycle_id) references review_cycle (id) +); diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..dbc16ed --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + ${CONSOLE_LOG_PATTERN} + utf8 + + + + + + + + + + + + ${LOG_PATH}/app.log + + ${FILE_LOG_PATTERN} + utf8 + + + ${LOG_PATH}/app-%d{yyyy-MM-dd}.%i.log.gz + 10MB + 180 + 3GB + + + + + + + + + diff --git a/src/main/resources/templates/auth_email.html b/src/main/resources/templates/auth_email.html new file mode 100644 index 0000000..5a2aa4d --- /dev/null +++ b/src/main/resources/templates/auth_email.html @@ -0,0 +1,14 @@ + + + + 이메일 인증 + + +

안녕하세요! 이메일 인증을 완료해주세요.

+

아래 버튼을 클릭하여 디바이스 인증을 완료하세요.

+ + 인증하기 + + + diff --git a/src/main/resources/templates/auth_success.html b/src/main/resources/templates/auth_success.html new file mode 100644 index 0000000..65639fa --- /dev/null +++ b/src/main/resources/templates/auth_success.html @@ -0,0 +1,53 @@ + + + + + + 이메일 인증 완료 + + + +
+

🎉 디바이스 인증이 성공적으로 완료되었습니다! 🎉

+

이제 새로운 디바이스에서 서비스를 안전하게 이용하실 수 있습니다.

+ +
+ + diff --git a/src/main/resources/templates/review_email.html b/src/main/resources/templates/review_email.html new file mode 100644 index 0000000..9fc1403 --- /dev/null +++ b/src/main/resources/templates/review_email.html @@ -0,0 +1,17 @@ + + + + 오늘의 복습 목록 + + +

오늘 복습할 목록입니다.

+

아래 링크들을 클릭하여 복습을 진행하세요.

+ + + diff --git a/src/test/java/com/recyclestudy/RecyclestudyApplicationTests.java b/src/test/java/com/recyclestudy/RecyclestudyApplicationTests.java new file mode 100644 index 0000000..ebc84fd --- /dev/null +++ b/src/test/java/com/recyclestudy/RecyclestudyApplicationTests.java @@ -0,0 +1,13 @@ +package com.recyclestudy; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class RecyclestudyApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/recyclestudy/common/BaseEntityTest.java b/src/test/java/com/recyclestudy/common/BaseEntityTest.java new file mode 100644 index 0000000..83923be --- /dev/null +++ b/src/test/java/com/recyclestudy/common/BaseEntityTest.java @@ -0,0 +1,101 @@ +package com.recyclestudy.common; + +import com.recyclestudy.member.domain.Device; +import com.recyclestudy.member.domain.Member; +import java.lang.reflect.Field; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BaseEntityTest { + + @Test + @DisplayName("서로 다른 타입이면 id가 같아도 동등하지 않다") + void fixedImplementation_differentTypes_sameId_shouldNotBeEqual() throws Exception { + // Given: Member와 Device 인스턴스를 생성하고, 둘 다 id=1로 설정 + final Member member = createMemberWithId(1L); + final Device device = createDeviceWithId(1L); + + // When + final boolean result = member.equals(device); + + // Then + assertThat(result).isFalse(); + assertThat(device.equals(member)).isFalse(); + } + + @Test + @DisplayName("같은 타입이고 같은 id면 동등하다") + void currentImplementation_sameType_sameId_shouldBeEqual() throws Exception { + // Given: 같은 타입의 인스턴스 2개, 같은 id + final Member member1 = createMemberWithId(1L); + final Member member2 = createMemberWithId(1L); + + // When & Then: 같은 타입이므로 동등해야 함 + assertThat(member1.equals(member2)).isTrue(); + } + + @Test + @DisplayName("같은 타입이지만 다른 id면 동등하지 않다") + void currentImplementation_sameType_differentId_shouldNotBeEqual() throws Exception { + // Given: 같은 타입의 인스턴스 2개, 다른 id + final Member member1 = createMemberWithId(1L); + final Member member2 = createMemberWithId(2L); + + // When & Then: id가 다르므로 동등하지 않아야 함 + assertThat(member1.equals(member2)).isFalse(); + } + + @Test + @DisplayName("동등한 객체는 같은 hashCode를 가져야 한다") + void hashCodeContract_equalObjects_sameHashCode() throws Exception { + // Given: 같은 타입, 같은 id + final Member member1 = createMemberWithId(1L); + final Member member2 = createMemberWithId(1L); + + // When: equals는 true + assertThat(member1.equals(member2)).isTrue(); + + // Then: hashCode도 같아야 함 (계약 준수) + assertThat(member1).hasSameHashCodeAs(member2); + } + + @Test + @DisplayName("id가 null인 엔티티의 hashCode() 호출 시 예외가 발생한다") + void hashCode_withNullId_throwsException() throws Exception { + // Given: id가 null인 엔티티 + final Member member = createMemberWithId(null); + + // When & Then + assertThatThrownBy(member::hashCode) + .isInstanceOf(IllegalStateException.class); + } + + private Member createMemberWithId(final Long id) throws Exception { + final var constructor = Member.class.getDeclaredConstructor(); + constructor.setAccessible(true); + final Member member = constructor.newInstance(); + if (id != null) { + setId(member, id); + } + return member; + } + + private Device createDeviceWithId(final Long id) throws Exception { + final var constructor = Device.class.getDeclaredConstructor(); + constructor.setAccessible(true); + final Device device = constructor.newInstance(); + if (id != null) { + setId(device, id); + } + return device; + } + + private void setId(final Object entity, final Long id) throws Exception { + final Field idField = entity.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } +} diff --git a/src/test/java/com/recyclestudy/email/DeviceAuthEmailSenderTest.java b/src/test/java/com/recyclestudy/email/DeviceAuthEmailSenderTest.java new file mode 100644 index 0000000..cb58fdb --- /dev/null +++ b/src/test/java/com/recyclestudy/email/DeviceAuthEmailSenderTest.java @@ -0,0 +1,82 @@ +package com.recyclestudy.email; + +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.member.domain.Email; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class DeviceAuthEmailSenderTest { + + @Mock + private EmailSender emailSender; + + @Mock + private TemplateEngine templateEngine; + + private DeviceAuthEmailSender deviceAuthEmailSender; + + @BeforeEach + void setUp() { + deviceAuthEmailSender = new DeviceAuthEmailSender(emailSender, templateEngine); + ReflectionTestUtils.setField(deviceAuthEmailSender, "baseUrl", "https://example.com"); + } + + @Test + @DisplayName("디바이스 인증 메일을 발송한다") + void sendDeviceAuthMail_success() { + // given + final Email email = Email.from("test@test.com"); + final DeviceIdentifier deviceIdentifier = DeviceIdentifier.from("device-123"); + final String expectedHtml = "인증 링크"; + + given(templateEngine.process(eq("auth_email"), any(Context.class))).willReturn(expectedHtml); + + // when + deviceAuthEmailSender.sendDeviceAuthMail(email, deviceIdentifier); + + // then + verify(emailSender).send( + eq(email), + eq("[Recycle Study] 디바이스 인증을 완료해주세요."), + eq(expectedHtml) + ); + } + + @Test + @DisplayName("올바른 인증 URL이 템플릿에 전달된다") + void sendDeviceAuthMail_correctAuthUrl() { + // given + final Email email = Email.from("test@test.com"); + final DeviceIdentifier deviceIdentifier = DeviceIdentifier.from("device-123"); + final ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + + given(templateEngine.process(eq("auth_email"), any(Context.class))).willReturn(""); + + // when + deviceAuthEmailSender.sendDeviceAuthMail(email, deviceIdentifier); + + // then + verify(templateEngine).process(eq("auth_email"), contextCaptor.capture()); + + final Context capturedContext = contextCaptor.getValue(); + final String authUrl = (String) capturedContext.getVariable("authUrl"); + + assertThat(authUrl) + .isEqualTo("https://example.com/api/v1/device/auth?email=test@test.com&identifier=device-123"); + } +} diff --git a/src/test/java/com/recyclestudy/email/EmailSenderTest.java b/src/test/java/com/recyclestudy/email/EmailSenderTest.java new file mode 100644 index 0000000..ae563cc --- /dev/null +++ b/src/test/java/com/recyclestudy/email/EmailSenderTest.java @@ -0,0 +1,68 @@ +package com.recyclestudy.email; + +import com.recyclestudy.exception.EmailSendException; +import com.recyclestudy.member.domain.Email; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.javamail.JavaMailSender; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class EmailSenderTest { + + @Mock + private JavaMailSender javaMailSender; + + @InjectMocks + private EmailSender emailSender; + + @Test + @DisplayName("메일을 성공적으로 발송한다") + void send_success() { + // given + final Email targetEmail = Email.from("test@test.com"); + final String subject = "테스트 제목"; + final String content = "테스트 내용"; + final MimeMessage mimeMessage = mock(MimeMessage.class); + + given(javaMailSender.createMimeMessage()).willReturn(mimeMessage); + + // when + emailSender.send(targetEmail, subject, content); + + // then + verify(javaMailSender).createMimeMessage(); + verify(javaMailSender).send(mimeMessage); + } + + @Test + @DisplayName("메일 발송 실패 시 EmailSendException을 던진다") + void send_fail_throwsException() throws MessagingException { + // given + final Email targetEmail = Email.from("test@test.com"); + final String subject = "테스트 제목"; + final String content = "테스트 내용"; + final MimeMessage mimeMessage = mock(MimeMessage.class); + + given(javaMailSender.createMimeMessage()).willReturn(mimeMessage); + willThrow(new MessagingException("메일 서버 오류")) + .given(mimeMessage).setRecipient(any(), any()); + + // when & then + assertThatThrownBy(() -> emailSender.send(targetEmail, subject, content)) + .isInstanceOf(EmailSendException.class) + .hasMessage("메일 전송 중 오류가 발생했습니다."); + } +} diff --git a/src/test/java/com/recyclestudy/email/ReviewEmailSenderTest.java b/src/test/java/com/recyclestudy/email/ReviewEmailSenderTest.java new file mode 100644 index 0000000..40e4b43 --- /dev/null +++ b/src/test/java/com/recyclestudy/email/ReviewEmailSenderTest.java @@ -0,0 +1,201 @@ +package com.recyclestudy.email; + +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.review.domain.NotificationStatus; +import com.recyclestudy.review.domain.ReviewURL; +import com.recyclestudy.review.service.NotificationHistoryService; +import com.recyclestudy.review.service.ReviewCycleService; +import com.recyclestudy.review.service.output.ReviewSendOutput; +import com.recyclestudy.review.service.output.ReviewSendOutput.ReviewSendElement; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ReviewEmailSenderTest { + + @Mock + EmailSender emailSender; + + @Mock + TemplateEngine templateEngine; + + @Mock + ReviewCycleService reviewCycleService; + + @Mock + NotificationHistoryService notificationHistoryService; + + @Spy + Clock clock = Clock.fixed(Instant.parse("2025-01-01T08:00:00Z"), ZoneId.of("UTC")); + + @InjectMocks + ReviewEmailSender reviewEmailSender; + + @Test + @DisplayName("복습 대상자에게 메일을 발송한다") + void sendReviewMail_success() { + // given + final Email targetEmail = Email.from("user@test.com"); + final List reviewCycleIds = List.of(1L, 2L); + final List targetUrls = List.of( + ReviewURL.from("https://example.com/article1"), + ReviewURL.from("https://example.com/article2") + ); + final ReviewSendElement element = ReviewSendElement.of(targetEmail, reviewCycleIds, targetUrls); + final ReviewSendOutput output = new ReviewSendOutput(List.of(element)); + + given(reviewCycleService.findTargetReviewCycle(any())).willReturn(output); + given(templateEngine.process(eq("review_email"), any(Context.class))).willReturn("복습 목록"); + + // when + reviewEmailSender.sendReviewMail(); + + // then + verify(emailSender).send( + eq(targetEmail), + eq("[Recycle Study] 오늘의 복습 목록이 도착했습니다"), + eq("복습 목록") + ); + } + + @Test + @DisplayName("여러 대상자에게 각각 메일을 발송한다") + void sendReviewMail_multipleRecipients() { + // given + final ReviewSendElement element1 = ReviewSendElement.of( + Email.from("user1@test.com"), + List.of(1L), + List.of(ReviewURL.from("https://example.com/1")) + ); + final ReviewSendElement element2 = ReviewSendElement.of( + Email.from("user2@test.com"), + List.of(2L), + List.of(ReviewURL.from("https://example.com/2")) + ); + final ReviewSendOutput output = new ReviewSendOutput(List.of(element1, element2)); + + given(reviewCycleService.findTargetReviewCycle(any())).willReturn(output); + given(templateEngine.process(eq("review_email"), any(Context.class))).willReturn(""); + + // when + reviewEmailSender.sendReviewMail(); + + // then + verify(emailSender, times(2)).send(any(Email.class), any(), any()); + verify(emailSender).send(eq(Email.from("user1@test.com")), any(), any()); + verify(emailSender).send(eq(Email.from("user2@test.com")), any(), any()); + } + + @Test + @DisplayName("복습 대상이 없으면 메일을 발송하지 않는다") + void sendReviewMail_noRecipients() { + // given + final ReviewSendOutput output = new ReviewSendOutput(List.of()); + + given(reviewCycleService.findTargetReviewCycle(any())).willReturn(output); + + // when + reviewEmailSender.sendReviewMail(); + + // then + verify(emailSender, never()).send(any(Email.class), any(), any()); + } + + @Test + @DisplayName("템플릿에 복습 URL 목록이 전달된다") + void sendReviewMail_templateReceivesUrls() { + // given + final List reviewCycleIds = List.of(1L, 2L); + final List targetUrls = List.of( + ReviewURL.from("https://example.com/article1"), + ReviewURL.from("https://example.com/article2") + ); + final ReviewSendElement element = ReviewSendElement.of(Email.from("user@test.com"), reviewCycleIds, targetUrls); + final ReviewSendOutput output = new ReviewSendOutput(List.of(element)); + final ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + + given(reviewCycleService.findTargetReviewCycle(any())).willReturn(output); + given(templateEngine.process(eq("review_email"), any(Context.class))).willReturn(""); + + // when + reviewEmailSender.sendReviewMail(); + + // then + verify(templateEngine).process(eq("review_email"), contextCaptor.capture()); + + final Context capturedContext = contextCaptor.getValue(); + final List capturedUrls = (List) capturedContext.getVariable("targetUrls"); + + assertSoftly(softAssertions -> { + softAssertions.assertThat(capturedUrls).hasSize(2); + softAssertions.assertThat(capturedUrls.getFirst().getValue()).isEqualTo("https://example.com/article1"); + softAssertions.assertThat(capturedUrls.get(1).getValue()).isEqualTo("https://example.com/article2"); + }); + } + + @Test + @DisplayName("메일 발송 성공 시 SENT 상태로 저장한다") + void sendReviewMail_success_savesSentStatus() { + // given + final List reviewCycleIds = List.of(1L, 2L); + final ReviewSendElement element = ReviewSendElement.of( + Email.from("user@test.com"), + reviewCycleIds, + List.of(ReviewURL.from("https://example.com/article")) + ); + final ReviewSendOutput output = new ReviewSendOutput(List.of(element)); + + given(reviewCycleService.findTargetReviewCycle(any())).willReturn(output); + given(templateEngine.process(eq("review_email"), any(Context.class))).willReturn(""); + + // when + reviewEmailSender.sendReviewMail(); + + // then + verify(notificationHistoryService).saveAll(reviewCycleIds, NotificationStatus.SENT); + } + + @Test + @DisplayName("메일 발송 실패 시 FAILED 상태로 저장한다") + void sendReviewMail_failure_savesFailedStatus() { + // given + final List reviewCycleIds = List.of(1L, 2L); + final ReviewSendElement element = ReviewSendElement.of( + Email.from("user@test.com"), + reviewCycleIds, + List.of(ReviewURL.from("https://example.com/article")) + ); + final ReviewSendOutput output = new ReviewSendOutput(List.of(element)); + + given(reviewCycleService.findTargetReviewCycle(any())).willReturn(output); + given(templateEngine.process(eq("review_email"), any(Context.class))).willReturn(""); + willThrow(new RuntimeException("메일 발송 실패")).given(emailSender).send(any(), any(), any()); + + // when + reviewEmailSender.sendReviewMail(); + + // then + verify(notificationHistoryService).saveAll(reviewCycleIds, NotificationStatus.FAILED); + } +} diff --git a/src/test/java/com/recyclestudy/member/controller/DeviceControllerTest.java b/src/test/java/com/recyclestudy/member/controller/DeviceControllerTest.java new file mode 100644 index 0000000..b8c1939 --- /dev/null +++ b/src/test/java/com/recyclestudy/member/controller/DeviceControllerTest.java @@ -0,0 +1,548 @@ +package com.recyclestudy.member.controller; + +import com.recyclestudy.exception.BadRequestException; +import com.recyclestudy.exception.DeviceActivationExpiredException; +import com.recyclestudy.exception.NotFoundException; +import com.recyclestudy.exception.UnauthorizedException; +import com.recyclestudy.member.controller.request.DeviceDeleteRequest; +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.member.service.MemberService; +import com.recyclestudy.restdocs.APIBaseTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static com.epages.restdocs.apispec.ResourceSnippetParameters.builder; +import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.Schema.schema; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; + +class DeviceControllerTest extends APIBaseTest { + + @MockitoBean + private MemberService memberService; + + @Test + @DisplayName("디바이스 인증 성공 시 200 응답을 반환한다") + void authenticateDevice_Success() { + // given + final String email = "test@test.com"; + final String identifier = "device-identifier"; + + doNothing().when(memberService).authenticateDevice(any(Email.class), any(DeviceIdentifier.class)); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + resource( + builder() + .tag("Device") + .summary("디바이스 인증") + .description("디바이스 인증 성공 시 인증 완료 안내 HTML 페이지를 반환합니다.") + .queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + .responseHeaders( + headerWithName(HttpHeaders.CONTENT_TYPE) + .description("text/html;charset=UTF-8") + ) + .responseSchema(schema("string")) + .build() + ), + queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + )) + .param("email", email) + .param("identifier", identifier) + .accept(MediaType.TEXT_HTML_VALUE) + .when() + .get("/api/v1/device/auth") + .then() + .statusCode(HttpStatus.OK.value()) + .contentType(MediaType.TEXT_HTML_VALUE); + } + + @Test + @DisplayName("이미 인증된 디바이스 인증 시도 시 400 응답을 반환한다") + void authenticateDevice_AlreadyAuthenticated() { + // given + final String email = "test@test.com"; + final String identifier = "device-identifier"; + + doThrow(new BadRequestException("이미 인증되었습니다")) + .when(memberService).authenticateDevice(any(Email.class), any(DeviceIdentifier.class)); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Device") + .summary("디바이스 인증") + .description("이미 인증된 디바이스 인증 시도 시 400 응답을 반환한다") + .queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .param("email", email) + .param("identifier", identifier) + .when() + .get("/api/v1/device/auth") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("message", equalTo("이미 인증되었습니다")); + } + + @Test + @DisplayName("인증 시 유효하지 않은 이메일 형식인 경우 400 응답을 반환한다") + void authenticateDevice_InvalidEmailFormat() { + // given + final String invalidEmail = "invalid-email"; + final String identifier = "device-identifier"; + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Device") + .summary("디바이스 인증") + .description("유효하지 않은 이메일 형식인 경우 400 응답을 반환한다") + .queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .param("email", invalidEmail) + .param("identifier", identifier) + .when() + .get("/api/v1/device/auth") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("message", equalTo("유효하지 않은 이메일 형식입니다.")); + } + + @Test + @DisplayName("인증 유효 시간이 만료된 경우 400 응답을 반환한다") + void authenticateDevice_Expired() { + // given + final String email = "test@test.com"; + final String identifier = "device-identifier"; + + doThrow(new DeviceActivationExpiredException("인증 유효 시간이 만료되었습니다.")) + .when(memberService).authenticateDevice(any(Email.class), any(DeviceIdentifier.class)); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Device") + .summary("디바이스 인증") + .description("인증 유효 시간이 만료된 경우 400 응답을 반환한다") + .queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .param("email", email) + .param("identifier", identifier) + .when() + .get("/api/v1/device/auth") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("message", equalTo("인증 유효 시간이 만료되었습니다.")); + } + + @Test + @DisplayName("디바이스 삭제 시 204 응답을 반환한다") + void deleteDevice() { + // given + final DeviceDeleteRequest request = new DeviceDeleteRequest("test@test.com", "device-id", "target-id"); + + doNothing().when(memberService).deleteDevice(any()); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Device") + .summary("디바이스 삭제") + .description("디바이스 삭제 시 204 응답을 반환한다") + .requestFields( + fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("identifier").type(JsonFieldType.STRING).description("디바이스 식별자"), + fieldWithPath("targetIdentifier").type(JsonFieldType.STRING) + .description("삭제할 디바이스 식별자") + ) + )) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .when() + .delete("/api/v1/device") + .then() + .statusCode(HttpStatus.NO_CONTENT.value()); + } + + @Test + @DisplayName("유효하지 않은 디바이스 아이디로 삭제 시 401 응답을 반환한다") + void deleteDevice_InvalidIdentifier() { + // given + final DeviceDeleteRequest request = new DeviceDeleteRequest("test@test.com", "not-existed", "target-id"); + + doThrow(new UnauthorizedException("유효하지 않은 디바이스 아이디입니다")) + .when(memberService).deleteDevice(any()); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Device") + .summary("디바이스 삭제") + .description("유효하지 않은 디바이스 아이디로 삭제 시 401 응답을 반환한다") + .requestFields( + fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("identifier").type(JsonFieldType.STRING).description("디바이스 식별자"), + fieldWithPath("targetIdentifier").type(JsonFieldType.STRING) + .description("삭제할 디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .when() + .delete("/api/v1/device") + .then() + .statusCode(HttpStatus.UNAUTHORIZED.value()) + .body("message", equalTo("유효하지 않은 디바이스 아이디입니다")); + } + + @Test + @DisplayName("소유자가 아닌 디바이스 삭제 시 400 응답을 반환한다") + void deleteDevice_NotOwner() { + // given + final DeviceDeleteRequest request = new DeviceDeleteRequest("other@test.com", "device-id", "target-id"); + + doThrow(new BadRequestException("디바이스 소유자가 아닙니다.")) + .when(memberService).deleteDevice(any()); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Device") + .summary("디바이스 삭제") + .description("소유자가 아닌 디바이스 삭제 시 400 응답을 반환한다") + .requestFields( + fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("identifier").type(JsonFieldType.STRING).description("디바이스 식별자"), + fieldWithPath("targetIdentifier").type(JsonFieldType.STRING) + .description("삭제할 디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .when() + .delete("/api/v1/device") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("message", equalTo("디바이스 소유자가 아닙니다.")); + } + + @Test + @DisplayName("존재하지 않는 멤버의 이메일로 인증 시도 시 404 응답을 반환한다") + void authenticateDevice_NotFoundMember() { + // given + final String email = "notfound@test.com"; + final String identifier = "device-identifier"; + + doThrow(new NotFoundException("존재하지 않는 멤버입니다")) + .when(memberService).authenticateDevice(any(Email.class), any(DeviceIdentifier.class)); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Device") + .summary("디바이스 인증") + .description("존재하지 않는 멤버의 이메일로 인증 시도 시 404 응답을 반환한다") + .queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .param("email", email) + .param("identifier", identifier) + .when() + .get("/api/v1/device/auth") + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .body("message", equalTo("존재하지 않는 멤버입니다")); + } + + @Test + @DisplayName("존재하지 않는 디바이스 식별자로 인증 시도 시 404 응답을 반환한다") + void authenticateDevice_NotFoundDevice() { + // given + final String email = "test@test.com"; + final String identifier = "not-found-id"; + + doThrow(new NotFoundException("존재하지 않는 디바이스 아이디입니다")) + .when(memberService).authenticateDevice(any(Email.class), any(DeviceIdentifier.class)); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Device") + .summary("디바이스 인증") + .description("존재하지 않는 디바이스 식별자로 인증 시도 시 404 응답을 반환한다") + .queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .param("email", email) + .param("identifier", identifier) + .when() + .get("/api/v1/device/auth") + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .body("message", equalTo("존재하지 않는 디바이스 아이디입니다")); + } + + @Test + @DisplayName("디바이스 소유자가 아닌 이메일로 인증 시도 시 400 응답을 반환한다") + void authenticateDevice_NotOwner() { + // given + final String email = "other@test.com"; + final String identifier = "device-identifier"; + + doThrow(new BadRequestException("디바이스 소유자가 아닙니다.")) + .when(memberService).authenticateDevice(any(Email.class), any(DeviceIdentifier.class)); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Device") + .summary("디바이스 인증") + .description("디바이스 소유자가 아닌 이메일로 인증 시도 시 400 응답을 반환한다") + .queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .param("email", email) + .param("identifier", identifier) + .when() + .get("/api/v1/device/auth") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("message", equalTo("디바이스 소유자가 아닙니다.")); + } + + @Test + @DisplayName("인증 시 이메일 파라미터가 누락된 경우 400 응답을 반환한다") + void authenticateDevice_NullEmail() { + // given + final String identifier = "device-identifier"; + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Device") + .summary("디바이스 인증") + .description("이메일 파라미터가 누락된 경우 400 응답을 반환한다") + .queryParameters( + parameterWithName("identifier").description("디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .param("identifier", identifier) + .when() + .get("/api/v1/device/auth") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("인증 시 디바이스 식별자 파라미터가 누락된 경우 400 응답을 반환한다") + void authenticateDevice_NullIdentifier() { + // given + final String email = "test@test.com"; + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Device") + .summary("디바이스 인증") + .description("디바이스 식별자 파라미터가 누락된 경우 400 응답을 반환한다") + .queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .param("email", email) + .when() + .get("/api/v1/device/auth") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("삭제 요청 시 이메일이 누락된 경우 400 응답을 반환한다") + void deleteDevice_NullEmail() { + // given + final DeviceDeleteRequest request = new DeviceDeleteRequest(null, "device-id", "target-id"); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Device") + .summary("디바이스 삭제") + .description("삭제 요청 시 이메일이 누락된 경우 400 응답을 반환한다") + .requestFields( + fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("identifier").type(JsonFieldType.STRING).description("디바이스 식별자"), + fieldWithPath("targetIdentifier").type(JsonFieldType.STRING) + .description("삭제할 디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .when() + .delete("/api/v1/device") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("message", equalTo("null이 될 수 없습니다: value")); + } + + @Test + @DisplayName("삭제 요청 시 디바이스 식별자가 누락된 경우 400 응답을 반환한다") + void deleteDevice_NullIdentifier() { + // given + final DeviceDeleteRequest request = new DeviceDeleteRequest("test@test.com", null, "target-id"); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Device") + .summary("디바이스 삭제") + .description("삭제 요청 시 디바이스 식별자가 누락된 경우 400 응답을 반환한다") + .requestFields( + fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("identifier").type(JsonFieldType.STRING).description("디바이스 식별자"), + fieldWithPath("targetIdentifier").type(JsonFieldType.STRING) + .description("삭제할 디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .when() + .delete("/api/v1/device") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("message", equalTo("null이 될 수 없습니다: value")); + } + + @Test + @DisplayName("삭제 요청 시 삭제할 디바이스 식별자가 누락된 경우 400 응답을 반환한다") + void deleteDevice_NullTargetIdentifier() { + // given + final DeviceDeleteRequest request = new DeviceDeleteRequest("test@test.com", "device-id", null); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Device") + .summary("디바이스 삭제") + .description("삭제 요청 시 삭제할 디바이스 식별자가 누락된 경우 400 응답을 반환한다") + .requestFields( + fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("identifier").type(JsonFieldType.STRING).description("디바이스 식별자"), + fieldWithPath("targetIdentifier").type(JsonFieldType.STRING) + .description("삭제할 디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .when() + .delete("/api/v1/device") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("message", equalTo("null이 될 수 없습니다: value")); + } +} diff --git a/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java b/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java new file mode 100644 index 0000000..37e9f5e --- /dev/null +++ b/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java @@ -0,0 +1,413 @@ +package com.recyclestudy.member.controller; + +import com.recyclestudy.email.DeviceAuthEmailSender; +import com.recyclestudy.exception.NotFoundException; +import com.recyclestudy.exception.UnauthorizedException; +import com.recyclestudy.member.controller.request.MemberSaveRequest; +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.member.service.MemberService; +import com.recyclestudy.member.service.output.MemberFindOutput; +import com.recyclestudy.member.service.output.MemberSaveOutput; +import com.recyclestudy.restdocs.APIBaseTest; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static com.epages.restdocs.apispec.ResourceSnippetParameters.builder; +import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.document; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; + +class MemberControllerTest extends APIBaseTest { + + @MockitoBean + private MemberService memberService; + + @MockitoBean + private DeviceAuthEmailSender deviceAuthEmailSender; + + @Test + @DisplayName("이메일을 통해 새로운 디바이스를 저장한다") + void saveMember() { + // given + final String email = "test@test.com"; + final String identifier = "device-identifier"; + final MemberSaveRequest request = new MemberSaveRequest(email); + final MemberSaveOutput output = new MemberSaveOutput(Email.from(email), DeviceIdentifier.from(identifier)); + + given(memberService.saveDevice(any())).willReturn(output); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Member") + .summary("멤버 저장") + .description("이메일을 통해 새로운 디바이스를 저장한다") + .requestFields( + fieldWithPath("email").type(JsonFieldType.STRING).description("이메일") + ) + .responseFields( + fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("identifier").type(JsonFieldType.STRING).description("디바이스 식별자") + ) + )) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .when() + .post("/api/v1/members") + .then() + .statusCode(HttpStatus.CREATED.value()) + .body("email", equalTo(email)) + .body("identifier", equalTo(identifier)); + + verify(deviceAuthEmailSender).sendDeviceAuthMail(Email.from(email), DeviceIdentifier.from(identifier)); + } + + @Test + @DisplayName("멤버의 모든 디바이스 정보를 조회한다") + void findAllMemberDevices() { + // given + final String email = "test@test.com"; + final String queryIdentifier = "device-id-1"; + + final MemberFindOutput.MemberFindElement device1 = new MemberFindOutput.MemberFindElement( + DeviceIdentifier.from(queryIdentifier), + LocalDateTime.now().minusDays(1) + ); + final MemberFindOutput.MemberFindElement device2 = new MemberFindOutput.MemberFindElement( + DeviceIdentifier.from("device-id-2"), + LocalDateTime.now() + ); + + final MemberFindOutput output = new MemberFindOutput( + Email.from(email), + List.of(device1, device2) + ); + + given(memberService.findAllMemberDevices(any())).willReturn(output); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Member") + .summary("멤버 디바이스 조회") + .description("멤버의 모든 디바이스 정보를 조회한다") + .queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + .responseFields( + fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("devices").type(JsonFieldType.ARRAY).description("디바이스 목록"), + fieldWithPath("devices[].identifier").type(JsonFieldType.STRING) + .description("디바이스 식별자 값"), + fieldWithPath("devices[].createdAt").type(JsonFieldType.STRING) + .description("디바이스 생성일") + ), + queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + )) + .param("email", email) + .param("identifier", queryIdentifier) + .when() + .get("/api/v1/members") + .then() + .statusCode(HttpStatus.OK.value()) + .body("devices", hasSize(2)); + } + + @Test + @DisplayName("존재하지 않는 멤버 조회 시 404 응답을 반환한다") + void findAllMemberDevices_NotFoundMember() { + // given + final String email = "notfound@test.com"; + final String identifier = "device-identifier"; + + given(memberService.findAllMemberDevices(any())) + .willThrow(new NotFoundException("존재하지 않는 멤버입니다")); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Member") + .summary("멤버 디바이스 조회") + .description("존재하지 않는 멤버 조회 시 404 응답을 반환한다") + .queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ), + queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + )) + .param("email", email) + .param("identifier", identifier) + .when() + .get("/api/v1/members") + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .body("message", equalTo("존재하지 않는 멤버입니다")); + } + + @Test + @DisplayName("인증되지 않은 디바이스로 조회 시 401 응답을 반환한다") + void findAllMemberDevices_UnauthorizedDevice() { + // given + final String email = "test@test.com"; + final String identifier = "unauthorized-id"; + + given(memberService.findAllMemberDevices(any())) + .willThrow(new UnauthorizedException("인증되지 않은 디바이스입니다")); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Member") + .summary("멤버 디바이스 조회") + .description("인증되지 않은 디바이스로 조회 시 401 응답을 반환한다") + .queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ), + queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + )) + .param("email", email) + .param("identifier", identifier) + .when() + .get("/api/v1/members") + .then() + .statusCode(HttpStatus.UNAUTHORIZED.value()) + .body("message", equalTo("인증되지 않은 디바이스입니다")); + } + + @Test + @DisplayName("조회 시 유효하지 않은 이메일 형식인 경우 400 응답을 반환한다") + void findAllMemberDevices_InvalidEmailFormat() { + // given + final String invalidEmail = "invalid-email"; + final String identifier = "device-identifier"; + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Member") + .summary("멤버 디바이스 조회") + .description("조회 시 유효하지 않은 이메일 형식인 경우 400 응답을 반환한다") + .queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ), + queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + )) + .param("email", invalidEmail) + .param("identifier", identifier) + .when() + .get("/api/v1/members") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("message", equalTo("유효하지 않은 이메일 형식입니다.")); + } + + @Test + @DisplayName("이메일이 누락된 경우 400 응답을 반환한다") + void saveMember_NullEmail() { + // given + final MemberSaveRequest request = new MemberSaveRequest(null); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Member") + .summary("멤버 저장") + .description("이메일이 누락된 경우 400 응답을 반환한다") + .requestFields( + fieldWithPath("email").type(JsonFieldType.STRING).description("이메일").optional() + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .when() + .post("/api/v1/members") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("message", equalTo("null이 될 수 없습니다: value")); + } + + @Test + @DisplayName("유효하지 않은 이메일 형식으로 요청 시 400 응답을 반환한다") + void saveMember_InvalidEmailFormat() { + // given + final String invalidEmail = "invalid-email"; + final MemberSaveRequest request = new MemberSaveRequest(invalidEmail); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Member") + .summary("멤버 저장") + .description("유효하지 않은 이메일 형식으로 요청 시 400 응답을 반환한다") + .requestFields( + fieldWithPath("email").description("이메일") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .when() + .post("/api/v1/members") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("message", equalTo("유효하지 않은 이메일 형식입니다.")); + } + + @Test + @DisplayName("존재하지 않는 디바이스 식별자로 조회 시 404 응답을 반환한다") + void findAllMemberDevices_NotFoundDevice() { + // given + final String email = "test@test.com"; + final String identifier = "not-found-id"; + + given(memberService.findAllMemberDevices(any())) + .willThrow(new NotFoundException("존재하지 않는 디바이스 아이디입니다")); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Member") + .summary("멤버 디바이스 조회") + .description("존재하지 않는 디바이스 식별자로 조회 시 404 응답을 반환한다") + .queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ), + queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자") + ) + )) + .param("email", email) + .param("identifier", identifier) + .when() + .get("/api/v1/members") + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .body("message", equalTo("존재하지 않는 디바이스 아이디입니다")); + } + + @Test + @DisplayName("이메일 파라미터가 누락된 경우 400 응답을 반환한다") + void findAllMemberDevices_NullEmail() { + // given + final String identifier = "device-identifier"; + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Member") + .summary("멤버 디바이스 조회") + .description("이메일 파라미터가 누락된 경우 400 응답을 반환한다") + .queryParameters( + parameterWithName("identifier").description("디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ), + queryParameters( + parameterWithName("identifier").description("디바이스 식별자") + ) + )) + .param("identifier", identifier) + .when() + .get("/api/v1/members") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("디바이스 식별자 파라미터가 누락된 경우 400 응답을 반환한다") + void findAllMemberDevices_NullIdentifier() { + // given + final String email = "test@test.com"; + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Member") + .summary("멤버 디바이스 조회") + .description("디바이스 식별자 파라미터가 누락된 경우 400 응답을 반환한다") + .queryParameters( + parameterWithName("email").description("이메일") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ), + queryParameters( + parameterWithName("email").description("이메일") + ) + )) + .param("email", email) + .when() + .get("/api/v1/members") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } +} diff --git a/src/test/java/com/recyclestudy/member/domain/DeviceIdentifierTest.java b/src/test/java/com/recyclestudy/member/domain/DeviceIdentifierTest.java new file mode 100644 index 0000000..cdc5fbb --- /dev/null +++ b/src/test/java/com/recyclestudy/member/domain/DeviceIdentifierTest.java @@ -0,0 +1,33 @@ +package com.recyclestudy.member.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DeviceIdentifierTest { + + @Test + @DisplayName("from 메서드를 활용하여 DeviceIdentifier를 생성할 수 있다") + void from() { + // given + final String value = "test"; + + // when + final DeviceIdentifier actual = DeviceIdentifier.from(value); + + // then + assertThat(actual.getValue()).isEqualTo(value); + } + + @Test + @DisplayName("null 값으로 생성 시도 시, 예외를 발생한다") + void throwExceptionWhenNull() { + // given + // when + // then + assertThatThrownBy(() -> DeviceIdentifier.from(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/recyclestudy/member/domain/DeviceTest.java b/src/test/java/com/recyclestudy/member/domain/DeviceTest.java new file mode 100644 index 0000000..8ad6dc2 --- /dev/null +++ b/src/test/java/com/recyclestudy/member/domain/DeviceTest.java @@ -0,0 +1,143 @@ +package com.recyclestudy.member.domain; + +import java.time.LocalDateTime; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +class DeviceTest { + + private static Stream provideInvalidValue() { + final Email email = Email.from("test@test.com"); + final Member member = Member.withoutId(email); + final DeviceIdentifier deviceIdentifier = DeviceIdentifier.from("test"); + final ActivationExpiredDateTime activationExpiredDateTime = ActivationExpiredDateTime.create( + LocalDateTime.now()); + + return Stream.of( + Arguments.of(member, null, activationExpiredDateTime), + Arguments.of(null, deviceIdentifier, activationExpiredDateTime), + Arguments.of(member, deviceIdentifier, null), + Arguments.of(null, null, null) + ); + } + + @Test + @DisplayName("withoutId 메서드를 통해 Device를 생성할 수 있다") + void withoutId() { + // given + final Email email = Email.from("test@test.com"); + final Member member = Member.withoutId(email); + final DeviceIdentifier deviceIdentifier = DeviceIdentifier.from("test"); + final ActivationExpiredDateTime activationExpiredDateTime = ActivationExpiredDateTime.create( + LocalDateTime.now()); + + // when + final Device actual = Device.withoutId(member, deviceIdentifier, false, activationExpiredDateTime); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(actual.getMember().getEmail()).isEqualTo(email); + softAssertions.assertThat(actual.getIdentifier()).isEqualTo(deviceIdentifier); + softAssertions.assertThat(actual.isActive()).isFalse(); + softAssertions.assertThat(actual.getActivationExpiresAt()).isEqualTo(activationExpiredDateTime); + }); + } + + @ParameterizedTest + @MethodSource("provideInvalidValue") + @DisplayName("null로 생성 시도 시, 예외를 던진다") + void throwExceptionWhenNull(final Member member, final DeviceIdentifier deviceIdentifier, + final ActivationExpiredDateTime activationExpiredDateTime) { + //given + //when + //then + assertThatThrownBy(() -> Device.withoutId(member, deviceIdentifier, false, activationExpiredDateTime)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("activate 메서드를 통해 Device를 활성화할 수 있다") + void activate() { + // given + final LocalDateTime now = LocalDateTime.now(); + final Device device = Device.withoutId( + Member.withoutId(Email.from("test@test.com")), + DeviceIdentifier.from("test"), + false, + ActivationExpiredDateTime.create(now) + ); + + // when + device.activate(now); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(device.isActive()).isTrue(); + }); + } + + @Test + @DisplayName("만료 시간이 지난 후 activate 메서드 호출 시, 예외를 던진다") + void activate_fail_expired() { + // given + final LocalDateTime now = LocalDateTime.now(); + final Device device = Device.withoutId( + Member.withoutId(Email.from("test@test.com")), + DeviceIdentifier.from("test"), + false, + ActivationExpiredDateTime.create(now) + ); + + // when + // then + final LocalDateTime expiredTime = now.plusMinutes(6); + assertThatThrownBy(() -> device.activate(expiredTime)) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("verifyOwner 메서드를 통해 소유자를 검증할 수 있다") + void verifyOwner() { + // given + final Email email = Email.from("test@test.com"); + final Device device = Device.withoutId( + Member.withoutId(email), + DeviceIdentifier.from("test"), + false, + ActivationExpiredDateTime.create(LocalDateTime.now()) + ); + + // when + // then + assertThatCode(() -> device.verifyOwner(email)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("소유자가 아닌 이메일로 verifyOwner 메서드 호출 시, 예외를 던진다") + void verifyOwner_fail() { + // given + final Email email = Email.from("test@test.com"); + final Device device = Device.withoutId( + Member.withoutId(email), + DeviceIdentifier.from("test"), + false, + ActivationExpiredDateTime.create(LocalDateTime.now()) + ); + + // when + // then + final Email otherEmail = Email.from("other@test.com"); + assertThatThrownBy(() -> device.verifyOwner(otherEmail)) + .isInstanceOf(RuntimeException.class) + .hasMessage("디바이스 소유자가 아닙니다."); + } +} diff --git a/src/test/java/com/recyclestudy/member/domain/EmailTest.java b/src/test/java/com/recyclestudy/member/domain/EmailTest.java new file mode 100644 index 0000000..d3d454a --- /dev/null +++ b/src/test/java/com/recyclestudy/member/domain/EmailTest.java @@ -0,0 +1,98 @@ +package com.recyclestudy.member.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EmailTest { + + @Test + @DisplayName("from 메서드를 활용하여 Email을 생성할 수 있다") + void from() { + // given + final String value = "test@test.com"; + + // when + final Email actual = Email.from(value); + + // then + assertThat(actual.getValue()).isEqualTo(value); + } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("null 값으로 생성 시도 시, 예외를 발생한다") + void throwExceptionWhenNull(final String emptyValue) { + // given + // when + // then + assertThatThrownBy(() -> Email.from(emptyValue)) + .isInstanceOf(IllegalArgumentException.class); + } + + @ParameterizedTest + @ValueSource(strings = { + "valid@example.com", + "another.email@sub.domain.co.kr", + "test-user_name+tag@domain.net", + "12345@test.org" + }) + @DisplayName("올바른 형식인 값으로 생성 시도 시, Email을 생성할 수 있다") + void from_validEmail_createsEmail(final String validValue) { + // given + // when + final Email actual = Email.from(validValue); + + // then + assertThat(actual.getValue()).isEqualTo(validValue); + } + + @ParameterizedTest + @ValueSource(strings = { + "testtest.com", // '@' 없음 + "test@", // 도메인 없음 + "@test.com", // 로컬 파트 없음 + "test@test", // 최상위 도메인 없음 + "test@test.c", // 최상위 도메인 너무 짧음 + "test@test.commmmmmmm", // 최상위 도메인 너무 김 + "te st@test.com", // 로컬 파트에 공백 + "test@test com" // 도메인에 공백 + }) + @DisplayName("맞지 않는 형식인 값으로 생성 시도 시, 예외를 발생한다") + void throwExceptionWhenInvalidFormat(final String invalidValue) { + //given + //when + //then + assertThatThrownBy(() -> Email.from(invalidValue)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("유효하지 않은 이메일 형식입니다."); + } + + @ParameterizedTest + @CsvSource({ + "a@test.com, a*@test.com", + "ab@test.com, a*@test.com", + "abc@test.com, a**@test.com", + "john@test.com, jo**@test.com", + "hello@test.com, he***@test.com", + "longname@test.com, lon*****@test.com", + "test1234@test.com, tes*****@test.com" + }) + @DisplayName("toMaskedValue 메서드를 활용하여 이메일을 마스킹할 수 있다") + void toMaskedValue(final String originValue, final String expectedValue) { + // given + final Email email = Email.from(originValue); + + // when + final String actual = email.toMaskedValue(); + + // then + assertThat(actual).isEqualTo(expectedValue); + } +} diff --git a/src/test/java/com/recyclestudy/member/domain/MemberTest.java b/src/test/java/com/recyclestudy/member/domain/MemberTest.java new file mode 100644 index 0000000..2a03659 --- /dev/null +++ b/src/test/java/com/recyclestudy/member/domain/MemberTest.java @@ -0,0 +1,61 @@ +package com.recyclestudy.member.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MemberTest { + + @Test + @DisplayName("withoutId 메서드를 통해 Member를 생성할 수 있다") + void withoutId() { + // given + final Email email = Email.from("test@test.com"); + + // when + final Member actual = Member.withoutId(email); + + // then + assertThat(actual.getEmail()).isEqualTo(email); + } + + @Test + @DisplayName("null로 생성 시도 시, 예외를 던진다") + void throwExceptionWhenNull() { + //given + //when + //then + assertThatThrownBy(() -> Member.withoutId(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("hasEmail 메서드를 통해 이메일 일치 여부를 확인할 수 있다") + void hasEmail() { + // given + final Email email = Email.from("test@test.com"); + final Member member = Member.withoutId(email); + + // when + final boolean actual = member.hasEmail(email); + + // then + assertThat(actual).isTrue(); + } + + @Test + @DisplayName("이메일이 일치하지 않으면 false를 반환한다") + void hasEmail_fail() { + // given + final Email email = Email.from("test@test.com"); + final Member member = Member.withoutId(email); + + // when + final boolean actual = member.hasEmail(Email.from("other@test.com")); + + // then + assertThat(actual).isFalse(); + } +} diff --git a/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java b/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java new file mode 100644 index 0000000..5e17cf3 --- /dev/null +++ b/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java @@ -0,0 +1,342 @@ +package com.recyclestudy.member.service; + +import com.recyclestudy.exception.BadRequestException; +import com.recyclestudy.exception.NotFoundException; +import com.recyclestudy.exception.UnauthorizedException; +import com.recyclestudy.member.domain.ActivationExpiredDateTime; +import com.recyclestudy.member.domain.Device; +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.member.domain.Member; +import com.recyclestudy.member.repository.DeviceRepository; +import com.recyclestudy.member.repository.MemberRepository; +import com.recyclestudy.member.service.input.DeviceDeleteInput; +import com.recyclestudy.member.service.input.MemberFindInput; +import com.recyclestudy.member.service.input.MemberSaveInput; +import com.recyclestudy.member.service.output.MemberFindOutput; +import com.recyclestudy.member.service.output.MemberSaveOutput; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + @Mock + MemberRepository memberRepository; + + @Mock + DeviceRepository deviceRepository; + + @Spy + Clock clock = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneId.of("UTC")); + + @InjectMocks + MemberService memberService; + + LocalDateTime now; + + @BeforeEach + void setUp() { + now = LocalDateTime.now(clock); + } + + @Test + @DisplayName("저장되지 않은 이메일일 경우 멤버를 저장 후, 새로운 디바이스 id를 저장한다") + void saveDevice_newMember() { + // given + final MemberSaveInput input = MemberSaveInput.from("new@test.com"); + final Member newMember = Member.withoutId(input.email()); + final Device device = Device.withoutId(newMember, DeviceIdentifier.create(), false, + ActivationExpiredDateTime.create(now)); + + given(memberRepository.findByEmail(any(Email.class))).willReturn(Optional.empty()); + given(memberRepository.save(any(Member.class))).willReturn(newMember); + given(deviceRepository.save(any(Device.class))).willReturn(device); + + // when + final MemberSaveOutput actual = memberService.saveDevice(input); + + // then + assertThat(actual.email()).isEqualTo(input.email()); + verify(memberRepository).findByEmail(any(Email.class)); + verify(memberRepository).save(any(Member.class)); + verify(deviceRepository).save(any(Device.class)); + } + + @Test + @DisplayName("이미 저장된 이메일일 경우 기존 멤버로 새로운 디바이스 id를 저장한다") + void saveDevice_existedMember() { + // given + final MemberSaveInput input = MemberSaveInput.from("existed@test.com"); + final Member existedMember = Member.withoutId(input.email()); + final Device device = Device.withoutId(existedMember, DeviceIdentifier.create(), false, + ActivationExpiredDateTime.create(now)); + + given(memberRepository.findByEmail(any(Email.class))).willReturn(Optional.of(existedMember)); + given(deviceRepository.save(any(Device.class))).willReturn(device); + + // when + final MemberSaveOutput actual = memberService.saveDevice(input); + + // then + assertThat(actual.email()).isEqualTo(input.email()); + verify(memberRepository).findByEmail(any(Email.class)); + verify(memberRepository, never()).save(any(Member.class)); + verify(deviceRepository).save(any(Device.class)); + } + + @Test + @DisplayName("대상 이메일을 가진 멤버의 디바이스를 모두 조회한다") + void findAllMemberDevices() { + // given + final String email = "existed@test.com"; + final String identifier = "device-id"; + final MemberFindInput input = MemberFindInput.from(email, identifier); + final Member existedMember = Member.withoutId(input.email()); + final Device device = Device.withoutId(existedMember, input.deviceIdentifier(), true, + ActivationExpiredDateTime.create(now)); + + given(memberRepository.existsByEmail(any(Email.class))).willReturn(true); + given(deviceRepository.findByIdentifier(any(DeviceIdentifier.class))).willReturn(Optional.of(device)); + given(deviceRepository.findAllByMemberEmail(any(Email.class))).willReturn(List.of(device)); + + // when + final MemberFindOutput actual = memberService.findAllMemberDevices(input); + + // then + SoftAssertions.assertSoftly(softAssertions -> { + softAssertions.assertThat(actual.elements()).hasSize(1); + softAssertions.assertThat(actual.elements().getFirst().identifier()).isEqualTo(input.deviceIdentifier()); + }); + } + + @Test + @DisplayName("대상 이메일을 가진 멤버의 디바이스가 없으면 빈 리스트를 리턴한다") + void findAllMemberDevices_notExistedDevice() { + // given + final String email = "existed@test.com"; + final String identifier = "device-id"; + final MemberFindInput input = MemberFindInput.from(email, identifier); + final Member existedMember = Member.withoutId(input.email()); + final Device device = Device.withoutId(existedMember, input.deviceIdentifier(), true, + ActivationExpiredDateTime.create(now)); + + given(memberRepository.existsByEmail(any(Email.class))).willReturn(true); + given(deviceRepository.findByIdentifier(any(DeviceIdentifier.class))).willReturn(Optional.of(device)); + given(deviceRepository.findAllByMemberEmail(any(Email.class))).willReturn(List.of()); + + // when + final MemberFindOutput actual = memberService.findAllMemberDevices(input); + + // then + assertThat(actual.elements()).isEmpty(); + } + + @Test + @DisplayName("대상 이메일을 가진 멤버가 존재하지 않을 경우 예외를 던진다") + void throwExceptionWhenNotExistedMemberByEmail() { + // given + final String notExistedEmailValue = "notExisted@test.com"; + final String identifier = "device-id"; + final MemberFindInput input = MemberFindInput.from(notExistedEmailValue, identifier); + + given(memberRepository.existsByEmail(any(Email.class))).willReturn(false); + + // when + // then + assertThatThrownBy(() -> memberService.findAllMemberDevices(input)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("존재하지 않는 디바이스 아이디일 경우 예외를 던진다") + void throwExceptionWhenNotExistedDeviceByIdentifier() { + // given + final String email = "existed@test.com"; + final String identifier = "not-existed-device-id"; + final MemberFindInput input = MemberFindInput.from(email, identifier); + + given(memberRepository.existsByEmail(any(Email.class))).willReturn(true); + given(deviceRepository.findByIdentifier(any(DeviceIdentifier.class))).willReturn(Optional.empty()); + + // when + // then + assertThatThrownBy(() -> memberService.findAllMemberDevices(input)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("활성화되지 않은 디바이스일 경우 예외를 던진다") + void throwExceptionWhenNotActiveDevice() { + // given + final String email = "existed@test.com"; + final String identifier = "inactive-device-id"; + final MemberFindInput input = MemberFindInput.from(email, identifier); + final Member existedMember = Member.withoutId(input.email()); + final Device inactiveDevice = Device.withoutId(existedMember, input.deviceIdentifier(), false, + ActivationExpiredDateTime.create(now)); + + given(memberRepository.existsByEmail(any(Email.class))).willReturn(true); + given(deviceRepository.findByIdentifier(any(DeviceIdentifier.class))).willReturn(Optional.of(inactiveDevice)); + + // when + // then + assertThatThrownBy(() -> memberService.findAllMemberDevices(input)) + .isInstanceOf(UnauthorizedException.class); + } + + @Test + @DisplayName("디바이스를 인증할 수 있다") + void authenticateDevice() { + // given + final Email email = Email.from("test@test.com"); + final DeviceIdentifier deviceIdentifier = DeviceIdentifier.from("test"); + final Member member = Member.withoutId(email); + final Device device = Device.withoutId(member, deviceIdentifier, false, ActivationExpiredDateTime.create(now)); + + given(memberRepository.existsByEmail(email)).willReturn(true); + given(deviceRepository.findByIdentifier(deviceIdentifier)).willReturn(Optional.of(device)); + + // when + memberService.authenticateDevice(email, deviceIdentifier); + + // then + assertThat(device.isActive()).isTrue(); + } + + @Test + @DisplayName("소유자가 아닌 이메일로 인증 시도 시 예외를 던진다") + void authenticateDevice_fail_owner() { + // given + final Email email = Email.from("test@test.com"); + final Email otherEmail = Email.from("other@test.com"); + final DeviceIdentifier deviceIdentifier = DeviceIdentifier.from("test"); + final Member member = Member.withoutId(email); + final Device device = Device.withoutId(member, deviceIdentifier, false, ActivationExpiredDateTime.create(now)); + + given(memberRepository.existsByEmail(otherEmail)).willReturn(true); + given(deviceRepository.findByIdentifier(deviceIdentifier)).willReturn(Optional.of(device)); + + // when + // then + assertThatThrownBy(() -> memberService.authenticateDevice(otherEmail, deviceIdentifier)) + .isInstanceOf(BadRequestException.class) + .hasMessage("디바이스 소유자가 아닙니다."); + } + + @Test + @DisplayName("존재하지 않는 디바이스로 인증 시도 시 예외를 던진다") + void authenticateDevice_not_existed_device() { + // given + final Email email = Email.from("test@test.com"); + final DeviceIdentifier deviceIdentifier = DeviceIdentifier.from("test"); + + given(memberRepository.existsByEmail(email)).willReturn(true); + given(deviceRepository.findByIdentifier(deviceIdentifier)).willReturn(Optional.empty()); + + // when + // then + assertThatThrownBy(() -> memberService.authenticateDevice(email, deviceIdentifier)) + .isInstanceOf(NotFoundException.class) + .hasMessage("존재하지 않는 디바이스 아이디입니다: %s".formatted(deviceIdentifier.getValue())); + } + + @Test + @DisplayName("이미 인증 된 디바이스에 다시 인증 시도 시 예외를 던진다") + void authenticateDevice_already_auth() { + // given + final Email email = Email.from("test@test.com"); + final Email otherEmail = Email.from("other@test.com"); + final DeviceIdentifier deviceIdentifier = DeviceIdentifier.from("test"); + final Member member = Member.withoutId(email); + final Device device = Device.withoutId(member, deviceIdentifier, true, ActivationExpiredDateTime.create(now)); + + given(memberRepository.existsByEmail(otherEmail)).willReturn(true); + given(deviceRepository.findByIdentifier(deviceIdentifier)).willReturn(Optional.of(device)); + + // when + // then + assertThatThrownBy(() -> memberService.authenticateDevice(otherEmail, deviceIdentifier)) + .isInstanceOf(BadRequestException.class) + .hasMessage("이미 인증되었습니다"); + } + + @Test + @DisplayName("디바이스를 삭제할 수 있다") + void deleteDevice() { + // given + final Email email = Email.from("test@test.com"); + final DeviceIdentifier deviceIdentifier = DeviceIdentifier.from("test"); + final DeviceIdentifier targetDeviceIdentifier = DeviceIdentifier.from("target"); + final DeviceDeleteInput input = DeviceDeleteInput.from( + email.getValue(), deviceIdentifier.getValue(), targetDeviceIdentifier.getValue()); + final Member member = Member.withoutId(email); + final Device device = Device.withoutId(member, deviceIdentifier, true, ActivationExpiredDateTime.create(now)); + + given(deviceRepository.findByIdentifier(deviceIdentifier)).willReturn(Optional.of(device)); + + // when + memberService.deleteDevice(input); + + // then + verify(deviceRepository).deleteByIdentifier(targetDeviceIdentifier); + } + + @Test + @DisplayName("유효하지 않은 디바이스 아이디로 삭제 시도 시 예외를 던진다") + void deleteDevice_fail_invalidIdentifier() { + // given + final Email email = Email.from("test@test.com"); + final DeviceIdentifier deviceIdentifier = DeviceIdentifier.from("not-existed"); + final DeviceDeleteInput input = DeviceDeleteInput.from( + email.getValue(), deviceIdentifier.getValue(), "target"); + + given(deviceRepository.findByIdentifier(deviceIdentifier)).willReturn(Optional.empty()); + + // when + // then + assertThatThrownBy(() -> memberService.deleteDevice(input)) + .isInstanceOf(UnauthorizedException.class); + } + + @Test + @DisplayName("소유자가 아닌 디바이스로 삭제 시도 시 예외를 던진다") + void deleteDevice_fail_owner() { + // given + final Email email = Email.from("test@test.com"); + final Email otherEmail = Email.from("other@test.com"); + final DeviceIdentifier deviceIdentifier = DeviceIdentifier.from("test"); + final DeviceDeleteInput input = DeviceDeleteInput.from( + otherEmail.getValue(), deviceIdentifier.getValue(), "target"); + final Member member = Member.withoutId(email); + final Device device = Device.withoutId(member, deviceIdentifier, true, ActivationExpiredDateTime.create(now)); + + given(deviceRepository.findByIdentifier(deviceIdentifier)).willReturn(Optional.of(device)); + + // when + // then + assertThatThrownBy(() -> memberService.deleteDevice(input)) + .isInstanceOf(BadRequestException.class) + .hasMessage("디바이스 소유자가 아닙니다."); + } +} diff --git a/src/test/java/com/recyclestudy/restdocs/APIBaseTest.java b/src/test/java/com/recyclestudy/restdocs/APIBaseTest.java new file mode 100644 index 0000000..24a9bf4 --- /dev/null +++ b/src/test/java/com/recyclestudy/restdocs/APIBaseTest.java @@ -0,0 +1,45 @@ +package com.recyclestudy.restdocs; + +import io.restassured.RestAssured; +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; + +import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyUris; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.documentationConfiguration; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@ExtendWith(RestDocumentationExtension.class) +public abstract class APIBaseTest { + + protected static final String DEFAULT_REST_DOC_PATH = "{class_name}/{method_name}/"; + + protected RequestSpecification spec; + + @LocalServerPort + int port; + + @BeforeEach + void setUpRestDocs(RestDocumentationContextProvider provider) { + RestAssured.port = port; + + this.spec = new RequestSpecBuilder() + .setPort(port) + .addFilter(documentationConfiguration(provider) + .operationPreprocessors() + .withRequestDefaults( + modifyUris().scheme("http").host("localhost").port(8080), + prettyPrint() + ) + .withResponseDefaults(prettyPrint()) + ) + .build(); + } +} diff --git a/src/test/java/com/recyclestudy/review/controller/ReviewControllerTest.java b/src/test/java/com/recyclestudy/review/controller/ReviewControllerTest.java new file mode 100644 index 0000000..514a75f --- /dev/null +++ b/src/test/java/com/recyclestudy/review/controller/ReviewControllerTest.java @@ -0,0 +1,142 @@ +package com.recyclestudy.review.controller; + +import com.recyclestudy.exception.UnauthorizedException; +import com.recyclestudy.restdocs.APIBaseTest; +import com.recyclestudy.review.controller.request.ReviewSaveRequest; +import com.recyclestudy.review.domain.ReviewURL; +import com.recyclestudy.review.service.ReviewService; +import com.recyclestudy.review.service.output.ReviewSaveOutput; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static com.epages.restdocs.apispec.ResourceSnippetParameters.builder; +import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.document; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; + +class ReviewControllerTest extends APIBaseTest { + + @MockitoBean + private ReviewService reviewService; + + @Test + @DisplayName("리뷰를 저장하면 201 응답을 반환한다") + void saveReview() { + // given + final String identifier = "device-id"; + final String url = "https://test.com"; + final ReviewSaveRequest request = new ReviewSaveRequest(identifier, url); + final ReviewSaveOutput output = ReviewSaveOutput.of(ReviewURL.from(url), List.of(LocalDateTime.now())); + + given(reviewService.saveReview(any())).willReturn(output); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Review") + .summary("리뷰 저장") + .description("리뷰를 저장하면 201 응답을 반환한다") + .requestFields( + fieldWithPath("identifier").type(JsonFieldType.STRING) + .description("디바이스 식별자"), + fieldWithPath("url").type(JsonFieldType.STRING) + .description("리뷰할 URL") + ) + .responseFields( + fieldWithPath("url").type(JsonFieldType.STRING).description("리뷰할 URL"), + fieldWithPath("scheduledAts").type(JsonFieldType.ARRAY) + .description("복습 예정 일시 목록") + ) + )) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .when() + .post("/api/v1/reviews") + .then() + .statusCode(HttpStatus.CREATED.value()) + .body("url", equalTo(url)); + } + + @Test + @DisplayName("유효하지 않은 디바이스로 리뷰 저장 시 401 응답을 반환한다") + void saveReview_Unauthorized() { + // given + final ReviewSaveRequest request = new ReviewSaveRequest("invalid-id", "https://test.com"); + + given(reviewService.saveReview(any())) + .willThrow(new UnauthorizedException("유효하지 않은 디바이스입니다")); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Review") + .summary("리뷰 저장") + .description("유효하지 않은 디바이스로 리뷰 저장 시 401 응답을 반환한다") + .requestFields( + fieldWithPath("identifier").type(JsonFieldType.STRING) + .description("디바이스 식별자"), + fieldWithPath("url").type(JsonFieldType.STRING) + .description("리뷰할 URL") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .when() + .post("/api/v1/reviews") + .then() + .statusCode(HttpStatus.UNAUTHORIZED.value()) + .body("message", equalTo("유효하지 않은 디바이스입니다")); + } + + @Test + @DisplayName("인증되지 않은 디바이스로 리뷰 저장 시 401 응답을 반환한다") + void saveReview_InactiveDevice() { + // given + final ReviewSaveRequest request = new ReviewSaveRequest("inactive-id", "https://test.com"); + + given(reviewService.saveReview(any())) + .willThrow(new UnauthorizedException("인증되지 않은 디바이스입니다")); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Review") + .summary("리뷰 저장") + .description("인증되지 않은 디바이스로 리뷰 저장 시 401 응답을 반환한다") + .requestFields( + fieldWithPath("identifier").type(JsonFieldType.STRING) + .description("디바이스 식별자"), + fieldWithPath("url").type(JsonFieldType.STRING) + .description("리뷰할 URL") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .when() + .post("/api/v1/reviews") + .then() + .statusCode(HttpStatus.UNAUTHORIZED.value()) + .body("message", equalTo("인증되지 않은 디바이스입니다")); + } +} diff --git a/src/test/java/com/recyclestudy/review/domain/NotificationHistoryTest.java b/src/test/java/com/recyclestudy/review/domain/NotificationHistoryTest.java new file mode 100644 index 0000000..a52cd50 --- /dev/null +++ b/src/test/java/com/recyclestudy/review/domain/NotificationHistoryTest.java @@ -0,0 +1,66 @@ +package com.recyclestudy.review.domain; + +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.member.domain.Member; +import java.time.LocalDateTime; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +class NotificationHistoryTest { + + private static Stream provideInvalidValue() { + final ReviewCycle reviewCycle = createReviewCycle(); + final NotificationStatus status = NotificationStatus.PENDING; + + return Stream.of( + Arguments.of(null, status), + Arguments.of(reviewCycle, null), + Arguments.of(null, null) + ); + } + + private static ReviewCycle createReviewCycle() { + final Email email = Email.from("test@test.com"); + final Member member = Member.withoutId(email); + final Review review = Review.withoutId(member, ReviewURL.from("https://test.com")); + return ReviewCycle.withoutId(review, LocalDateTime.now()); + } + + @Test + @DisplayName("NotificationHistory를 생성할 수 있다") + void withoutId() { + // given + final ReviewCycle reviewCycle = createReviewCycle(); + final NotificationStatus status = NotificationStatus.PENDING; + + // when + final NotificationHistory actual = NotificationHistory.withoutId(reviewCycle, status); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(actual.getReviewCycle()).isEqualTo(reviewCycle); + softAssertions.assertThat(actual.getStatus()).isEqualTo(status); + }); + } + + @ParameterizedTest + @MethodSource("provideInvalidValue") + @DisplayName("null로 생성 시도 시, 예외를 던진다") + void throwExceptionWhenNull( + final ReviewCycle reviewCycle, + final NotificationStatus status + ) { + // given + // when + // then + assertThatThrownBy(() -> NotificationHistory.withoutId(reviewCycle, status)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/recyclestudy/review/domain/ReviewCycleDurationTest.java b/src/test/java/com/recyclestudy/review/domain/ReviewCycleDurationTest.java new file mode 100644 index 0000000..87f7be0 --- /dev/null +++ b/src/test/java/com/recyclestudy/review/domain/ReviewCycleDurationTest.java @@ -0,0 +1,52 @@ +package com.recyclestudy.review.domain; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ReviewCycleDurationTest { + + @Test + @DisplayName("특정 날짜와 시간을 기준으로 5가지 주기를 계산할 수 있다") + void calculate_with_time() { + // given + final LocalDate target = LocalDate.of(2025, 1, 1); + final LocalTime time = LocalTime.of(10, 0); + + // when + final List actual = ReviewCycleDuration.calculate(target, time); + + // then + assertThat(actual).containsExactly( + LocalDateTime.of(2025, 1, 2, 10, 0), // DAY (1) + LocalDateTime.of(2025, 1, 8, 10, 0), // WEEK (7) + LocalDateTime.of(2025, 1, 31, 10, 0), // MONTH (30) + LocalDateTime.of(2025, 4, 1, 10, 0), // QUARTER (90) + LocalDateTime.of(2025, 6, 30, 10, 0) // HALF_YEAR (180) + ); + } + + @Test + @DisplayName("날짜만 주어졌을 때 기본 시간(08:00)으로 5가지 주기를 계산한다") + void calculate_without_time() { + // given + final LocalDate target = LocalDate.of(2025, 1, 1); + + // when + final List actual = ReviewCycleDuration.calculate(target); + + // then + assertThat(actual).containsExactly( + LocalDateTime.of(2025, 1, 2, 8, 0), + LocalDateTime.of(2025, 1, 8, 8, 0), + LocalDateTime.of(2025, 1, 31, 8, 0), + LocalDateTime.of(2025, 4, 1, 8, 0), + LocalDateTime.of(2025, 6, 30, 8, 0) + ); + } +} diff --git a/src/test/java/com/recyclestudy/review/domain/ReviewCycleTest.java b/src/test/java/com/recyclestudy/review/domain/ReviewCycleTest.java new file mode 100644 index 0000000..678a6eb --- /dev/null +++ b/src/test/java/com/recyclestudy/review/domain/ReviewCycleTest.java @@ -0,0 +1,61 @@ +package com.recyclestudy.review.domain; + +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.member.domain.Member; +import java.time.LocalDateTime; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ReviewCycleTest { + + private static Stream provideInvalidValue() { + final Email email = Email.from("test@test.com"); + final Member member = Member.withoutId(email); + final Review review = Review.withoutId(member, ReviewURL.from("https://test.com")); + final LocalDateTime scheduledAt = LocalDateTime.now(); + + return Stream.of( + Arguments.of(null, scheduledAt), + Arguments.of(review, null), + Arguments.of(null, null) + ); + } + + @Test + @DisplayName("ReviewCycle을 생성할 수 있다") + void withoutId() { + // given + final Email email = Email.from("test@test.com"); + final Member member = Member.withoutId(email); + final Review review = Review.withoutId(member, ReviewURL.from("https://test.com")); + final LocalDateTime scheduledAt = LocalDateTime.now(); + + // when + final ReviewCycle actual = ReviewCycle.withoutId(review, scheduledAt); + + // then + assertThat(actual.getReview()).isEqualTo(review); + assertThat(actual.getScheduledAt()).isEqualTo(scheduledAt); + } + + @ParameterizedTest + @MethodSource("provideInvalidValue") + @DisplayName("null로 생성 시도 시, 예외를 던진다") + void throwExceptionWhenNull( + final Review review, + final LocalDateTime scheduledAt + ) { + // given + // when + // then + assertThatThrownBy(() -> ReviewCycle.withoutId(review, scheduledAt)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/recyclestudy/review/domain/ReviewTest.java b/src/test/java/com/recyclestudy/review/domain/ReviewTest.java new file mode 100644 index 0000000..6648831 --- /dev/null +++ b/src/test/java/com/recyclestudy/review/domain/ReviewTest.java @@ -0,0 +1,53 @@ +package com.recyclestudy.review.domain; + +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.member.domain.Member; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ReviewTest { + + private static Stream provideInvalidValue() { + final Email email = Email.from("test@test.com"); + final Member member = Member.withoutId(email); + final ReviewURL reviewURL = ReviewURL.from("https://test.com"); + + return Stream.of( + Arguments.of(null, reviewURL), + Arguments.of(member, null) + ); + } + + @Test + @DisplayName("Review를 생성할 수 있다") + void withoutId() { + // given + final Email email = Email.from("test@test.com"); + final Member member = Member.withoutId(email); + final ReviewURL url = ReviewURL.from("https://test.com"); + + // when + final Review actual = Review.withoutId(member, url); + + // then + assertThat(actual.getUrl()).isEqualTo(url); + } + + @ParameterizedTest + @MethodSource("provideInvalidValue") + @DisplayName("null로 생성 시도 시, 예외를 던진다") + void throwExceptionWhenNull(final Member member, final ReviewURL url) { + // given + // when + // then + assertThatThrownBy(() -> Review.withoutId(member, url)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/recyclestudy/review/domain/ReviewURLTest.java b/src/test/java/com/recyclestudy/review/domain/ReviewURLTest.java new file mode 100644 index 0000000..b1f3ecb --- /dev/null +++ b/src/test/java/com/recyclestudy/review/domain/ReviewURLTest.java @@ -0,0 +1,33 @@ +package com.recyclestudy.review.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ReviewURLTest { + + @Test + @DisplayName("ReviewURL을 생성할 수 있다") + void from() { + // given + final String value = "https://test.com"; + + // when + final ReviewURL actual = ReviewURL.from(value); + + // then + assertThat(actual.getValue()).isEqualTo(value); + } + + @Test + @DisplayName("null로 생성 시도 시, 예외를 던진다") + void throwExceptionWhenNull() { + // given + // when + // then + assertThatThrownBy(() -> ReviewURL.from(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/recyclestudy/review/service/NotificationHistoryServiceTest.java b/src/test/java/com/recyclestudy/review/service/NotificationHistoryServiceTest.java new file mode 100644 index 0000000..a0f3dd6 --- /dev/null +++ b/src/test/java/com/recyclestudy/review/service/NotificationHistoryServiceTest.java @@ -0,0 +1,85 @@ +package com.recyclestudy.review.service; + +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.member.domain.Member; +import com.recyclestudy.review.domain.NotificationHistory; +import com.recyclestudy.review.domain.NotificationStatus; +import com.recyclestudy.review.domain.Review; +import com.recyclestudy.review.domain.ReviewCycle; +import com.recyclestudy.review.domain.ReviewURL; +import com.recyclestudy.review.repository.NotificationHistoryRepository; +import com.recyclestudy.review.repository.ReviewCycleRepository; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class NotificationHistoryServiceTest { + + @Mock + NotificationHistoryRepository notificationHistoryRepository; + + @Mock + ReviewCycleRepository reviewCycleRepository; + + @InjectMocks + NotificationHistoryService notificationHistoryService; + + @Test + @DisplayName("ReviewCycle ID 목록으로 NotificationHistory를 저장한다") + void saveAll() { + // given + final List reviewCycleIds = List.of(1L, 2L); + final NotificationStatus status = NotificationStatus.SENT; + + final Member member = Member.withoutId(Email.from("test@test.com")); + final Review review = Review.withoutId(member, ReviewURL.from("https://test.com")); + final ReviewCycle cycle1 = ReviewCycle.withoutId(review, LocalDateTime.now()); + final ReviewCycle cycle2 = ReviewCycle.withoutId(review, LocalDateTime.now().plusDays(1)); + + given(reviewCycleRepository.findAllById(reviewCycleIds)).willReturn(List.of(cycle1, cycle2)); + + // when + notificationHistoryService.saveAll(reviewCycleIds, status); + + // then + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(notificationHistoryRepository).saveAll(captor.capture()); + + final List savedHistories = captor.getValue(); + assertSoftly(softAssertions -> { + softAssertions.assertThat(savedHistories).hasSize(2); + softAssertions.assertThat(savedHistories).allMatch(h -> h.getStatus() == NotificationStatus.SENT); + }); + } + + @Test + @DisplayName("빈 ID 목록이면 빈 NotificationHistory 목록을 저장한다") + void saveAll_emptyIds() { + // given + final List reviewCycleIds = List.of(); + final NotificationStatus status = NotificationStatus.SENT; + + given(reviewCycleRepository.findAllById(reviewCycleIds)).willReturn(List.of()); + + // when + notificationHistoryService.saveAll(reviewCycleIds, status); + + // then + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(notificationHistoryRepository).saveAll(captor.capture()); + + assertThat(captor.getValue()).isEmpty(); + } +} diff --git a/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java b/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java new file mode 100644 index 0000000..cd912e2 --- /dev/null +++ b/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java @@ -0,0 +1,149 @@ +package com.recyclestudy.review.service; + +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.member.domain.Member; +import com.recyclestudy.review.domain.Review; +import com.recyclestudy.review.domain.ReviewCycle; +import com.recyclestudy.review.domain.ReviewURL; +import com.recyclestudy.review.repository.ReviewCycleRepository; +import com.recyclestudy.review.service.input.ReviewSendInput; +import com.recyclestudy.review.service.output.ReviewSendOutput; +import com.recyclestudy.review.service.output.ReviewSendOutput.ReviewSendElement; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ReviewCycleServiceTest { + + @Mock + private ReviewCycleRepository reviewCycleRepository; + + @InjectMocks + private ReviewCycleService reviewCycleService; + + @Test + @DisplayName("스케줄된 시간에 해당하는 복습 사이클을 조회한다") + void findTargetReviewCycle_success() { + // given + final LocalDateTime scheduledAt = LocalDateTime.of(2025, 1, 1, 8, 0); + final ReviewSendInput input = ReviewSendInput.from( + LocalDate.of(2025, 1, 1), + LocalTime.of(8, 0) + ); + + final Member member = Member.withoutId(Email.from("user@test.com")); + final Review review = Review.withoutId(member, ReviewURL.from("https://example.com/article")); + final ReviewCycle reviewCycle = ReviewCycle.withoutId(review, scheduledAt); + + given(reviewCycleRepository.findAllByScheduledAt(scheduledAt)).willReturn(List.of(reviewCycle)); + + // when + final ReviewSendOutput result = reviewCycleService.findTargetReviewCycle(input); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(result.elements()).hasSize(1); + softAssertions.assertThat(result.elements().getFirst().email()).isEqualTo(Email.from("user@test.com")); + }); + verify(reviewCycleRepository).findAllByScheduledAt(scheduledAt); + } + + @Test + @DisplayName("복습 대상이 없으면 빈 결과를 반환한다") + void findTargetReviewCycle_empty() { + // given + final LocalDateTime scheduledAt = LocalDateTime.of(2025, 1, 1, 8, 0); + final ReviewSendInput input = ReviewSendInput.from( + LocalDate.of(2025, 1, 1), + LocalTime.of(8, 0) + ); + + given(reviewCycleRepository.findAllByScheduledAt(scheduledAt)).willReturn(List.of()); + + // when + final ReviewSendOutput result = reviewCycleService.findTargetReviewCycle(input); + + // then + assertThat(result.elements()).isEmpty(); + } + + @Test + @DisplayName("동일 사용자의 여러 복습 URL을 하나의 요소로 그룹화한다") + void findTargetReviewCycle_groupByEmail() { + // given + final LocalDateTime scheduledAt = LocalDateTime.of(2025, 1, 1, 8, 0); + final ReviewSendInput input = ReviewSendInput.from( + LocalDate.of(2025, 1, 1), + LocalTime.of(8, 0) + ); + + final Member member = Member.withoutId(Email.from("user@test.com")); + final Review review1 = Review.withoutId(member, ReviewURL.from("https://example.com/article1")); + final Review review2 = Review.withoutId(member, ReviewURL.from("https://example.com/article2")); + final ReviewCycle cycle1 = ReviewCycle.withoutId(review1, scheduledAt); + final ReviewCycle cycle2 = ReviewCycle.withoutId(review2, scheduledAt); + + given(reviewCycleRepository.findAllByScheduledAt(scheduledAt)).willReturn(List.of(cycle1, cycle2)); + + // when + final ReviewSendOutput result = reviewCycleService.findTargetReviewCycle(input); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(result.elements()).hasSize(1); + + final ReviewSendElement element = result.elements().getFirst(); + softAssertions.assertThat(element.email()).isEqualTo(Email.from("user@test.com")); + softAssertions.assertThat(element.targetUrls()).hasSize(2); + }); + } + + @Test + @DisplayName("여러 사용자의 복습 사이클을 각각 그룹화하여 반환한다") + void findTargetReviewCycle_multipleUsers() { + // given + final LocalDateTime scheduledAt = LocalDateTime.of(2025, 1, 1, 8, 0); + final ReviewSendInput input = ReviewSendInput.from( + LocalDate.of(2025, 1, 1), + LocalTime.of(8, 0) + ); + + final Member member1 = Member.withoutId(Email.from("user1@test.com")); + final Member member2 = Member.withoutId(Email.from("user2@test.com")); + final Review review1 = Review.withoutId(member1, ReviewURL.from("https://example.com/article1")); + final Review review2 = Review.withoutId(member2, ReviewURL.from("https://example.com/article2")); + final ReviewCycle cycle1 = ReviewCycle.withoutId(review1, scheduledAt); + final ReviewCycle cycle2 = ReviewCycle.withoutId(review2, scheduledAt); + + given(reviewCycleRepository.findAllByScheduledAt(scheduledAt)).willReturn(List.of(cycle1, cycle2)); + + // when + final ReviewSendOutput result = reviewCycleService.findTargetReviewCycle(input); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(result.elements()).hasSize(2); + + final List emails = result.elements().stream() + .map(ReviewSendElement::email) + .toList(); + softAssertions.assertThat(emails).containsExactlyInAnyOrder( + Email.from("user1@test.com"), + Email.from("user2@test.com") + ); + }); + } +} diff --git a/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java b/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java new file mode 100644 index 0000000..fb16a84 --- /dev/null +++ b/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java @@ -0,0 +1,149 @@ +package com.recyclestudy.review.service; + +import com.recyclestudy.exception.UnauthorizedException; +import com.recyclestudy.member.domain.ActivationExpiredDateTime; +import com.recyclestudy.member.domain.Device; +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.member.domain.Member; +import com.recyclestudy.member.repository.DeviceRepository; +import com.recyclestudy.review.domain.NotificationHistory; +import com.recyclestudy.review.domain.NotificationStatus; +import com.recyclestudy.review.domain.Review; +import com.recyclestudy.review.domain.ReviewCycle; +import com.recyclestudy.review.domain.ReviewURL; +import com.recyclestudy.review.repository.NotificationHistoryRepository; +import com.recyclestudy.review.repository.ReviewCycleRepository; +import com.recyclestudy.review.repository.ReviewRepository; +import com.recyclestudy.review.service.input.ReviewSaveInput; +import com.recyclestudy.review.service.output.ReviewSaveOutput; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ReviewServiceTest { + + @Mock + ReviewRepository reviewRepository; + + @Mock + ReviewCycleRepository reviewCycleRepository; + + @Mock + DeviceRepository deviceRepository; + + @Mock + NotificationHistoryRepository notificationHistoryRepository; + + @Spy + Clock clock = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneId.of("UTC")); + + @InjectMocks + ReviewService reviewService; + + LocalDateTime now; + + @BeforeEach + void setUp() { + now = LocalDateTime.now(clock); + } + + @Test + @DisplayName("리뷰와 리뷰 주기를 저장한다") + void saveReview() { + // given + final String identifier = "device-id"; + final String urlValue = "https://test.com"; + final ReviewSaveInput input = ReviewSaveInput.of(identifier, urlValue); + + final Device device = Device.withoutId( + Member.withoutId(Email.from("test@test.com")), + DeviceIdentifier.from(identifier), + true, + ActivationExpiredDateTime.create(now) + ); + + final Email email = Email.from("test@test.com"); + final Member member = Member.withoutId(email); + final Review review = Review.withoutId(member, ReviewURL.from(urlValue)); + final ReviewCycle cycle = ReviewCycle.withoutId(review, now.plusDays(1)); + + given(deviceRepository.findByIdentifier(any())).willReturn(Optional.of(device)); + given(reviewRepository.save(any(Review.class))).willReturn(review); + given(reviewCycleRepository.saveAll(anyList())).willReturn(List.of(cycle)); + + // when + final ReviewSaveOutput actual = reviewService.saveReview(input); + + // then + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(notificationHistoryRepository).saveAll(captor.capture()); + + assertSoftly(softAssertions -> { + softAssertions.assertThat(actual.url()).isEqualTo(ReviewURL.from(urlValue)); + softAssertions.assertThat(actual.scheduledAts()).hasSize(1); + softAssertions.assertThat(captor.getValue()).allMatch(h -> h.getStatus() == NotificationStatus.PENDING); + }); + + verify(deviceRepository).findByIdentifier(any()); + verify(reviewRepository).save(any(Review.class)); + verify(reviewCycleRepository).saveAll(anyList()); + } + + @Test + @DisplayName("존재하지 않는 디바이스 아이디일 경우 예외를 던진다") + void saveReview_fail_notFoundDevice() { + // given + final ReviewSaveInput input = ReviewSaveInput.of("not-found", "https://test.com"); + given(deviceRepository.findByIdentifier(any())).willReturn(Optional.empty()); + + // when + // then + assertThatThrownBy(() -> reviewService.saveReview(input)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage("유효하지 않은 디바이스입니다"); + } + + @Test + @DisplayName("활성화되지 않은 디바이스일 경우 예외를 던진다") + void saveReview_fail_inactiveDevice() { + // given + final String identifier = "inactive-device"; + final ReviewSaveInput input = ReviewSaveInput.of(identifier, "https://test.com"); + + final Device inactiveDevice = Device.withoutId( + Member.withoutId(Email.from("test@test.com")), + DeviceIdentifier.from(identifier), + false, + ActivationExpiredDateTime.create(now) + ); + + given(deviceRepository.findByIdentifier(any())).willReturn(Optional.of(inactiveDevice)); + + // when + // then + assertThatThrownBy(() -> reviewService.saveReview(input)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage("인증되지 않은 디바이스입니다"); + } +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml new file mode 100644 index 0000000..8a79bce --- /dev/null +++ b/src/test/resources/application.yaml @@ -0,0 +1,30 @@ +spring: + flyway: + enabled: false + + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect + + mail: + host: localhost + port: 3025 + properties: + mail: + smtp: + auth: false + starttls: + enable: false + +auth: + base-url: http://localhost:8080