diff --git a/ktb-be-dev-assignment/.dockerignore b/ktb-be-dev-assignment/.dockerignore new file mode 100644 index 0000000..f14c0c1 --- /dev/null +++ b/ktb-be-dev-assignment/.dockerignore @@ -0,0 +1,5 @@ +target/ +*.war +*.log +node_modules/ +.env diff --git a/ktb-be-dev-assignment/.gitattributes b/ktb-be-dev-assignment/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/ktb-be-dev-assignment/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/ktb-be-dev-assignment/.gitignore b/ktb-be-dev-assignment/.gitignore new file mode 100644 index 0000000..c40be4e --- /dev/null +++ b/ktb-be-dev-assignment/.gitignore @@ -0,0 +1,42 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +.env + +### logs ### +logs/ diff --git a/ktb-be-dev-assignment/Dockerfile b/ktb-be-dev-assignment/Dockerfile new file mode 100644 index 0000000..1f4cb83 --- /dev/null +++ b/ktb-be-dev-assignment/Dockerfile @@ -0,0 +1,15 @@ +FROM eclipse-temurin:17-jdk-alpine + +WORKDIR /app + +COPY build/libs/app.jar app.jar + +RUN chmod +x app.jar + +# 5️⃣ 환경 변수 파일을 지원하기 위해 envsubst 설치 +RUN apk add --no-cache bash gettext + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/ktb-be-dev-assignment/build.gradle b/ktb-be-dev-assignment/build.gradle new file mode 100644 index 0000000..1559390 --- /dev/null +++ b/ktb-be-dev-assignment/build.gradle @@ -0,0 +1,57 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.2' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'org.ktb' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +dependencies { + // Springboot starter + implementation 'org.springframework.boot:spring-boot-starter-jdbc' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-aop' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-logging' + + // etc + implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.18.2' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2' // LocalDateTime parsing 을 위해 추가 + + // DB + runtimeOnly 'com.mysql:mysql-connector-j' + + // test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'com.h2database:h2:2.3.232' +} + +jar { + enabled = false // 기본 JAR 빌드를 비활성화하여 실행 가능 JAR를 생성 +} + +bootJar { + manifest { + attributes 'Main-Class': 'org.springframework.boot.loader.launch.JarLauncher' + } + + archiveBaseName.set('app') // JAR 파일 이름을 'app'으로 설정 + archiveVersion.set('') // 버전 정보 제거 + archiveClassifier.set('') // 추가적인 접미사 제거 +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/ktb-be-dev-assignment/entrypoint.sh b/ktb-be-dev-assignment/entrypoint.sh new file mode 100644 index 0000000..fa8a62d --- /dev/null +++ b/ktb-be-dev-assignment/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +echo "🔥 [INFO] EntryPoint Script 시작됨" # stdout에 로그 남기기 + +# app.jar 파일 존재 여부 확인 +if [ -f "/app/app.jar" ]; then + echo "✅ [INFO] app.jar 파일이 존재합니다." +else + echo "❌ [ERROR] app.jar 파일을 찾을 수 없습니다! 애플리케이션을 실행할 수 없습니다." >&2 + exit 1 +fi + +# Spring Boot 애플리케이션 실행 +echo "🚀 [INFO] 애플리케이션 실행 시작..." +exec java -jar /app/app.jar diff --git a/ktb-be-dev-assignment/gradle/wrapper/gradle-wrapper.jar b/ktb-be-dev-assignment/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/ktb-be-dev-assignment/gradle/wrapper/gradle-wrapper.jar differ diff --git a/ktb-be-dev-assignment/gradle/wrapper/gradle-wrapper.properties b/ktb-be-dev-assignment/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e18bc25 --- /dev/null +++ b/ktb-be-dev-assignment/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/ktb-be-dev-assignment/gradlew b/ktb-be-dev-assignment/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/ktb-be-dev-assignment/gradlew @@ -0,0 +1,252 @@ +#!/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 +' "$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=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# 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, 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" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# 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/ktb-be-dev-assignment/gradlew.bat b/ktb-be-dev-assignment/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/ktb-be-dev-assignment/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=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +: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/ktb-be-dev-assignment/settings.gradle b/ktb-be-dev-assignment/settings.gradle new file mode 100644 index 0000000..f8a8f87 --- /dev/null +++ b/ktb-be-dev-assignment/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'ktb-be-dev-assignment' diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/KtbBeDevAssignmentApplication.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/KtbBeDevAssignmentApplication.java new file mode 100644 index 0000000..d6dc43a --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/KtbBeDevAssignmentApplication.java @@ -0,0 +1,13 @@ +package org.ktb.ktbbedevassignment; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class KtbBeDevAssignmentApplication { + + public static void main(String[] args) { + SpringApplication.run(KtbBeDevAssignmentApplication.class, args); + } + +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/aop/JsonXmlResponse.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/aop/JsonXmlResponse.java new file mode 100644 index 0000000..13edcd5 --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/aop/JsonXmlResponse.java @@ -0,0 +1,13 @@ +package org.ktb.ktbbedevassignment.aop; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface JsonXmlResponse { +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/aop/JsonXmlResponseAspect.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/aop/JsonXmlResponseAspect.java new file mode 100644 index 0000000..8d64e16 --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/aop/JsonXmlResponseAspect.java @@ -0,0 +1,62 @@ +package org.ktb.ktbbedevassignment.aop; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import jakarta.servlet.http.HttpServletRequest; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Aspect +@Component +public class JsonXmlResponseAspect { + + private final ObjectMapper jsonMapper; + private final XmlMapper xmlMapper; + + public JsonXmlResponseAspect( + @Qualifier("objectMapper") ObjectMapper jsonMapper, // MapperConfig 의 objectMapper 빈을 주입 + XmlMapper xmlMapper + ) { + this.jsonMapper = jsonMapper; + this.xmlMapper = xmlMapper; + } + + @Around("@annotation(org.ktb.ktbbedevassignment.aop.JsonXmlResponse)") + public Object handleJsonXmlResponse(ProceedingJoinPoint joinPoint) throws Throwable { + Object result = joinPoint.proceed(); + + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return result; + } + + HttpServletRequest request = attributes.getRequest(); + + if (!(result instanceof ResponseEntity responseEntity)) { + return result; // ResponseEntity가 아니면 원본 그대로 반환 + } + + HttpStatusCode status = responseEntity.getStatusCode(); + Object responseBody = responseEntity.getBody(); + + String format = request.getParameter("format") != null ? request.getParameter("format") : "json"; + + if (format.equalsIgnoreCase("xml")) { + return ResponseEntity.status(status) + .contentType(MediaType.APPLICATION_XML) + .body(xmlMapper.writeValueAsString(responseBody)); + } + + return ResponseEntity.status(status) + .contentType(MediaType.APPLICATION_JSON) + .body(jsonMapper.writeValueAsString(responseBody)); + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/application/ApiKeyValidator.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/application/ApiKeyValidator.java new file mode 100644 index 0000000..a1deeff --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/application/ApiKeyValidator.java @@ -0,0 +1,26 @@ +package org.ktb.ktbbedevassignment.application; + +import org.ktb.ktbbedevassignment.exception.InvalidApiKeyException; +import org.ktb.ktbbedevassignment.exception.NotMatchedApiKeyException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class ApiKeyValidator { + + private final String validApiKey; + + public ApiKeyValidator(@Value("${api.key}") String validApiKey) { + this.validApiKey = validApiKey; + } + + public void validateApiKey(String apiKey) { + if (apiKey == null) { + throw new InvalidApiKeyException(); + } + + if (!apiKey.equals(validApiKey)) { + throw new NotMatchedApiKeyException(); + } + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/application/StockService.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/application/StockService.java new file mode 100644 index 0000000..620aef5 --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/application/StockService.java @@ -0,0 +1,28 @@ +package org.ktb.ktbbedevassignment.application; + +import java.util.List; +import org.ktb.ktbbedevassignment.dto.StockInfoDto; +import org.ktb.ktbbedevassignment.exception.CompanyNotFoundException; +import org.ktb.ktbbedevassignment.infrastructure.CompanyRepository; +import org.ktb.ktbbedevassignment.infrastructure.StockRepository; +import org.springframework.stereotype.Service; + +@Service +public class StockService { + + private final StockRepository stockRepository; + private final CompanyRepository companyRepository; + + public StockService(StockRepository stockRepository, CompanyRepository companyRepository) { + this.stockRepository = stockRepository; + this.companyRepository = companyRepository; + } + + public List getStockInfo(String companyCode, String startDate, String endDate) { + if (!companyRepository.existsByCompanyCode(companyCode)) { + throw new CompanyNotFoundException(companyCode); + } + + return stockRepository.findStockInfoList(companyCode, startDate, endDate); + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/config/AppConfig.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/config/AppConfig.java new file mode 100644 index 0000000..a9c0edf --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/config/AppConfig.java @@ -0,0 +1,9 @@ +package org.ktb.ktbbedevassignment.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableAspectJAutoProxy; + +@Configuration +@EnableAspectJAutoProxy +public class AppConfig { +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/config/FilterConfig.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/config/FilterConfig.java new file mode 100644 index 0000000..dfe0538 --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/config/FilterConfig.java @@ -0,0 +1,55 @@ +package org.ktb.ktbbedevassignment.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import org.ktb.ktbbedevassignment.application.ApiKeyValidator; +import org.ktb.ktbbedevassignment.filter.ApiKeyAuthFilter; +import org.ktb.ktbbedevassignment.filter.ExceptionHandlingFilter; +import org.ktb.ktbbedevassignment.filter.RateLimiterFilter; +import org.ktb.ktbbedevassignment.util.RateLimiter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class FilterConfig { + + private final ApiKeyValidator apiKeyValidator; + private final ObjectMapper objectMapper; + private final XmlMapper xmlMapper; + + public FilterConfig( + ApiKeyValidator apiKeyValidator, + @Qualifier("objectMapper") ObjectMapper objectMapper, // MapperConfig 의 objectMapper 빈을 주입 + XmlMapper xmlMapper + ) { + this.apiKeyValidator = apiKeyValidator; + this.objectMapper = objectMapper; + this.xmlMapper = xmlMapper; + } + + @Bean + public FilterRegistrationBean exceptionHandlingFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new ExceptionHandlingFilter(objectMapper, xmlMapper)); + registrationBean.setOrder(Integer.MIN_VALUE); + return registrationBean; + } + + @Bean + public FilterRegistrationBean apiKeyAuthFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new ApiKeyAuthFilter(apiKeyValidator)); + registrationBean.setOrder(1); + return registrationBean; + } + + @Bean + public FilterRegistrationBean rateLimiterFilter(RateLimiter rateLimiter) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new RateLimiterFilter(rateLimiter)); + registrationBean.setOrder(2); + return registrationBean; + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/config/MapperConfig.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/config/MapperConfig.java new file mode 100644 index 0000000..2efd388 --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/config/MapperConfig.java @@ -0,0 +1,23 @@ +package org.ktb.ktbbedevassignment.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MapperConfig { + + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper() + .registerModule(new JavaTimeModule()); // LocalDateTime 직렬화를 위한 모듈 등록 + } + + @Bean + public XmlMapper xmlMapper() { + return (XmlMapper) new XmlMapper() + .registerModule(new JavaTimeModule()); + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/config/RateLimiterConfig.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/config/RateLimiterConfig.java new file mode 100644 index 0000000..65873a7 --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/config/RateLimiterConfig.java @@ -0,0 +1,18 @@ +package org.ktb.ktbbedevassignment.config; + +import java.time.Clock; +import org.ktb.ktbbedevassignment.util.RateLimiter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RateLimiterConfig { + + private static final int MAX_REQUESTS = 10; + private static final long TIME_WINDOW_MS = 10_000; + + @Bean + public RateLimiter rateLimiter() { + return new RateLimiter(MAX_REQUESTS, TIME_WINDOW_MS, Clock.systemDefaultZone()); + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/constant/ApiConstant.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/constant/ApiConstant.java new file mode 100644 index 0000000..17dbf4c --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/constant/ApiConstant.java @@ -0,0 +1,8 @@ +package org.ktb.ktbbedevassignment.constant; + +public class ApiConstant { + + public static final String API_KEY_HEADER = "x-api-key"; + + public static final String API_KEY_PARAM = "apikey"; +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/dto/ApiResponse.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/dto/ApiResponse.java new file mode 100644 index 0000000..f17a6c8 --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/dto/ApiResponse.java @@ -0,0 +1,36 @@ +package org.ktb.ktbbedevassignment.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.LocalDateTime; +import org.springframework.http.HttpStatus; + +/* + 특정 응답 값을 설계한 이유 + 일관된 특정 으답 값을 가짐으로 사용자에게 예측을 가능하게 도울 수 있습니다. + 사용자의 코드도 일관적으로 관리할 수 있습니다. + 오류 처리 시에도 중앙에서 일관적으로 처리할 수 있습니다. + + * API 응답을 담는 DTO 클래스 + * 성공, 실패 시 공통적인 형태를 가지기 위해 ApiResponse 클래스를 사용 + * timestamp 를 통해 응답 시간을 기록 + * status 를 통해 HTTP 상태 코드를 기록 + * message 를 통해 응답 메시지를 기록 + * data 를 통해 응답 데이터를 기록 (성공 시 데이터가 있거나 빈 배열이며, 실패 시 반환 결과에서 제외될 수 있습니다.) + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ApiResponse( + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime timestamp, + int status, + String message, + T data +) { + public static ApiResponse success(T data) { + return new ApiResponse<>(LocalDateTime.now(), HttpStatus.OK.value(), "Success", data); + } + + public static ApiResponse error(HttpStatus status, String message) { + return new ApiResponse<>(LocalDateTime.now(), status.value(), message, null); + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/dto/StockInfoDto.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/dto/StockInfoDto.java new file mode 100644 index 0000000..3ac3b34 --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/dto/StockInfoDto.java @@ -0,0 +1,4 @@ +package org.ktb.ktbbedevassignment.dto; + +public record StockInfoDto(String companyName, String tradeDate, float closingPrice) { +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/dto/StockInfoRequest.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/dto/StockInfoRequest.java new file mode 100644 index 0000000..5c1d03f --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/dto/StockInfoRequest.java @@ -0,0 +1,20 @@ +package org.ktb.ktbbedevassignment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record StockInfoRequest( + @NotBlank(message = "companyCode는 필수입니다.") + @Size(max = 10, message = "companyCode는 10자 이하여야 합니다.") + String companyCode, + + @NotBlank(message = "startDate는 필수입니다.") + @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "startDate는 yyyy-MM-dd 형식이어야 합니다.") + String startDate, + + @NotBlank(message = "endDate는 필수입니다.") + @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "endDate는 yyyy-MM-dd 형식이어야 합니다.") + String endDate +) { +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/exception/CompanyNotFoundException.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/exception/CompanyNotFoundException.java new file mode 100644 index 0000000..3bdb75e --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/exception/CompanyNotFoundException.java @@ -0,0 +1,10 @@ +package org.ktb.ktbbedevassignment.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public class CompanyNotFoundException extends ResponseStatusException { + public CompanyNotFoundException(String companyCode) { + super(HttpStatus.NOT_FOUND, "해당 기업이 존재하지 않습니다: " + companyCode); + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/exception/GlobalExceptionHandler.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..b3f4072 --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/exception/GlobalExceptionHandler.java @@ -0,0 +1,99 @@ +package org.ktb.ktbbedevassignment.exception; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import jakarta.servlet.http.HttpServletRequest; +import org.ktb.ktbbedevassignment.dto.ApiResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + private final ObjectMapper objectMapper; + private final XmlMapper xmlMapper; + + public GlobalExceptionHandler( + @Qualifier("objectMapper") ObjectMapper objectMapper, // MapperConfig 의 objectMapper 빈을 주입 + XmlMapper xmlMapper + ) { + this.objectMapper = objectMapper; + this.xmlMapper = xmlMapper; + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingServletRequestParameter( + MissingServletRequestParameterException ex, HttpServletRequest request) throws Exception { + + logger.warn("필수 요청 파라미터 누락: {}", ex.getParameterName(), ex); + + ApiResponse response = ApiResponse.error(HttpStatus.BAD_REQUEST, "필수 요청 파라미터가 없습니다: " + ex.getParameterName()); + return getFormattedResponse(request, response, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(CompanyNotFoundException.class) + public ResponseEntity handleCompanyNotFoundException( + CompanyNotFoundException ex, HttpServletRequest request) throws Exception { + + logger.warn("존재하지 않는 회사 요청: {}", ex.getReason(), ex); + + ApiResponse response = ApiResponse.error(HttpStatus.NOT_FOUND, ex.getReason()); + return getFormattedResponse(request, response, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFoundException( + NoResourceFoundException ex, HttpServletRequest request) throws Exception { + + logger.warn("리소스를 찾을 수 없음: {}", ex.getMessage(), ex); + + ApiResponse response = ApiResponse.error(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."); + return getFormattedResponse(request, response, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException( + MethodArgumentNotValidException ex, HttpServletRequest request) throws Exception { + + String errorMessage = ex.getBindingResult().getFieldError().getDefaultMessage(); + + logger.warn("유효성 검증 실패: {}", errorMessage, ex); + + ApiResponse response = ApiResponse.error(HttpStatus.BAD_REQUEST, errorMessage); + return getFormattedResponse(request, response, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneralException( + Exception ex, HttpServletRequest request) throws Exception { + + logger.error("서버 내부 오류 발생: {}", ex.getMessage(), ex); + + ApiResponse response = ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + return getFormattedResponse(request, response, HttpStatus.INTERNAL_SERVER_ERROR); + } + + private ResponseEntity getFormattedResponse(HttpServletRequest request, ApiResponse response, HttpStatus status) throws Exception { + String format = request.getParameter("format") != null ? request.getParameter("format") : "json"; + + if (format.equals("xml")) { + return ResponseEntity.status(status) + .header("Content-Type", "application/xml; charset=UTF-8") + .body(xmlMapper.writeValueAsString(response)); + } + + return ResponseEntity.status(status) + .header("Content-Type", "application/json; charset=UTF-8") + .body(objectMapper.writeValueAsString(response)); + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/exception/InvalidApiKeyException.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/exception/InvalidApiKeyException.java new file mode 100644 index 0000000..f1e0fe0 --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/exception/InvalidApiKeyException.java @@ -0,0 +1,10 @@ +package org.ktb.ktbbedevassignment.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public class InvalidApiKeyException extends ResponseStatusException { + public InvalidApiKeyException() { + super(HttpStatus.BAD_REQUEST, "API Key가 존재하지 않습니다."); + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/exception/NotMatchedApiKeyException.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/exception/NotMatchedApiKeyException.java new file mode 100644 index 0000000..91cee0a --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/exception/NotMatchedApiKeyException.java @@ -0,0 +1,10 @@ +package org.ktb.ktbbedevassignment.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public class NotMatchedApiKeyException extends ResponseStatusException { + public NotMatchedApiKeyException() { + super(HttpStatus.FORBIDDEN, "잘못된 API Key입니다."); + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/exception/RateLimitExceededException.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/exception/RateLimitExceededException.java new file mode 100644 index 0000000..c2e5888 --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/exception/RateLimitExceededException.java @@ -0,0 +1,10 @@ +package org.ktb.ktbbedevassignment.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public class RateLimitExceededException extends ResponseStatusException { + public RateLimitExceededException() { + super(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 10초 내 최대 10건의 요청만 허용됩니다. 잠시 후 다시 시도해주세요."); + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/filter/ApiKeyAuthFilter.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/filter/ApiKeyAuthFilter.java new file mode 100644 index 0000000..a8dd7dd --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/filter/ApiKeyAuthFilter.java @@ -0,0 +1,38 @@ +package org.ktb.ktbbedevassignment.filter; + +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 org.ktb.ktbbedevassignment.application.ApiKeyValidator; +import static org.ktb.ktbbedevassignment.constant.ApiConstant.API_KEY_HEADER; +import static org.ktb.ktbbedevassignment.constant.ApiConstant.API_KEY_PARAM; + +public class ApiKeyAuthFilter implements Filter { + + private final ApiKeyValidator apiKeyValidator; + + public ApiKeyAuthFilter(ApiKeyValidator apiKeyValidator) { + this.apiKeyValidator = apiKeyValidator; + } + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + + HttpServletRequest httpRequest = (HttpServletRequest) servletRequest; + HttpServletResponse httpResponse = (HttpServletResponse) servletResponse; + + String apiKey = Optional.ofNullable(httpRequest.getHeader(API_KEY_HEADER)) + .orElse(httpRequest.getParameter(API_KEY_PARAM)); + + apiKeyValidator.validateApiKey(apiKey); + + filterChain.doFilter(httpRequest, httpResponse); + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/filter/ExceptionHandlingFilter.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/filter/ExceptionHandlingFilter.java new file mode 100644 index 0000000..399889e --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/filter/ExceptionHandlingFilter.java @@ -0,0 +1,95 @@ +package org.ktb.ktbbedevassignment.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +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 org.ktb.ktbbedevassignment.dto.ApiResponse; +import org.ktb.ktbbedevassignment.exception.InvalidApiKeyException; +import org.ktb.ktbbedevassignment.exception.NotMatchedApiKeyException; +import org.ktb.ktbbedevassignment.exception.RateLimitExceededException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; + +public class ExceptionHandlingFilter implements Filter { + + private static final Logger logger = LoggerFactory.getLogger(ExceptionHandlingFilter.class); + + private final ObjectMapper objectMapper; + private final XmlMapper xmlMapper; + + public ExceptionHandlingFilter( + @Qualifier("objectMapper") ObjectMapper objectMapper, // MapperConfig 의 objectMapper 빈을 주입 + XmlMapper xmlMapper + ) { + this.objectMapper = objectMapper; + this.xmlMapper = xmlMapper; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + try { + chain.doFilter(request, response); + } catch (InvalidApiKeyException | NotMatchedApiKeyException e) { + logger.warn("API Key 검증 실패: {}", e.getMessage(), e); + + sendErrorResponse( + (HttpServletRequest) request, + (HttpServletResponse) response, + HttpStatus.valueOf(e.getStatusCode().value()), + e.getReason() + ); + } catch (RateLimitExceededException e) { + logger.warn("Rate Limiter 발동: {}", e.getMessage(), e); + + sendErrorResponse( + (HttpServletRequest) request, + (HttpServletResponse) response, + HttpStatus.valueOf(e.getStatusCode().value()), + e.getReason() + ); + } catch (Exception e) { + logger.error("필터에서 처리되지 않은 예외 발생: {}", e.getMessage(), e); + + sendErrorResponse( + (HttpServletRequest) request, + (HttpServletResponse) response, + HttpStatus.INTERNAL_SERVER_ERROR, + "서버 내부 오류가 발생했습니다." + ); + } + } + + private void sendErrorResponse( + HttpServletRequest request, + HttpServletResponse response, + HttpStatus status, + String message + ) throws IOException { + response.setStatus(status.value()); + response.setCharacterEncoding("UTF-8"); + + String format = request.getParameter("format") != null ? request.getParameter("format") : "json"; + + if ("xml".equals(format)) { + response.setContentType(MediaType.APPLICATION_XML_VALUE); + xmlMapper.writeValue(response.getWriter(), ApiResponse.error(status, message)); + return; + } + + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + ApiResponse apiResponse = ApiResponse.error(status, message); + objectMapper.writeValue(response.getWriter(), apiResponse); + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/filter/RateLimiterFilter.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/filter/RateLimiterFilter.java new file mode 100644 index 0000000..93c095d --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/filter/RateLimiterFilter.java @@ -0,0 +1,41 @@ +package org.ktb.ktbbedevassignment.filter; + +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 org.ktb.ktbbedevassignment.exception.RateLimitExceededException; +import org.ktb.ktbbedevassignment.util.RateLimiter; +import static org.ktb.ktbbedevassignment.constant.ApiConstant.API_KEY_HEADER; +import static org.ktb.ktbbedevassignment.constant.ApiConstant.API_KEY_PARAM; + +public class RateLimiterFilter implements Filter { + + private final RateLimiter rateLimiter; + + public RateLimiterFilter(RateLimiter rateLimiter) { + this.rateLimiter = rateLimiter; + } + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + + HttpServletRequest httpRequest = (HttpServletRequest) servletRequest; + HttpServletResponse httpResponse = (HttpServletResponse) servletResponse; + + String apiKey = Optional.ofNullable(httpRequest.getHeader(API_KEY_HEADER)) + .orElse(httpRequest.getParameter(API_KEY_PARAM)); + + if (!rateLimiter.allowRequest(apiKey)) { + throw new RateLimitExceededException(); + } + + filterChain.doFilter(httpRequest, httpResponse); + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/infrastructure/CompanyRepository.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/infrastructure/CompanyRepository.java new file mode 100644 index 0000000..61c41af --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/infrastructure/CompanyRepository.java @@ -0,0 +1,20 @@ +package org.ktb.ktbbedevassignment.infrastructure; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class CompanyRepository { + + private final JdbcTemplate jdbcTemplate; + + public CompanyRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public boolean existsByCompanyCode(String companyCode) { + String sql = "SELECT COUNT(*) FROM company WHERE company_code = ?"; + Integer count = jdbcTemplate.queryForObject(sql, Integer.class, companyCode); + return count != null && count > 0; + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/infrastructure/StockRepository.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/infrastructure/StockRepository.java new file mode 100644 index 0000000..0d1ee36 --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/infrastructure/StockRepository.java @@ -0,0 +1,36 @@ +package org.ktb.ktbbedevassignment.infrastructure; + +import java.util.List; +import org.ktb.ktbbedevassignment.dto.StockInfoDto; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +@Repository +public class StockRepository { + + private final JdbcTemplate jdbcTemplate; + + public StockRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public List findStockInfoList(String companyCode, String startDate, String endDate) { + String sql = """ + SELECT c.company_name, s.trade_date, s.close_price + FROM stocks_history s + JOIN company c ON s.company_code = c.company_code + WHERE s.company_code = ? + AND s.trade_date BETWEEN ? AND ? + ORDER BY s.trade_date + """; + + RowMapper stockRowMapper = (rs, rowNum) -> new StockInfoDto( + rs.getString("company_name"), + rs.getString("trade_date"), + rs.getFloat("close_price") + ); + + return jdbcTemplate.query(sql, stockRowMapper, companyCode, startDate, endDate); + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/presentation/StockController.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/presentation/StockController.java new file mode 100644 index 0000000..68453fa --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/presentation/StockController.java @@ -0,0 +1,39 @@ +package org.ktb.ktbbedevassignment.presentation; + +import jakarta.validation.Valid; +import java.util.List; +import org.ktb.ktbbedevassignment.aop.JsonXmlResponse; +import org.ktb.ktbbedevassignment.application.StockService; +import org.ktb.ktbbedevassignment.dto.ApiResponse; +import org.ktb.ktbbedevassignment.dto.StockInfoDto; +import org.ktb.ktbbedevassignment.dto.StockInfoRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/stocks") +public class StockController { + + private final StockService stockService; + + public StockController(StockService stockService) { + this.stockService = stockService; + } + + @GetMapping + @JsonXmlResponse + public ResponseEntity>> getStocks( + @Valid StockInfoRequest stockInfoRequest + ) { + List result = stockService.getStockInfo( + stockInfoRequest.companyCode(), + stockInfoRequest.startDate(), + stockInfoRequest.endDate() + ); + + ApiResponse> response = ApiResponse.success(result); + return ResponseEntity.status(response.status()).body(response); + } +} diff --git a/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/util/RateLimiter.java b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/util/RateLimiter.java new file mode 100644 index 0000000..a4470cd --- /dev/null +++ b/ktb-be-dev-assignment/src/main/java/org/ktb/ktbbedevassignment/util/RateLimiter.java @@ -0,0 +1,40 @@ +package org.ktb.ktbbedevassignment.util; + +import java.time.Clock; +import java.util.Deque; +import java.util.LinkedList; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +public class RateLimiter { + private final int maxRequests; + private final long timeWindowMillis; + private final ConcurrentMap> requestLogs = new ConcurrentHashMap<>(); + private final Clock clock; + + public RateLimiter(int maxRequests, long timeWindowMillis, Clock clock) { + this.maxRequests = maxRequests; + this.timeWindowMillis = timeWindowMillis; + this.clock = clock; + } + + public boolean allowRequest(String clientId) { + long now = clock.millis(); + requestLogs.putIfAbsent(clientId, new LinkedList<>()); + + Deque timestamps = requestLogs.get(clientId); + + synchronized (timestamps) { + while (!timestamps.isEmpty() && now - timestamps.peekFirst() > timeWindowMillis) { + timestamps.pollFirst(); + } + + if (timestamps.size() >= maxRequests) { + return false; + } + + timestamps.addLast(now); + return true; + } + } +} diff --git a/ktb-be-dev-assignment/src/main/resources/application.yml b/ktb-be-dev-assignment/src/main/resources/application.yml new file mode 100644 index 0000000..ecde5f8 --- /dev/null +++ b/ktb-be-dev-assignment/src/main/resources/application.yml @@ -0,0 +1,22 @@ +spring: + application.name: ktb-be-dev-assignment + datasource: + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + minimum-idle: 1 # 최소한의 커넥션 유지 (이 값을 설정해야 `idle-timeout` 적용됨) + maximum-pool-size: 3 # 최대 5 개 조건 + idle-timeout: 15000 # 최소 15초 이상 유지 후 해제 + max-lifetime: 1800000 # 30분으로 설정 (DB timeout보다 작게) + connection-timeout: 10000 # 10초 이내 응답 받도록 조정 + +logging: + file: + name: logs/app.log # 로그 파일 경로 지정 + level: + org.ktb.ktbbedevassignment: INFO + org.springframework: WARN + +api.key: ${API_KEY} diff --git a/ktb-be-dev-assignment/src/main/resources/logback-spring.xml b/ktb-be-dev-assignment/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..3a88e82 --- /dev/null +++ b/ktb-be-dev-assignment/src/main/resources/logback-spring.xml @@ -0,0 +1,52 @@ + + + + + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + + logs/app.log + + logs/app-%d{yyyy-MM-dd}.log + 30 + 500MB + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + + logs/error.log + + logs/error-%d{yyyy-MM-dd}.log + 7 + 100MB + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + + + + diff --git a/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/KtbBeDevAssignmentApplicationTests.java b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/KtbBeDevAssignmentApplicationTests.java new file mode 100644 index 0000000..c2572be --- /dev/null +++ b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/KtbBeDevAssignmentApplicationTests.java @@ -0,0 +1,15 @@ +package org.ktb.ktbbedevassignment; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest +class KtbBeDevAssignmentApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/aop/JsonXmlResponseAspectTest.java b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/aop/JsonXmlResponseAspectTest.java new file mode 100644 index 0000000..21efa27 --- /dev/null +++ b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/aop/JsonXmlResponseAspectTest.java @@ -0,0 +1,123 @@ +package org.ktb.ktbbedevassignment.aop; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.ktb.ktbbedevassignment.config.MapperConfig; +import org.ktb.ktbbedevassignment.dto.ApiResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.xpath; + +@WebMvcTest(controllers = JsonXmlMapperTestController.class) +@Import({JsonXmlResponseAspect.class, MapperConfig.class}) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@EnableAspectJAutoProxy +class JsonXmlResponseAspectTest { + + // 변경 시 하드코딩된 JsonXmlMapperTestController의 URL을 변경해야 함 + private static final String REQUEST_URL = "/test/json-xml"; + + @Autowired + private MockMvc mockMvc; + + private void performRequest(String format, MediaType expectedMediaType) throws Exception { + if (format.equalsIgnoreCase("json")) { + mockMvc.perform(get(REQUEST_URL) + .param("format", format)) + .andExpect(status().isOk()) + .andExpect(content().contentType(expectedMediaType)) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.message").value("Success")); + return; + } + + if (format.equalsIgnoreCase("xml")) { + mockMvc.perform(get(REQUEST_URL) + .param("format", format)) + .andExpect(status().isOk()) + .andExpect(content().contentType(expectedMediaType)) + .andExpect(xpath("/ApiResponse/status").string("200")) + .andExpect(xpath("/ApiResponse/message").string("Success")); + } + } + + @Nested + @DisplayName("JSON 응답 테스트") + class JsonResponseTest { + + @Test + @DisplayName("Format이 존재하지 않은 경우 JSON 응답이 정상적으로 반환된다") + void whenFormatIsNotSpecified_thenReturnsJsonResponse() throws Exception { + performRequest("json", MediaType.APPLICATION_JSON); + } + + @Test + @DisplayName("Format이 json(소문자)인 경우 JSON 응답이 정상적으로 반환된다") + void whenFormatIsLowercaseJson_thenReturnsJsonResponse() throws Exception { + performRequest("json", MediaType.APPLICATION_JSON); + } + + @Test + @DisplayName("Format이 JSON(대문자)인 경우 JSON 응답이 정상적으로 반환된다") + void whenFormatIsUppercaseJson_thenReturnsJsonResponse() throws Exception { + performRequest("JSON", MediaType.APPLICATION_JSON); + } + + @Test + @DisplayName("Format이 JsOn(대, 소문자 혼합)인 경우 JSON 응답이 정상적으로 반환된다") + void whenFormatIsMixedCaseJson_thenReturnsJsonResponse() throws Exception { + performRequest("JsOn", MediaType.APPLICATION_JSON); + } + } + + @Nested + @DisplayName("XML 응답 테스트") + class XmlResponseTest { + + @Test + @DisplayName("Format이 xml(소문자)인 경우 XML 응답이 정상적으로 반환된다") + void whenFormatIsLowercaseXml_thenReturnsXmlResponse() throws Exception { + performRequest("xml", MediaType.APPLICATION_XML); + } + + @Test + @DisplayName("Format이 XML(대문자)인 경우 XML 응답이 정상적으로 반환된다") + void whenFormatIsUppercaseXml_thenReturnsXmlResponse() throws Exception { + performRequest("XML", MediaType.APPLICATION_XML); + } + + @Test + @DisplayName("Format이 xMl(대, 소문자 혼합)인 경우 XML 응답이 정상적으로 반환된다") + void whenFormatIsMixedCaseXml_thenReturnsXmlResponse() throws Exception { + performRequest("xMl", MediaType.APPLICATION_XML); + } + } +} + +@RestController +@RequestMapping("/test") +class JsonXmlMapperTestController { + + @GetMapping("/json-xml") + @JsonXmlResponse + public ResponseEntity>> testJsonXml() { + List testData = List.of("Data1", "Data2", "Data3"); + ApiResponse> result = ApiResponse.success(testData); + return ResponseEntity.ok(result); + } +} diff --git a/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/application/ApiKeyValidatorTest.java b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/application/ApiKeyValidatorTest.java new file mode 100644 index 0000000..34c6a54 --- /dev/null +++ b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/application/ApiKeyValidatorTest.java @@ -0,0 +1,64 @@ +package org.ktb.ktbbedevassignment.application; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.ktb.ktbbedevassignment.exception.InvalidApiKeyException; +import org.ktb.ktbbedevassignment.exception.NotMatchedApiKeyException; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ApiKeyValidatorTest { + + private final String validApiKey = "testValidApiKey"; + + @Nested + @DisplayName("validateApiKey 테스트") + class validateApiKeyTest { + + @Nested + @DisplayName("성공 케이스") + class SuccessCases { + + @Test + @DisplayName("API 키가 유효한 경우 반환값 없이 성공한다.") + void validateApiKey_WhenApiKeyIsValid_ReturnsNothing() { + // given + String apiKey = validApiKey; + ApiKeyValidator apiKeyValidator = new ApiKeyValidator(validApiKey); + + // when + apiKeyValidator.validateApiKey(apiKey); + + // then + // 예외가 발생하지 않으면 성공 + } + } + + @Nested + @DisplayName("실패 케이스") + class FailureCases { + + @Test + @DisplayName("API 키가 null인 경우 InvalidApiKeyException 예외를 던진다.") + void validateApiKey_WhenApiKeyIsNull_ThrowsInvalidApiKeyException() { + // given + String apiKey = null; + ApiKeyValidator apiKeyValidator = new ApiKeyValidator(validApiKey); + + // when & then + assertThrows(InvalidApiKeyException.class, () -> apiKeyValidator.validateApiKey(apiKey)); + } + + @Test + @DisplayName("입력한 API 키가 맞지 않을 경우 NotMatchedApiKeyException 예외를 던진다.") + void validateApiKey_WhenApiKeyIsInvalid_ThrowsNotMatchedApiKeyException() { + // given + String apiKey = "invalidApiKey"; + ApiKeyValidator apiKeyValidator = new ApiKeyValidator(validApiKey); + + // when & then + assertThrows(NotMatchedApiKeyException.class, () -> apiKeyValidator.validateApiKey(apiKey)); + } + } + } +} diff --git a/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/application/StockServiceTest.java b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/application/StockServiceTest.java new file mode 100644 index 0000000..fc7544b --- /dev/null +++ b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/application/StockServiceTest.java @@ -0,0 +1,92 @@ +package org.ktb.ktbbedevassignment.application; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.ktb.ktbbedevassignment.dto.StockInfoDto; +import org.ktb.ktbbedevassignment.exception.CompanyNotFoundException; +import org.ktb.ktbbedevassignment.infrastructure.CompanyRepository; +import org.ktb.ktbbedevassignment.infrastructure.StockRepository; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.ktb.ktbbedevassignment.fixture.CompanyTestFixture.TEST_NOT_EXIST_COMPANY_CODE; +import static org.ktb.ktbbedevassignment.fixture.StockTestFixture.createTestStockInfoDtoList; +import static org.ktb.ktbbedevassignment.fixture.StockTestFixture.plusDay; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class StockServiceTest { + + private final int stockInfoDtoListSize = 2; + private final List stockInfoDtoList = createTestStockInfoDtoList(stockInfoDtoListSize); + private StockService stockService; + private StockRepository stockRepository; + private CompanyRepository companyRepository; + + @BeforeEach + void setUp() { + stockRepository = mock(StockRepository.class); + companyRepository = mock(CompanyRepository.class); + stockService = new StockService(stockRepository, companyRepository); + } + + @Nested + @DisplayName("getStockInfo 테스트") + class GetStockInfoTest { + + @Nested + @DisplayName("성공 케이스") + class SuccessCases { + + @Test + @DisplayName("기업 코드가 존재하면 주가 정보를 조회한다") + void getStockInfo_WhenCompanyExists_ReturnsStockList() { + // given + String companyCode = stockInfoDtoList.get(0).companyName(); + String startDate = stockInfoDtoList.get(0).tradeDate(); + String endDate = plusDay(startDate, 10); + + when(companyRepository.existsByCompanyCode(companyCode)).thenReturn(true); + when(stockRepository.findStockInfoList(companyCode, startDate, endDate)).thenReturn(stockInfoDtoList); + + // when + List stocks = stockService.getStockInfo(companyCode, startDate, endDate); + + // then + assertThat(stocks).hasSize(stockInfoDtoListSize); + + verify(companyRepository, times(1)).existsByCompanyCode(companyCode); + verify(stockRepository, times(1)).findStockInfoList(companyCode, startDate, endDate); + verifyNoMoreInteractions(companyRepository, stockRepository); + } + } + + @Nested + @DisplayName("실패 케이스") + class FailureCases { + + @Test + @DisplayName("존재하지 않는 기업 코드를 조회하면 예외가 발생한다") + void getStockInfo_WhenCompanyDoesNotExist_ThrowsException() { + // given + String companyCode = TEST_NOT_EXIST_COMPANY_CODE; + String startDate = stockInfoDtoList.get(0).tradeDate(); + String endDate = plusDay(startDate, 10); + + when(companyRepository.existsByCompanyCode(companyCode)).thenReturn(false); + + // when & then + assertThrows(CompanyNotFoundException.class, () -> + stockService.getStockInfo(companyCode, startDate, endDate)); + + verify(companyRepository, times(1)).existsByCompanyCode(companyCode); + verifyNoMoreInteractions(companyRepository, stockRepository); + } + } + } +} diff --git a/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/exception/GlobalExceptionHandlerTest.java b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..5aaac44 --- /dev/null +++ b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,82 @@ +package org.ktb.ktbbedevassignment.exception; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.ktb.ktbbedevassignment.config.MapperConfig; +import org.ktb.ktbbedevassignment.dto.ApiResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = GlobalExceptionTestController.class) +@Import(MapperConfig.class) +class GlobalExceptionHandlerTest { + + // 변경 시 하드코딩된 GlobalExceptionTestController의 URL을 변경해야 함 + private static final String REQUEST_URL = "/test/exception"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private XmlMapper xmlMapper; + + @Nested + @DisplayName("JSON 응답 테스트") + class JsonResponseTest { + + @Test + @DisplayName("필수 파라미터 누락 시 JSON 에러 응답을 반환") + void missingParameter_ReturnsJsonError() throws Exception { + ApiResponse expectedResponse = + ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + + mockMvc.perform(get(REQUEST_URL)) + .andExpect(status().isInternalServerError()) + .andExpect(content().json(objectMapper.writeValueAsString(expectedResponse))); + } + } + + @Nested + @DisplayName("XML 응답 테스트") + class XmlResponseTest { + + @Test + @DisplayName("필수 파라미터 누락 시 XML 에러 응답을 반환") + void missingParameter_ReturnsXmlError() throws Exception { + ApiResponse expectedResponse = + ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + + mockMvc.perform(get(REQUEST_URL).param("format", "xml")) + .andExpect(status().isInternalServerError()) + .andExpect(content().xml(xmlMapper.writeValueAsString(expectedResponse))); + } + } +} + +@RestController +@RequestMapping("/test") +class GlobalExceptionTestController { + + @GetMapping("/exception") + public ResponseEntity>> testException() { + + throw new RuntimeException("Test exception"); + } +} diff --git a/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/filter/ApiKeyAuthFilterTest.java b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/filter/ApiKeyAuthFilterTest.java new file mode 100644 index 0000000..38784b6 --- /dev/null +++ b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/filter/ApiKeyAuthFilterTest.java @@ -0,0 +1,105 @@ +package org.ktb.ktbbedevassignment.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.ktb.ktbbedevassignment.application.ApiKeyValidator; +import org.ktb.ktbbedevassignment.exception.InvalidApiKeyException; +import org.ktb.ktbbedevassignment.exception.NotMatchedApiKeyException; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.ktb.ktbbedevassignment.fixture.ApiKeyTestFixture.TEST_API_KEY; +import static org.ktb.ktbbedevassignment.fixture.ApiKeyTestFixture.TEST_INVALID_API_KEY; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ApiKeyAuthFilterTest { + + private ApiKeyAuthFilter filter; + + @Mock + private ApiKeyValidator apiKeyValidator; + @Mock + private HttpServletRequest request; + @Mock + private HttpServletResponse response; + @Mock + private FilterChain chain; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + filter = new ApiKeyAuthFilter(apiKeyValidator); + } + + @Nested + @DisplayName("정상 요청 테스트") + class ValidRequestTests { + + @Test + @DisplayName("올바른 API Key 헤더가 있으면 요청이 정상적으로 통과된다.") + void givenValidApiKeyHeader_whenFiltering_thenProceedWithoutError() throws IOException, ServletException { + when(request.getHeader("x-api-key")).thenReturn(TEST_API_KEY); + + filter.doFilter(request, response, chain); + + verify(apiKeyValidator).validateApiKey(TEST_API_KEY); + verify(chain, times(1)).doFilter(request, response); + } + + @Test + @DisplayName("올바른 API Key 파라미터가 있으면 요청이 정상적으로 통과된다.") + void givenValidApiKeyParam_whenFiltering_thenProceedWithoutError() throws IOException, ServletException { + when(request.getHeader("x-api-key")).thenReturn(null); + when(request.getParameter("apikey")).thenReturn(TEST_API_KEY); + + filter.doFilter(request, response, chain); + + verify(apiKeyValidator).validateApiKey(TEST_API_KEY); + verify(chain, times(1)).doFilter(request, response); + } + } + + @Nested + @DisplayName("API Key 예외 발생 테스트") + class ApiKeyExceptionTests { + + @Test + @DisplayName("API Key가 없으면 InvalidApiKeyException이 발생해야 한다.") + void givenMissingApiKey_whenFiltering_thenThrowsInvalidApiKeyException() throws ServletException, IOException { + when(request.getHeader("x-api-key")).thenReturn(null); + when(request.getParameter("apikey")).thenReturn(null); + doThrow(new InvalidApiKeyException()).when(apiKeyValidator).validateApiKey(null); + + assertThatThrownBy(() -> filter.doFilter(request, response, chain)) + .isInstanceOf(InvalidApiKeyException.class); + + verify(apiKeyValidator).validateApiKey(null); + verify(chain, never()).doFilter(any(), any()); + } + + @Test + @DisplayName("잘못된 API Key가 있으면 NotMatchedApiKeyException이 발생해야 한다.") + void givenInvalidApiKey_whenFiltering_thenThrowsNotMatchedApiKeyException() throws ServletException, IOException { + when(request.getHeader("x-api-key")).thenReturn(TEST_INVALID_API_KEY); + doThrow(new NotMatchedApiKeyException()).when(apiKeyValidator).validateApiKey(TEST_INVALID_API_KEY); + + assertThatThrownBy(() -> filter.doFilter(request, response, chain)) + .isInstanceOf(NotMatchedApiKeyException.class); + + verify(apiKeyValidator).validateApiKey(TEST_INVALID_API_KEY); + verify(chain, never()).doFilter(any(), any()); + } + } +} diff --git a/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/filter/ExceptionHandlingFilterTest.java b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/filter/ExceptionHandlingFilterTest.java new file mode 100644 index 0000000..97a4f0d --- /dev/null +++ b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/filter/ExceptionHandlingFilterTest.java @@ -0,0 +1,163 @@ +package org.ktb.ktbbedevassignment.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.ktb.ktbbedevassignment.config.MapperConfig; +import org.ktb.ktbbedevassignment.dto.ApiResponse; +import org.ktb.ktbbedevassignment.exception.InvalidApiKeyException; +import org.ktb.ktbbedevassignment.exception.NotMatchedApiKeyException; +import org.ktb.ktbbedevassignment.exception.RateLimitExceededException; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ExceptionHandlingFilterTest { + + private ExceptionHandlingFilter filter; + private ObjectMapper objectMapper; + private XmlMapper xmlMapper; + + @Mock + private HttpServletRequest request; + @Mock + private HttpServletResponse response; + @Mock + private FilterChain chain; + private StringWriter responseWriter; + + @BeforeAll + void beforeAll() { + MapperConfig mapperConfig = new MapperConfig(); + objectMapper = mapperConfig.objectMapper(); + xmlMapper = mapperConfig.xmlMapper(); + filter = new ExceptionHandlingFilter(objectMapper, xmlMapper); + } + + @BeforeEach + void setUp() throws IOException { + MockitoAnnotations.openMocks(this); + responseWriter = new StringWriter(); + PrintWriter printWriter = new PrintWriter(responseWriter); + when(response.getWriter()).thenReturn(printWriter); + } + + @Test + @DisplayName("정상 요청 시 필터가 예외 없이 doFilter 진행") + void givenValidRequest_whenFiltering_thenProceedWithoutError() throws IOException, ServletException { + filter.doFilter(request, response, chain); + verify(chain, times(1)).doFilter(request, response); + } + + private void verifyErrorResponse(HttpStatus expectedStatus, String expectedMessage) throws IOException { + verify(response).setStatus(expectedStatus.value()); + verify(response).setContentType(MediaType.APPLICATION_JSON_VALUE); + ApiResponse apiResponse = objectMapper.readValue(responseWriter.toString(), ApiResponse.class); + assertThat(apiResponse.status()).isEqualTo(expectedStatus.value()); + assertThat(apiResponse.message()).isEqualTo(expectedMessage); + } + + @Nested + @DisplayName("API Key 예외 처리 테스트") + class ApiKeyExceptionTests { + + @Test + @DisplayName("InvalidApiKeyException 발생 시 400 JSON 응답을 반환한다.") + void givenInvalidApiKeyException_whenFiltering_thenReturnsBadRequestJson() throws IOException, ServletException { + doThrow(new InvalidApiKeyException()).when(chain).doFilter(request, response); + + filter.doFilter(request, response, chain); + + verifyErrorResponse(HttpStatus.BAD_REQUEST, "API Key가 존재하지 않습니다."); + } + + @Test + @DisplayName("NotMatchedApiKeyException 발생 시 403 JSON 응답을 반환한다.") + void givenNotMatchedApiKeyException_whenFiltering_thenReturnsForbiddenJson() throws IOException, ServletException { + doThrow(new NotMatchedApiKeyException()).when(chain).doFilter(request, response); + + filter.doFilter(request, response, chain); + + verifyErrorResponse(HttpStatus.FORBIDDEN, "잘못된 API Key입니다."); + } + } + + @Nested + @DisplayName("Rate Limit 초과 테스트") + class RateLimitExceededTests { + + @Test + @DisplayName("RateLimitExceededException 발생 시 429 JSON 응답을 반환한다.") + void givenRateLimitExceededException_whenFiltering_thenReturnsTooManyRequestsJson() throws IOException, ServletException { + doThrow(new RateLimitExceededException()).when(chain).doFilter(request, response); + + filter.doFilter(request, response, chain); + + verifyErrorResponse(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 10초 내 최대 10건의 요청만 허용됩니다. 잠시 후 다시 시도해주세요."); + } + } + + @Nested + @DisplayName("JSON vs XML 응답 테스트") + class JsonXmlResponseTests { + + @Test + @DisplayName("JSON 응답이 정상적으로 반환된다.") + void givenException_whenFiltering_thenReturnsJsonResponse() throws IOException, ServletException { + when(request.getParameter("format")).thenReturn("json"); + doThrow(new InvalidApiKeyException()).when(chain).doFilter(request, response); + + filter.doFilter(request, response, chain); + + verifyErrorResponse(HttpStatus.BAD_REQUEST, "API Key가 존재하지 않습니다."); + } + + @Test + @DisplayName("XML 응답이 정상적으로 반환된다.") + void givenException_whenFiltering_thenReturnsXmlResponse() throws IOException, ServletException { + when(request.getParameter("format")).thenReturn("xml"); + doThrow(new InvalidApiKeyException()).when(chain).doFilter(request, response); + + filter.doFilter(request, response, chain); + + verify(response).setContentType(MediaType.APPLICATION_XML_VALUE); + String xmlResponse = responseWriter.toString(); + assertThat(xmlResponse).contains("400"); + assertThat(xmlResponse).contains("API Key가 존재하지 않습니다."); + } + } + + @Nested + @DisplayName("기타 예외 처리 테스트") + class GeneralExceptionTests { + + @Test + @DisplayName("일반적인 예외 발생 시 500 응답을 반환한다.") + void givenGeneralException_whenFiltering_thenReturnsInternalServerError() throws IOException, ServletException { + doThrow(new RuntimeException("Unexpected error")).when(chain).doFilter(request, response); + + filter.doFilter(request, response, chain); + + verifyErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + } + } +} diff --git a/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/filter/RateLimiterFilterTest.java b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/filter/RateLimiterFilterTest.java new file mode 100644 index 0000000..09a5735 --- /dev/null +++ b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/filter/RateLimiterFilterTest.java @@ -0,0 +1,99 @@ +package org.ktb.ktbbedevassignment.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.ktb.ktbbedevassignment.exception.RateLimitExceededException; +import org.ktb.ktbbedevassignment.util.RateLimiter; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.ktb.ktbbedevassignment.fixture.ApiKeyTestFixture.TEST_API_KEY; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RateLimiterFilterTest { + + private RateLimiterFilter filter; + + @Mock + private RateLimiter rateLimiter; + @Mock + private HttpServletRequest request; + @Mock + private HttpServletResponse response; + @Mock + private FilterChain chain; + + @BeforeEach + void setUp() throws IOException { + MockitoAnnotations.openMocks(this); + filter = new RateLimiterFilter(rateLimiter); + } + + @Nested + @DisplayName("정상 요청 테스트") + class AllowedRequests { + + @Test + @DisplayName("요청이 제한 내에서 허용되면 필터 체인이 계속 실행된다.") + void givenAllowedRequest_whenFiltering_thenProceedWithoutError() throws IOException, ServletException { + when(request.getHeader("x-api-key")).thenReturn(TEST_API_KEY); + when(rateLimiter.allowRequest(TEST_API_KEY)).thenReturn(true); + + filter.doFilter(request, response, chain); + + verify(rateLimiter).allowRequest(TEST_API_KEY); + verify(chain, times(1)).doFilter(request, response); + verify(response, never()).sendError(anyInt(), anyString()); + } + } + + @Nested + @DisplayName("Rate Limit 초과 테스트") + class RateLimitExceeded { + + @Test + @DisplayName("요청이 너무 많으면 Rate Limit 초과 예외를 던지고 필터 체인을 실행하지 않는다.") + void givenRateLimitExceeded_whenFiltering_thenReturnsTooManyRequests() throws IOException, ServletException { + when(request.getHeader("x-api-key")).thenReturn(TEST_API_KEY); + when(rateLimiter.allowRequest(TEST_API_KEY)).thenReturn(false); + + assertThatThrownBy(() -> filter.doFilter(request, response, chain)) + .isInstanceOf(RateLimitExceededException.class); + + verify(rateLimiter).allowRequest(TEST_API_KEY); + verify(chain, never()).doFilter(any(), any()); + } + } + + @Nested + @DisplayName("API Key 없이 요청 시") + class MissingApiKey { + + @Test + @DisplayName("API Key가 없으면 요청을 차단하고 Rate Limit 초과 예외를 던진다.") + void givenNoApiKey_whenFiltering_thenReturnsTooManyRequests() throws IOException, ServletException { + when(request.getHeader("x-api-key")).thenReturn(null); + when(request.getParameter("apikey")).thenReturn(null); + when(rateLimiter.allowRequest(null)).thenReturn(false); + + assertThatThrownBy(() -> filter.doFilter(request, response, chain)) + .isInstanceOf(RateLimitExceededException.class); + + verify(rateLimiter).allowRequest(null); + verify(chain, never()).doFilter(any(), any()); + } + } +} diff --git a/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/fixture/ApiKeyTestFixture.java b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/fixture/ApiKeyTestFixture.java new file mode 100644 index 0000000..338cf5e --- /dev/null +++ b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/fixture/ApiKeyTestFixture.java @@ -0,0 +1,7 @@ +package org.ktb.ktbbedevassignment.fixture; + +public class ApiKeyTestFixture { + public static final String TEST_API_KEY = "test-api-key"; + + public static final String TEST_INVALID_API_KEY = "test-invalid-api-key"; +} diff --git a/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/fixture/CompanyTestFixture.java b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/fixture/CompanyTestFixture.java new file mode 100644 index 0000000..ffa262b --- /dev/null +++ b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/fixture/CompanyTestFixture.java @@ -0,0 +1,13 @@ +package org.ktb.ktbbedevassignment.fixture; + +public class CompanyTestFixture { + + // 비교할 회사가 3개 이상된다면 동적으로 생성하는 방법을 고려해볼 수 있음 + public static final String TEST_COMPANY_CODE = "TEST CO"; + public static final String TEST_COMPANY_NAME = "TEST COM"; + + public static final String TEST_OTHER_COMPANY_CODE = "OTHER CO"; + public static final String TEST_OTHER_COMPANY_NAME = "OTHER COM"; + + public static final String TEST_NOT_EXIST_COMPANY_CODE = "NOT EX"; +} diff --git a/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/fixture/RateLimiterTestFixture.java b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/fixture/RateLimiterTestFixture.java new file mode 100644 index 0000000..5be6312 --- /dev/null +++ b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/fixture/RateLimiterTestFixture.java @@ -0,0 +1,20 @@ +package org.ktb.ktbbedevassignment.fixture; + +import java.time.Instant; + +public class RateLimiterTestFixture { + + public static final int TEST_MAX_REQUESTS = 500; + + public static final long TEST_TIME_WINDOW_MILLIS = 2000; + + public static final Instant TEST_FIXED_TIME_INSTANT = Instant.parse("2025-02-16T12:00:00Z"); + + public static String generateRandomClientId() { + return "client-" + (int) (Math.random() * 1000); + } + + public static Instant plusMillis(Instant instant, long millis) { + return instant.plusMillis(millis); + } +} diff --git a/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/fixture/StockTestFixture.java b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/fixture/StockTestFixture.java new file mode 100644 index 0000000..ee32b76 --- /dev/null +++ b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/fixture/StockTestFixture.java @@ -0,0 +1,54 @@ +package org.ktb.ktbbedevassignment.fixture; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.ktb.ktbbedevassignment.dto.StockInfoDto; +import static org.ktb.ktbbedevassignment.fixture.CompanyTestFixture.TEST_COMPANY_NAME; + +public class StockTestFixture { + + public static final String TEST_TRADE_DATE = "2025-02-01"; + + public static final float TEST_OPENING_PRICE = 150.0f; + + public static final float TEST_HIGHEST_PRICE = 155.0f; + + public static final float TEST_LOWEST_PRICE = 148.0f; + + public static final float TEST_CLOSING_PRICE = 152.5f; + + public static final float TEST_VOLUME = 1000000; + private static final List INVALID_DATES = List.of( + "25-02-20", + "2021-02-25T00:00:00", + "2021-02-25T00:00:00Z", + "2021-02-25 00:00:00", + "2021-02-25 00:00:00.000", + "2021-02-25 00:00:00.000Z", + "2021-02-25 00:00:00.000+09:00", + "2021-02-25 00:00:00.000+0900", + "2021-02-25 00:00:00.000+09" + ); + + public static String plusDay(String date, int day) { + return LocalDate.parse(date).plusDays(day).toString(); + } + + public static StockInfoDto createTestStockInfoDto(String companyName, String tradeDate, float closingPrice) { + return new StockInfoDto(companyName, tradeDate, closingPrice); + } + + public static List createTestStockInfoDtoList(int size) { + List stockInfoDtoList = new ArrayList<>(); + for (int i = 0; i < size; i++) { + stockInfoDtoList.add(createTestStockInfoDto(TEST_COMPANY_NAME, plusDay(TEST_TRADE_DATE, i), TEST_CLOSING_PRICE)); + } + return stockInfoDtoList; + } + + public static Stream provideInvalidDates() { + return INVALID_DATES.stream(); + } +} diff --git a/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/infrastructure/CompanyRepositoryTest.java b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/infrastructure/CompanyRepositoryTest.java new file mode 100644 index 0000000..a7eb6ab --- /dev/null +++ b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/infrastructure/CompanyRepositoryTest.java @@ -0,0 +1,65 @@ +package org.ktb.ktbbedevassignment.infrastructure; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.ktb.ktbbedevassignment.support.TestDataHelper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import static org.assertj.core.api.Assertions.assertThat; +import static org.ktb.ktbbedevassignment.fixture.CompanyTestFixture.TEST_COMPANY_CODE; +import static org.ktb.ktbbedevassignment.fixture.CompanyTestFixture.TEST_COMPANY_NAME; +import static org.ktb.ktbbedevassignment.fixture.CompanyTestFixture.TEST_NOT_EXIST_COMPANY_CODE; + +@JdbcTest +@Import({CompanyRepository.class, TestDataHelper.class}) +@ActiveProfiles("test") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CompanyRepositoryTest { + + @Autowired + private CompanyRepository companyRepository; + + @Autowired + private TestDataHelper testDataHelper; + + @BeforeEach + void setup() { + testDataHelper.clearAllTables(); + testDataHelper.insertCompany(TEST_COMPANY_CODE, TEST_COMPANY_NAME); + } + + @Nested + @DisplayName("기업 코드로 기업 존재 여부를 조회할 경우") + class Describe_existsByCompanyCode { + @Test + @DisplayName("해당 기업 코드가 존재한다면 true 를 반환한다.") + void shouldReturnTrueIfCompanyExists() { + // given + String companyCode = TEST_COMPANY_CODE; + + // when + boolean result = companyRepository.existsByCompanyCode(companyCode); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("해당 기업 코드가 존재하지 않는다면 false 를 반환한다.") + void shouldReturnFalseIfCompanyDoesNotExist() { + // given + String companyCode = TEST_NOT_EXIST_COMPANY_CODE; + + // when + boolean result = companyRepository.existsByCompanyCode(companyCode); + + // then + assertThat(result).isFalse(); + } + } +} diff --git a/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/infrastructure/StockRepositoryTest.java b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/infrastructure/StockRepositoryTest.java new file mode 100644 index 0000000..2dc52bc --- /dev/null +++ b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/infrastructure/StockRepositoryTest.java @@ -0,0 +1,101 @@ +package org.ktb.ktbbedevassignment.infrastructure; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.ktb.ktbbedevassignment.dto.StockInfoDto; +import org.ktb.ktbbedevassignment.support.TestDataHelper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import static org.assertj.core.api.Assertions.assertThat; +import static org.ktb.ktbbedevassignment.fixture.CompanyTestFixture.TEST_COMPANY_CODE; +import static org.ktb.ktbbedevassignment.fixture.CompanyTestFixture.TEST_COMPANY_NAME; +import static org.ktb.ktbbedevassignment.fixture.CompanyTestFixture.TEST_OTHER_COMPANY_CODE; +import static org.ktb.ktbbedevassignment.fixture.CompanyTestFixture.TEST_OTHER_COMPANY_NAME; +import static org.ktb.ktbbedevassignment.fixture.StockTestFixture.TEST_CLOSING_PRICE; +import static org.ktb.ktbbedevassignment.fixture.StockTestFixture.TEST_TRADE_DATE; +import static org.ktb.ktbbedevassignment.fixture.StockTestFixture.plusDay; + +@JdbcTest +@Import({StockRepository.class, TestDataHelper.class}) +@ActiveProfiles("test") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class StockRepositoryTest { + + @Autowired + private StockRepository stockRepository; + + @Autowired + private TestDataHelper testDataHelper; + + @BeforeEach + void setup() { + testDataHelper.clearAllTables(); + + testDataHelper.insertCompany(TEST_COMPANY_CODE, TEST_COMPANY_NAME); + testDataHelper.insertCompany(TEST_OTHER_COMPANY_CODE, TEST_OTHER_COMPANY_NAME); + + testDataHelper.insertStockHistory(TEST_COMPANY_CODE, TEST_TRADE_DATE); + testDataHelper.insertStockHistory(TEST_COMPANY_CODE, plusDay(TEST_TRADE_DATE, 1)); + testDataHelper.insertStockHistory(TEST_OTHER_COMPANY_CODE, TEST_TRADE_DATE); + } + + @Nested + @DisplayName("기업 코드와 기간으로 주가 정보를 조회할 경우") + class Describe_findStockInfoList { + @Test + @DisplayName("해당 기간에 주가 데이터가 있다면 반환한다.") + void shouldReturnStockDataForGivenCompanyAndDateRange() { + // given + String companyCode = TEST_COMPANY_CODE; + String startDate = TEST_TRADE_DATE; + String endDate = plusDay(TEST_TRADE_DATE, 1); + + // when + List stocks = stockRepository.findStockInfoList(companyCode, startDate, endDate); + + // then + assertThat(stocks).hasSize(2); + assertThat(stocks.get(0).companyName()).isEqualTo(TEST_COMPANY_NAME); + assertThat(stocks.get(0).tradeDate()).isBetween(startDate, endDate); + assertThat(stocks.get(0).closingPrice()).isEqualTo(TEST_CLOSING_PRICE); + } + + @Test + @DisplayName("해당 기간에 주가 데이터가 없다면 빈 리스트를 반환한다.") + void shouldReturnEmptyListWhenNoStockDataForGivenCompanyAndDateRange() { + // given + String companyCode = TEST_COMPANY_CODE; + String startDate = plusDay(TEST_TRADE_DATE, 999); + String endDate = plusDay(TEST_TRADE_DATE, 999); + + // when + List stocks = stockRepository.findStockInfoList(companyCode, startDate, endDate); + + // then + assertThat(stocks).isEmpty(); + } + + @Test + @DisplayName("해당 기업 코드에 해당하는 주가 데이터가 없다면 빈 리스트를 반환한다.") + void shouldReturnEmptyListWhenNoStockDataForGivenCompanyCode() { + // given + testDataHelper.clearStockHistory(); + + String companyCode = TEST_COMPANY_CODE; + String startDate = TEST_TRADE_DATE; + String endDate = plusDay(TEST_TRADE_DATE, 1); + + // when + List stocks = stockRepository.findStockInfoList(companyCode, startDate, endDate); + + // then + assertThat(stocks).isEmpty(); + } + } +} diff --git a/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/presentation/StockControllerTest.java b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/presentation/StockControllerTest.java new file mode 100644 index 0000000..906c88e --- /dev/null +++ b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/presentation/StockControllerTest.java @@ -0,0 +1,203 @@ +package org.ktb.ktbbedevassignment.presentation; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.ktb.ktbbedevassignment.application.StockService; +import org.ktb.ktbbedevassignment.config.MapperConfig; +import org.ktb.ktbbedevassignment.dto.StockInfoDto; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import static org.ktb.ktbbedevassignment.fixture.ApiKeyTestFixture.TEST_API_KEY; +import static org.ktb.ktbbedevassignment.fixture.CompanyTestFixture.TEST_COMPANY_CODE; +import static org.ktb.ktbbedevassignment.fixture.CompanyTestFixture.TEST_COMPANY_NAME; +import static org.ktb.ktbbedevassignment.fixture.StockTestFixture.TEST_CLOSING_PRICE; +import static org.ktb.ktbbedevassignment.fixture.StockTestFixture.TEST_TRADE_DATE; +import static org.ktb.ktbbedevassignment.fixture.StockTestFixture.createTestStockInfoDtoList; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(StockController.class) +@Import(MapperConfig.class) +@ActiveProfiles("test") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class StockControllerTest { + + private static final String REQUEST_URL = "/api/v1/stocks"; + private final int stockInfoListSize = 2; + private final List stockInfoList = createTestStockInfoDtoList(stockInfoListSize); + @Autowired + private MockMvc mockMvc; + @MockitoBean + private StockService stockService; + + @BeforeEach + void setUp() { + reset(stockService); + } + + @Nested + @DisplayName("getStocks() API 테스트") + class GetStocksTest { + + @Nested + @DisplayName("성공 케이스") + class SuccessCases { + + @Test + @DisplayName("정상적인 요청 시 200 OK를 반환한다") + void getStocks_ValidRequest_Returns200() throws Exception { + // given + when(stockService.getStockInfo(any(), any(), any())) + .thenReturn(stockInfoList); + + // when & then + mockMvc.perform(get(REQUEST_URL) + .param("companyCode", TEST_COMPANY_CODE) + .param("startDate", TEST_TRADE_DATE) + .param("endDate", TEST_TRADE_DATE) + .header("x-api-key", TEST_API_KEY) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.message").value("Success")) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data[0].companyName").value(TEST_COMPANY_NAME)) + .andExpect(jsonPath("$.data[0].tradeDate").value(TEST_TRADE_DATE)) + .andExpect(jsonPath("$.data[0].closingPrice").value(TEST_CLOSING_PRICE)); + } + } + + @Nested + @DisplayName("실패 케이스") + class FailureCases { + @Test + @DisplayName("필수 파라미터(companyCode)가 없으면 400 Bad Request를 반환한다.") + void getStocks_MissingCompanyCode_Returns400() throws Exception { + mockMvc.perform(get(REQUEST_URL) + .param("startDate", TEST_TRADE_DATE) + .param("endDate", TEST_TRADE_DATE) + .header("x-api-key", TEST_API_KEY) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.message").value("companyCode는 필수입니다.")); + } + + @Test + @DisplayName("companyCode가 공백이면 400 Bad Request를 반환한다.") + void getStocks_BlankCompanyCode_Returns400() throws Exception { + mockMvc.perform(get(REQUEST_URL) + .param("companyCode", " ") + .param("startDate", TEST_TRADE_DATE) + .param("endDate", TEST_TRADE_DATE) + .header("x-api-key", TEST_API_KEY) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.message").value("companyCode는 필수입니다.")); + } + + @Test + @DisplayName("companyCode가 null이면 400 Bad Request를 반환한다.") + void getStocks_NullCompanyCode_Returns400() throws Exception { + mockMvc.perform(get(REQUEST_URL) + .param("companyCode", (String) null) + .param("startDate", TEST_TRADE_DATE) + .param("endDate", TEST_TRADE_DATE) + .header("x-api-key", TEST_API_KEY) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.message").value("companyCode는 필수입니다.")); + } + + @Test + @DisplayName("companyCode가 10자리 초과이면 400 Bad Request를 반환한다.") + void getStocks_LongCompanyCode_Returns400() throws Exception { + // given + String over10LengthCompanyCode = "A".repeat(11); + + mockMvc.perform(get(REQUEST_URL) + .param("companyCode", over10LengthCompanyCode) + .param("startDate", TEST_TRADE_DATE) + .param("endDate", TEST_TRADE_DATE) + .header("x-api-key", TEST_API_KEY) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.message").value("companyCode는 10자 이하여야 합니다.")); + } + + @Test + @DisplayName("필수 파라미터(startDate)가 없으면 400 Bad Request를 반환한다.") + void getStocks_MissingStartDate_Returns400() throws Exception { + mockMvc.perform(get(REQUEST_URL) + .param("companyCode", TEST_COMPANY_CODE) + .param("endDate", TEST_TRADE_DATE) + .header("x-api-key", TEST_API_KEY) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.message").value("startDate는 필수입니다.")); + } + + @ParameterizedTest + @MethodSource("org.ktb.ktbbedevassignment.fixture.StockTestFixture#provideInvalidDates") + @DisplayName("startDate 가 정해진 형식이 아니라면 400 Bad Request를 반환한다.") + void getStocks_InvalidStartDate_Returns400(String startDate) throws Exception { + mockMvc.perform(get(REQUEST_URL) + .param("companyCode", TEST_COMPANY_CODE) + .param("startDate", startDate) + .param("endDate", TEST_TRADE_DATE) + .header("x-api-key", TEST_API_KEY) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.message").value("startDate는 yyyy-MM-dd 형식이어야 합니다.")); + } + + @Test + @DisplayName("필수 파라미터(endDate)가 없으면 400 Bad Request를 반환한다.") + void getStocks_MissingEndDate_Returns400() throws Exception { + mockMvc.perform(get(REQUEST_URL) + .param("companyCode", TEST_COMPANY_CODE) + .param("startDate", TEST_TRADE_DATE) + .header("x-api-key", TEST_API_KEY) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.message").value("endDate는 필수입니다.")); + } + + @ParameterizedTest + @MethodSource("org.ktb.ktbbedevassignment.fixture.StockTestFixture#provideInvalidDates") + @DisplayName("endDate 가 정해진 형식이 아니라면 400 Bad Request를 반환한다.") + void getStocks_InvalidEndDate_Returns400(String endDate) throws Exception { + mockMvc.perform(get(REQUEST_URL) + .param("companyCode", TEST_COMPANY_CODE) + .param("startDate", TEST_TRADE_DATE) + .param("endDate", endDate) + .header("x-api-key", TEST_API_KEY) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.message").value("endDate는 yyyy-MM-dd 형식이어야 합니다.")); + } + } + } +} diff --git a/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/support/TestDataHelper.java b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/support/TestDataHelper.java new file mode 100644 index 0000000..65ec25e --- /dev/null +++ b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/support/TestDataHelper.java @@ -0,0 +1,42 @@ +package org.ktb.ktbbedevassignment.support; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import static org.ktb.ktbbedevassignment.fixture.StockTestFixture.TEST_CLOSING_PRICE; +import static org.ktb.ktbbedevassignment.fixture.StockTestFixture.TEST_HIGHEST_PRICE; +import static org.ktb.ktbbedevassignment.fixture.StockTestFixture.TEST_LOWEST_PRICE; +import static org.ktb.ktbbedevassignment.fixture.StockTestFixture.TEST_OPENING_PRICE; +import static org.ktb.ktbbedevassignment.fixture.StockTestFixture.TEST_VOLUME; + +@Component +public class TestDataHelper { + + private final JdbcTemplate jdbcTemplate; + + public TestDataHelper(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public void clearStockHistory() { + jdbcTemplate.execute("DELETE FROM stocks_history"); + } + + public void clearAllTables() { + clearStockHistory(); + jdbcTemplate.execute("DELETE FROM company"); + } + + public void insertCompany(String companyCode, String companyName) { + String sql = "INSERT INTO company (company_code, company_name) VALUES (?, ?)"; + jdbcTemplate.update(sql, companyCode, companyName); + } + + public void insertStockHistory(String companyCode, String tradeDate, float open, float high, float low, float close, float volume) { + String sql = "INSERT INTO stocks_history (company_code, trade_date, open_price, high_price, low_price, close_price, volume) VALUES (?, ?, ?, ?, ?, ?, ?)"; + jdbcTemplate.update(sql, companyCode, tradeDate, open, high, low, close, volume); + } + + public void insertStockHistory(String companyCode, String tradeDate) { + insertStockHistory(companyCode, tradeDate, TEST_OPENING_PRICE, TEST_HIGHEST_PRICE, TEST_LOWEST_PRICE, TEST_CLOSING_PRICE, TEST_VOLUME); + } +} diff --git a/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/util/RateLimiterTest.java b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/util/RateLimiterTest.java new file mode 100644 index 0000000..3270823 --- /dev/null +++ b/ktb-be-dev-assignment/src/test/java/org/ktb/ktbbedevassignment/util/RateLimiterTest.java @@ -0,0 +1,76 @@ +package org.ktb.ktbbedevassignment.util; + +import java.time.Clock; +import java.time.Instant; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.ktb.ktbbedevassignment.fixture.RateLimiterTestFixture.TEST_FIXED_TIME_INSTANT; +import static org.ktb.ktbbedevassignment.fixture.RateLimiterTestFixture.TEST_MAX_REQUESTS; +import static org.ktb.ktbbedevassignment.fixture.RateLimiterTestFixture.TEST_TIME_WINDOW_MILLIS; +import static org.ktb.ktbbedevassignment.fixture.RateLimiterTestFixture.generateRandomClientId; +import static org.ktb.ktbbedevassignment.fixture.RateLimiterTestFixture.plusMillis; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class RateLimiterTest { + + private Clock clock; + private RateLimiter rateLimiter; + + @BeforeEach + void setUp() { + Instant fixedInstant = TEST_FIXED_TIME_INSTANT; + clock = mock(Clock.class); + when(clock.millis()).thenReturn(fixedInstant.toEpochMilli()); + + rateLimiter = new RateLimiter(TEST_MAX_REQUESTS, TEST_TIME_WINDOW_MILLIS, clock); + } + + @Test + @DisplayName("요청이 허용된 한도 내에 있으면 정상적으로 처리된다") + void givenRequestsWithinLimit_whenCheckingAllowance_thenAllRequestsAreAllowed() { + String clientId = generateRandomClientId(); + + IntStream.range(0, TEST_MAX_REQUESTS).forEach(i -> assertThat(rateLimiter.allowRequest(clientId)).isTrue()); + } + + @Test + @DisplayName("요청이 허용된 한도를 초과하면 추가 요청이 차단된다") + void givenRequestsExceedingLimit_whenCheckingAllowance_thenExcessRequestsAreBlocked() { + String clientId = generateRandomClientId(); + + IntStream.range(0, TEST_MAX_REQUESTS).forEach(i -> assertThat(rateLimiter.allowRequest(clientId)).isTrue()); + + assertThat(rateLimiter.allowRequest(clientId)).isFalse(); + } + + @Test + @DisplayName("요청 제한 시간이 지나면 다시 요청이 허용된다") + void givenTimeWindowExpires_whenCheckingAllowance_thenRequestsAreAllowedAgain() { + String clientId = generateRandomClientId(); + + IntStream.range(0, TEST_MAX_REQUESTS).forEach(i -> assertThat(rateLimiter.allowRequest(clientId)).isTrue()); + assertThat(rateLimiter.allowRequest(clientId)).isFalse(); + + Instant plusMillis = plusMillis(TEST_FIXED_TIME_INSTANT, TEST_TIME_WINDOW_MILLIS + 1); + when(clock.millis()).thenReturn(plusMillis.toEpochMilli()); + + assertThat(rateLimiter.allowRequest(clientId)).isTrue(); + } + + @Test + @DisplayName("서로 다른 클라이언트는 독립적으로 요청 제한이 적용된다") + void givenMultipleClients_whenCheckingAllowance_thenRateLimitIsAppliedIndependently() { + String client1 = generateRandomClientId(); + String client2 = generateRandomClientId(); + + IntStream.range(0, TEST_MAX_REQUESTS).forEach(i -> assertThat(rateLimiter.allowRequest(client1)).isTrue()); + assertThat(rateLimiter.allowRequest(client1)).isFalse(); + + IntStream.range(0, TEST_MAX_REQUESTS).forEach(i -> assertThat(rateLimiter.allowRequest(client2)).isTrue()); + assertThat(rateLimiter.allowRequest(client2)).isFalse(); + } +} diff --git a/ktb-be-dev-assignment/src/test/resources/application-test.yml b/ktb-be-dev-assignment/src/test/resources/application-test.yml new file mode 100644 index 0000000..2fcc051 --- /dev/null +++ b/ktb-be-dev-assignment/src/test/resources/application-test.yml @@ -0,0 +1,12 @@ +spring: + application.name: ktb-be-dev-assignment + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: + sql: + init: + mode: always # 테스트 실행 시 schema.sql 적용 + +api.key: test-api-key diff --git a/ktb-be-dev-assignment/src/test/resources/schema.sql b/ktb-be-dev-assignment/src/test/resources/schema.sql new file mode 100644 index 0000000..ebded8d --- /dev/null +++ b/ktb-be-dev-assignment/src/test/resources/schema.sql @@ -0,0 +1,18 @@ +CREATE TABLE company +( + company_code VARCHAR(10) PRIMARY KEY, + company_name VARCHAR(100) NOT NULL +); + +CREATE TABLE stocks_history +( + company_code VARCHAR(10), + trade_date DATE, + open_price FLOAT, + high_price FLOAT, + low_price FLOAT, + close_price FLOAT, + volume FLOAT, + PRIMARY KEY (company_code, trade_date), + FOREIGN KEY (company_code) REFERENCES company (company_code) +);