diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..b9256c22 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/cicd-dev.yml b/.github/workflows/cicd-dev.yml new file mode 100644 index 00000000..171c50e8 --- /dev/null +++ b/.github/workflows/cicd-dev.yml @@ -0,0 +1,83 @@ +name: cicd-dev + +on: + push: + branches: [ "dev" ] + +env: + AWS_REGION: ap-northeast-2 + ECR_REPOSITORY: dev_morandi_backend + ECR_REGISTRY: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com/dev_morandi_backend + EC2_BASTION_HOST: ${{ secrets.RELEASE_BASTION_HOST }} + EC2_BACKEND_HOST: ${{ secrets.DEV_BACKEND_HOST }} # EC2 인스턴스의 Private IP + GITHUB_SHA: ${{ github.sha }} + +permissions: + contents: read + +jobs: + build-and-push: + runs-on: ubuntu-latest + env: + redis.enabled: "false" + steps: + - name: Checkout + uses: actions/checkout@v3 + + # Gradle 빌드를 추가합니다. + - name: Set up JDK 17 + uses: actions/setup-java@v2 + with: + distribution: 'adopt' + java-version: '17' + + - name: Cleanup application.yml + run: rm -f src/main/resources/application.yml + + # GitHub Secret에서 application-prod.yml 내용을 불러와 파일로 저장 + - name: Create application-dev.yml from GitHub Secret + run: | + mkdir -p src/main/resources + echo "${{ secrets.DEV_APPLICATION_YML }}" > src/main/resources/application.yml + + + - name: Build with Gradle + env: + ORG_GRADLE_OPTS: "-Duser.timezone=Asia/Seoul" + run: ./gradlew clean bootJar -x test + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.ECR_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.ECR_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + mask-aws-account-id: true + + - name: Login to Private ECR + run: aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin ${{ env.ECR_REGISTRY }} + + - name: Build Docker Image + run: docker build -t ${{ env.ECR_REGISTRY }}:${{ github.sha }} . + + - name: Push Docker Image to ECR + run: docker push ${{ env.ECR_REGISTRY }}:${{ github.sha }} + + deploy: + name: Deploy to EC2 + needs: build-and-push + runs-on: ubuntu-latest + env: + redis.enabled: "true" + steps: + - name: appleboy SSH and Deploy to EC2 + uses: appleboy/ssh-action@master # ssh 접속하는 오픈소스 + with: + host: ${{ env.EC2_BASTION_HOST }} + debug: true + key: ${{ secrets.BASTION_SSH_SECRET_KEY }} + username: ubuntu + port: 22 + envs: EC2_BACKEND_HOST,GITHUB_SHA,ECR_REGISTRY + script: | + ssh -o StrictHostKeyChecking=no ubuntu@$EC2_BACKEND_HOST "export TAG=$GITHUB_SHA && bash /home/ubuntu/morandi-backend/deploy.sh" diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 00000000..b4d3741b --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,46 @@ +name: SonarCloud +on: + push: + branches: + - main + - dev + pull_request: + types: [opened, synchronize, reopened] +jobs: + build: + name: Build and analyze + runs-on: ubuntu-latest + env: + redis.enabled: "false" + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: 17 + distribution: 'zulu' # Alternative distribution options are available + + - name: Create application-test.yml from GitHub Secret + run: | + mkdir -p src/main/resources + echo "${{ secrets.TEST_APPLICATION_YML }}" > src/main/resources/application.yml + + - name: Cache SonarCloud packages + uses: actions/cache@v3 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + - name: Build and analyze + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: ./gradlew build sonar --info diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..f255b205 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM openjdk:17-oracle +WORKDIR /app +COPY build/libs/*.jar app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index b79c72ed..2fb723fe 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,57 @@ plugins { id 'java' id 'org.springframework.boot' version '3.2.2' id 'io.spring.dependency-management' version '1.1.4' + id 'org.asciidoctor.jvm.convert' version '3.3.2' +} +plugins { + id "org.sonarqube" version "4.4.1.3373" +} + +plugins { + id 'jacoco' +} + + +def jacocoDir = layout.buildDirectory.dir("reports/") +def QDomains = [] +for (qPattern in '*.QA'..'*.QZ') { // qPattern = '*.QA', '*.QB', ... '*.QZ' + QDomains.add(qPattern + '*') +} + +def jacocoExcludePatterns = [ + // 측정 안하고 싶은 패턴 + "**/*Application*", + "**/*Config*", + "**/*Exception*", + "**/*Request*", + "**/*Response*", + "**/*Dto*", + "**/*Interceptor*", + "**/*Filter*", + "**/*Resolver*", + "**/*Entity*", + "**/test/**", + "**/resources/**" +] + +sonar { + properties { + property "sonar.projectKey", "SWM-Morandi_NewMorandi-Backend" + property "sonar.organization", "swm-morandi" + property "sonar.host.url", "https://sonarcloud.io" + property 'sonar.gradle.skipCompile', 'true' + property 'sonar.sources', 'src' + property 'sonar.language', 'java' + property 'sonar.sourceEncoding', 'UTF-8' + property 'sonar.test.exclusions', jacocoExcludePatterns.join(',') + property 'sonar.test.inclusions', '**/*Test.java' + property 'sonar.java.coveragePlugin', 'jacoco' + property 'sonar.coverage.jacoco.xmlReportPaths', jacocoDir.get().file("jacoco/index.xml").asFile + } +} + +jacoco { + toolVersion = "0.8.8" } group = 'kr.co.morandi' @@ -15,6 +66,8 @@ configurations { compileOnly { extendsFrom annotationProcessor } + + asciidoctorExt } repositories { @@ -22,18 +75,142 @@ repositories { } dependencies { + + // Spring Data Jpa implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + // Spring Data Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + // Spring Security implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + + // validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Jsoup + implementation 'org.jsoup:jsoup:1.16.1' + + // Pusher java client + implementation group: 'com.pusher', name: 'pusher-java-client', version: '2.4.4' + + + // WebFlux (WebClient) + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // Spring Web implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.2' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2' + + // MariaDB runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + + // lombok + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + + // Test Dependencies testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'com.h2database:h2' + + // RestDocs + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + + // AWS SQS + implementation group: 'org.springframework.cloud', name: 'spring-cloud-aws-messaging', version: '2.2.1.RELEASE' + implementation group: 'org.springframework.cloud', name: 'spring-cloud-aws-autoconfigure', version: '2.2.1.RELEASE' + implementation group: 'org.springframework.cloud', name: 'spring-cloud-starter-aws', version: '2.2.1.RELEASE' + implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.0.1") + implementation 'io.awspring.cloud:spring-cloud-aws-starter-sqs' + implementation 'org.springframework:spring-messaging:6.1.3' +} + +tasks.named('sonarqube').configure { + dependsOn 'compileJava' } tasks.named('test') { useJUnitPlatform() + finalizedBy 'jacocoTestReport' +} + +ext { //전역 변수 + snippetsDir = file("build/generated-snippets") +} + +test { + outputs.dir snippetsDir +} + +asciidoctor { + inputs.dir snippetsDir + configurations 'asciidoctorExt' + + sources { // 특정 파일만 html로 만든다 + include '**/index.adoc' + } + baseDirFollowsSourceFile() // 다른 adoc 파일을 참조할 때 경로를 baseDir 기준으로 찾음 + dependsOn test +} + +bootJar { + dependsOn asciidoctor + from("${asciidoctor.outputDir}") { + into 'static/docs' + } } + +jacocoTestReport { + dependsOn test + reports { + html.required.set(true) + xml.required.set(true) + csv.required.set(true) + html.destination jacocoDir.get().file("jacoco/index.html").asFile + xml.destination jacocoDir.get().file("jacoco/index.xml").asFile + csv.destination jacocoDir.get().file("jacoco/index.csv").asFile + } + + afterEvaluate { + classDirectories.setFrom( + files(classDirectories.files.collect { + fileTree(dir: it, excludes: jacocoExcludePatterns + QDomains) // Querydsl 관련 제거 + }) + ) + } + finalizedBy jacocoTestCoverageVerification +} + + + +jacocoTestCoverageVerification { + + violationRules { + rule { + enabled = true + + element = 'BUNDLE' + + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.40 + } + + limit { + counter = 'BRANCH' + value = 'COVEREDRATIO' + minimum = 0.40 + } + + + excludes = jacocoExcludePatterns + QDomains + } + } +} \ No newline at end of file diff --git a/src/docs/asciidoc/api/cookie/cookie.adoc b/src/docs/asciidoc/api/cookie/cookie.adoc new file mode 100644 index 00000000..a0952464 --- /dev/null +++ b/src/docs/asciidoc/api/cookie/cookie.adoc @@ -0,0 +1,11 @@ +[[Baekjoon-Cookie]] +== 백준 쿠키 저장하기 + +=== Request +include::{snippets}/save-member-baekjoon-cookie/http-request.adoc[] +include::{snippets}/save-member-baekjoon-cookie/request-fields.adoc[] + +=== Response +include::{snippets}/save-member-baekjoon-cookie/http-response.adoc[] + + diff --git a/src/docs/asciidoc/api/dailydefense/dailydefense.adoc b/src/docs/asciidoc/api/dailydefense/dailydefense.adoc new file mode 100644 index 00000000..95ec52fa --- /dev/null +++ b/src/docs/asciidoc/api/dailydefense/dailydefense.adoc @@ -0,0 +1,28 @@ +[[Daily-Defense]] +== 오늘의 문제 정보 조회 + +=== Request +include::{snippets}/daily-defense-info/http-request.adoc[] + +=== Response +include::{snippets}/daily-defense-info/http-response.adoc[] +include::{snippets}/daily-defense-info/response-fields.adoc[] + +== 오늘의 문제 기록 조회 + +=== Request +include::{snippets}/daily-defense-ranking/http-request.adoc[] + +=== Response +include::{snippets}/daily-defense-ranking/http-response.adoc[] +include::{snippets}/daily-defense-ranking/response-fields.adoc[] + + +== 오늘의 문제 시작 + +=== Request +include::{snippets}/daily-defense-start/http-request.adoc[] + +=== Response +include::{snippets}/daily-defense-start/http-response.adoc[] +include::{snippets}/daily-defense-start/response-fields.adoc[] diff --git a/src/docs/asciidoc/api/defensesession/defensesession.adoc b/src/docs/asciidoc/api/defensesession/defensesession.adoc new file mode 100644 index 00000000..7575a2c6 --- /dev/null +++ b/src/docs/asciidoc/api/defensesession/defensesession.adoc @@ -0,0 +1,9 @@ +== 디펜스 세션 연결 + +=== Request +include::{snippets}/connect-session/http-request.adoc[] + +=== Response +include::{snippets}/connect-session/response-body.adoc[] +include::{snippets}/connect-session/http-response.adoc[] + diff --git a/src/docs/asciidoc/api/submit/submit.adoc b/src/docs/asciidoc/api/submit/submit.adoc new file mode 100644 index 00000000..aa5c1c5a --- /dev/null +++ b/src/docs/asciidoc/api/submit/submit.adoc @@ -0,0 +1,10 @@ +[[Submit-for-Judgement]] +== 제출 후 채점 요청 + +=== Request +include::{snippets}/submit-for-judgement/http-request.adoc[] +include::{snippets}/submit-for-judgement/request-fields.adoc[] + +=== Response +include::{snippets}/submit-for-judgement/http-response.adoc[] + diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 00000000..cb2128a7 --- /dev/null +++ b/src/docs/asciidoc/index.adoc @@ -0,0 +1,21 @@ +ifndef::snippets[] +:snippets: ../../build/generated-snippets +endif::[] += 모두의 랜덤 디펜스 REST API +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 1 +:sectlinks: + +include::api/defensesession/defensesession.adoc[] + +include::api/dailydefense/dailydefense.adoc[] + +include::api/cookie/cookie.adoc[] + +include::api/submit/submit.adoc[] + + +[[Daily-Defense-List]] \ No newline at end of file diff --git a/src/main/java/kr/co/morandi/backend/NewMorandiApplication.java b/src/main/java/kr/co/morandi/backend/NewMorandiApplication.java index 1dec55a6..a0d77b46 100644 --- a/src/main/java/kr/co/morandi/backend/NewMorandiApplication.java +++ b/src/main/java/kr/co/morandi/backend/NewMorandiApplication.java @@ -5,9 +5,7 @@ @SpringBootApplication public class NewMorandiApplication { - public static void main(String[] args) { SpringApplication.run(NewMorandiApplication.class, args); } - } diff --git a/src/main/java/kr/co/morandi/backend/common/annotation/Adapter.java b/src/main/java/kr/co/morandi/backend/common/annotation/Adapter.java new file mode 100644 index 00000000..6ceeb956 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/common/annotation/Adapter.java @@ -0,0 +1,17 @@ +package kr.co.morandi.backend.common.annotation; + + +import org.springframework.core.annotation.AliasFor; +import org.springframework.stereotype.Component; + +import java.lang.annotation.*; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +public @interface Adapter { + @AliasFor(annotation = Component.class) + String value() default ""; + +} diff --git a/src/main/java/kr/co/morandi/backend/common/annotation/Usecase.java b/src/main/java/kr/co/morandi/backend/common/annotation/Usecase.java new file mode 100644 index 00000000..05fd11c7 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/common/annotation/Usecase.java @@ -0,0 +1,17 @@ +package kr.co.morandi.backend.common.annotation; + + +import org.springframework.core.annotation.AliasFor; +import org.springframework.stereotype.Component; + +import java.lang.annotation.*; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +public @interface Usecase { + @AliasFor(annotation = Component.class) + String value() default ""; + +} diff --git a/src/main/java/kr/co/morandi/backend/common/config/AsyncConfig.java b/src/main/java/kr/co/morandi/backend/common/config/AsyncConfig.java new file mode 100644 index 00000000..873a6ec2 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/common/config/AsyncConfig.java @@ -0,0 +1,48 @@ +package kr.co.morandi.backend.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@EnableAsync +@Configuration +public class AsyncConfig { + + // 백준 채점 API 요청을 비동기로 처리하기 위한 Executor + @Bean(name = "submitBaekjoonApiExecutor") + public ThreadPoolTaskExecutor baekjoonJudgementThreadPool() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); // 기본 스레드 수 + executor.setMaxPoolSize(10); // 최대 스레드 수 + executor.setQueueCapacity(25); // 대기열 크기 + executor.setThreadNamePrefix("baekjoon-judgement-"); + executor.initialize(); + return executor; + } + + // 임시 코드 저장을 비동기로 처리하기 위한 Executor + @Bean(name = "tempCodeSaveExecutor") + public ThreadPoolTaskExecutor tempCodeSaveThreadPool() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); // 기본 스레드 수 + executor.setMaxPoolSize(10); // 최대 스레드 수 + executor.setQueueCapacity(25); // 대기열 크기 + executor.setThreadNamePrefix("temp-code-save-"); + executor.initialize(); + return executor; + } + + // 채점 결과 업데이트를 비동기로 처리하기 위한 Executor + @Bean(name = "updateJudgementStatusExecutor") + public ThreadPoolTaskExecutor updateJudgementStatusThreadPool() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); // 기본 스레드 수 + executor.setMaxPoolSize(10); // 최대 스레드 수 + executor.setQueueCapacity(25); // 대기열 크기 + executor.setThreadNamePrefix("update-judgement-status-"); + executor.initialize(); + return executor; + } + +} diff --git a/src/main/java/kr/co/morandi/backend/common/config/CorsConfig.java b/src/main/java/kr/co/morandi/backend/common/config/CorsConfig.java new file mode 100644 index 00000000..b90429eb --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/common/config/CorsConfig.java @@ -0,0 +1,28 @@ +package kr.co.morandi.backend.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +public class CorsConfig { + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.setAllowedOrigins(List.of("http://localhost:3000", "http://localhost:8080", "http://morandi.co.kr", + "https://morandi.co.kr", "http://api.morandi.co.kr", "https://api.morandi.co.kr")); + + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setExposedHeaders(List.of("*")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/src/main/java/kr/co/morandi/backend/common/config/JpaAuditingConfig.java b/src/main/java/kr/co/morandi/backend/common/config/JpaAuditingConfig.java new file mode 100644 index 00000000..caebfccb --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/common/config/JpaAuditingConfig.java @@ -0,0 +1,8 @@ +package kr.co.morandi.backend.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/src/main/java/kr/co/morandi/backend/common/config/RedisConfig.java b/src/main/java/kr/co/morandi/backend/common/config/RedisConfig.java new file mode 100644 index 00000000..2768c0fe --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/common/config/RedisConfig.java @@ -0,0 +1,49 @@ +package kr.co.morandi.backend.common.config; + +import kr.co.morandi.backend.defense_management.application.service.codesubmit.ExampleCodeSubscriber; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Slf4j +@Configuration +@EnableRedisRepositories +@ConditionalOnProperty(name = "redis.enabled", havingValue = "true", matchIfMissing = false) +public class RedisConfig { + + @Value("${redis.host}") + private String redisHost; + + @Value("${redis.port}") + private int redisPort; + @Bean + public RedisConnectionFactory redisConnectionFactory() { + LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisHost, redisPort); + return lettuceConnectionFactory; + } + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + return redisTemplate; + } + @Bean + RedisMessageListenerContainer redisContainer(RedisConnectionFactory connectionFactory, + ExampleCodeSubscriber subscriber) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + container.addMessageListener(subscriber, new ChannelTopic("channel")); + return container; + } +} diff --git a/src/main/java/kr/co/morandi/backend/common/config/WebClientConfig.java b/src/main/java/kr/co/morandi/backend/common/config/WebClientConfig.java new file mode 100644 index 00000000..2760f0df --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/common/config/WebClientConfig.java @@ -0,0 +1,14 @@ +package kr.co.morandi.backend.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient webClient() { + return WebClient.builder().build(); + } +} diff --git a/src/main/java/kr/co/morandi/backend/common/config/WebMvcConfig.java b/src/main/java/kr/co/morandi/backend/common/config/WebMvcConfig.java new file mode 100644 index 00000000..3c5f8756 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/common/config/WebMvcConfig.java @@ -0,0 +1,24 @@ +package kr.co.morandi.backend.common.config; + +import kr.co.morandi.backend.common.web.resolver.MemberHandlerMethodArgumentResolver; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Bean + MemberHandlerMethodArgumentResolver memberHandlerMethodArgumentResolver() { + return new MemberHandlerMethodArgumentResolver(); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(memberHandlerMethodArgumentResolver()); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/common/exception/MorandiException.java b/src/main/java/kr/co/morandi/backend/common/exception/MorandiException.java new file mode 100644 index 00000000..dc6e3c69 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/common/exception/MorandiException.java @@ -0,0 +1,19 @@ +package kr.co.morandi.backend.common.exception; + +import kr.co.morandi.backend.common.exception.errorcode.ErrorCode; +import lombok.Getter; +@Getter +public class MorandiException extends RuntimeException { + + private final ErrorCode errorCode; + + public MorandiException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public MorandiException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/kr/co/morandi/backend/common/exception/errorcode/ErrorCode.java b/src/main/java/kr/co/morandi/backend/common/exception/errorcode/ErrorCode.java new file mode 100644 index 00000000..71ecbff6 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/common/exception/errorcode/ErrorCode.java @@ -0,0 +1,9 @@ +package kr.co.morandi.backend.common.exception.errorcode; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + String name(); + HttpStatus getHttpStatus(); + String getMessage(); +} diff --git a/src/main/java/kr/co/morandi/backend/common/exception/handler/GlobalExceptionHandler.java b/src/main/java/kr/co/morandi/backend/common/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 00000000..980d1a78 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/common/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,90 @@ +package kr.co.morandi.backend.common.exception.handler; + +import jakarta.servlet.http.Cookie; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.common.exception.errorcode.ErrorCode; +import kr.co.morandi.backend.common.exception.response.ErrorResponse; +import kr.co.morandi.backend.common.web.response.ApiUtils; +import kr.co.morandi.backend.member_management.infrastructure.config.cookie.utils.CookieUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.common.exception.handler.exception.CommonErrorCode.INTERNAL_SERVER_ERROR; +import static kr.co.morandi.backend.member_management.infrastructure.config.jwt.constants.TokenType.REFRESH_TOKEN; + +@Slf4j +@RestControllerAdvice +@RequiredArgsConstructor +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + private final CookieUtils cookieUtils; + + @ExceptionHandler(MorandiException.class) + public ResponseEntity morandiExceptionHandler(MorandiException e) { + log.error(e.getErrorCode().name()+" : ", e.getErrorCode().getMessage() + " : ", e); + + // Unauthorized 에러가 발생한 경우 + if (e.getErrorCode().getHttpStatus() == HttpStatus.UNAUTHORIZED) { + HttpHeaders headers = createUnauthorizedHeaders(); + + return new ResponseEntity<>(headers, HttpStatus.UNAUTHORIZED); + } + + // 그 외의 에러가 발생한 경우 + return handleException(e.getErrorCode()); + } + + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { + String errorsString = ex.getBindingResult() + .getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .collect(Collectors.joining(", ")); + + ApiUtils.ApiResult errorResponse = ApiUtils.error(errorsString); + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + /** + * 예상 외의 에러가 발생한 경우 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleAllException(Exception e) { + log.error(INTERNAL_SERVER_ERROR.name() + " : ", e); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(e.getMessage()); + } + + /** + * Unauthorized 에러가 발생한 경우 + * Refresh Token 쿠키를 제거하고 + */ + private HttpHeaders createUnauthorizedHeaders() { + HttpHeaders headers = new HttpHeaders(); + Cookie cookie = cookieUtils.removeCookie(REFRESH_TOKEN, null); + headers.add("Set-Cookie", cookie.toString()); + return headers; + } + + /** + * 에러 응답 생성 + */ + private ResponseEntity handleException(ErrorCode errorCode) { + return ResponseEntity.status(errorCode.getHttpStatus()) + .body(ErrorResponse.of(errorCode)); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/common/exception/handler/exception/CommonErrorCode.java b/src/main/java/kr/co/morandi/backend/common/exception/handler/exception/CommonErrorCode.java new file mode 100644 index 00000000..719ea45a --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/common/exception/handler/exception/CommonErrorCode.java @@ -0,0 +1,17 @@ +package kr.co.morandi.backend.common.exception.handler.exception; + +import kr.co.morandi.backend.common.exception.errorcode.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum CommonErrorCode implements ErrorCode { + + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "내부 서버 오류입니다."); + + private final HttpStatus httpStatus; + + private final String message; +} \ No newline at end of file diff --git a/src/main/java/kr/co/morandi/backend/common/exception/response/ErrorResponse.java b/src/main/java/kr/co/morandi/backend/common/exception/response/ErrorResponse.java new file mode 100644 index 00000000..dddbc644 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/common/exception/response/ErrorResponse.java @@ -0,0 +1,25 @@ +package kr.co.morandi.backend.common.exception.response; + +import kr.co.morandi.backend.common.exception.errorcode.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class ErrorResponse { + + private final String code; + private final String message; + + public static ErrorResponse of(ErrorCode errorCode) { + return ErrorResponse.builder() + .code(errorCode.name()) + .message(errorCode.getMessage()) + .build(); + } + @Builder + private ErrorResponse(String code, String message) { + this.code = code; + this.message = message; + } +} \ No newline at end of file diff --git a/src/main/java/kr/co/morandi/backend/common/model/BaseEntity.java b/src/main/java/kr/co/morandi/backend/common/model/BaseEntity.java new file mode 100644 index 00000000..38c38397 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/common/model/BaseEntity.java @@ -0,0 +1,25 @@ +package kr.co.morandi.backend.common.model; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@MappedSuperclass +@Getter +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class BaseEntity { + @CreatedDate + private LocalDateTime createdDateTime; + + @LastModifiedDate + private LocalDateTime modifiedDateTime; + +} diff --git a/src/main/java/kr/co/morandi/backend/common/web/MemberId.java b/src/main/java/kr/co/morandi/backend/common/web/MemberId.java new file mode 100644 index 00000000..c1858536 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/common/web/MemberId.java @@ -0,0 +1,11 @@ +package kr.co.morandi.backend.common.web; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface MemberId { +} diff --git a/src/main/java/kr/co/morandi/backend/common/web/resolver/MemberHandlerMethodArgumentResolver.java b/src/main/java/kr/co/morandi/backend/common/web/resolver/MemberHandlerMethodArgumentResolver.java new file mode 100644 index 00000000..c5891b03 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/common/web/resolver/MemberHandlerMethodArgumentResolver.java @@ -0,0 +1,22 @@ +package kr.co.morandi.backend.common.web.resolver; + +import kr.co.morandi.backend.common.web.MemberId; +import kr.co.morandi.backend.member_management.infrastructure.config.security.utils.SecurityUtils; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +public class MemberHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterAnnotation(MemberId.class) != null + && parameter.getParameterType().equals(Long.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + return SecurityUtils.getCurrentMemberId(); + } +} diff --git a/src/main/java/kr/co/morandi/backend/common/web/response/ApiUtils.java b/src/main/java/kr/co/morandi/backend/common/web/response/ApiUtils.java new file mode 100644 index 00000000..2cc93d84 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/common/web/response/ApiUtils.java @@ -0,0 +1,51 @@ +package kr.co.morandi.backend.common.web.response; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ApiUtils { + + public static ApiResult success(T response) { + return new ApiResult<>(true, response); + } + + public static ApiResult success() { + return new ApiResult<>(true, null); + } + + public static ApiResult error(Throwable throwable) { + return new ApiResult<>(false, new ApiError(throwable)); + } + + public static ApiResult error(String message) { + return new ApiResult<>(false, new ApiError(message)); + } + + @Getter + public static class ApiError { + private final String message; + + ApiError(Throwable throwable) { + this(throwable.getMessage()); + } + + ApiError(String message) { + this.message = message; + } + + } + + @Getter + public static class ApiResult { + private final boolean success; + private final T response; + + private ApiResult(boolean success, T response) { + this.success = success; + this.response = response; + } + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/application/dto/response/DailyDefenseInfoResponse.java b/src/main/java/kr/co/morandi/backend/defense_information/application/dto/response/DailyDefenseInfoResponse.java new file mode 100644 index 00000000..8c72a786 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/application/dto/response/DailyDefenseInfoResponse.java @@ -0,0 +1,27 @@ +package kr.co.morandi.backend.defense_information.application.dto.response; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DailyDefenseInfoResponse { + + private String defenseName; + private Integer problemCount; + private Long attemptCount; + private List problems; + + + @Builder + private DailyDefenseInfoResponse(String defenseName, Integer problemCount, Long attemptCount, List problems) { + this.defenseName = defenseName; + this.problemCount = problemCount; + this.attemptCount = attemptCount; + this.problems = problems; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/application/dto/response/DailyDefenseProblemInfoResponse.java b/src/main/java/kr/co/morandi/backend/defense_information/application/dto/response/DailyDefenseProblemInfoResponse.java new file mode 100644 index 00000000..37927f6a --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/application/dto/response/DailyDefenseProblemInfoResponse.java @@ -0,0 +1,35 @@ +package kr.co.morandi.backend.defense_information.application.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class DailyDefenseProblemInfoResponse { + + private Long problemNumber; + private Long problemId; + private Long baekjoonProblemId; + private ProblemTier difficulty; + private Long solvedCount; + private Long submitCount; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private Boolean isSolved; + + @Builder + private DailyDefenseProblemInfoResponse(Long problemNumber, Long problemId, Long baekjoonProblemId, ProblemTier difficulty, Long solvedCount, Long submitCount, Boolean isSolved) { + this.problemNumber = problemNumber; + this.problemId = problemId; + this.baekjoonProblemId = baekjoonProblemId; + this.difficulty = difficulty; + this.solvedCount = solvedCount; + this.submitCount = submitCount; + this.isSolved = isSolved; + } +} + + diff --git a/src/main/java/kr/co/morandi/backend/defense_information/application/mapper/dailydefense/DailyDefenseInfoMapper.java b/src/main/java/kr/co/morandi/backend/defense_information/application/mapper/dailydefense/DailyDefenseInfoMapper.java new file mode 100644 index 00000000..ff38cd66 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/application/mapper/dailydefense/DailyDefenseInfoMapper.java @@ -0,0 +1,32 @@ +package kr.co.morandi.backend.defense_information.application.mapper.dailydefense; + +import kr.co.morandi.backend.defense_information.application.dto.response.DailyDefenseInfoResponse; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class DailyDefenseInfoMapper { + + public static DailyDefenseInfoResponse fromNonAttempted(DailyDefense dailyDefense) { + return DailyDefenseInfoResponse.builder() + .defenseName(dailyDefense.getContentName()) + .problemCount(dailyDefense.getProblemCount()) + .attemptCount(dailyDefense.getAttemptCount()) + .problems(DailyDefenseProblemInfoMapper.ofNonAttempted(dailyDefense.getDailyDefenseProblems())) + .build(); + } + + public static DailyDefenseInfoResponse ofAttempted(DailyDefense dailyDefense, DailyRecord dailyRecord) { + return DailyDefenseInfoResponse.builder() + .defenseName(dailyDefense.getContentName()) + .problemCount(dailyDefense.getProblemCount()) + .attemptCount(dailyDefense.getAttemptCount()) + .problems(DailyDefenseProblemInfoMapper.ofAttempted( + dailyDefense.getDailyDefenseProblems(), + dailyRecord.getSolvedProblemNumbers()) + ) + .build(); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/application/mapper/dailydefense/DailyDefenseProblemInfoMapper.java b/src/main/java/kr/co/morandi/backend/defense_information/application/mapper/dailydefense/DailyDefenseProblemInfoMapper.java new file mode 100644 index 00000000..fec9293a --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/application/mapper/dailydefense/DailyDefenseProblemInfoMapper.java @@ -0,0 +1,37 @@ +package kr.co.morandi.backend.defense_information.application.mapper.dailydefense; + +import kr.co.morandi.backend.defense_information.application.dto.response.DailyDefenseProblemInfoResponse; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Set; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class DailyDefenseProblemInfoMapper { + + public static List ofNonAttempted(List dailyDefenseProblems) { + return dailyDefenseProblems.stream().map(problem -> DailyDefenseProblemInfoResponse.builder() + .problemNumber(problem.getProblemNumber()) + .problemId(problem.getProblem().getProblemId()) + .baekjoonProblemId(problem.getProblem().getBaekjoonProblemId()) + .difficulty(problem.getProblem().getProblemTier()) + .solvedCount(problem.getSolvedCount()) + .submitCount(problem.getSubmitCount()) + .build() + ).toList(); + } + public static List ofAttempted(List dailyDefenseProblems, Set solvedProblemNumbers) { + return dailyDefenseProblems.stream().map(problem -> DailyDefenseProblemInfoResponse.builder() + .problemNumber(problem.getProblemNumber()) + .problemId(problem.getProblem().getProblemId()) + .baekjoonProblemId(problem.getProblem().getBaekjoonProblemId()) + .difficulty(problem.getProblem().getProblemTier()) + .solvedCount(problem.getSolvedCount()) + .submitCount(problem.getSubmitCount()) + .isSolved(solvedProblemNumbers.contains(problem.getProblemNumber())) + .build() + ).toList(); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/application/port/in/DailyDefenseUseCase.java b/src/main/java/kr/co/morandi/backend/defense_information/application/port/in/DailyDefenseUseCase.java new file mode 100644 index 00000000..da2cd7b1 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/application/port/in/DailyDefenseUseCase.java @@ -0,0 +1,9 @@ +package kr.co.morandi.backend.defense_information.application.port.in; + +import kr.co.morandi.backend.defense_information.application.dto.response.DailyDefenseInfoResponse; + +import java.time.LocalDateTime; + +public interface DailyDefenseUseCase { + DailyDefenseInfoResponse getDailyDefenseInfo(Long memberId, LocalDateTime requestDateTime); +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/application/port/out/dailydefense/DailyDefensePort.java b/src/main/java/kr/co/morandi/backend/defense_information/application/port/out/dailydefense/DailyDefensePort.java new file mode 100644 index 00000000..610daafe --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/application/port/out/dailydefense/DailyDefensePort.java @@ -0,0 +1,12 @@ +package kr.co.morandi.backend.defense_information.application.port.out.dailydefense; + +import kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; + +import java.time.LocalDate; + +public interface DailyDefensePort { + DailyDefense findDailyDefense(DefenseType defenseType, LocalDate date); + + DailyDefense saveDailyDefense(DailyDefense dailyDefense); +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/application/port/out/dailydefense/DailyDefenseProblemPort.java b/src/main/java/kr/co/morandi/backend/defense_information/application/port/out/dailydefense/DailyDefenseProblemPort.java new file mode 100644 index 00000000..d05ffef0 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/application/port/out/dailydefense/DailyDefenseProblemPort.java @@ -0,0 +1,15 @@ +package kr.co.morandi.backend.defense_information.application.port.out.dailydefense; + +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import kr.co.morandi.backend.defense_information.domain.model.defense.RandomCriteria; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; + +import java.util.List; +import java.util.Map; + +public interface DailyDefenseProblemPort { + + Map getDailyDefenseProblem(Map criteria); + + List findAllProblemsContainsDefenseId(Long defenseId); +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/application/usecase/DailyDefenseUseCaseImpl.java b/src/main/java/kr/co/morandi/backend/defense_information/application/usecase/DailyDefenseUseCaseImpl.java new file mode 100644 index 00000000..cc80aae5 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/application/usecase/DailyDefenseUseCaseImpl.java @@ -0,0 +1,57 @@ +package kr.co.morandi.backend.defense_information.application.usecase; + +import kr.co.morandi.backend.defense_information.application.dto.response.DailyDefenseInfoResponse; +import kr.co.morandi.backend.defense_information.application.mapper.dailydefense.DailyDefenseInfoMapper; +import kr.co.morandi.backend.defense_information.application.port.in.DailyDefenseUseCase; +import kr.co.morandi.backend.defense_information.application.port.out.dailydefense.DailyDefensePort; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_record.application.port.out.dailyrecord.DailyRecordPort; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.member_management.application.port.out.member.MemberPort; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType.DAILY; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class DailyDefenseUseCaseImpl implements DailyDefenseUseCase { + + private final MemberPort memberPort; + private final DailyDefensePort dailyDefensePort; + private final DailyRecordPort dailyRecordPort; + + @Override + public DailyDefenseInfoResponse getDailyDefenseInfo(Long memberId, LocalDateTime requestDateTime) { + final DailyDefense dailyDefense = dailyDefensePort.findDailyDefense(DAILY, requestDateTime.toLocalDate()); + /* + * 비로그인 상태인 경우 + * */ + if(memberId == null) { + return DailyDefenseInfoMapper.fromNonAttempted(dailyDefense); + } + /* + * 로그인 상태인 경우 + * */ + final Member member = memberPort.findMemberById(memberId); + Optional maybeDailyRecord = dailyRecordPort.findDailyRecord(member, requestDateTime.toLocalDate()); + + /* + * 시험 기록이 존재하는 경우 + * */ + if(maybeDailyRecord.isPresent()) { + DailyRecord dailyRecord = maybeDailyRecord.get(); + return DailyDefenseInfoMapper.ofAttempted(dailyDefense, dailyRecord); + } + /* + * 시험 응시 기록이 없는 경우 + * */ + return DailyDefenseInfoMapper.fromNonAttempted(dailyDefense); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/BookMark.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/BookMark.java new file mode 100644 index 00000000..b4877c60 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/BookMark.java @@ -0,0 +1,27 @@ +package kr.co.morandi.backend.defense_information.domain.model.customdefense; + +import jakarta.persistence.*; +import kr.co.morandi.backend.common.model.BaseEntity; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor +public class BookMark extends BaseEntity { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long bookMarkId; + + @ManyToOne(fetch = FetchType.LAZY) + private Defense defense; + + @ManyToOne(fetch = FetchType.LAZY) + private Member member; + + @Builder + private BookMark(Defense defense, Member member) { + this.defense = defense; + this.member = member; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/ContentMemberLikes.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/ContentMemberLikes.java new file mode 100644 index 00000000..a387882d --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/ContentMemberLikes.java @@ -0,0 +1,28 @@ +package kr.co.morandi.backend.defense_information.domain.model.customdefense; + +import jakarta.persistence.*; +import kr.co.morandi.backend.common.model.BaseEntity; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; + +@Table +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ContentMemberLikes extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long memberLikesId; + @ManyToOne(fetch = FetchType.LAZY) + private Member member; + @ManyToOne(fetch = FetchType.LAZY) + private Defense defense; + + @Builder + private ContentMemberLikes(Member member, Defense defense) { + this.member = member; + this.defense = defense; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/CustomDefense.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/CustomDefense.java new file mode 100644 index 00000000..51d0ed85 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/CustomDefense.java @@ -0,0 +1,87 @@ +package kr.co.morandi.backend.defense_information.domain.model.customdefense; + +import jakarta.persistence.*; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.defense_information.domain.model.defense.DefenseTier; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType.CUSTOM; + +@Entity +@DiscriminatorValue("CustomDefense") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CustomDefense extends Defense { + + private LocalDateTime createDate; + + private Integer problemCount; + + private String description; + + @Enumerated(EnumType.STRING) + private Visibility visibility; + + @Enumerated(EnumType.STRING) + private DefenseTier defenseTier; + + private Long timeLimit; + + @ManyToOne(fetch = FetchType.LAZY) + private Member member; + + @OneToMany(mappedBy = "customDefense", cascade = CascadeType.ALL) + private List customDefenseProblems = new ArrayList<>(); + + @Override + public LocalDateTime getEndTime(LocalDateTime startTime) { + return startTime.plusMinutes(timeLimit); + } + + public static CustomDefense create(List problems, Member member, String contentName, + String description, Visibility visibility, DefenseTier defenseTier, + Long timeLimit, LocalDateTime createDate) { + return new CustomDefense(problems, member, contentName, description, visibility, defenseTier, timeLimit, createDate); + } + + private Long isValidTimeLimit(Long timeLimit) { + if (timeLimit < 0) { + throw new IllegalArgumentException("커스텀 디펜스 제한 시간은 0보다 커야 합니다."); + } + return timeLimit; + } + + private int isValidProblemCount(int problemCount) { + if (problemCount < 1) { + throw new IllegalArgumentException("커스텀 디펜스에는 최소 한 개의 문제가 포함되어야 합니다."); + } + return problemCount; + } + + @Builder + private CustomDefense(List problems, Member member, String contentName, String description, + Visibility visibility, DefenseTier defenseTier, Long timeLimit, LocalDateTime createDate) { + super(contentName, CUSTOM); + this.problemCount = isValidProblemCount(problems.size()); + this.timeLimit = isValidTimeLimit(timeLimit); + this.description = description; + this.visibility = visibility; + this.defenseTier = defenseTier; + this.member = member; + AtomicLong problemNumber = new AtomicLong(1); + this.customDefenseProblems = problems.stream() + .map(problem -> CustomDefenseProblem.create(this, problemNumber.getAndIncrement(), problem)) + .toList(); + this.createDate = createDate; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/CustomDefenseProblem.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/CustomDefenseProblem.java new file mode 100644 index 00000000..fe7d6e2f --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/CustomDefenseProblem.java @@ -0,0 +1,46 @@ +package kr.co.morandi.backend.defense_information.domain.model.customdefense; + +import jakarta.persistence.*; +import kr.co.morandi.backend.common.model.BaseEntity; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CustomDefenseProblem extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long customProblemsId; + + @ManyToOne(fetch = FetchType.LAZY) + private CustomDefense customDefense; + + @ManyToOne(fetch = FetchType.LAZY) + private Problem problem; + + private Long submitCount; + + private Long solvedCount; + + private Long problemNumber; + + private CustomDefenseProblem(CustomDefense customDefense, Long problemNumber, Problem problem) { + this.customDefense = customDefense; + this.problem = problem; + this.submitCount = 0L; + this.problemNumber = problemNumber; + this.solvedCount = 0L; + } + public static CustomDefenseProblem create(CustomDefense customDefense, Long problemNumber, Problem problem) { + return new CustomDefenseProblem(customDefense, problemNumber, problem); + } + @Builder + private CustomDefenseProblem(CustomDefense customDefense, Long problemNumber, Problem problem, Long submitCount, Long solvedCount) { + this.customDefense = customDefense; + this.problem = problem; + this.submitCount = submitCount; + this.problemNumber = problemNumber; + this.solvedCount = solvedCount; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/Visibility.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/Visibility.java new file mode 100644 index 00000000..9ea30ffe --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/Visibility.java @@ -0,0 +1,13 @@ +package kr.co.morandi.backend.defense_information.domain.model.customdefense; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Visibility { + OPEN(true), + CLOSE(false); + + private final boolean isVisible; +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/model/dailydefense/DailyDefense.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/dailydefense/DailyDefense.java new file mode 100644 index 00000000..a8483467 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/dailydefense/DailyDefense.java @@ -0,0 +1,68 @@ +package kr.co.morandi.backend.defense_information.domain.model.dailydefense; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import jakarta.persistence.OneToMany; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.defense_information.domain.service.defense.ProblemGenerationService; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType.DAILY; + +@Entity +@DiscriminatorValue("DailyDefense") +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class DailyDefense extends Defense { + + private LocalDate date; + + private Integer problemCount; + + @OneToMany(mappedBy = "dailyDefense", cascade = CascadeType.ALL) + List dailyDefenseProblems = new ArrayList<>(); + + @Override + public LocalDateTime getEndTime(LocalDateTime startTime) { + //시작 날까지 + return date.atStartOfDay().plusDays(1).minusSeconds(1); + } + public Map getTryingProblem(Long problemNumber, ProblemGenerationService problemGenerationService) { + Map tryProblem = this.getDefenseProblems(problemGenerationService).entrySet().stream() + .filter(p -> p.getKey().equals(problemNumber)) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + if (tryProblem.isEmpty()) { + throw new IllegalArgumentException("해당 문제가 오늘의 문제 목록에 없습니다."); + } + return tryProblem; + } + + @Builder + private DailyDefense(LocalDate date, String contentName, Map problems) { + super(contentName, DAILY); + this.date = date; + this.dailyDefenseProblems = problems.entrySet().stream() + .map(problem -> DailyDefenseProblem.create(this, problem.getValue(), problem.getKey())) + .toList(); + + this.problemCount = problems.size(); + } + public static DailyDefense create(LocalDate date, String contentName, Map problems) { + return new DailyDefense(date, contentName, problems); + } + + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/model/dailydefense/DailyDefenseProblem.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/dailydefense/DailyDefenseProblem.java new file mode 100644 index 00000000..8804b1d7 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/dailydefense/DailyDefenseProblem.java @@ -0,0 +1,46 @@ +package kr.co.morandi.backend.defense_information.domain.model.dailydefense; + +import jakarta.persistence.*; +import kr.co.morandi.backend.common.model.BaseEntity; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DailyDefenseProblem extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long dailyDefenseProblemId; + + private Long submitCount; + + private Long solvedCount; + + private Long problemNumber; + + @ManyToOne(fetch = FetchType.LAZY) + private DailyDefense dailyDefense; + + @ManyToOne(fetch = FetchType.LAZY) + private Problem problem; + + private static final Long INITIAL_SUBMIT_COUNT = 0L; + + private static final Long INITIAL_SOLVED_COUNT = 0L; + + @Builder + private DailyDefenseProblem(DailyDefense dailyDefense, Problem problem, Long problemNumber) { + this.dailyDefense = dailyDefense; + this.problem = problem; + this.submitCount = INITIAL_SUBMIT_COUNT; + this.solvedCount = INITIAL_SOLVED_COUNT; + this.problemNumber = problemNumber; + } + public static DailyDefenseProblem create(DailyDefense dailyDefense, Problem problem, Long problemNumber) { + return new DailyDefenseProblem(dailyDefense, problem, problemNumber); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/model/defense/Defense.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/defense/Defense.java new file mode 100644 index 00000000..0da79f38 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/defense/Defense.java @@ -0,0 +1,48 @@ +package kr.co.morandi.backend.defense_information.domain.model.defense; + +import jakarta.persistence.*; +import kr.co.morandi.backend.common.model.BaseEntity; +import kr.co.morandi.backend.defense_information.domain.service.defense.ProblemGenerationService; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Map; + +@Entity +@Inheritance(strategy = InheritanceType.JOINED) +@DiscriminatorColumn +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class Defense extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long defenseId; + + private String contentName; + + private Long attemptCount; + + @Enumerated(EnumType.STRING) + private DefenseType defenseType; + + public void increaseAttemptCount() { + ++this.attemptCount; + } + public abstract LocalDateTime getEndTime(LocalDateTime startTime); + //팩토리 메소드 패턴 + public Map getDefenseProblems(ProblemGenerationService problemGenerationService) { + return problemGenerationService.getDefenseProblems(this); + } + public DefenseType getType() { + return defenseType; + } + protected Defense(String contentName, DefenseType defenseType) { + this.contentName = contentName; + this.attemptCount = 0L; + this.defenseType = defenseType; + } + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/model/defense/DefenseTier.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/defense/DefenseTier.java new file mode 100644 index 00000000..3b4f6c8a --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/defense/DefenseTier.java @@ -0,0 +1,16 @@ +package kr.co.morandi.backend.defense_information.domain.model.defense; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum DefenseTier { + BRONZE(1), + SILVER(2), + GOLD(3), + PLATINUM(4), + DIAMOND(5), + RUBY(6); + private final int tier; +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/model/defense/DefenseType.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/defense/DefenseType.java new file mode 100644 index 00000000..73778505 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/defense/DefenseType.java @@ -0,0 +1,6 @@ +package kr.co.morandi.backend.defense_information.domain.model.defense; + +public enum DefenseType { + DAILY, CUSTOM, STAGE, RANDOM + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/model/defense/ProblemTier.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/defense/ProblemTier.java new file mode 100644 index 00000000..44d5db17 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/defense/ProblemTier.java @@ -0,0 +1,32 @@ +package kr.co.morandi.backend.defense_information.domain.model.defense; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.List; + +@Getter +@RequiredArgsConstructor +public enum ProblemTier { + UNRANKED(0), + B5(1),B4(2),B3(3),B2(4),B1(5), + S5(6),S4(7),S3(8),S2(9),S1(10), + G5(11),G4(12),G3(13),G2(14),G1(15), + P5(16),P4(17),P3(18),P2(19),P1(20), + D5(21),D4(22),D3(23),D2(24),D1(25), + R5(26),R4(27),R3(28),R2(29),R1(30); + + private final int tier; + + private static final List VALUES = Arrays.asList(values()); + + public static List tierRangeOf(ProblemTier start, ProblemTier end) { + if (start.tier > end.tier) + throw new IllegalArgumentException("시작 티어가 끝 티어보다 높을 수 없습니다."); + + return VALUES.stream() + .filter(tier -> tier.tier >= start.tier && tier.tier <= end.tier) + .toList(); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/model/defense/RandomCriteria.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/defense/RandomCriteria.java new file mode 100644 index 00000000..eec66780 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/defense/RandomCriteria.java @@ -0,0 +1,63 @@ +package kr.co.morandi.backend.defense_information.domain.model.defense; + +import jakarta.persistence.Embeddable; +import jakarta.persistence.Embedded; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RandomCriteria { + @Embedded + private DifficultyRange difficultyRange; + private Long minSolvedCount; + private Long maxSolvedCount; + @Builder + private RandomCriteria(DifficultyRange difficultyRange, Long minSolvedCount, Long maxSolvedCount) { + this.difficultyRange = difficultyRange; + this.minSolvedCount = minSolvedCount; + this.maxSolvedCount = maxSolvedCount; + } + public static RandomCriteria of(DifficultyRange difficultyRange, Long minSolvedCount, Long maxSolvedCount) { + if (minSolvedCount == null || maxSolvedCount == null) + throw new IllegalArgumentException("Solved count must not be null"); + if (minSolvedCount < 0 || maxSolvedCount < 0) + throw new IllegalArgumentException("Solved count must be greater than or equal to 0"); + if (minSolvedCount >= maxSolvedCount) + throw new IllegalArgumentException("Min solved count must be less than or equal to max solved count"); + + return RandomCriteria.builder() + .difficultyRange(difficultyRange) + .minSolvedCount(minSolvedCount) + .maxSolvedCount(maxSolvedCount) + .build(); + } + @Embeddable + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class DifficultyRange { + private ProblemTier startDifficulty; + private ProblemTier endDifficulty; + + @Builder + private DifficultyRange(ProblemTier startDifficulty, ProblemTier endDifficulty) { + this.startDifficulty = startDifficulty; + this.endDifficulty = endDifficulty; + } + + public static DifficultyRange of(ProblemTier startDifficulty, ProblemTier endDifficulty) { + if (startDifficulty == null || endDifficulty == null) + throw new IllegalArgumentException("DifficultyRange must not be null"); + if (startDifficulty.compareTo(endDifficulty) > 0) + throw new IllegalArgumentException("Start difficulty must be less than or equal to end difficulty"); + + return DifficultyRange.builder() + .startDifficulty(startDifficulty) + .endDifficulty(endDifficulty) + .build(); + } + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/model/problem_generation_strategy/ProblemGenerationStrategy.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/problem_generation_strategy/ProblemGenerationStrategy.java new file mode 100644 index 00000000..bc96682b --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/problem_generation_strategy/ProblemGenerationStrategy.java @@ -0,0 +1,14 @@ +package kr.co.morandi.backend.defense_information.domain.model.problem_generation_strategy; + +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; + +import java.util.Map; + +public interface ProblemGenerationStrategy { + + Map generateDefenseProblems(Defense defense); + DefenseType getDefenseType(); + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/model/randomdefense/model/RandomDefense.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/randomdefense/model/RandomDefense.java new file mode 100644 index 00000000..9cd8ce9f --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/randomdefense/model/RandomDefense.java @@ -0,0 +1,57 @@ +package kr.co.morandi.backend.defense_information.domain.model.randomdefense.model; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.defense_information.domain.model.defense.RandomCriteria; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType.RANDOM; + +@Entity +@DiscriminatorValue("RandomDefense") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RandomDefense extends Defense { + + @Embedded + private RandomCriteria randomCriteria; + + private Integer problemCount; + + private Long timeLimit; + + @Override + public LocalDateTime getEndTime(LocalDateTime startTime) { + return startTime.plusMinutes(timeLimit); + } + + @Builder + private RandomDefense(RandomCriteria randomCriteria, Integer problemCount, Long timeLimit, String contentName) { + super(contentName, RANDOM); + this.randomCriteria = randomCriteria; + this.problemCount = isValidProblemCount(problemCount); + this.timeLimit = isValidTimeLimit(timeLimit); + } + public static RandomDefense create(RandomCriteria randomCriteria, Integer problemCount, Long timeLimit, String contentName) { + return new RandomDefense(randomCriteria, problemCount, timeLimit, contentName); + } + private Integer isValidProblemCount(Integer problemCount) { + if (problemCount <= 0) { + throw new IllegalArgumentException("랜덤 디펜스 문제 수는 1문제 이상 이어야 합니다."); + } + return problemCount; + } + private Long isValidTimeLimit(Long timeLimit) { + if (timeLimit <= 0) { + throw new IllegalArgumentException("랜덤 디펜스 제한 시간은 0보다 커야 합니다."); + } + return timeLimit; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/model/stagedefense/model/StageDefense.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/stagedefense/model/StageDefense.java new file mode 100644 index 00000000..7f2f508c --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/model/stagedefense/model/StageDefense.java @@ -0,0 +1,54 @@ +package kr.co.morandi.backend.defense_information.domain.model.stagedefense.model; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.defense_information.domain.model.defense.RandomCriteria; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType.STAGE; + +@Entity +@DiscriminatorValue("StageDefense") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class StageDefense extends Defense { + + @Embedded + private RandomCriteria randomCriteria; + + private Double averageStage; + + private Long timeLimit; + + @Override + public LocalDateTime getEndTime(LocalDateTime startTime) { + return startTime.plusMinutes(timeLimit); + } + + public static StageDefense create(RandomCriteria randomCriteria, Long timeLimit, String contentName) { + return new StageDefense(randomCriteria, timeLimit, contentName); + } + + private Long isValidTimeLimit(Long timeLimit) { + if (timeLimit <= 0) { + throw new IllegalArgumentException("스테이지 모드 제한 시간은 0보다 커야 합니다."); + } + return timeLimit; + } + + @Builder + private StageDefense(RandomCriteria randomCriteria, Long timeLimit, String contentName) { + super(contentName, STAGE); + this.randomCriteria = randomCriteria; + this.averageStage = 0.0; + this.timeLimit = isValidTimeLimit(timeLimit); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/service/customdefense/CustomDefenseStrategy.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/service/customdefense/CustomDefenseStrategy.java new file mode 100644 index 00000000..8e65ad3b --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/service/customdefense/CustomDefenseStrategy.java @@ -0,0 +1,24 @@ +package kr.co.morandi.backend.defense_information.domain.service.customdefense; + +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType; +import kr.co.morandi.backend.defense_information.domain.model.problem_generation_strategy.ProblemGenerationStrategy; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import org.springframework.stereotype.Component; + +import java.util.Map; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType.CUSTOM; + +@Component +public class CustomDefenseStrategy implements ProblemGenerationStrategy { + + @Override + public Map generateDefenseProblems(Defense defense) { + return null; + } + @Override + public DefenseType getDefenseType() { + return CUSTOM; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/service/dailydefense/DailyDefenseGenerationService.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/service/dailydefense/DailyDefenseGenerationService.java new file mode 100644 index 00000000..da3ad8d5 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/service/dailydefense/DailyDefenseGenerationService.java @@ -0,0 +1,62 @@ +package kr.co.morandi.backend.defense_information.domain.service.dailydefense; + +import kr.co.morandi.backend.defense_information.application.port.out.dailydefense.DailyDefensePort; +import kr.co.morandi.backend.defense_information.application.port.out.dailydefense.DailyDefenseProblemPort; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.model.defense.RandomCriteria; +import kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; + +@Service +@RequiredArgsConstructor +public class DailyDefenseGenerationService { + + private final DailyDefenseProblemPort dailyDefenseProblemPort; + private final DailyDefensePort dailyDefensePort; + + private static final Map.Entry PROBLEM_1 = getRandomCriteria(1L, B5, B1, 1000L, 300000L); + private static final Map.Entry PROBLEM_2 = getRandomCriteria(2L, S5, S4, 1000L, 300000L); + private static final Map.Entry PROBLEM_3 = getRandomCriteria(3L, S3, S1, 1000L, 300000L); + private static final Map.Entry PROBLEM_4 = getRandomCriteria(4L, G5, G4, 1000L, 300000L); + private static final Map.Entry PROBLEM_5 = getRandomCriteria(5L, G3, G1, 1000L, 300000L); + private static final String POSTFIX = "%d월 %d일 오늘의 문제"; + + @Transactional + public boolean generateDailyDefense(LocalDateTime requestTime) { + final Map request = Map.ofEntries(PROBLEM_1, PROBLEM_2, PROBLEM_3, PROBLEM_4, PROBLEM_5); + + final Map dailyDefenseProblem = dailyDefenseProblemPort.getDailyDefenseProblem(request); + + final LocalDate targetDate = requestTime.plusDays(1L).toLocalDate(); + + final DailyDefense dailyDefense = DailyDefense.create(targetDate, + String.format(POSTFIX, targetDate.getMonthValue(), targetDate.getDayOfMonth()), dailyDefenseProblem); + + dailyDefensePort.saveDailyDefense(dailyDefense); + + return true; + } + + private static Map.Entry getRandomCriteria(Long problemNumber, + ProblemTier startTier, + ProblemTier endTier, + Long minSolvedCount, + Long maxSolvedCount) { + + return Map.entry(problemNumber, RandomCriteria.builder() + .minSolvedCount(minSolvedCount) + .maxSolvedCount(maxSolvedCount) + .difficultyRange(RandomCriteria.DifficultyRange.of(startTier, endTier)) + .build()); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/service/dailydefense/DailyDefenseProblemStrategy.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/service/dailydefense/DailyDefenseProblemStrategy.java new file mode 100644 index 00000000..07b3fed5 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/service/dailydefense/DailyDefenseProblemStrategy.java @@ -0,0 +1,33 @@ +package kr.co.morandi.backend.defense_information.domain.service.dailydefense; + +import kr.co.morandi.backend.defense_information.application.port.out.dailydefense.DailyDefenseProblemPort; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType; +import kr.co.morandi.backend.defense_information.domain.model.problem_generation_strategy.ProblemGenerationStrategy; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType.DAILY; + +@Component +@RequiredArgsConstructor +public class DailyDefenseProblemStrategy implements ProblemGenerationStrategy { + + private final DailyDefenseProblemPort dailyDefenseProblemPort; + @Override + public Map generateDefenseProblems(Defense defense) { + final List defenseProblems = dailyDefenseProblemPort.findAllProblemsContainsDefenseId(defense.getDefenseId()); + return defenseProblems.stream() + .collect(Collectors.toMap(DailyDefenseProblem::getProblemNumber, DailyDefenseProblem::getProblem)); + } + @Override + public DefenseType getDefenseType() { + return DAILY; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/domain/service/defense/ProblemGenerationService.java b/src/main/java/kr/co/morandi/backend/defense_information/domain/service/defense/ProblemGenerationService.java new file mode 100644 index 00000000..9500a5ab --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/domain/service/defense/ProblemGenerationService.java @@ -0,0 +1,26 @@ +package kr.co.morandi.backend.defense_information.domain.service.defense; + +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType; +import kr.co.morandi.backend.defense_information.domain.model.problem_generation_strategy.ProblemGenerationStrategy; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class ProblemGenerationService { + + private final Map strategies; + + public ProblemGenerationService(List strategies) { + this.strategies = strategies.stream() + .collect(Collectors.toMap(ProblemGenerationStrategy::getDefenseType, strategy -> strategy)); + } + public Map getDefenseProblems(Defense defense) { + return strategies.get(defense.getType()).generateDefenseProblems(defense); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/adapter/dailydefense/DailyDefenseAdapter.java b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/adapter/dailydefense/DailyDefenseAdapter.java new file mode 100644 index 00000000..60c175f1 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/adapter/dailydefense/DailyDefenseAdapter.java @@ -0,0 +1,29 @@ +package kr.co.morandi.backend.defense_information.infrastructure.adapter.dailydefense; + +import kr.co.morandi.backend.defense_information.application.port.out.dailydefense.DailyDefensePort; +import kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; + +@Service +@RequiredArgsConstructor +public class DailyDefenseAdapter implements DailyDefensePort { + + private final DailyDefenseRepository dailyDefenseRepository; + + @Override + public DailyDefense findDailyDefense(DefenseType defenseType, LocalDate date) { + return dailyDefenseRepository.findDailyDefenseByTypeAndDate(defenseType, date) + .orElseThrow(() -> new IllegalArgumentException("DailyDefense가 존재하지 않습니다")); + } + + @Override + public DailyDefense saveDailyDefense(DailyDefense dailyDefense) { + return dailyDefenseRepository.save(dailyDefense); + } + +} \ No newline at end of file diff --git a/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/adapter/dailydefense/DailyDefenseProblemAdapter.java b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/adapter/dailydefense/DailyDefenseProblemAdapter.java new file mode 100644 index 00000000..1e18aef5 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/adapter/dailydefense/DailyDefenseProblemAdapter.java @@ -0,0 +1,55 @@ +package kr.co.morandi.backend.defense_information.infrastructure.adapter.dailydefense; + +import kr.co.morandi.backend.defense_information.application.port.out.dailydefense.DailyDefenseProblemPort; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier; +import kr.co.morandi.backend.defense_information.domain.model.defense.RandomCriteria; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseProblemRepository; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class DailyDefenseProblemAdapter implements DailyDefenseProblemPort { + + private final ProblemRepository problemRepository; + + private final DailyDefenseProblemRepository dailyDefenseProblemRepository; + + @Override + public Map getDailyDefenseProblem(Map criteria) { + + Pageable pageable = PageRequest.of(0, 1); + + return criteria.entrySet().stream() + .map(entry -> { + final RandomCriteria randomCriteria = entry.getValue(); + final RandomCriteria.DifficultyRange difficultyRange = randomCriteria.getDifficultyRange(); + final ProblemTier startTier = difficultyRange.getStartDifficulty(); + final ProblemTier endTier = difficultyRange.getEndDifficulty(); + + final List dailyDefenseProblems = + problemRepository.getDailyDefenseProblems(ProblemTier.tierRangeOf(startTier, endTier), + randomCriteria.getMinSolvedCount(), + randomCriteria.getMaxSolvedCount(), + pageable); + + return Map.entry(entry.getKey(), dailyDefenseProblems.get(0)); + }) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + @Override + public List findAllProblemsContainsDefenseId(Long defenseId) { + return dailyDefenseProblemRepository.findAllProblemsContainsDefenseId(defenseId); + } +} + diff --git a/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/config/defense/SchedulingConfig.java b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/config/defense/SchedulingConfig.java new file mode 100644 index 00000000..9f6a4f9c --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/config/defense/SchedulingConfig.java @@ -0,0 +1,9 @@ +package kr.co.morandi.backend.defense_information.infrastructure.config.defense; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/controller/DailyDefenseController.java b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/controller/DailyDefenseController.java new file mode 100644 index 00000000..bd57233c --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/controller/DailyDefenseController.java @@ -0,0 +1,25 @@ +package kr.co.morandi.backend.defense_information.infrastructure.controller; + +import kr.co.morandi.backend.common.web.MemberId; +import kr.co.morandi.backend.defense_information.application.dto.response.DailyDefenseInfoResponse; +import kr.co.morandi.backend.defense_information.application.port.in.DailyDefenseUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; + +@RestController +@RequiredArgsConstructor +public class DailyDefenseController { + + private final DailyDefenseUseCase dailyDefenseUseCase; + + @GetMapping("/daily-defense") + public DailyDefenseInfoResponse getDailyDefenseInfo(@MemberId Long memberId) { + + return dailyDefenseUseCase.getDailyDefenseInfo(memberId, LocalDateTime.now()); + } + + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/customdefense/BookMarkRepository.java b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/customdefense/BookMarkRepository.java new file mode 100644 index 00000000..8260cbc4 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/customdefense/BookMarkRepository.java @@ -0,0 +1,7 @@ +package kr.co.morandi.backend.defense_information.infrastructure.persistence.customdefense; + +import kr.co.morandi.backend.defense_information.domain.model.customdefense.BookMark; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BookMarkRepository extends JpaRepository { +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/customdefense/ContentMemberLikesRepository.java b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/customdefense/ContentMemberLikesRepository.java new file mode 100644 index 00000000..80b5553f --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/customdefense/ContentMemberLikesRepository.java @@ -0,0 +1,7 @@ +package kr.co.morandi.backend.defense_information.infrastructure.persistence.customdefense; + +import kr.co.morandi.backend.defense_information.domain.model.customdefense.ContentMemberLikes; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ContentMemberLikesRepository extends JpaRepository { +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/customdefense/CustomDefenseProblemRepository.java b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/customdefense/CustomDefenseProblemRepository.java new file mode 100644 index 00000000..c6a28664 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/customdefense/CustomDefenseProblemRepository.java @@ -0,0 +1,7 @@ +package kr.co.morandi.backend.defense_information.infrastructure.persistence.customdefense; + +import kr.co.morandi.backend.defense_information.domain.model.customdefense.CustomDefenseProblem; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CustomDefenseProblemRepository extends JpaRepository { +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/customdefense/CustomDefenseRepository.java b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/customdefense/CustomDefenseRepository.java new file mode 100644 index 00000000..b163574c --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/customdefense/CustomDefenseRepository.java @@ -0,0 +1,11 @@ +package kr.co.morandi.backend.defense_information.infrastructure.persistence.customdefense; + +import kr.co.morandi.backend.defense_information.domain.model.customdefense.CustomDefense; +import kr.co.morandi.backend.defense_information.domain.model.customdefense.Visibility; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CustomDefenseRepository extends JpaRepository { + List findAllByVisibility(Visibility visibility); +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/dailydefense/DailyDefenseProblemRepository.java b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/dailydefense/DailyDefenseProblemRepository.java new file mode 100644 index 00000000..732b8905 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/dailydefense/DailyDefenseProblemRepository.java @@ -0,0 +1,17 @@ +package kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense; + +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface DailyDefenseProblemRepository extends JpaRepository { + @Query(""" + select ddp + from DailyDefenseProblem as ddp + left join fetch ddp.problem p + where ddp.dailyDefense.defenseId = :defenseId + """) + List findAllProblemsContainsDefenseId(Long defenseId); +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/dailydefense/DailyDefenseRepository.java b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/dailydefense/DailyDefenseRepository.java new file mode 100644 index 00000000..9cce7ceb --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/dailydefense/DailyDefenseRepository.java @@ -0,0 +1,23 @@ +package kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense; + +import kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.time.LocalDate; +import java.util.Optional; + +public interface DailyDefenseRepository extends JpaRepository { + + @Query(""" + SELECT dd + from DailyDefense dd + left join fetch dd.dailyDefenseProblems ddp + left join fetch ddp.problem + where dd.defenseType = :defenseType + and dd.date = :date + """) + Optional findDailyDefenseByTypeAndDate(DefenseType defenseType, LocalDate date); + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/dailydefense/DailyDetailRepository.java b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/dailydefense/DailyDetailRepository.java new file mode 100644 index 00000000..9bf62f6e --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/dailydefense/DailyDetailRepository.java @@ -0,0 +1,7 @@ +package kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense; + +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyDetail; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DailyDetailRepository extends JpaRepository { +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/defense/DefenseRepository.java b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/defense/DefenseRepository.java new file mode 100644 index 00000000..b309795c --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/defense/DefenseRepository.java @@ -0,0 +1,7 @@ +package kr.co.morandi.backend.defense_information.infrastructure.persistence.defense; + +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DefenseRepository extends JpaRepository { +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/randomdefense/RandomDefenseRepository.java b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/randomdefense/RandomDefenseRepository.java new file mode 100644 index 00000000..19f003c7 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/randomdefense/RandomDefenseRepository.java @@ -0,0 +1,7 @@ +package kr.co.morandi.backend.defense_information.infrastructure.persistence.randomdefense; + +import kr.co.morandi.backend.defense_information.domain.model.randomdefense.model.RandomDefense; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RandomDefenseRepository extends JpaRepository { +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/stagedefense/StageDefenseRepository.java b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/stagedefense/StageDefenseRepository.java new file mode 100644 index 00000000..6aa51cda --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/stagedefense/StageDefenseRepository.java @@ -0,0 +1,7 @@ +package kr.co.morandi.backend.defense_information.infrastructure.persistence.stagedefense; + +import kr.co.morandi.backend.defense_information.domain.model.stagedefense.model.StageDefense; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StageDefenseRepository extends JpaRepository { +} diff --git a/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/scheduler/dailydefense/DailyDefenseGenerationScheduler.java b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/scheduler/dailydefense/DailyDefenseGenerationScheduler.java new file mode 100644 index 00000000..d23daea9 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_information/infrastructure/scheduler/dailydefense/DailyDefenseGenerationScheduler.java @@ -0,0 +1,29 @@ +package kr.co.morandi.backend.defense_information.infrastructure.scheduler.dailydefense; + +import kr.co.morandi.backend.defense_information.domain.service.dailydefense.DailyDefenseGenerationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Component +@Slf4j +@RequiredArgsConstructor +public class DailyDefenseGenerationScheduler { + + private final DailyDefenseGenerationService dailyDefenseGenerationService; + + @Scheduled(cron = "0 0 23 * * ?", zone = "Asia/Seoul") + public void generateDailyDefense() { + LocalDateTime now = LocalDateTime.now(); + boolean result = dailyDefenseGenerationService.generateDailyDefense(now); + if (!result) { + throw new RuntimeException("Daily defense generation failed"); + } + + log.info("Daily defense generation success"); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/mapper/defenseproblem/DefenseProblemMapper.java b/src/main/java/kr/co/morandi/backend/defense_management/application/mapper/defenseproblem/DefenseProblemMapper.java new file mode 100644 index 00000000..8f52adf7 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/mapper/defenseproblem/DefenseProblemMapper.java @@ -0,0 +1,49 @@ +package kr.co.morandi.backend.defense_management.application.mapper.defenseproblem; + +import kr.co.morandi.backend.defense_management.application.mapper.tempcode.TempCodeMapper; +import kr.co.morandi.backend.problem_information.application.response.problemcontent.ProblemContent; +import kr.co.morandi.backend.defense_management.application.response.session.DefenseProblemResponse; +import kr.co.morandi.backend.defense_management.application.response.tempcode.TempCodeResponse; +import kr.co.morandi.backend.defense_management.domain.model.session.DefenseSession; +import kr.co.morandi.backend.defense_management.domain.model.session.SessionDetail; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class DefenseProblemMapper { + + public static List of(Map tryProblem, + DefenseSession defenseSession, + DailyRecord dailyRecord, + Map problemContents) { + return tryProblem.entrySet().stream() + .map(entry -> { + final Long problemNumber = entry.getKey(); + final Problem problem = entry.getValue(); + final boolean isCorrect = dailyRecord.isSolvedProblem(problemNumber); + + final SessionDetail sessionDetail = defenseSession.getSessionDetail(problemNumber); + + final Language lastAccessLanguage = sessionDetail.getLastAccessLanguage(); + final Set tempCodeResponses = TempCodeMapper.createTempCodeResponses(sessionDetail.getTempCodes()); + + return DefenseProblemResponse.builder() + .problemId(problem.getProblemId()) + .baekjoonProblemId(problem.getBaekjoonProblemId()) + .problemNumber(problemNumber) + .isCorrect(isCorrect) + .lastAccessLanguage(lastAccessLanguage) + .content(problemContents.get(problem.getBaekjoonProblemId())) + .tempCodes(tempCodeResponses) + .build(); + }) + .toList(); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/mapper/session/StartDailyDefenseMapper.java b/src/main/java/kr/co/morandi/backend/defense_management/application/mapper/session/StartDailyDefenseMapper.java new file mode 100644 index 00000000..59385a72 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/mapper/session/StartDailyDefenseMapper.java @@ -0,0 +1,31 @@ +package kr.co.morandi.backend.defense_management.application.mapper.session; + +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_management.application.mapper.defenseproblem.DefenseProblemMapper; +import kr.co.morandi.backend.problem_information.application.response.problemcontent.ProblemContent; +import kr.co.morandi.backend.defense_management.application.response.session.StartDailyDefenseResponse; +import kr.co.morandi.backend.defense_management.domain.model.session.DefenseSession; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class StartDailyDefenseMapper { + + public static StartDailyDefenseResponse of(Map tryProblem, + DailyDefense dailyDefense, + DefenseSession defenseSession, + DailyRecord dailyRecord, + Map problemContents) { + return StartDailyDefenseResponse.builder() + .defenseSessionId(defenseSession.getDefenseSessionId()) + .contentName(dailyDefense.getContentName()) + .defenseType(dailyDefense.getDefenseType()) + .lastAccessTime(defenseSession.getLastAccessDateTime()) + .defenseProblems(DefenseProblemMapper.of(tryProblem, defenseSession, dailyRecord, problemContents)) + .build(); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/mapper/tempcode/TempCodeMapper.java b/src/main/java/kr/co/morandi/backend/defense_management/application/mapper/tempcode/TempCodeMapper.java new file mode 100644 index 00000000..e9588ddf --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/mapper/tempcode/TempCodeMapper.java @@ -0,0 +1,42 @@ +package kr.co.morandi.backend.defense_management.application.mapper.tempcode; + +import kr.co.morandi.backend.defense_management.application.response.tempcode.TempCodeResponse; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.TempCode; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.*; +import java.util.stream.Collectors; + + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class TempCodeMapper { + private static final Map intialTempCodeMap = + Arrays.stream(Language.values()) + .collect(Collectors.toMap( + language -> language, + language -> TempCodeResponse.builder() + .language(language) + .code(language.getInitialCode()) + .build())); + + /* + * TempCode 전체를 한 번에 반환하기 위해 initialTempCodeMap을 이용하여 + * 수집된 TempCode들로 replace하며 TempCodeResponse를 만들어 반환한다. + * */ + public static Set createTempCodeResponses(Set tempCodes) { + + // 기본 코드를 가지고 있는 Map을 만들어서 + Map tempCodeMap = new EnumMap<>(intialTempCodeMap); + + // tempCode를 순회하면서 tempCodeMap에 해당 언어의 TempCodeResponse를 넣어준다. + tempCodes.forEach(tempCode -> tempCodeMap.replace(tempCode.getLanguage(), TempCodeResponse.builder() + .language(tempCode.getLanguage()) + .code(tempCode.getCode()) + .build())); + + return new HashSet<>(tempCodeMap.values()); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/port/out/defensemessaging/DefenseMessagePort.java b/src/main/java/kr/co/morandi/backend/defense_management/application/port/out/defensemessaging/DefenseMessagePort.java new file mode 100644 index 00000000..fff02006 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/port/out/defensemessaging/DefenseMessagePort.java @@ -0,0 +1,11 @@ +package kr.co.morandi.backend.defense_management.application.port.out.defensemessaging; + +import kr.co.morandi.backend.defense_management.application.response.codesubmit.CodeResponse; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +public interface DefenseMessagePort { + + void createConnection(Long defenseSessionId); + SseEmitter getConnection(Long defenseSessionId); + boolean sendMessage(Long defenseSessionId, String message); +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/port/out/session/DefenseSessionPort.java b/src/main/java/kr/co/morandi/backend/defense_management/application/port/out/session/DefenseSessionPort.java new file mode 100644 index 00000000..65a1f401 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/port/out/session/DefenseSessionPort.java @@ -0,0 +1,15 @@ +package kr.co.morandi.backend.defense_management.application.port.out.session; + +import kr.co.morandi.backend.defense_management.domain.model.session.DefenseSession; +import kr.co.morandi.backend.member_management.domain.model.member.Member; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface DefenseSessionPort { + + DefenseSession saveDefenseSession(DefenseSession defenseSession); + Optional findTodaysDailyDefenseSession(Member member, LocalDateTime now); + Optional findDefenseSessionById(Long sessionId); + Optional findDefenseSessionJoinFetchTempCode(Long sessionId); +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/port/out/session/SessionDetailPort.java b/src/main/java/kr/co/morandi/backend/defense_management/application/port/out/session/SessionDetailPort.java new file mode 100644 index 00000000..7ced5a6a --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/port/out/session/SessionDetailPort.java @@ -0,0 +1,10 @@ +package kr.co.morandi.backend.defense_management.application.port.out.session; + +import kr.co.morandi.backend.defense_management.domain.model.session.DefenseSession; +import kr.co.morandi.backend.defense_management.domain.model.session.SessionDetail; + +import java.util.Optional; + +public interface SessionDetailPort { + Optional findSessionDetail(DefenseSession defenseSession, Long problemId); +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/request/session/StartDailyDefenseServiceRequest.java b/src/main/java/kr/co/morandi/backend/defense_management/application/request/session/StartDailyDefenseServiceRequest.java new file mode 100644 index 00000000..e58d875a --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/request/session/StartDailyDefenseServiceRequest.java @@ -0,0 +1,20 @@ +package kr.co.morandi.backend.defense_management.application.request.session; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class StartDailyDefenseServiceRequest { + + private Long problemNumber; + + @Builder + private StartDailyDefenseServiceRequest(LocalDateTime requestDateTime, Long problemNumber) { + this.problemNumber = problemNumber; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/response/codesubmit/CodeResponse.java b/src/main/java/kr/co/morandi/backend/defense_management/application/response/codesubmit/CodeResponse.java new file mode 100644 index 00000000..e4262882 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/response/codesubmit/CodeResponse.java @@ -0,0 +1,27 @@ +package kr.co.morandi.backend.defense_management.application.response.codesubmit; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@Getter +public class CodeResponse { + + private String result; + private double executeTime; + private String output; + + public static CodeResponse create(MessageResponse messageResponse) { + return CodeResponse.builder() + .result(messageResponse.getResult()) + .executeTime(messageResponse.getExecute_time()) + .output(messageResponse.getOutput()) + .build(); + } + @Builder + private CodeResponse(String result, double executeTime, String output) { + this.result = result; + this.executeTime = executeTime; + this.output = output; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/response/codesubmit/MessageResponse.java b/src/main/java/kr/co/morandi/backend/defense_management/application/response/codesubmit/MessageResponse.java new file mode 100644 index 00000000..d1152cd1 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/response/codesubmit/MessageResponse.java @@ -0,0 +1,28 @@ +package kr.co.morandi.backend.defense_management.application.response.codesubmit; + +import lombok.*; + +@Getter +public class MessageResponse { + + private String result; + private double execute_time; + private String output; + private String sseId; + + public static MessageResponse create(String result, double execute_time, String output, String sseId) { + return MessageResponse.builder() + .result(result) + .execute_time(execute_time) + .output(output) + .sseId(sseId) + .build(); + } + @Builder + private MessageResponse(String result, double execute_time, String output, String sseId) { + this.result = result; + this.execute_time = execute_time; + this.output = output; + this.sseId = sseId; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/response/session/DefenseProblemResponse.java b/src/main/java/kr/co/morandi/backend/defense_management/application/response/session/DefenseProblemResponse.java new file mode 100644 index 00000000..3c8205e4 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/response/session/DefenseProblemResponse.java @@ -0,0 +1,41 @@ +package kr.co.morandi.backend.defense_management.application.response.session; + +import kr.co.morandi.backend.defense_management.application.response.tempcode.TempCodeResponse; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.problem_information.application.response.problemcontent.ProblemContent; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Set; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DefenseProblemResponse { + + private Long problemId; + private Long problemNumber; + private Long baekjoonProblemId; + private ProblemContent content; + private boolean isCorrect; + private Language lastAccessLanguage; + private Set tempCodes; + + public boolean getIsCorrect() { + return isCorrect; + } + + @Builder + private DefenseProblemResponse(Long problemId, Long problemNumber, Long baekjoonProblemId, + ProblemContent content, boolean isCorrect, Language lastAccessLanguage, + Set tempCodes) { + this.problemId = problemId; + this.problemNumber = problemNumber; + this.baekjoonProblemId = baekjoonProblemId; + this.content = content; + this.isCorrect = isCorrect; + this.lastAccessLanguage = lastAccessLanguage; + this.tempCodes = tempCodes; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/response/session/StartDailyDefenseResponse.java b/src/main/java/kr/co/morandi/backend/defense_management/application/response/session/StartDailyDefenseResponse.java new file mode 100644 index 00000000..1b485a72 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/response/session/StartDailyDefenseResponse.java @@ -0,0 +1,34 @@ +package kr.co.morandi.backend.defense_management.application.response.session; + +import kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class StartDailyDefenseResponse { + + private Long defenseSessionId; + private String contentName; + private DefenseType defenseType; + private LocalDateTime lastAccessTime; + private List defenseProblems; + + @Builder + private StartDailyDefenseResponse(Long defenseSessionId, + String contentName, + DefenseType defenseType, + LocalDateTime lastAccessTime, + List defenseProblems) { + this.defenseSessionId = defenseSessionId; + this.defenseType = defenseType; + this.contentName = contentName; + this.lastAccessTime = lastAccessTime; + this.defenseProblems = defenseProblems; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/response/tempcode/TempCodeResponse.java b/src/main/java/kr/co/morandi/backend/defense_management/application/response/tempcode/TempCodeResponse.java new file mode 100644 index 00000000..eaee2872 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/response/tempcode/TempCodeResponse.java @@ -0,0 +1,21 @@ +package kr.co.morandi.backend.defense_management.application.response.tempcode; + +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TempCodeResponse { + + private Language language; + private String code; + + @Builder + private TempCodeResponse(Language language, String code) { + this.language = language; + this.code = code; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/service/codesubmit/ExampleCodeSubmitService.java b/src/main/java/kr/co/morandi/backend/defense_management/application/service/codesubmit/ExampleCodeSubmitService.java new file mode 100644 index 00000000..1681a1da --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/service/codesubmit/ExampleCodeSubmitService.java @@ -0,0 +1,7 @@ +package kr.co.morandi.backend.defense_management.application.service.codesubmit; + +import kr.co.morandi.backend.defense_management.infrastructure.request.codesubmit.CodeRequest; + +public interface ExampleCodeSubmitService { + void submitCodeToQueue(CodeRequest codeRequest); +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/service/codesubmit/ExampleCodeSubscriber.java b/src/main/java/kr/co/morandi/backend/defense_management/application/service/codesubmit/ExampleCodeSubscriber.java new file mode 100644 index 00000000..33c87c9a --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/service/codesubmit/ExampleCodeSubscriber.java @@ -0,0 +1,54 @@ +package kr.co.morandi.backend.defense_management.application.service.codesubmit; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_management.application.port.out.defensemessaging.DefenseMessagePort; +import kr.co.morandi.backend.defense_management.application.response.codesubmit.CodeResponse; +import kr.co.morandi.backend.defense_management.application.response.codesubmit.MessageResponse; +import kr.co.morandi.backend.defense_management.infrastructure.exception.RedisMessageErrorCode; +import kr.co.morandi.backend.defense_management.infrastructure.exception.SQSMessageErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ExampleCodeSubscriber implements MessageListener { + + private final ObjectMapper objectMapper; + + private final DefenseMessagePort defenseMessagePort; + private static final int MAX_RETRIES = 3; + + @Override + public void onMessage(Message message, byte[] pattern) { + try { + String resultString = new String(message.getBody()); + MessageResponse messageResponse = objectMapper.readValue(resultString, MessageResponse.class); + String jsonMessage = objectMapper.writeValueAsString(CodeResponse.create(messageResponse)); + defenseMessagePort.sendMessage(Long.valueOf(messageResponse.getSseId()), jsonMessage); + } catch (JsonProcessingException | NullPointerException e) { + throw new MorandiException(RedisMessageErrorCode.MESSAGE_PARSE_ERROR); + } catch (Exception e) { + retrySendMessage(message, MAX_RETRIES); + } + } + public void retrySendMessage(Message message, int count) { + if (count == 0) + throw new MorandiException(RedisMessageErrorCode.MESSAGE_SEND_ERROR); + try { + String resultString = new String(message.getBody()); + MessageResponse messageResponse = objectMapper.readValue(resultString, MessageResponse.class); + String jsonMessage = objectMapper.writeValueAsString(CodeResponse.create(messageResponse)); + defenseMessagePort.sendMessage(Long.valueOf(messageResponse.getSseId()), jsonMessage); + } catch (JsonProcessingException e) { + throw new MorandiException(SQSMessageErrorCode.MESSAGE_PARSE_ERROR); + } catch (Exception e) { + retrySendMessage(message, count - 1); + } + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/service/codesubmit/SQSCodeSubmitService.java b/src/main/java/kr/co/morandi/backend/defense_management/application/service/codesubmit/SQSCodeSubmitService.java new file mode 100644 index 00000000..f6e7bc9f --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/service/codesubmit/SQSCodeSubmitService.java @@ -0,0 +1,53 @@ +package kr.co.morandi.backend.defense_management.application.service.codesubmit; + +import com.amazonaws.services.sqs.AmazonSQS; +import com.amazonaws.services.sqs.model.SendMessageRequest; +import com.amazonaws.services.sqs.model.SendMessageResult; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_management.infrastructure.exception.SQSMessageErrorCode; +import kr.co.morandi.backend.defense_management.infrastructure.request.codesubmit.CodeRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class SQSCodeSubmitService implements ExampleCodeSubmitService { + + @Value("${cloud.aws.sqs.queue.example-compile-url}") + private String url; + + private final AmazonSQS amazonSQS; + + private final ObjectMapper objectMapper; + private static final int MAX_RETRIES = 3; + @Override + public void submitCodeToQueue(CodeRequest codeRequest) { + try { + String requestString = objectMapper.writeValueAsString(codeRequest); + SendMessageRequest sendMessageRequest = new SendMessageRequest(url, requestString); + amazonSQS.sendMessage(sendMessageRequest); + } catch (JsonProcessingException e) { + throw new MorandiException(SQSMessageErrorCode.MESSAGE_PARSE_ERROR); + } catch (Exception e) { + // 재전송 로직 추가 + retrySendMessage(codeRequest, MAX_RETRIES); + } + } + public void retrySendMessage(CodeRequest codeRequest, int count) { + if (count == 0) + throw new MorandiException(SQSMessageErrorCode.MESSAGE_SEND_FAILED); + + try { + String requestString = objectMapper.writeValueAsString(codeRequest); + SendMessageRequest sendMessageRequest = new SendMessageRequest(url, requestString); + amazonSQS.sendMessage(sendMessageRequest); + } catch (JsonProcessingException e) { + throw new MorandiException(SQSMessageErrorCode.MESSAGE_PARSE_ERROR); + } catch (Exception e) { + retrySendMessage(codeRequest, count - 1); + } + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/service/message/CreateDefenseCableService.java b/src/main/java/kr/co/morandi/backend/defense_management/application/service/message/CreateDefenseCableService.java new file mode 100644 index 00000000..1bc437b4 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/service/message/CreateDefenseCableService.java @@ -0,0 +1,25 @@ +package kr.co.morandi.backend.defense_management.application.service.message; + +import kr.co.morandi.backend.defense_management.application.port.out.defensemessaging.DefenseMessagePort; +import kr.co.morandi.backend.defense_management.domain.event.CreateDefenseMessageEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Service +@RequiredArgsConstructor +public class CreateDefenseCableService { + + private final DefenseMessagePort defenseMessagePort; + + /* + * Defense가 시작되면 DefenseMessagePort를 통해 SSE 연결을 생성한다. + * */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void createConnection(CreateDefenseMessageEvent event) { + Long defenseSessionId = event.getDefenseSessionId(); + + defenseMessagePort.createConnection(defenseSessionId); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/service/message/DefenseMessageService.java b/src/main/java/kr/co/morandi/backend/defense_management/application/service/message/DefenseMessageService.java new file mode 100644 index 00000000..7d0e2fe9 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/service/message/DefenseMessageService.java @@ -0,0 +1,34 @@ +package kr.co.morandi.backend.defense_management.application.service.message; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_management.application.port.out.defensemessaging.DefenseMessagePort; +import kr.co.morandi.backend.defense_management.application.port.out.session.DefenseSessionPort; +import kr.co.morandi.backend.defense_management.domain.error.SessionErrorCode; +import kr.co.morandi.backend.defense_management.domain.model.session.DefenseSession; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Service +@RequiredArgsConstructor +public class DefenseMessageService { + + private final DefenseMessagePort defenseMessagePort; + private final DefenseSessionPort defenseSessionPort; + + public SseEmitter getConnection(Long defenseSessionId, Long memberId) { + DefenseSession defenseSession = defenseSessionPort.findDefenseSessionById(defenseSessionId) + .orElseThrow(() -> new MorandiException(SessionErrorCode.SESSION_NOT_FOUND)); + + /* + * 세션의 소유자인지 확인 + * 세션의 소유자가 아닐 경우 예외 발생 + * */ + defenseSession.validateSessionOwner(memberId); + if(defenseSession.isTerminated()) { + throw new MorandiException(SessionErrorCode.SESSION_TERMINATED); + } + return defenseMessagePort.getConnection(defenseSessionId); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/service/timer/DefenseTimerService.java b/src/main/java/kr/co/morandi/backend/defense_management/application/service/timer/DefenseTimerService.java new file mode 100644 index 00000000..b720b118 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/service/timer/DefenseTimerService.java @@ -0,0 +1,94 @@ + package kr.co.morandi.backend.defense_management.application.service.timer; + + import kr.co.morandi.backend.common.exception.MorandiException; + import kr.co.morandi.backend.defense_management.domain.error.SessionErrorCode; + import kr.co.morandi.backend.defense_management.domain.service.SessionService; + import kr.co.morandi.backend.defense_record.domain.error.RecordErrorCode; + import lombok.RequiredArgsConstructor; + import lombok.extern.slf4j.Slf4j; + import org.springframework.stereotype.Service; + + import java.time.Duration; + import java.time.LocalDateTime; + import java.util.concurrent.Executors; + import java.util.concurrent.ScheduledExecutorService; + import java.util.concurrent.TimeUnit; + + @Service + @Slf4j + @RequiredArgsConstructor + public class DefenseTimerService { + + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + private final SessionService sessionService; + + public void startDefenseTimer(Long defenseSessionId, LocalDateTime startDateTime, LocalDateTime endDateTime) { + + long delay = Duration.between(startDateTime, endDateTime).toMillis(); + + scheduler.schedule(() -> { + try { + sessionService.terminateDefense(defenseSessionId); + } + catch (MorandiException e) { + //SessionErrorCode.SESSION_ALREADY_ENDED인 경우는 이미 종료된 세션이므로 무시합니다. + if (e.getErrorCode().equals(SessionErrorCode.SESSION_ALREADY_ENDED)) { + return; + } + // RecordErrorCode.RECORD_ALREADY_TERMINATED인 경우는 이미 종료된 시험기록이므로 무시합니다. + if (e.getErrorCode().equals(RecordErrorCode.RECORD_ALREADY_TERMINATED)) { + return; + } + /* + * 예외 발생 시 다시 큐에 넣어 5초 후에 동작하도록 만들었고 + * 최대 3번까지 재시도하도록 만들었습니다. + * */ + retryTermination(defenseSessionId, 5000, 3); + } + catch (Exception e) { + /* + * 예외 발생 시 다시 큐에 넣어 5초 후에 동작하도록 만들었고 + * 최대 3번까지 재시도하도록 만들었습니다. + * */ + retryTermination(defenseSessionId, 5000, 3); + } + }, delay, TimeUnit.MILLISECONDS); + + } + + private void retryTermination(Long defenseSessionId, long retryDelay, int retryCount) { + + /* + * 남은 재시도 횟수가 0이하일 경우 종료 + * */ + if(retryCount <= 0) { + log.error("재시도 횟수가 초과되어 시험 종료에 실패했습니다. defenseSessionId: {}", defenseSessionId); + return; + } + + + scheduler.schedule(() -> { + try { + sessionService.terminateDefense(defenseSessionId); + } + catch (MorandiException e) { + //SessionErrorCode.SESSION_ALREADY_ENDED인 경우는 이미 종료된 세션이므로 무시합니다. + if (e.getErrorCode().equals(SessionErrorCode.SESSION_ALREADY_ENDED)) { + return; + } + // RecordErrorCode.RECORD_ALREADY_TERMINATED인 경우는 이미 종료된 시험기록이므로 무시합니다. + if (e.getErrorCode().equals(RecordErrorCode.RECORD_ALREADY_TERMINATED)) { + return; + } + retryTermination(defenseSessionId, retryDelay, retryCount - 1); + } + catch (Exception e) { + /* + * 남은 재시도 횟수를 1 감소시키면서 재시도합니다. + * */ + retryTermination(defenseSessionId, retryDelay, retryCount - 1); + } + }, retryDelay, TimeUnit.MILLISECONDS); + } + + } diff --git a/src/main/java/kr/co/morandi/backend/defense_management/application/usecase/session/DailyDefenseManagementUsecase.java b/src/main/java/kr/co/morandi/backend/defense_management/application/usecase/session/DailyDefenseManagementUsecase.java new file mode 100644 index 00000000..26cef926 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/application/usecase/session/DailyDefenseManagementUsecase.java @@ -0,0 +1,116 @@ +package kr.co.morandi.backend.defense_management.application.usecase.session; + +import kr.co.morandi.backend.defense_information.application.port.out.dailydefense.DailyDefensePort; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.service.defense.ProblemGenerationService; +import kr.co.morandi.backend.defense_management.application.mapper.session.StartDailyDefenseMapper; +import kr.co.morandi.backend.defense_management.application.port.out.session.DefenseSessionPort; +import kr.co.morandi.backend.defense_management.application.request.session.StartDailyDefenseServiceRequest; +import kr.co.morandi.backend.defense_management.application.response.session.StartDailyDefenseResponse; +import kr.co.morandi.backend.defense_management.domain.event.CreateDefenseMessageEvent; +import kr.co.morandi.backend.defense_management.domain.event.DefenseStartTimerEvent; +import kr.co.morandi.backend.defense_management.domain.model.session.DefenseSession; +import kr.co.morandi.backend.defense_record.application.port.out.dailyrecord.DailyRecordPort; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.member_management.application.port.out.member.MemberPort; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.application.port.out.problemcontent.ProblemContentPort; +import kr.co.morandi.backend.problem_information.application.response.problemcontent.ProblemContent; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Optional; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType.DAILY; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class DailyDefenseManagementUsecase { + + private final DailyDefensePort dailyDefensePort; + private final DailyRecordPort dailyRecordPort; + private final ProblemGenerationService problemGenerationService; + private final DefenseSessionPort defenseSessionPort; + private final MemberPort memberPort; + private final ProblemContentPort problemContentPort; + private final ApplicationEventPublisher publisher; + + @Transactional + public StartDailyDefenseResponse startDailyDefense(StartDailyDefenseServiceRequest request, Long memberId, LocalDateTime requestTime) { + Long problemNumber = request.getProblemNumber(); + Member member = memberPort.findMemberById(memberId); + + // 세션이랑 세션 Detail을 찾아서 응시 기록이 있는지 살펴보기 + final Optional maybeDefenseSession = defenseSessionPort.findTodaysDailyDefenseSession(member, requestTime); + + // 오늘의 Defense를 찾아오기 + final DailyDefense dailyDefense = dailyDefensePort.findDailyDefense(DAILY, requestTime.toLocalDate()); + + // 오늘의 문제 목록 중에서 원하는 문제를 찾아서 시도하려는 문제 목록에 추가 (오늘의 문제 목록에 해당 문제가 없으면 예외 발생) + final Map tryProblem = dailyDefense.getTryingProblem(problemNumber, problemGenerationService); + + // DefenseSession이 있으면 get, 없으면 새로운 DefenseSession을 생성 + final DefenseSession defenseSession = maybeDefenseSession.orElseGet(() -> createNewSession(member, requestTime, dailyDefense, tryProblem)); + + // DefenseSession의 recordId로 DailyRecord를 찾고 문제를 시도했는지 확인하고 시도하지 않았으면 시도하도록 함 + Long recordId = defenseSession.getRecordId(); + DailyRecord dailyRecord = dailyRecordPort.findDailyRecord(member, recordId, requestTime.toLocalDate()) + .orElseThrow(() -> new IllegalArgumentException("세션에 해당하는 응시 기록이 없습니다.")); + + if (!defenseSession.hasTriedProblem(problemNumber)) { + dailyRecord.tryMoreProblem(tryProblem); + defenseSession.tryMoreProblem(problemNumber, requestTime); + } + + final DefenseSession savedDefenseSession = defenseSessionPort.saveDefenseSession(defenseSession); + + + // 문제 내용 가져오기 + final Map problemContent = getProblemContents(tryProblem); + + // 문제 목록을 DefenseProblemResponse DTO로 변환 + return StartDailyDefenseMapper.of(tryProblem, dailyDefense, savedDefenseSession, dailyRecord, problemContent); + } + + /* + * 백준 문제 ID 목록을 받아서 문제 내용을 가져오는 메소드 + * */ + private Map getProblemContents(Map tryProblem) { + return problemContentPort.getProblemContents(tryProblem.values() + .stream() + .map(Problem::getBaekjoonProblemId) + .toList()); + } + + /* + * 세션이 존재하지 않을 경우 새롭게 시험을 시작하는 메소드 + * */ + private DefenseSession createNewSession(Member member, LocalDateTime now, DailyDefense dailyDefense, Map tryProblem) { + DailyRecord dailyRecord = DailyRecord.tryDefense(now, dailyDefense, member, tryProblem); + DailyRecord savedDailyRecord = dailyRecordPort.saveDailyRecord(dailyRecord); + Long recordId = savedDailyRecord.getRecordId(); + + final DefenseSession defenseSession = defenseSessionPort.saveDefenseSession( + DefenseSession.startSession(member, recordId, dailyDefense.getDefenseType(), tryProblem.keySet(), now, dailyDefense.getEndTime(now))); + + /* + * DefenseSession에 관련된 타이머 시작 이벤트 발행 + * */ + publisher.publishEvent(new DefenseStartTimerEvent(defenseSession.getDefenseSessionId(), defenseSession.getStartDateTime(), defenseSession.getEndDateTime())); + + /* + * DefenseMessage 연결 이벤트 발행 (현재는 SSE로 구현되어 있습니다.) + * */ + publisher.publishEvent(new CreateDefenseMessageEvent(defenseSession.getDefenseSessionId())); + + return defenseSession; + } + + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/domain/error/LanguageErrorCode.java b/src/main/java/kr/co/morandi/backend/defense_management/domain/error/LanguageErrorCode.java new file mode 100644 index 00000000..deaa93a0 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/domain/error/LanguageErrorCode.java @@ -0,0 +1,25 @@ +package kr.co.morandi.backend.defense_management.domain.error; + +import kr.co.morandi.backend.common.exception.errorcode.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum LanguageErrorCode implements ErrorCode { + + LANGUAGE_NOT_FOUND(HttpStatus.BAD_REQUEST, "존재하지 않는 언어 값입니다."); + + private final HttpStatus httpStatus; + private final String message; + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/domain/error/SessionErrorCode.java b/src/main/java/kr/co/morandi/backend/defense_management/domain/error/SessionErrorCode.java new file mode 100644 index 00000000..989d5c04 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/domain/error/SessionErrorCode.java @@ -0,0 +1,28 @@ +package kr.co.morandi.backend.defense_management.domain.error; + +import kr.co.morandi.backend.common.exception.errorcode.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum SessionErrorCode implements ErrorCode { + SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "세션을 찾을 수 없습니다."), + SESSION_ALREADY_STARTED(HttpStatus.BAD_REQUEST, "이미 시작된 세션입니다."), + SESSION_ALREADY_ENDED(HttpStatus.BAD_REQUEST, "이미 종료된 세션입니다."), + INVALID_SESSION_OWNER(HttpStatus.FORBIDDEN, "사용자의 시험 세션이 아닙니다."), + SESSION_CONNECTION_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "디펜스 세션 연결에 실패하였습니다."), + SESSION_TERMINATED(HttpStatus.BAD_REQUEST, "이미 종료된 디펜스 세션입니다."); + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + @Override + public String getMessage() { + return message; + } + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/domain/event/CreateDefenseMessageEvent.java b/src/main/java/kr/co/morandi/backend/defense_management/domain/event/CreateDefenseMessageEvent.java new file mode 100644 index 00000000..3613dd52 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/domain/event/CreateDefenseMessageEvent.java @@ -0,0 +1,11 @@ +package kr.co.morandi.backend.defense_management.domain.event; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CreateDefenseMessageEvent { + + private final Long defenseSessionId; +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/domain/event/DefenseStartTimerEvent.java b/src/main/java/kr/co/morandi/backend/defense_management/domain/event/DefenseStartTimerEvent.java new file mode 100644 index 00000000..7b12032e --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/domain/event/DefenseStartTimerEvent.java @@ -0,0 +1,16 @@ +package kr.co.morandi.backend.defense_management.domain.event; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@RequiredArgsConstructor +public class DefenseStartTimerEvent { + + private final Long sessionId; + private final LocalDateTime startDateTime; + private final LocalDateTime endDateTime; + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/domain/model/session/DefenseSession.java b/src/main/java/kr/co/morandi/backend/defense_management/domain/model/session/DefenseSession.java new file mode 100644 index 00000000..f08e8b12 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/domain/model/session/DefenseSession.java @@ -0,0 +1,129 @@ +package kr.co.morandi.backend.defense_management.domain.model.session; + +import jakarta.persistence.*; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.common.model.BaseEntity; +import kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType; +import kr.co.morandi.backend.defense_management.domain.error.SessionErrorCode; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DefenseSession extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long defenseSessionId; + + @ManyToOne(fetch = FetchType.LAZY) + private Member member; + + private Long recordId; + + @OneToMany(mappedBy = "defenseSession", cascade = CascadeType.ALL) + private List sessionDetails = new ArrayList<>(); + + private LocalDateTime startDateTime; + + private LocalDateTime endDateTime; + + private Long lastAccessProblemNumber; + + private LocalDateTime lastAccessDateTime; + + @Enumerated(EnumType.STRING) + private DefenseType defenseType; + + @Enumerated(EnumType.STRING) + private ExamStatus examStatus; + + private static final Long INITIAL_ACCESS_PROBLEM_NUMBER = 1L; + + public void validateSessionOwner(Long memberId) { + if (!this.member.getMemberId().equals(memberId)) { + throw new MorandiException(SessionErrorCode.INVALID_SESSION_OWNER); + } + } + + public void updateTempCode(Long problemNumber, Language language, String code) { + if(isTerminated()) { + throw new MorandiException(SessionErrorCode.SESSION_ALREADY_ENDED); + } + SessionDetail sessionDetail = getSessionDetail(problemNumber); + sessionDetail.updateTempCode(language, code); + } + + public boolean isTerminated() { + return examStatus == ExamStatus.COMPLETED; + } + + public boolean terminateSession() { + if(examStatus == ExamStatus.COMPLETED) { + throw new MorandiException(SessionErrorCode.SESSION_ALREADY_ENDED); + } + examStatus = ExamStatus.COMPLETED; + return true; + } + + public SessionDetail getSessionDetail(Long problemNumber) { + return getSessionDetails().stream() + .filter(detail -> Objects.equals(detail.getProblemNumber(), problemNumber)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("해당 문제가 없습니다.")); + } + public boolean hasTriedProblem(Long problemNumber) { + return getSessionDetails().stream() + .anyMatch(detail -> Objects.equals(detail.getProblemNumber(), problemNumber)); + } + public void tryMoreProblem(Long problemNumber, LocalDateTime accessDateTime) { + if (examStatus == ExamStatus.COMPLETED || accessDateTime.isAfter(endDateTime)) { + throw new IllegalStateException("이미 종료된 시험입니다."); + } + // 이미 있는 시험이라면 + if (sessionDetails.stream() + .anyMatch(sessionDetail -> sessionDetail.getProblemNumber().equals(problemNumber))) { + return ; + } + sessionDetails.add(SessionDetail.create(this, problemNumber)); + lastAccessProblemNumber = problemNumber; + lastAccessDateTime = accessDateTime; + + } + + public static DefenseSession startSession(Member member, Long recordId, DefenseType defenseType, Set problemNumbers, + LocalDateTime startDateTime, LocalDateTime endDateTime) { + return new DefenseSession(member, recordId, defenseType, problemNumbers, startDateTime, endDateTime); + } + + @Builder + private DefenseSession(Member member, Long recordId, DefenseType defenseType, Set problemNumbers, + LocalDateTime startDateTime, LocalDateTime endDateTime) { + if(problemNumbers==null || problemNumbers.isEmpty()) + throw new IllegalArgumentException("문제 번호가 없습니다."); + this.member = member; + this.recordId = recordId; + this.defenseType = defenseType; + this.sessionDetails = problemNumbers.stream() + .map(problemNumber -> SessionDetail.create(this, problemNumber)) + .collect(Collectors.toCollection(ArrayList::new)); + this.startDateTime = startDateTime; + this.endDateTime = endDateTime; + this.examStatus = ExamStatus.IN_PROGRESS; + this.lastAccessDateTime = startDateTime; + this.lastAccessProblemNumber = problemNumbers.stream() + .findFirst() + .orElse(INITIAL_ACCESS_PROBLEM_NUMBER); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/domain/model/session/ExamStatus.java b/src/main/java/kr/co/morandi/backend/defense_management/domain/model/session/ExamStatus.java new file mode 100644 index 00000000..6890a3a0 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/domain/model/session/ExamStatus.java @@ -0,0 +1,5 @@ +package kr.co.morandi.backend.defense_management.domain.model.session; + +public enum ExamStatus { + IN_PROGRESS, COMPLETED, STAGE_PROGRESS; +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/domain/model/session/SessionDetail.java b/src/main/java/kr/co/morandi/backend/defense_management/domain/model/session/SessionDetail.java new file mode 100644 index 00000000..7117a8be --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/domain/model/session/SessionDetail.java @@ -0,0 +1,70 @@ +package kr.co.morandi.backend.defense_management.domain.model.session; + +import jakarta.persistence.*; +import kr.co.morandi.backend.common.model.BaseEntity; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.TempCode; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SessionDetail extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long sessionDetailId; + + @ManyToOne(fetch = FetchType.LAZY) + private DefenseSession defenseSession; + + @OneToMany(mappedBy = "sessionDetail", cascade = CascadeType.ALL) + private Set tempCodes = new HashSet<>(); + + private Long problemNumber; + + @Enumerated(EnumType.STRING) + private Language lastAccessLanguage; + + public static final Language INITIAL_LANGUAGE = Language.CPP; + public static SessionDetail create(DefenseSession defenseSession, Long problemNumber) { + return new SessionDetail(defenseSession, problemNumber); + } + /* + * getTempCode는 만약 없는 언어로 tempCode를 get해도 + * addTempCode를 호출해서 추가하고, 예외를 반환하지 않는다. + * */ + public TempCode getTempCode(Language language) { + Optional maybeTempCode = getTempCodes().stream() + .filter(tempcode -> tempcode.getLanguage().equals(language)) + .findFirst(); + + return maybeTempCode.orElseGet(() -> addTempCode(language, language.getInitialCode())); + } + + public void updateTempCode(Language language, String code) { + this.lastAccessLanguage = language; + + TempCode tempCode = getTempCode(language); + tempCode.updateTempCode(code); + } + + protected TempCode addTempCode(Language language, String code) { + TempCode tempCode = TempCode.create(language, code, this); + getTempCodes().add(tempCode); + + return tempCode; + } + + @Builder + private SessionDetail(DefenseSession defenseSession, Long problemNumber) { + this.defenseSession = defenseSession; + this.problemNumber = problemNumber; + this.lastAccessLanguage = INITIAL_LANGUAGE; + this.tempCodes.add(TempCode.create(INITIAL_LANGUAGE, INITIAL_LANGUAGE.getInitialCode(), this)); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/domain/model/tempcode/model/Language.java b/src/main/java/kr/co/morandi/backend/defense_management/domain/model/tempcode/model/Language.java new file mode 100644 index 00000000..b7b5f149 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/domain/model/tempcode/model/Language.java @@ -0,0 +1,49 @@ +package kr.co.morandi.backend.defense_management.domain.model.tempcode.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_management.domain.error.LanguageErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Language { + JAVA("JAVA", """ + public class Main { + public static void main(String[] args) { + System.out.println("Hello World"); + } + } + """), + CPP("CPP",""" + #include + using namespace std; + + int main() { + cout << "Hello World" << endl; + return 0; + } + """), + PYTHON("PYTHON",""" + print("Hello World") + """); + + private final String value; + private final String initialCode; + + @JsonValue + public String getValue() { + return value; + } + @JsonCreator + public static Language from(String value) { + for (Language language : values()) { + if(language.getValue().equals(value)) { + return language; + } + } + throw new MorandiException(LanguageErrorCode.LANGUAGE_NOT_FOUND); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/domain/model/tempcode/model/TempCode.java b/src/main/java/kr/co/morandi/backend/defense_management/domain/model/tempcode/model/TempCode.java new file mode 100644 index 00000000..d9c8c6ee --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/domain/model/tempcode/model/TempCode.java @@ -0,0 +1,65 @@ +package kr.co.morandi.backend.defense_management.domain.model.tempcode.model; + +import jakarta.persistence.*; +import kr.co.morandi.backend.common.model.BaseEntity; +import kr.co.morandi.backend.defense_management.domain.model.session.SessionDetail; +import kr.co.morandi.backend.judgement.domain.model.submit.SourceCode; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TempCode extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long tempCodeId; + + @ManyToOne(fetch = FetchType.LAZY) + private SessionDetail sessionDetail; + + @Embedded + private SourceCode sourceCode; + + public Language getLanguage() { + return sourceCode.getLanguage(); + } + + public String getCode() { + return sourceCode.getSourceCode(); + } + + public void updateTempCode(String code) { + sourceCode.updateSourceCode(code); + } + + public static TempCode create(Language language, String code, SessionDetail sessionDetail) { + return TempCode.builder() + .language(language) + .code(code) + .sessionDetail(sessionDetail) + .build(); + } + + @Builder + private TempCode(SessionDetail sessionDetail, Language language, String code) { + this.sessionDetail = sessionDetail; + this.sourceCode = SourceCode.of(code, language); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + TempCode tempCode = (TempCode) o; + + return getLanguage() == tempCode.getLanguage(); + } + @Override + public int hashCode() { + return getLanguage() != null ? getLanguage().hashCode() : 0; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/domain/service/DefenseEventService.java b/src/main/java/kr/co/morandi/backend/defense_management/domain/service/DefenseEventService.java new file mode 100644 index 00000000..36196158 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/domain/service/DefenseEventService.java @@ -0,0 +1,20 @@ +package kr.co.morandi.backend.defense_management.domain.service; + +import kr.co.morandi.backend.defense_management.application.service.timer.DefenseTimerService; +import kr.co.morandi.backend.defense_management.domain.event.DefenseStartTimerEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Service +@RequiredArgsConstructor +public class DefenseEventService { + + private final DefenseTimerService defenseTimerService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onDefenseStartTimerEvent(DefenseStartTimerEvent event) { + defenseTimerService.startDefenseTimer(event.getSessionId(), event.getStartDateTime(), event.getEndDateTime()); + } +} \ No newline at end of file diff --git a/src/main/java/kr/co/morandi/backend/defense_management/domain/service/SessionService.java b/src/main/java/kr/co/morandi/backend/defense_management/domain/service/SessionService.java new file mode 100644 index 00000000..5d23f996 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/domain/service/SessionService.java @@ -0,0 +1,37 @@ +package kr.co.morandi.backend.defense_management.domain.service; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_management.application.port.out.session.DefenseSessionPort; +import kr.co.morandi.backend.defense_management.domain.error.SessionErrorCode; +import kr.co.morandi.backend.defense_management.domain.model.session.DefenseSession; +import kr.co.morandi.backend.defense_record.application.port.out.record.RecordPort; +import kr.co.morandi.backend.defense_record.domain.error.RecordErrorCode; +import kr.co.morandi.backend.defense_record.domain.model.record.Detail; +import kr.co.morandi.backend.defense_record.domain.model.record.Record; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SessionService { + + private final DefenseSessionPort defenseSessionPort; + private final RecordPort recordPort; + + @Transactional + public void terminateDefense(Long sessionId) { + final DefenseSession defenseSession = defenseSessionPort.findDefenseSessionById(sessionId) + .orElseThrow(() -> new MorandiException(SessionErrorCode.SESSION_NOT_FOUND)); + + defenseSession.terminateSession(); + + final Record foundRecord = recordPort.findRecordById(defenseSession.getRecordId()) + .orElseThrow(() -> new MorandiException(RecordErrorCode.RECORD_NOT_FOUND)); + + foundRecord.terminteDefense(); + + defenseSessionPort.saveDefenseSession(defenseSession); + recordPort.saveRecord(foundRecord); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/domain/service/TempCodeSaveService.java b/src/main/java/kr/co/morandi/backend/defense_management/domain/service/TempCodeSaveService.java new file mode 100644 index 00000000..feabee60 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/domain/service/TempCodeSaveService.java @@ -0,0 +1,39 @@ +package kr.co.morandi.backend.defense_management.domain.service; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_management.application.port.out.session.DefenseSessionPort; +import kr.co.morandi.backend.defense_management.domain.error.SessionErrorCode; +import kr.co.morandi.backend.defense_management.domain.model.session.DefenseSession; +import kr.co.morandi.backend.judgement.domain.event.TempCodeSaveEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Slf4j +@RequiredArgsConstructor +public class TempCodeSaveService { + + private final DefenseSessionPort defenseSessionPort; + + @EventListener + @Transactional + @Async("tempCodeSaveExecutor") + public void saveTempCode(final TempCodeSaveEvent tempCodeSaveEvent) { + log.info("Save Temp Code Event: defenseSessionId: {}, problemNumber: {}, language: {}", + tempCodeSaveEvent.defenseSessionId(), tempCodeSaveEvent.problemNumber(), tempCodeSaveEvent.language()); + final Long defenseSessionId = tempCodeSaveEvent.defenseSessionId(); + + final DefenseSession defenseSession = defenseSessionPort.findDefenseSessionJoinFetchTempCode(defenseSessionId) + .orElseThrow(() -> new MorandiException(SessionErrorCode.SESSION_NOT_FOUND)); + + defenseSession.updateTempCode(tempCodeSaveEvent.problemNumber(), + tempCodeSaveEvent.language(), + tempCodeSaveEvent.sourceCode()); + + defenseSessionPort.saveDefenseSession(defenseSession); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/adapter/defensemessaging/DefenseMessageSseAdapter.java b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/adapter/defensemessaging/DefenseMessageSseAdapter.java new file mode 100644 index 00000000..d20d901f --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/adapter/defensemessaging/DefenseMessageSseAdapter.java @@ -0,0 +1,142 @@ +package kr.co.morandi.backend.defense_management.infrastructure.adapter.defensemessaging; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_management.application.port.out.defensemessaging.DefenseMessagePort; +import kr.co.morandi.backend.defense_management.application.response.codesubmit.CodeResponse; +import kr.co.morandi.backend.defense_management.domain.error.SessionErrorCode; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + + +@Component +public class DefenseMessageSseAdapter implements DefenseMessagePort { + + private final Map emitters = new ConcurrentHashMap<>(); + private static final Long DEFAULT_TIMEOUT = 60 * 60 * 24L; + + /* + * createConnection 메서드는 defenseSessionId를 받아서 해당 defenseSessionId에 해당하는 SseEmitter를 생성합니다. + * 이는 시험을 시작할 때 이벤트를 받았을 때 이 메서드를 실행하여 SseEmitter를 생성합니다. + * */ + @Override + public void createConnection(Long defenseSessionId) { + SseEmitter emitter = createSseEmitter(defenseSessionId); + emitters.put(defenseSessionId, emitter); + } + + /* + * getConnection 메서드에서는 defenseSessionId를 받아서 해당 defenseSessionId에 해당하는 SseEmitter를 반환합니다. + * send를 실패할 경우 SseEmitter를 다시 만드는 재시도 로직을 구현했습니다. (retryConnection) + * 재귀 호출로 최대 3번까지 재시도 합니다. + * */ + @Override + public SseEmitter getConnection(Long defenseSessionId) { + emitters.putIfAbsent(defenseSessionId, createSseEmitter(defenseSessionId)); + + final SseEmitter sseEmitter = emitters.get(defenseSessionId); + + // init 메세지 전송 + try { + sseEmitter.send(SseEmitter.event() + .name("init") + .data(defenseSessionId) + ); + } catch (IOException e) { + emitters.remove(defenseSessionId); + + // 재시도 로직 추가 + return retryConnection(defenseSessionId, 3); + } + + return emitters.get(defenseSessionId); + } + /* + * 메세지 보낼 떄 사용하면 됩니다. defenseSessionId를 기준으로 SseEmitter를 찾아서 해당 SseEmitter에 message를 전송합니다. + * (message에 직렬화하여 전송) + * 성공적으로 전송되면 true를 반환하고, 실패하면 false를 반환합니다. + * */ + @Override + public boolean sendMessage(Long defenseSessionId, String message) { + SseEmitter emitter = emitters.get(defenseSessionId); + + if (emitter != null) { + try { + emitter.send(SseEmitter.event() + .name("message") + .data(message) + ); + return true; + } catch (Exception e) { + emitters.remove(defenseSessionId); + } + } + return false; + } + + // 임시로 ping 메세지 전송 + @Scheduled(fixedRate = 5000) + public void checkConnection() { + emitters.forEach((k, emitter) -> { + try { + emitter.send(SseEmitter.event() + .name("ping") + .data("ping") + .reconnectTime(3000L) + ); + } + catch (Exception e) { + emitters.remove(k); + } + + }); + } + private SseEmitter createSseEmitter(Long defenseSessionId) { + SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT); + + emitter.onTimeout(() -> { + emitters.remove(defenseSessionId); + }); + + + emitter.onCompletion(() -> { + emitters.remove(defenseSessionId); + emitter.complete(); + }); + + emitter.onError((e) -> { + emitters.remove(defenseSessionId); + }); + + return emitter; + } + /** + * 재시도 로직을 구현했습니다. + * 재귀 호출로 최대 3번까지 재시도 + */ + private SseEmitter retryConnection(Long defenseSessionId, int count) { + if(count == 0) { + throw new MorandiException(SessionErrorCode.SESSION_CONNECTION_FAIL); + } + emitters.putIfAbsent(defenseSessionId, createSseEmitter(defenseSessionId)); + + final SseEmitter sseEmitter = emitters.get(defenseSessionId); + + try { + sseEmitter.send(SseEmitter.event() + .name("init") + .data(defenseSessionId) + ); + } catch (IOException e) { + emitters.remove(defenseSessionId); + + return retryConnection(defenseSessionId, count - 1); + } + return sseEmitter; + } + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/adapter/session/DefenseSessionAdapter.java b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/adapter/session/DefenseSessionAdapter.java new file mode 100644 index 00000000..ae0dd86f --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/adapter/session/DefenseSessionAdapter.java @@ -0,0 +1,40 @@ +package kr.co.morandi.backend.defense_management.infrastructure.adapter.session; + +import kr.co.morandi.backend.defense_management.application.port.out.session.DefenseSessionPort; +import kr.co.morandi.backend.defense_management.domain.model.session.DefenseSession; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.defense_management.infrastructure.persistence.session.DefenseSessionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType.DAILY; + +@Service +@RequiredArgsConstructor +public class DefenseSessionAdapter implements DefenseSessionPort { + + private final DefenseSessionRepository defenseSessionRepository; + + @Override + public DefenseSession saveDefenseSession(DefenseSession defenseSession) { + return defenseSessionRepository.save(defenseSession); + } + + @Override + public Optional findTodaysDailyDefenseSession(Member member, LocalDateTime now) { + return defenseSessionRepository.findDailyDefenseSession(member, DAILY, now); + } + + @Override + public Optional findDefenseSessionById(Long sessionId) { + return defenseSessionRepository.findById(sessionId); + } + + @Override + public Optional findDefenseSessionJoinFetchTempCode(Long sessionId) { + return defenseSessionRepository.findDefenseSessionJoinFetchTempCode(sessionId); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/config/codesubmit/AmazonSqsConfig.java b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/config/codesubmit/AmazonSqsConfig.java new file mode 100644 index 00000000..abf1ea18 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/config/codesubmit/AmazonSqsConfig.java @@ -0,0 +1,54 @@ +package kr.co.morandi.backend.defense_management.infrastructure.config.codesubmit; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.regions.Regions; +import com.amazonaws.services.sqs.AmazonSQS; +import com.amazonaws.services.sqs.AmazonSQSClientBuilder; +import io.awspring.cloud.sqs.operations.SqsTemplate; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; + +@Slf4j +@Configuration +public class AmazonSqsConfig { + + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public SqsAsyncClient sqsAsyncClient(){ + return SqsAsyncClient + .builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider + .create(AwsBasicCredentials.create(accessKey, secretKey))) + .build(); + } + + @Bean + public SqsTemplate sqsTemplate(SqsAsyncClient sqsAsyncClient){ + return SqsTemplate.builder().sqsAsyncClient(sqsAsyncClient).build(); + } + + @Bean + public AmazonSQS amazonSQS() { + BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey); + return AmazonSQSClientBuilder.standard() + .withRegion(Regions.AP_NORTHEAST_2) + .withCredentials(new AWSStaticCredentialsProvider(awsCreds)) + .build(); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/controller/DefenseMangementController.java b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/controller/DefenseMangementController.java new file mode 100644 index 00000000..1d793a78 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/controller/DefenseMangementController.java @@ -0,0 +1,29 @@ +package kr.co.morandi.backend.defense_management.infrastructure.controller; + +import jakarta.validation.Valid; +import kr.co.morandi.backend.common.web.MemberId; +import kr.co.morandi.backend.defense_management.application.response.session.StartDailyDefenseResponse; +import kr.co.morandi.backend.defense_management.application.usecase.session.DailyDefenseManagementUsecase; +import kr.co.morandi.backend.defense_management.infrastructure.request.dailydefense.StartDailyDefenseRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; + +@RestController +@RequestMapping("/daily-defense") +@RequiredArgsConstructor +public class DefenseMangementController { + + private final DailyDefenseManagementUsecase dailyDefenseManagementUsecase; + + @PostMapping + public ResponseEntity startDailyDefense(@MemberId Long memberId, + @Valid @RequestBody StartDailyDefenseRequest request) { + + return ResponseEntity.ok(dailyDefenseManagementUsecase + .startDailyDefense(request.toServiceRequest(), memberId, LocalDateTime.now()) + ); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/controller/ExampleCodeSubmitController.java b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/controller/ExampleCodeSubmitController.java new file mode 100644 index 00000000..0ece9544 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/controller/ExampleCodeSubmitController.java @@ -0,0 +1,26 @@ +package kr.co.morandi.backend.defense_management.infrastructure.controller; + +import kr.co.morandi.backend.defense_management.application.service.codesubmit.ExampleCodeSubmitService; +import kr.co.morandi.backend.defense_management.infrastructure.request.codesubmit.CodeRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/submit") +public class ExampleCodeSubmitController { + + private final ExampleCodeSubmitService exampleCodeQueueService; + + @PostMapping("/example") + public ResponseEntity submit(@RequestBody CodeRequest codeRequest) { + exampleCodeQueueService.submitCodeToQueue(codeRequest); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/controller/SessionConnectionController.java b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/controller/SessionConnectionController.java new file mode 100644 index 00000000..131ea5d7 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/controller/SessionConnectionController.java @@ -0,0 +1,26 @@ +package kr.co.morandi.backend.defense_management.infrastructure.controller; + +import kr.co.morandi.backend.common.web.MemberId; +import kr.co.morandi.backend.defense_management.application.service.message.DefenseMessageService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@RestController +@RequestMapping("/session") +@RequiredArgsConstructor +public class SessionConnectionController { + + private final DefenseMessageService defenseMessageService; + + @GetMapping(value = "/{sessionId}/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter connectSession(@PathVariable Long sessionId, @MemberId Long memberId) { + + return defenseMessageService.getConnection(sessionId, memberId); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/exception/RedisMessageErrorCode.java b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/exception/RedisMessageErrorCode.java new file mode 100644 index 00000000..9343e5e2 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/exception/RedisMessageErrorCode.java @@ -0,0 +1,16 @@ +package kr.co.morandi.backend.defense_management.infrastructure.exception; + +import kr.co.morandi.backend.common.exception.errorcode.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +@Getter +@RequiredArgsConstructor +public enum RedisMessageErrorCode implements ErrorCode { + + MESSAGE_PARSE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Redis의 메시지를 파싱하지 못했습니다."), + MESSAGE_SEND_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Redis 메시지를 전송하지 못했습니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/exception/SQSMessageErrorCode.java b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/exception/SQSMessageErrorCode.java new file mode 100644 index 00000000..55cf624c --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/exception/SQSMessageErrorCode.java @@ -0,0 +1,16 @@ +package kr.co.morandi.backend.defense_management.infrastructure.exception; + +import kr.co.morandi.backend.common.exception.errorcode.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum SQSMessageErrorCode implements ErrorCode { + MESSAGE_PARSE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "AWS SQS에 보낼 메시지를 파싱하지 못했습니다."), + MESSAGE_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AWS SQS에 메시지를 전송하지 못했습니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/persistence/session/DefenseSessionRepository.java b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/persistence/session/DefenseSessionRepository.java new file mode 100644 index 00000000..8de66e0e --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/persistence/session/DefenseSessionRepository.java @@ -0,0 +1,31 @@ +package kr.co.morandi.backend.defense_management.infrastructure.persistence.session; + +import kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType; +import kr.co.morandi.backend.defense_management.domain.model.session.DefenseSession; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface DefenseSessionRepository extends JpaRepository { + @Query(""" + select ds + from DefenseSession as ds + left join fetch ds.sessionDetails + where ds.endDateTime > :now + and ds.defenseType = :defenseType + and ds.member = :member + """) + Optional findDailyDefenseSession(Member member, DefenseType defenseType, LocalDateTime now); + + @Query(""" + select ds + from DefenseSession as ds + left join fetch ds.sessionDetails d + left join fetch d.tempCodes + where ds.defenseSessionId = :sessionId + """) + Optional findDefenseSessionJoinFetchTempCode(Long sessionId); +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/persistence/session/SessionDetailRepository.java b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/persistence/session/SessionDetailRepository.java new file mode 100644 index 00000000..ac9d9c12 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/persistence/session/SessionDetailRepository.java @@ -0,0 +1,7 @@ +package kr.co.morandi.backend.defense_management.infrastructure.persistence.session; + +import kr.co.morandi.backend.defense_management.domain.model.session.SessionDetail; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SessionDetailRepository extends JpaRepository { +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/persistence/tempcode/TempCodeRepository.java b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/persistence/tempcode/TempCodeRepository.java new file mode 100644 index 00000000..f873ddec --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/persistence/tempcode/TempCodeRepository.java @@ -0,0 +1,7 @@ +package kr.co.morandi.backend.defense_management.infrastructure.persistence.tempcode; + +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.TempCode; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TempCodeRepository extends JpaRepository { +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/request/codesubmit/CodeRequest.java b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/request/codesubmit/CodeRequest.java new file mode 100644 index 00000000..77adbd45 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/request/codesubmit/CodeRequest.java @@ -0,0 +1,30 @@ +package kr.co.morandi.backend.defense_management.infrastructure.request.codesubmit; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@Getter +public class CodeRequest { + + private String code; + private String language; + private String input; + private String defenseSessionId; + + public static CodeRequest create(String code, String language, String input, String defenseSessionId) { + return CodeRequest.builder() + .code(code) + .language(language) + .input(input) + .defenseSessionId(defenseSessionId) + .build(); + } + @Builder + private CodeRequest(String code, String language, String input, String defenseSessionId) { + this.code = code; + this.language = language; + this.input = input; + this.defenseSessionId = defenseSessionId; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/request/codesubmit/CodeResponse.java b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/request/codesubmit/CodeResponse.java new file mode 100644 index 00000000..9299dd5b --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/request/codesubmit/CodeResponse.java @@ -0,0 +1,12 @@ +package kr.co.morandi.backend.defense_management.infrastructure.request.codesubmit; + +import lombok.Getter; +import lombok.Setter; + +@Getter @Setter +public class CodeResponse { + + private String result; + private String executeTime; + private String output; +} diff --git a/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/request/dailydefense/StartDailyDefenseRequest.java b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/request/dailydefense/StartDailyDefenseRequest.java new file mode 100644 index 00000000..0ce7b738 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_management/infrastructure/request/dailydefense/StartDailyDefenseRequest.java @@ -0,0 +1,25 @@ +package kr.co.morandi.backend.defense_management.infrastructure.request.dailydefense; + +import kr.co.morandi.backend.defense_management.application.request.session.StartDailyDefenseServiceRequest; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class StartDailyDefenseRequest { + + private Long problemNumber; + + @Builder + private StartDailyDefenseRequest(Long problemNumber) { + this.problemNumber = problemNumber; + } + + public StartDailyDefenseServiceRequest toServiceRequest() { + return StartDailyDefenseServiceRequest.builder() + .problemNumber(problemNumber) + .build(); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/application/dto/DailyDefenseRankPageResponse.java b/src/main/java/kr/co/morandi/backend/defense_record/application/dto/DailyDefenseRankPageResponse.java new file mode 100644 index 00000000..05de8aee --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/application/dto/DailyDefenseRankPageResponse.java @@ -0,0 +1,31 @@ +package kr.co.morandi.backend.defense_record.application.dto; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DailyDefenseRankPageResponse { + + private List dailyRecords; + private Integer totalPage; + private Integer currentPage; + + public static DailyDefenseRankPageResponse of(List dailyRecords, Integer totalPage, Integer currentPage) { + return DailyDefenseRankPageResponse.builder() + .dailyRecords(dailyRecords) + .totalPage(totalPage) + .currentPage(currentPage) + .build(); + } + @Builder + private DailyDefenseRankPageResponse(List dailyRecords, Integer totalPage, Integer currentPage) { + this.dailyRecords = dailyRecords; + this.totalPage = totalPage; + this.currentPage = currentPage; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/application/dto/DailyDetailRankResponse.java b/src/main/java/kr/co/morandi/backend/defense_record/application/dto/DailyDetailRankResponse.java new file mode 100644 index 00000000..4f5777c8 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/application/dto/DailyDetailRankResponse.java @@ -0,0 +1,36 @@ +package kr.co.morandi.backend.defense_record.application.dto; + +import kr.co.morandi.backend.defense_record.application.util.TimeFormatHelper; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyDetail; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DailyDetailRankResponse { + + private Long problemNumber; + private Boolean isSolved; + private String solvedTime; + + public static List of(List dailyDetails) { + return dailyDetails.stream() + .map(details -> DailyDetailRankResponse.builder() + .problemNumber(details.getProblemNumber()) + .isSolved(details.getIsSolved()) + .solvedTime(TimeFormatHelper.solvedTimeToString(details.getSolvedTime())) + .build()) + .toList(); + } + + @Builder + private DailyDetailRankResponse(Long problemNumber, Boolean isSolved, String solvedTime) { + this.problemNumber = problemNumber; + this.isSolved = isSolved; + this.solvedTime = solvedTime; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/application/dto/DailyRecordRankResponse.java b/src/main/java/kr/co/morandi/backend/defense_record/application/dto/DailyRecordRankResponse.java new file mode 100644 index 00000000..977791d0 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/application/dto/DailyRecordRankResponse.java @@ -0,0 +1,43 @@ +package kr.co.morandi.backend.defense_record.application.dto; + +import kr.co.morandi.backend.defense_record.application.util.TimeFormatHelper; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DailyRecordRankResponse { + + private String nickname; + private Long rank; + private Long solvedCount; + private LocalDateTime updatedAt; + private String totalSolvedTime; + private List rankDetails; + + public static DailyRecordRankResponse of(String nickname, Long rank, LocalDateTime updatedAt, Long totalSolvedTime, Long totalSolvedCount, List rankDetails) { + return DailyRecordRankResponse.builder() + .nickname(nickname) + .rank(rank) + .updatedAt(updatedAt) + .solvedCount(totalSolvedCount) + .rankDetails(rankDetails) + .totalSolvedTime(TimeFormatHelper.solvedTimeToString(totalSolvedTime)) + .build(); + } + @Builder + private DailyRecordRankResponse(String nickname, Long rank, Long solvedCount, LocalDateTime updatedAt, String totalSolvedTime, List rankDetails) { + this.nickname = nickname; + this.rank = rank; + this.solvedCount = solvedCount; + this.updatedAt = updatedAt; + this.totalSolvedTime = totalSolvedTime; + this.rankDetails = rankDetails; + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/application/port/in/DailyRecordRankUseCase.java b/src/main/java/kr/co/morandi/backend/defense_record/application/port/in/DailyRecordRankUseCase.java new file mode 100644 index 00000000..2d72d3f9 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/application/port/in/DailyRecordRankUseCase.java @@ -0,0 +1,11 @@ +package kr.co.morandi.backend.defense_record.application.port.in; + +import kr.co.morandi.backend.defense_record.application.dto.DailyDefenseRankPageResponse; + +import java.time.LocalDateTime; + +public interface DailyRecordRankUseCase { + + // TODO 공통 등수 로직 부분 빠짐 + DailyDefenseRankPageResponse getDailyRecordRank(LocalDateTime requestTime, int page, int size); +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/application/port/out/dailyrecord/DailyRecordPort.java b/src/main/java/kr/co/morandi/backend/defense_record/application/port/out/dailyrecord/DailyRecordPort.java new file mode 100644 index 00000000..ebdf1f39 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/application/port/out/dailyrecord/DailyRecordPort.java @@ -0,0 +1,16 @@ +package kr.co.morandi.backend.defense_record.application.port.out.dailyrecord; + +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import org.springframework.data.domain.Page; + +import java.time.LocalDate; +import java.util.Optional; + +public interface DailyRecordPort { + + DailyRecord saveDailyRecord(DailyRecord dailyRecord); + Optional findDailyRecord(Member member, LocalDate date); + Optional findDailyRecord(Member member, Long recordId, LocalDate date); + Page findDailyRecordRank(LocalDate requestDate, Integer page, Integer size); +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/application/port/out/record/RecordPort.java b/src/main/java/kr/co/morandi/backend/defense_record/application/port/out/record/RecordPort.java new file mode 100644 index 00000000..a5f1963d --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/application/port/out/record/RecordPort.java @@ -0,0 +1,13 @@ +package kr.co.morandi.backend.defense_record.application.port.out.record; + +import kr.co.morandi.backend.defense_record.domain.model.record.Detail; +import kr.co.morandi.backend.defense_record.domain.model.record.Record; + +import java.util.Optional; + +public interface RecordPort { + Optional> findRecordById(Long recordId); + Optional> findRecordFetchJoinWithDetail(Long recordId); + Optional> findRecordFetchJoinWithDetailAndProblem(Long recordId); + void saveRecord(Record record); +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/application/util/TimeFormatHelper.java b/src/main/java/kr/co/morandi/backend/defense_record/application/util/TimeFormatHelper.java new file mode 100644 index 00000000..6d6ea7ea --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/application/util/TimeFormatHelper.java @@ -0,0 +1,17 @@ +package kr.co.morandi.backend.defense_record.application.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class TimeFormatHelper { + + public static String solvedTimeToString(Long solvedTime) { + return String.format("%02d:%02d:%02d", solvedTime / 3600, (solvedTime % 3600) / 60, solvedTime % 60); + } + + public static Long stringToSolvedTime(String solvedTime) { + String[] time = solvedTime.split(":"); + return Long.parseLong(time[0]) * 3600 + Long.parseLong(time[1]) * 60 + Long.parseLong(time[2]); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/domain/error/RecordErrorCode.java b/src/main/java/kr/co/morandi/backend/defense_record/domain/error/RecordErrorCode.java new file mode 100644 index 00000000..03ac35cc --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/domain/error/RecordErrorCode.java @@ -0,0 +1,26 @@ +package kr.co.morandi.backend.defense_record.domain.error; + +import kr.co.morandi.backend.common.exception.errorcode.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum RecordErrorCode implements ErrorCode { + RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "시험 기록(Record)를 찾을 수 없습니다."), + RECORD_ALREADY_TERMINATED(HttpStatus.BAD_REQUEST, "이미 종료된 시험 기록입니다."), + DETAIL_NOT_FOUND(HttpStatus.BAD_REQUEST, "해당 번호의 문제 풀이 기록을 찾을 수 없습니다."),; + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getMessage() { + return message; + } + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/domain/model/customdefense_record/CustomDetail.java b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/customdefense_record/CustomDetail.java new file mode 100644 index 00000000..29ab9f8b --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/customdefense_record/CustomDetail.java @@ -0,0 +1,40 @@ +package kr.co.morandi.backend.defense_record.domain.model.customdefense_record; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.defense_record.domain.model.record.Detail; +import kr.co.morandi.backend.defense_record.domain.model.record.Record; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@DiscriminatorValue("CustomDefenseProblemRecord") +public class CustomDetail extends Detail { + + private Long problemNumber; + private Long solvedTime; + + @Override + public Long getSequenceNumber() { + return problemNumber; + } + + private static final long INITIAL_SOLVED_TIME = 0L; + + @Builder + private CustomDetail(Member member, Long sequenceNumber, Problem problem, Record records, Defense defense) { + super(member, problem, records, defense); + this.problemNumber = sequenceNumber; + this.solvedTime = INITIAL_SOLVED_TIME; + } + public static CustomDetail create(Member member, Long sequenceNumber, Problem problem, Record records, Defense defense) { + return new CustomDetail(member, sequenceNumber, problem, records, defense); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/domain/model/customdefense_record/CustomRecord.java b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/customdefense_record/CustomRecord.java new file mode 100644 index 00000000..826815b5 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/customdefense_record/CustomRecord.java @@ -0,0 +1,38 @@ +package kr.co.morandi.backend.defense_record.domain.model.customdefense_record; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import kr.co.morandi.backend.defense_information.domain.model.customdefense.CustomDefense; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.defense_record.domain.model.record.Record; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Map; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@DiscriminatorValue("CustomRecord") +public class CustomRecord extends Record { + + private Integer problemCount; + @Override + public CustomDetail createDetail(Member member, Long sequenceNumber, Problem problem, Record records, Defense defense) { + return CustomDetail.create(member, sequenceNumber, problem, records, defense); + } + + @Builder + private CustomRecord(CustomDefense customDefense, Member member, LocalDateTime testDate, Map problems) { + super(testDate, customDefense, member, problems); + this.problemCount = customDefense.getProblemCount(); + } + public static CustomRecord create(CustomDefense customDefense, Member member, LocalDateTime testDate, Map problems) { + return new CustomRecord(customDefense, member, testDate, problems); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/domain/model/dailydefense_record/DailyDetail.java b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/dailydefense_record/DailyDetail.java new file mode 100644 index 00000000..7d381913 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/dailydefense_record/DailyDetail.java @@ -0,0 +1,36 @@ +package kr.co.morandi.backend.defense_record.domain.model.dailydefense_record; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.defense_record.domain.model.record.Detail; +import kr.co.morandi.backend.defense_record.domain.model.record.Record; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DiscriminatorValue("DailyDefenseProblemRecord") +public class DailyDetail extends Detail { + + private Long problemNumber; + + @Override + public Long getSequenceNumber() { + return problemNumber; + } + + @Builder + private DailyDetail(Member member, Long problemNumber, Problem problem, Record records, Defense defense) { + super(member, problem, records, defense); + this.problemNumber = problemNumber; + } + public static DailyDetail create(Member member, Long problemNumber, Problem problem, Record records, Defense defense) { + return new DailyDetail(member, problemNumber, problem, records, defense); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/domain/model/dailydefense_record/DailyRecord.java b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/dailydefense_record/DailyRecord.java new file mode 100644 index 00000000..4f84cd16 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/dailydefense_record/DailyRecord.java @@ -0,0 +1,83 @@ +package kr.co.morandi.backend.defense_record.domain.model.dailydefense_record; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.defense_record.domain.model.record.Detail; +import kr.co.morandi.backend.defense_record.domain.model.record.Record; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DiscriminatorValue("DailyDefenseRecord") +public class DailyRecord extends Record { + + + private Integer problemCount; + + public Set getSolvedProblemNumbers() { + return super.getDetails().stream() + .filter(DailyDetail::getIsSolved) + .map(DailyDetail::getProblemNumber) + .collect(Collectors.toSet()); + } + public boolean isSolvedProblem(Long problemNumber) { + return super.getDetails().stream() + .anyMatch(detail -> detail.getProblemNumber().equals(problemNumber) + && detail.getIsSolved()); + } + @Override + protected DailyDetail createDetail(Member member, Long sequenceNumber, Problem problem, Record records, Defense defense) { + return DailyDetail.create(member, sequenceNumber, problem, records, defense); + } + + public static DailyRecord tryDefense(LocalDateTime date, DailyDefense dailyDefense, Member member, Map problems) { + + if (!date.toLocalDate().equals(dailyDefense.getDate())) { + throw new IllegalArgumentException("오늘의 문제 기록은 출제 날짜와 같은 날에 생성되어야 합니다."); + } + + return new DailyRecord(date, dailyDefense, member, problems); + } + + public void tryMoreProblem(Map problem) { + // 이미 시도한 문제들의 problemId를 가져오고 + final Set collect = super.getDetails().stream() + .map(Detail::getProblem) + .map(Problem::getProblemId) + .collect(Collectors.toSet()); + + // 시도하려는 문제들 중 이미 시도한 문제들을 제외한 문제들만 추가 + final List newDetails = problem.entrySet().stream() + .filter(entry -> !collect.contains(entry.getValue().getProblemId())) + .map(p -> createDetail(this.getMember(), p.getKey(), p.getValue(), this, this.getDefense())) + .toList(); + + // 문제 추가 + super.getDetails().addAll(newDetails); + + // 새로운 문제 추가로 문제 수 증가 + this.problemCount += newDetails.size(); + } + + @Builder + private DailyRecord(LocalDateTime date, Defense defense, Member member, Map problems) { + super(date, defense, member, problems); + this.problemCount = problems.size(); + defense.increaseAttemptCount(); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/domain/model/randomdefense_record/RandomDetail.java b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/randomdefense_record/RandomDetail.java new file mode 100644 index 00000000..53f1b3c8 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/randomdefense_record/RandomDetail.java @@ -0,0 +1,41 @@ +package kr.co.morandi.backend.defense_record.domain.model.randomdefense_record; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.defense_record.domain.model.record.Detail; +import kr.co.morandi.backend.defense_record.domain.model.record.Record; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@DiscriminatorValue("RandomDefenseProblemRecord") +public class RandomDetail extends Detail { + + private Long problemNumber; + private Long solvedTime; + + @Override + public Long getSequenceNumber() { + return problemNumber; + } + + private static final long INITIAL_SOLVED_TIME = 0L; + + @Builder + private RandomDetail(Member member, Long sequenceNumber, Problem problem, Record records, Defense defense) { + super(member, problem, records, defense); + this.problemNumber = sequenceNumber; + this.solvedTime = INITIAL_SOLVED_TIME; + } + + public static RandomDetail create(Member member, Long sequenceNumber, Problem problem, Record records, Defense defense) { + return new RandomDetail(member, sequenceNumber, problem, records, defense); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/domain/model/randomdefense_record/RandomRecord.java b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/randomdefense_record/RandomRecord.java new file mode 100644 index 00000000..19e59fe4 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/randomdefense_record/RandomRecord.java @@ -0,0 +1,38 @@ +package kr.co.morandi.backend.defense_record.domain.model.randomdefense_record; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.defense_information.domain.model.randomdefense.model.RandomDefense; +import kr.co.morandi.backend.defense_record.domain.model.record.Record; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Map; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DiscriminatorValue("RandomDefenseRecord") +public class RandomRecord extends Record { + + private Integer problemCount; + + @Builder + private RandomRecord(LocalDateTime testDate, RandomDefense randomDefense, Member member, Map problems) { + super(testDate, randomDefense, member, problems); + this.problemCount = problems.size(); + } + @Override + protected RandomDetail createDetail(Member member, Long sequenceNumber, Problem problem, Record records, Defense defense) { + return RandomDetail.create(member, sequenceNumber, problem, records, defense); + } + public static RandomRecord create(RandomDefense randomDefense, Member member, LocalDateTime testDate, Map problems) { + return new RandomRecord(testDate, randomDefense, member, problems); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/domain/model/record/Detail.java b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/record/Detail.java new file mode 100644 index 00000000..1bbac35c --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/record/Detail.java @@ -0,0 +1,84 @@ +package kr.co.morandi.backend.defense_record.domain.model.record; + +import jakarta.persistence.*; +import kr.co.morandi.backend.common.model.BaseEntity; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Duration; +import java.time.LocalDateTime; + +@Entity +@Inheritance(strategy = InheritanceType.JOINED) +@DiscriminatorColumn +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class Detail extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long detailId; + + private Boolean isSolved; + + private Long submitCount; + + @ManyToOne(fetch = FetchType.LAZY) + private Defense defense; + + @ManyToOne(fetch = FetchType.LAZY) + private Record record; + + @ManyToOne(fetch = FetchType.LAZY) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + private Problem problem; + + private Long correctSubmitId; + + private Long solvedTime; + + public abstract Long getSequenceNumber(); + + private static final Long INITIAL_SUBMIT_COUNT = 0L; + private static final Long INITIAL_SOLVED_TIME = 0L; + private static final Boolean INITIAL_IS_SOLVED = false; + + public void trySolveProblem(Long submitId, LocalDateTime solvedDateTime) { + if(isSolvedDetail()) { + return ; + } + this.isSolved = true; + this.correctSubmitId = submitId; + this.solvedTime = calculateSolvedTime(solvedDateTime); + record.addSolvedCountAndTime(this.solvedTime); + } + public void increaseSubmitCount() { + this.submitCount++; + } + + private boolean isSolvedDetail() { + return Boolean.TRUE.equals(this.isSolved); + } + + private long calculateSolvedTime(LocalDateTime nowDateTime) { + LocalDateTime startTime = this.record.getTestDate(); + return Duration.between(startTime, nowDateTime).toSeconds(); + } + + protected Detail(Member member, Problem problem, Record records, Defense defense) { + this.isSolved = INITIAL_IS_SOLVED; + this.submitCount = INITIAL_SUBMIT_COUNT; + this.solvedTime = INITIAL_SOLVED_TIME; + this.correctSubmitId = null; + this.defense = defense; + this.record = records; + this.member = member; + this.problem = problem; + } + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/domain/model/record/Record.java b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/record/Record.java new file mode 100644 index 00000000..cf6cec99 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/record/Record.java @@ -0,0 +1,89 @@ +package kr.co.morandi.backend.defense_record.domain.model.record; + +import jakarta.persistence.*; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.common.model.BaseEntity; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.defense_record.domain.error.RecordErrorCode; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Entity +@Inheritance(strategy = InheritanceType.JOINED) +@DiscriminatorColumn +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class Record extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long recordId; + + private LocalDateTime testDate; + + @ManyToOne(fetch = FetchType.LAZY) + private Defense defense; + + @ManyToOne(fetch = FetchType.LAZY) + private Member member; + + @OneToMany(mappedBy = "record", cascade = CascadeType.ALL, targetEntity = Detail.class) + private List details = new ArrayList<>(); + + private Long totalSolvedTime; + + @Enumerated(EnumType.STRING) + private RecordStatus status; + + private Long totalSolvedCount; + + private static final Long INITIAL_TOTAL_SOLVED_TIME = 0L; + private static final Long INITIAL_TOTAL_SOLVED_COUNT = 0L; + + public T getDetail(Long sequenceNumber) { + return this.details.stream() + .filter(detail -> detail.getSequenceNumber().equals(sequenceNumber)) + .findFirst() + .orElseThrow(() -> new MorandiException(RecordErrorCode.DETAIL_NOT_FOUND)); + } + + public Problem getProblem(Long sequenceNumber) { + return getDetail(sequenceNumber).getProblem(); + } + public boolean isTerminated() { + return this.status.equals(RecordStatus.COMPLETED); + } + + public boolean terminteDefense() { + if(this.status.equals(RecordStatus.COMPLETED)) { + throw new MorandiException(RecordErrorCode.RECORD_ALREADY_TERMINATED); + } + this.status = RecordStatus.COMPLETED; + return true; + } + public void addSolvedCountAndTime(Long totalSolvedTime) { + this.totalSolvedTime += totalSolvedTime; + this.totalSolvedCount += 1; + } + protected abstract T createDetail(Member member, Long sequenceNumber, Problem problem, Record records, Defense defense); + + protected Record(LocalDateTime testDate, Defense defense, Member member, Map problems) { + this.testDate = testDate; + this.defense = defense; + this.totalSolvedCount = INITIAL_TOTAL_SOLVED_COUNT; + this.member = member; + this.status = RecordStatus.IN_PROGRESS; + this.totalSolvedTime = INITIAL_TOTAL_SOLVED_TIME; + this.details = problems.entrySet().stream() + .map(problem -> this.createDetail(member, problem.getKey(), problem.getValue(), this, defense)) + .collect(Collectors.toCollection(ArrayList::new)); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/domain/model/record/RecordStatus.java b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/record/RecordStatus.java new file mode 100644 index 00000000..a344ed71 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/record/RecordStatus.java @@ -0,0 +1,5 @@ +package kr.co.morandi.backend.defense_record.domain.model.record; + +public enum RecordStatus { + IN_PROGRESS, COMPLETED +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/domain/model/stagedefense_record/StageDetail.java b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/stagedefense_record/StageDetail.java new file mode 100644 index 00000000..b9ca3a06 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/stagedefense_record/StageDetail.java @@ -0,0 +1,36 @@ +package kr.co.morandi.backend.defense_record.domain.model.stagedefense_record; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.defense_record.domain.model.record.Detail; +import kr.co.morandi.backend.defense_record.domain.model.record.Record; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@DiscriminatorValue("StageDefenseProblemRecord") +public class StageDetail extends Detail { + + private Long stageNumber; + + @Override + public Long getSequenceNumber() { + return stageNumber; + } + + @Builder + private StageDetail(Member member, Long stageNumber, Problem problem, Record records, Defense defense) { + super(member, problem, records, defense); + this.stageNumber = stageNumber; + } + public static StageDetail create(Member member, Long stageNumber, Problem problem, Record records, Defense defense) { + return new StageDetail(member, stageNumber, problem, records, defense); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/domain/model/stagedefense_record/StageRecord.java b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/stagedefense_record/StageRecord.java new file mode 100644 index 00000000..66436211 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/domain/model/stagedefense_record/StageRecord.java @@ -0,0 +1,40 @@ +package kr.co.morandi.backend.defense_record.domain.model.stagedefense_record; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.defense_record.domain.model.record.Record; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Map; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DiscriminatorValue("StageDefenseRecord") +public class StageRecord extends Record { + + private Long stageCount; + + private static final Long INITIAL_STAGE_NUMBER = 1L; + private static final Long INITIAL_STAGE_COUNT = 1L; + + @Builder + private StageRecord(Defense defense, LocalDateTime testDate, Member member, Map problems) { + super(testDate, defense, member, problems); + this.stageCount = INITIAL_STAGE_COUNT; + } + @Override + protected StageDetail createDetail(Member member, Long sequenceNumber, Problem problem, Record records, Defense defense) { + return StageDetail.create(member, INITIAL_STAGE_NUMBER, problem, records, defense); + } + public static StageRecord create(Defense defense, LocalDateTime testDate, Member member, Problem problem) { + return new StageRecord(defense, testDate, member, Map.of(INITIAL_STAGE_NUMBER, problem)); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/adapter/dailyrecord/DailyRecordAdapter.java b/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/adapter/dailyrecord/DailyRecordAdapter.java new file mode 100644 index 00000000..2ce8a4fa --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/adapter/dailyrecord/DailyRecordAdapter.java @@ -0,0 +1,43 @@ +package kr.co.morandi.backend.defense_record.infrastructure.adapter.dailyrecord; + +import kr.co.morandi.backend.defense_record.application.port.out.dailyrecord.DailyRecordPort; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.defense_record.infrastructure.persistence.dailydefense_record.DailyRecordRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class DailyRecordAdapter implements DailyRecordPort { + + private final DailyRecordRepository dailyRecordRepository; + + @Override + public DailyRecord saveDailyRecord(DailyRecord dailyRecord) { + return dailyRecordRepository.save(dailyRecord); + } + @Override + public Optional findDailyRecord(Member member, LocalDate date) { + return dailyRecordRepository.findDailyRecordByMemberAndDate(member, date); + + } + @Override + public Optional findDailyRecord(Member member, Long recordId, LocalDate date) { + return dailyRecordRepository.findDailyRecordByRecordId(member, recordId, date); + } + /* + * 조회 시간 별 DailyDefense 등수 조회 + * */ + @Override + public Page findDailyRecordRank(LocalDate requestDate, Integer page, Integer size) { + Pageable pageable = PageRequest.of(page, size); + return dailyRecordRepository.getDailyRecordsRankByDate(requestDate, pageable); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/adapter/record/RecordAdapter.java b/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/adapter/record/RecordAdapter.java new file mode 100644 index 00000000..d45794e0 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/adapter/record/RecordAdapter.java @@ -0,0 +1,36 @@ +package kr.co.morandi.backend.defense_record.infrastructure.adapter.record; + +import kr.co.morandi.backend.common.annotation.Adapter; +import kr.co.morandi.backend.defense_record.application.port.out.record.RecordPort; +import kr.co.morandi.backend.defense_record.domain.model.record.Detail; +import kr.co.morandi.backend.defense_record.domain.model.record.Record; +import kr.co.morandi.backend.defense_record.infrastructure.persistence.record.RecordRepository; +import lombok.RequiredArgsConstructor; + +import java.util.Optional; + +@Adapter +@RequiredArgsConstructor +public class RecordAdapter implements RecordPort { + + private final RecordRepository recordRepository; + @Override + public Optional> findRecordById(Long defenseRecordId) { + return recordRepository.findById(defenseRecordId); + } + + @Override + public Optional> findRecordFetchJoinWithDetail(Long defenseRecordId) { + return recordRepository.findRecordFetchJoinWithDetail(defenseRecordId); + } + + @Override + public Optional> findRecordFetchJoinWithDetailAndProblem(Long defenseRecordId) { + return recordRepository.findRecordFetchJoinWithDetailAndProblem(defenseRecordId); + } + + @Override + public void saveRecord(Record defenseRecord) { + recordRepository.save(defenseRecord); + } +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/controller/DailyRecordController.java b/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/controller/DailyRecordController.java new file mode 100644 index 00000000..e1d6a157 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/controller/DailyRecordController.java @@ -0,0 +1,26 @@ +package kr.co.morandi.backend.defense_record.infrastructure.controller; + +import kr.co.morandi.backend.defense_record.application.dto.DailyDefenseRankPageResponse; +import kr.co.morandi.backend.defense_record.application.port.in.DailyRecordRankUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; + +@RestController +@RequiredArgsConstructor +public class DailyRecordController { + + private final DailyRecordRankUseCase dailyRecordRankUseCase; + + @GetMapping("/daily-record/rankings") + public ResponseEntity getDailyRecordRank(@RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "5") int size) { + + return ResponseEntity.ok(dailyRecordRankUseCase.getDailyRecordRank(LocalDateTime.now(), page, size)); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/persistence/customdefense_record/CustomDefenseRecordRepository.java b/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/persistence/customdefense_record/CustomDefenseRecordRepository.java new file mode 100644 index 00000000..919464e2 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/persistence/customdefense_record/CustomDefenseRecordRepository.java @@ -0,0 +1,7 @@ +package kr.co.morandi.backend.defense_record.infrastructure.persistence.customdefense_record; + +import kr.co.morandi.backend.defense_record.domain.model.customdefense_record.CustomRecord; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CustomDefenseRecordRepository extends JpaRepository { +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/persistence/dailydefense_record/DailyRecordRepository.java b/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/persistence/dailydefense_record/DailyRecordRepository.java new file mode 100644 index 00000000..fc82264b --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/persistence/dailydefense_record/DailyRecordRepository.java @@ -0,0 +1,45 @@ +package kr.co.morandi.backend.defense_record.infrastructure.persistence.dailydefense_record; + +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.time.LocalDate; +import java.util.Optional; + +public interface DailyRecordRepository extends JpaRepository { + + @Query(""" + select dr + from DailyRecord dr + left join fetch dr.details d + left join fetch d.problem + where dr.member = :member + and CAST(dr.testDate as localdate) = :date + """) + Optional findDailyRecordByMemberAndDate(Member member, LocalDate date); + + @Query(""" + select dr + from DailyRecord dr + left join fetch dr.details d + left join fetch d.problem + where dr.member = :member + and dr.recordId = :recordId + and CAST(dr.testDate as localdate) = :date + """) + Optional findDailyRecordByRecordId(Member member, Long recordId, LocalDate date); + /* + * Paging 처리이기 때문에 fetch join을 사용하지 않는다. + * */ + @Query(""" + select dr + from DailyRecord dr + where CAST(dr.testDate as localdate) = :requestDate + order by dr.totalSolvedCount desc, dr.totalSolvedTime asc, dr.recordId asc + """) + Page getDailyRecordsRankByDate(LocalDate requestDate, Pageable pageable); +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/persistence/record/RecordRepository.java b/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/persistence/record/RecordRepository.java new file mode 100644 index 00000000..2bc07da0 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/infrastructure/persistence/record/RecordRepository.java @@ -0,0 +1,28 @@ +package kr.co.morandi.backend.defense_record.infrastructure.persistence.record; + +import kr.co.morandi.backend.defense_record.domain.model.record.Detail; +import kr.co.morandi.backend.defense_record.domain.model.record.Record; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +public interface RecordRepository extends JpaRepository, Long> { + + @Query(""" + SELECT r + FROM Record r + LEFT JOIN FETCH r.details d + WHERE r.recordId = :recordId + """) + Optional> findRecordFetchJoinWithDetail(Long recordId); + + @Query(""" + SELECT r + FROM Record r + LEFT JOIN FETCH r.details d + LEFT JOIN FETCH d.problem + WHERE r.recordId = :recordId + """) + Optional> findRecordFetchJoinWithDetailAndProblem(Long recordId); +} diff --git a/src/main/java/kr/co/morandi/backend/defense_record/usecase/DailyRecordRankUseCaseImpl.java b/src/main/java/kr/co/morandi/backend/defense_record/usecase/DailyRecordRankUseCaseImpl.java new file mode 100644 index 00000000..ec27406a --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/defense_record/usecase/DailyRecordRankUseCaseImpl.java @@ -0,0 +1,56 @@ +package kr.co.morandi.backend.defense_record.usecase; + +import kr.co.morandi.backend.defense_record.application.dto.DailyDefenseRankPageResponse; +import kr.co.morandi.backend.defense_record.application.dto.DailyDetailRankResponse; +import kr.co.morandi.backend.defense_record.application.dto.DailyRecordRankResponse; +import kr.co.morandi.backend.defense_record.application.port.in.DailyRecordRankUseCase; +import kr.co.morandi.backend.defense_record.application.port.out.dailyrecord.DailyRecordPort; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyDetail; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class DailyRecordRankUseCaseImpl implements DailyRecordRankUseCase { + + private final DailyRecordPort dailyRecordPort; + + + // TODO 공통 등수 로직 부분 빠짐 + @Override + public DailyDefenseRankPageResponse getDailyRecordRank(LocalDateTime requestTime, int page, int size) { + final Page dailyRecords = dailyRecordPort.findDailyRecordRank(requestTime.toLocalDate(), page, size); + + // 등수 계산 + // TODO 동점자 처리 필요 + AtomicLong initialRank = new AtomicLong((long) page * size + 1); + + final List dailyRecordRanks = dailyRecords.stream() + .map(dr -> { + String member = dr.getMember().getNickname(); + Long rank = initialRank.getAndIncrement(); + List details = DailyDetailRankResponse.of(dr.getDetails()); + Long totalSolvedTime = dr.getDetails().stream() + .mapToLong(DailyDetail::getSolvedTime) + .sum(); + Long solvedCount = dr.getDetails().stream() + .filter(DailyDetail::getIsSolved) + .count(); + + return DailyRecordRankResponse.of(member, rank, requestTime, totalSolvedTime, solvedCount, details); + }) + .toList(); + + return DailyDefenseRankPageResponse.of(dailyRecordRanks, dailyRecords.getTotalPages(), page); + } + + +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/application/port/out/BaekjoonSubmitPort.java b/src/main/java/kr/co/morandi/backend/judgement/application/port/out/BaekjoonSubmitPort.java new file mode 100644 index 00000000..2100b11e --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/application/port/out/BaekjoonSubmitPort.java @@ -0,0 +1,11 @@ +package kr.co.morandi.backend.judgement.application.port.out; + +import kr.co.morandi.backend.judgement.domain.model.baekjoon.submit.BaekjoonSubmit; + +import java.util.Optional; + +public interface BaekjoonSubmitPort { + BaekjoonSubmit save(BaekjoonSubmit submit); + + Optional findSubmitJoinFetchDetailAndRecord(Long submitId); +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/application/request/JudgementServiceRequest.java b/src/main/java/kr/co/morandi/backend/judgement/application/request/JudgementServiceRequest.java new file mode 100644 index 00000000..bad20948 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/application/request/JudgementServiceRequest.java @@ -0,0 +1,35 @@ +package kr.co.morandi.backend.judgement.application.request; + +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.judgement.domain.model.submit.SubmitVisibility; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class JudgementServiceRequest { + + private Long defenseSessionId; + private Long memberId; + private Long problemNumber; + private Language language; + private String sourceCode; + private SubmitVisibility submitVisibility; + private LocalDateTime nowDateTime; + + @Builder + private JudgementServiceRequest(Long defenseSessionId, Long memberId, Long problemNumber, Language language, String sourceCode, SubmitVisibility submitVisibility, LocalDateTime nowDateTime) { + this.defenseSessionId = defenseSessionId; + this.memberId = memberId; + this.problemNumber = problemNumber; + this.language = language; + this.sourceCode = sourceCode; + this.submitVisibility = submitVisibility; + this.nowDateTime = nowDateTime; + } +} + diff --git a/src/main/java/kr/co/morandi/backend/judgement/application/request/cookie/BaekjoonMemberCookieServiceRequest.java b/src/main/java/kr/co/morandi/backend/judgement/application/request/cookie/BaekjoonMemberCookieServiceRequest.java new file mode 100644 index 00000000..222394f2 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/application/request/cookie/BaekjoonMemberCookieServiceRequest.java @@ -0,0 +1,10 @@ +package kr.co.morandi.backend.judgement.application.request.cookie; + + +import java.time.LocalDateTime; + +public record BaekjoonMemberCookieServiceRequest( + String cookie, + Long memberId, + LocalDateTime nowDateTime +) {} diff --git a/src/main/java/kr/co/morandi/backend/judgement/application/service/SubmitFacade.java b/src/main/java/kr/co/morandi/backend/judgement/application/service/SubmitFacade.java new file mode 100644 index 00000000..d58280ee --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/application/service/SubmitFacade.java @@ -0,0 +1,43 @@ +package kr.co.morandi.backend.judgement.application.service; + +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.judgement.application.service.baekjoon.result.JudgementResultSubscriber; +import kr.co.morandi.backend.judgement.domain.model.submit.SubmitVisibility; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +@Slf4j +@RequiredArgsConstructor +public class SubmitFacade { + + private final SubmitStrategy submitStrategy; + private final JudgementResultSubscriber judgementResultSubscriber; + + /* + * 외부 API이기 때문에 트랜잭션 내에서 수행하지 않고 + * 비동기로 처리한다. + * */ + @Async("submitBaekjoonApiExecutor") + public void asyncProcessSubmitAndSubscribeJudgement(final Long submitId, + final Long memberId, + final Problem problem, + final Language language, + final String sourceCode, + final SubmitVisibility submitVisibility, + final LocalDateTime nowDateTime) { + log.info("Submit and Subscribe Judgement submitId: {}, baekjoonProblemId: {}, language: {}, submitVisibility: {}", + submitId, problem.getBaekjoonProblemId(), language, submitVisibility); + final String solutionId = submitStrategy.submit(memberId, language, problem, sourceCode, submitVisibility, nowDateTime); + /* + * solutionId를 바탕으로 websocket을 채널을 등록하는 로직 + * */ + judgementResultSubscriber.subscribeJudgement(solutionId, submitId); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/application/service/SubmitStrategy.java b/src/main/java/kr/co/morandi/backend/judgement/application/service/SubmitStrategy.java new file mode 100644 index 00000000..646a34ed --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/application/service/SubmitStrategy.java @@ -0,0 +1,11 @@ +package kr.co.morandi.backend.judgement.application.service; + +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.judgement.domain.model.submit.SubmitVisibility; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; + +import java.time.LocalDateTime; + +public interface SubmitStrategy { + String submit(Long memberId, Language language, Problem problem, String sourceCode, SubmitVisibility submitVisibility, LocalDateTime nowDateTime); +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/application/service/baekjoon/cookie/BaekjoonCookieManager.java b/src/main/java/kr/co/morandi/backend/judgement/application/service/baekjoon/cookie/BaekjoonCookieManager.java new file mode 100644 index 00000000..47f4d555 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/application/service/baekjoon/cookie/BaekjoonCookieManager.java @@ -0,0 +1,64 @@ +package kr.co.morandi.backend.judgement.application.service.baekjoon.cookie; + + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.domain.error.BaekjoonCookieErrorCode; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie.BaekjoonCookie; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie.BaekjoonGlobalCookie; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie.BaekjoonMemberCookie; +import kr.co.morandi.backend.judgement.infrastructure.persistence.baekjoon.BaekjoonGlobalCookieRepository; +import kr.co.morandi.backend.judgement.infrastructure.persistence.baekjoon.BaekjoonMemberCookieRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class BaekjoonCookieManager { + + private final BaekjoonMemberCookieRepository memberCookieRepository; + private final BaekjoonGlobalCookieRepository globalCookieRepository; + + public String getCurrentMemberCookie(final Long memberId, final LocalDateTime now) { + return memberCookieRepository.findBaekjoonMemberCookieByMember_MemberId(memberId) + .map(cookie -> this.getValidCookieValue(cookie, now)) + .orElseGet(() -> this.getGlobalCookie(now)); + // 사용자 쿠키가 유효하지 않으면 임시방편으로 글로벌 쿠키를 가져와서 채점하도록 유도함 + + // TODO 사용자 쿠키를 재발급하도록 어떻게 알려줄 지 + } + private String getValidCookieValue(BaekjoonMemberCookie memberCookie, LocalDateTime nowDateTime) { + if(memberCookie.isValidCookie(nowDateTime)) { + return memberCookie.getBaekjoonCookie() + .getValue(); + } + log.warn("Member cookie가 유효하지 않습니다. memberId: {}", memberCookie.getMember().getMemberId()); + return getGlobalCookie(nowDateTime); + } + + private String getGlobalCookie(LocalDateTime nowDateTime) { + List validCookies = globalCookieRepository.findValidGlobalCookies(nowDateTime); + if (validCookies.isEmpty()) { + log.warn("Global cookie가 존재하지 않습니다."); + throw new MorandiException(BaekjoonCookieErrorCode.NOT_EXIST_GLOBAL_COOKIE); + } + + Collections.shuffle(validCookies); + // 등록된 쿠키가 여러 개일 때, 여러 쿠키가 균일하게 사용되도록 하기 위해 + + return validCookies.stream() + .filter(cookie -> cookie.isValidCookie(nowDateTime)) + .map(BaekjoonGlobalCookie::getBaekjoonCookie) + .map(BaekjoonCookie::getValue) + .findFirst() + .orElseThrow(() -> { + log.warn("Global cookie가 존재하지 않습니다."); + return new MorandiException(BaekjoonCookieErrorCode.NOT_EXIST_GLOBAL_COOKIE); + }); + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/application/service/baekjoon/cookie/BaekjoonMemberCookieService.java b/src/main/java/kr/co/morandi/backend/judgement/application/service/baekjoon/cookie/BaekjoonMemberCookieService.java new file mode 100644 index 00000000..df7fb74c --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/application/service/baekjoon/cookie/BaekjoonMemberCookieService.java @@ -0,0 +1,35 @@ +package kr.co.morandi.backend.judgement.application.service.baekjoon.cookie; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.application.request.cookie.BaekjoonMemberCookieServiceRequest; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie.BaekjoonMemberCookie; +import kr.co.morandi.backend.judgement.infrastructure.persistence.baekjoon.BaekjoonMemberCookieRepository; +import kr.co.morandi.backend.member_management.domain.model.error.MemberErrorCode; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class BaekjoonMemberCookieService { + + private final MemberRepository memberRepository; + + @Transactional + public void saveMemberBaekjoonCookie(BaekjoonMemberCookieServiceRequest request) { + final Long memberId = request.memberId(); + final String cookie = request.cookie(); + final LocalDateTime nowDateTime = request.nowDateTime(); + + final Member member = memberRepository.findMemberJoinFetchCookie(memberId) + .orElseThrow(() -> new MorandiException(MemberErrorCode.MEMBER_NOT_FOUND)); + + member.saveBaekjoonCookie(cookie, nowDateTime); + } +} + diff --git a/src/main/java/kr/co/morandi/backend/judgement/application/service/baekjoon/result/BaekjoonJudgementStatus.java b/src/main/java/kr/co/morandi/backend/judgement/application/service/baekjoon/result/BaekjoonJudgementStatus.java new file mode 100644 index 00000000..94499a67 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/application/service/baekjoon/result/BaekjoonJudgementStatus.java @@ -0,0 +1,79 @@ +package kr.co.morandi.backend.judgement.application.service.baekjoon.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.result.BaekjoonResultType; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class BaekjoonJudgementStatus { + + @JsonProperty("result") + private BaekjoonResultType result; + + @JsonProperty("progress") + private Integer progress; + + @JsonProperty("memory") + private Integer memory; + + @JsonProperty("time") + private Integer time; + + @JsonProperty("subtask_score") + private Double subtaskScore; + + @JsonProperty("partial_score") + private Double partialScore; + + @JsonProperty("ac") + private Integer ac; + + @JsonProperty("tot") + private Integer tot; + + @JsonProperty("feedback") + private String feedback; + + @JsonProperty("rte_reason") + private String rteReason; + + @JsonProperty("remain") + private Integer remain; + + public boolean isAccepted() { + return this.getResult().equals(BaekjoonResultType.CORRECT); + } + + public boolean isRejected() { + return this.getResult().equals(BaekjoonResultType.WRONG_ANSWER) || this.getResult().equals(BaekjoonResultType.RUNTIME_ERROR) + || this.getResult().equals(BaekjoonResultType.COMPILE_ERROR) || this.getResult().equals(BaekjoonResultType.TIME_LIMIT_EXCEEDED) + || this.getResult().equals(BaekjoonResultType.OTHER); + } + + public boolean isFinalResult() { + BaekjoonResultType baekjoonResultType = this.getResult(); + return baekjoonResultType.equals(BaekjoonResultType.CORRECT) || baekjoonResultType.equals(BaekjoonResultType.WRONG_ANSWER) + || baekjoonResultType.equals(BaekjoonResultType.RUNTIME_ERROR) || baekjoonResultType.equals(BaekjoonResultType.COMPILE_ERROR) + || baekjoonResultType.equals(BaekjoonResultType.TIME_LIMIT_EXCEEDED) || baekjoonResultType.equals(BaekjoonResultType.OTHER); + } + + @Builder + private BaekjoonJudgementStatus(BaekjoonResultType result, Integer progress, Integer memory, Integer time, + Double subtaskScore, Double partialScore, Integer ac, Integer tot, String feedback, String rteReason, Integer remain) { + this.result = result; + this.progress = progress; + this.memory = memory; + this.time = time; + this.subtaskScore = subtaskScore; + this.partialScore = partialScore; + this.ac = ac; + this.tot = tot; + this.feedback = feedback; + this.rteReason = rteReason; + this.remain = remain; + } +} \ No newline at end of file diff --git a/src/main/java/kr/co/morandi/backend/judgement/application/service/baekjoon/result/JudgementResultSubscriber.java b/src/main/java/kr/co/morandi/backend/judgement/application/service/baekjoon/result/JudgementResultSubscriber.java new file mode 100644 index 00000000..7c7f621e --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/application/service/baekjoon/result/JudgementResultSubscriber.java @@ -0,0 +1,82 @@ +package kr.co.morandi.backend.judgement.application.service.baekjoon.result; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.domain.error.JudgementResultErrorCode; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.result.BaekjoonJudgementResult; +import kr.co.morandi.backend.judgement.domain.model.submit.JudgementStatus; +import kr.co.morandi.backend.judgement.domain.service.BaekjoonJudgementService; +import kr.co.morandi.backend.judgement.infrastructure.baekjoon.result.PusherService; +import kr.co.morandi.backend.judgement.infrastructure.helper.JudgementStatusMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class JudgementResultSubscriber { + + private final PusherService pusherService; + private final ObjectMapper objectMapper; + + // TODO Subscriber가 고수준 Service를 의존하는 것을 해결하고 싶음 + // 1. 인터페이스를 만들어 의존성을 주입하는 방법 + // 2. 이벤트를 이용하는 방법 + // 3. 콜백함수를 이용하는 방법 + // + // Pusher에서 실행하는 콜백함수에서 BaekjoonJudgementService를 사용하게 될 것이고, 저장 행위가 비동기로 이루어져야함 + private final BaekjoonJudgementService baekjoonJudgementService; + private static final String CHANNEL_FORMAT = "solution-%s"; + + public void subscribeJudgement(final String solutionId, final Long submitId) { + /* + * solutionId를 바탕으로 pusher 채널을 구독하는 로직 + * */ + final String solutionChannelId = String.format(CHANNEL_FORMAT, solutionId); + + /* + * 콜백 함수를 파라미터로 전달하여 Listener에서 메세지가 도착하면 콜백함수를 실행하도록 구현 + * */ + pusherService.subscribeJudgement(solutionChannelId, submitId, this::handleResult); + } + + + /* + * 등록된 콜백함수에서 메세지가 도착하면 실행되는 함수 + * + * 결과를 파싱하여 저장하는 로직 + * */ + private void handleResult(final Long submitId, final String data) { + final BaekjoonJudgementStatus baekjoonJudgementStatus = parseJudgementStatus(data); + + if(baekjoonJudgementStatus.isFinalResult()) { + pusherService.unsubscribeJudgement(String.format(CHANNEL_FORMAT, submitId)); + + log.info("BaekjoonJudgement : submitId: {}, status: {}", submitId, baekjoonJudgementStatus.getResult()); + + final JudgementStatus judgementStatus = JudgementStatusMapper.mapToJudgementStatus(baekjoonJudgementStatus.getResult()); + + final Integer memory = baekjoonJudgementStatus.getMemory(); + final Integer time = baekjoonJudgementStatus.getTime(); + + // JudgementStatus를 바탕으로 DB에 저장하는 로직 + // 비동기로 처리해야 PusherService의 스레드가 블로킹되지 않음 + baekjoonJudgementService.asyncUpdateJudgementStatus(submitId, judgementStatus, memory, time, BaekjoonJudgementResult.defaultResult()); + } + } + + private BaekjoonJudgementStatus parseJudgementStatus(String data) { + try { + final BaekjoonJudgementStatus baekjoonJudgementStatus = objectMapper.readValue(data, BaekjoonJudgementStatus.class); + if(baekjoonJudgementStatus != null) { + return baekjoonJudgementStatus; + } + } catch (JsonProcessingException e) { + throw new MorandiException(JudgementResultErrorCode.INVALID_JUDGEMENT_RESULT); + } + throw new MorandiException(JudgementResultErrorCode.INVALID_JUDGEMENT_RESULT); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/application/service/baekjoon/submit/BaekjoonSubmitStrategy.java b/src/main/java/kr/co/morandi/backend/judgement/application/service/baekjoon/submit/BaekjoonSubmitStrategy.java new file mode 100644 index 00000000..fd96fe14 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/application/service/baekjoon/submit/BaekjoonSubmitStrategy.java @@ -0,0 +1,37 @@ +package kr.co.morandi.backend.judgement.application.service.baekjoon.submit; + +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.judgement.application.service.SubmitStrategy; +import kr.co.morandi.backend.judgement.application.service.baekjoon.cookie.BaekjoonCookieManager; +import kr.co.morandi.backend.judgement.domain.model.submit.SubmitVisibility; +import kr.co.morandi.backend.judgement.infrastructure.baekjoon.submit.BaekjoonSubmitApiAdapter; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class BaekjoonSubmitStrategy implements SubmitStrategy { + + private final BaekjoonSubmitApiAdapter baekjoonSubmitApiAdapter; + private final BaekjoonCookieManager baekjoonCookieManager; + + @Override + public String submit(final Long memberId, + final Language language, + final Problem problem, + final String sourceCode, + final SubmitVisibility submitVisibility, + final LocalDateTime nowDateTime) { + final String baejoonProblemId = String.valueOf(problem.getBaekjoonProblemId()); + final String cookie = baekjoonCookieManager.getCurrentMemberCookie(memberId, nowDateTime); + /* + * 제출을 하고 솔루션 아이디를 가져오는 메소드 + * */ + return baekjoonSubmitApiAdapter.submitAndGetSolutionId(baejoonProblemId, cookie, language, sourceCode, submitVisibility.getValue().toLowerCase()); + } + + +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/application/usecase/submit/BaekjoonSubmitUsecase.java b/src/main/java/kr/co/morandi/backend/judgement/application/usecase/submit/BaekjoonSubmitUsecase.java new file mode 100644 index 00000000..bcbe6fa9 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/application/usecase/submit/BaekjoonSubmitUsecase.java @@ -0,0 +1,105 @@ +package kr.co.morandi.backend.judgement.application.usecase.submit; + + +import kr.co.morandi.backend.common.annotation.Usecase; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_management.application.port.out.session.DefenseSessionPort; +import kr.co.morandi.backend.defense_management.domain.error.SessionErrorCode; +import kr.co.morandi.backend.defense_management.domain.model.session.DefenseSession; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.defense_record.application.port.out.record.RecordPort; +import kr.co.morandi.backend.defense_record.domain.error.RecordErrorCode; +import kr.co.morandi.backend.defense_record.domain.model.record.Detail; +import kr.co.morandi.backend.defense_record.domain.model.record.Record; +import kr.co.morandi.backend.judgement.application.port.out.BaekjoonSubmitPort; +import kr.co.morandi.backend.judgement.application.request.JudgementServiceRequest; +import kr.co.morandi.backend.judgement.application.service.SubmitFacade; +import kr.co.morandi.backend.judgement.domain.event.TempCodeSaveEvent; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.submit.BaekjoonSubmit; +import kr.co.morandi.backend.judgement.domain.model.submit.SourceCode; +import kr.co.morandi.backend.judgement.domain.model.submit.SubmitVisibility; +import kr.co.morandi.backend.member_management.application.port.out.member.MemberPort; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.exception.OAuthErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Usecase +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class BaekjoonSubmitUsecase { + + private final BaekjoonSubmitPort baekjoonSubmitPort; + private final DefenseSessionPort defenseSessionport; + private final RecordPort recordPort; + private final MemberPort memberPort; + private final SubmitFacade submitFacade; + private final ApplicationEventPublisher applicationEventPublisher; + + @Transactional + public void judgement(final JudgementServiceRequest request) { + final Long memberId = request.getMemberId(); + final Long defenseSessionId = request.getDefenseSessionId(); + final Language language = request.getLanguage(); + final String sourceCode = request.getSourceCode(); + final Long problemNumber = request.getProblemNumber(); + final SubmitVisibility submitVisibility = request.getSubmitVisibility(); + final LocalDateTime nowDateTime = request.getNowDateTime(); + + DefenseSession defenseSession = defenseSessionport.findDefenseSessionById(defenseSessionId) + .orElseThrow(() -> new MorandiException(SessionErrorCode.SESSION_NOT_FOUND)); + + Member mebmer = memberPort.findById(memberId) + .orElseThrow(() -> new MorandiException(OAuthErrorCode.MEMBER_NOT_FOUND)); + + defenseSession.validateSessionOwner(memberId); + + /* + * 시험이 종료되어 있는지 확인한다. + * */ + if (defenseSession.isTerminated()) { + throw new MorandiException(SessionErrorCode.SESSION_ALREADY_ENDED); + } + + /* + * DefenseSession에 대응하는 + * Record를 찾아온다. Fetch Join을 통해 Detail, Problem을 같이 가져온다. + * */ + final Record defenseRecord = recordPort.findRecordFetchJoinWithDetailAndProblem(defenseSession.getRecordId()) + .orElseThrow(() -> new MorandiException(RecordErrorCode.RECORD_NOT_FOUND)); + + /* + * Record가 종료되어 있는지 확인한다. + * */ + if (defenseRecord.isTerminated()) { + throw new MorandiException(RecordErrorCode.RECORD_ALREADY_TERMINATED); + } + /* + * 문제 번호로 Detail을 찾아온다. + * */ + final Detail detail = defenseRecord.getDetail(problemNumber); + /* + * 제출 기록을 저장한다. + * */ + final BaekjoonSubmit submit = BaekjoonSubmit.submit(mebmer, detail, SourceCode.of(sourceCode, language), nowDateTime, submitVisibility); + final BaekjoonSubmit savedSubmit = baekjoonSubmitPort.save(submit); + + /* + * 외부 API 요청이 트랜잭션을 잡고 있는 것을 최소화하기 위함 + * + * 채점 시작을 비동기 별도 스레드로 처리하고 + * 채점 결과를 받아서 성공하면 그 결과를 채점 기록에 저장한다. + * */ + submitFacade.asyncProcessSubmitAndSubscribeJudgement(savedSubmit.getSubmitId(), memberId, + detail.getProblem(), language, sourceCode, submitVisibility, nowDateTime); + + /* + * 비동기로 시험 채점 서비스를 호출했던 코드를 TempCode에 저장한다. + * 이 요청은 이벤트로 발행하여 TempCode 저장이 비즈니스 로직 실행에 영향을 주지 않도록 한다. + * */ + applicationEventPublisher.publishEvent(new TempCodeSaveEvent(defenseSessionId, problemNumber, sourceCode, language)); + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/domain/error/BaekjoonCookieErrorCode.java b/src/main/java/kr/co/morandi/backend/judgement/domain/error/BaekjoonCookieErrorCode.java new file mode 100644 index 00000000..a804a788 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/domain/error/BaekjoonCookieErrorCode.java @@ -0,0 +1,34 @@ +package kr.co.morandi.backend.judgement.domain.error; + +import kr.co.morandi.backend.common.exception.errorcode.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum BaekjoonCookieErrorCode implements ErrorCode { + + INVALID_COOKIE_VALUE(HttpStatus.BAD_REQUEST, "쿠키 값을 생성하는데 필요한 정보가 부족합니다."), + ALREADY_LOGGED_OUT(HttpStatus.BAD_REQUEST, "이미 로그아웃된 쿠키입니다."), + BAEKJOON_COOKIE_NOT_FOUND(HttpStatus.BAD_REQUEST, "백준 쿠키를 찾을 수 없습니다."), + + // 관리자 + INVALID_GLOBAL_USER_ID(HttpStatus.BAD_REQUEST, "글로벌 유저 아이디는 null이거나 빈 문자열일 수 없습니다."), + INVALID_BAEKJOON_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "백준 관리자 Refresh Token은 null이거나 빈 문자열일 수 없습니다."), + NOT_EXIST_GLOBAL_COOKIE(HttpStatus.INTERNAL_SERVER_ERROR, "현재 유효한 관리 쿠키가 존재하지 않습니다. 관리자에게 문의하세요."), ; + + + private final HttpStatus httpStatus; + private final String message; + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/domain/error/JudgementResultErrorCode.java b/src/main/java/kr/co/morandi/backend/judgement/domain/error/JudgementResultErrorCode.java new file mode 100644 index 00000000..f1e61757 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/domain/error/JudgementResultErrorCode.java @@ -0,0 +1,56 @@ +package kr.co.morandi.backend.judgement.domain.error; + +import kr.co.morandi.backend.common.exception.errorcode.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum JudgementResultErrorCode implements ErrorCode { + JUDGEMENT_RESULT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 결과를 찾을 수 없습니다."), + AC_GREATER_THAN_TOT(HttpStatus.INTERNAL_SERVER_ERROR, "ac가 tot보다 큽니다."), + AC_OR_TOT_IS_NULL(HttpStatus.INTERNAL_SERVER_ERROR, "ac와 tot의 값이 null입니다."), + AC_OR_TOT_IS_NEGATIVE(HttpStatus.INTERNAL_SERVER_ERROR, "ac와 tot의 값이 음수입니다."), + + MEMORY_IS_NULL(HttpStatus.INTERNAL_SERVER_ERROR, "memory 값이 null입니다."), + MEMORY_IS_NEGATIVE(HttpStatus.INTERNAL_SERVER_ERROR, "memory 값이 음수입니다."), + + TIME_IS_NULL(HttpStatus.INTERNAL_SERVER_ERROR, "time 값이 null입니다."), + TIME_IS_NEGATIVE(HttpStatus.INTERNAL_SERVER_ERROR, "time 값이 음수입니다."), + + SUBTASK_SCORE_IS_NULL(HttpStatus.INTERNAL_SERVER_ERROR, "subtaskScore 값이 null입니다."), + SUBTASK_SCORE_IS_NEGATIVE(HttpStatus.INTERNAL_SERVER_ERROR, "subtaskScore 값이 올바르지 않습니다."), + + PARTIAL_SCORE_IS_NULL(HttpStatus.INTERNAL_SERVER_ERROR, "partialScore 값이 null입니다."), + PARTIAL_SCORE_IS_NEGATIVE(HttpStatus.INTERNAL_SERVER_ERROR, "partialScore 값이 올바르지 않습니다."), + + CORRECT_INFO_WHEN_NOT_CORRECT(HttpStatus.INTERNAL_SERVER_ERROR, "정답 상태가 아닐 경우 정답 정보는 초기상태여야 합니다."), + + RESULT_CODE_IS_NULL(HttpStatus.BAD_REQUEST, "결과 코드는 null일 수 없습니다"), + + + RESULT_INFO_WHEN_CORRECT(HttpStatus.INTERNAL_SERVER_ERROR, "정답 상태일 때는 결과 정보를 업데이트 할 수 없습니다."), + TRIAL_NUMBER_IS_NULL(HttpStatus.BAD_REQUEST, "시도 횟수는 null일 수 없습니다."), + TRIAL_NUMBER_IS_NEGATIVE(HttpStatus.BAD_REQUEST, "시도 횟수는 음수일 수 없습니다."), + ALREADY_ACCEPTED(HttpStatus.BAD_REQUEST, "이미 정답 처리된 결과입니다."), + SUBMIT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 제출을 찾을 수 없습니다."), + INVALID_JUDGEMENT_RESULT(HttpStatus.INTERNAL_SERVER_ERROR, "백준으로부터 받은 채점 결과 응답이 올바르지 않습니다."), + ALREADY_JUDGED(HttpStatus.BAD_REQUEST, "이미 채점된 결과입니다."), + ACCEPT_MUST_HAVE_MEMORY_AND_TIME(HttpStatus.BAD_REQUEST, "정답 상태일 때는 메모리와 시간이 있어야 합니다."), + NOT_ACCEPTED_CANNOT_HAVE_MEMORY_AND_TIME(HttpStatus.BAD_REQUEST, "정답 상태가 아닐 때는 메모리와 시간이 있을 수 없습니다."); + + + private final HttpStatus httpStatus; + private final String message; + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/domain/error/SubmitErrorCode.java b/src/main/java/kr/co/morandi/backend/judgement/domain/error/SubmitErrorCode.java new file mode 100644 index 00000000..e7a7b11c --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/domain/error/SubmitErrorCode.java @@ -0,0 +1,55 @@ +package kr.co.morandi.backend.judgement.domain.error; + +import kr.co.morandi.backend.common.exception.errorcode.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum SubmitErrorCode implements ErrorCode { + + LANGUAGE_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "언어 코드를 찾을 수 없습니다."), + BAEKJOON_SUBMIT_PAGE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "백준 제출 페이지를 가져오는 중 오류가 발생했습니다."), + CANT_FIND_SOLUTION_ID(HttpStatus.INTERNAL_SERVER_ERROR, "제출 결과 페이지에서 솔루션 ID를 찾을 수 없습니다."), + BAEKJOON_SUBMIT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "백준 제출 중 오류가 발생했습니다."), + CSRF_KEY_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "제출 페이지에서 CSRF 키를 찾을 수 없습니다."), + REDIRECTION_LOCATION_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "제출 후 리다이렉션 위치를 찾을 수 없습니다."), + + //제출 결과 저장 시 BAD_REQUEST + SOURCE_CODE_NOT_FOUND(HttpStatus.BAD_REQUEST, "제출할 소스코드가 비어있습니다."), + SUBMIT_VISIBILITY_NOT_FOUND(HttpStatus.BAD_REQUEST, "제출 공개 여부를 찾을 수 없습니다."), + PROBLEM_NUMBER_NOT_FOUND(HttpStatus.BAD_REQUEST, "문제 번호를 찾을 수 없습니다."), + DEFENSE_SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "디펜스 세션을 찾을 수 없습니다."), + LANGUAGE_NOT_FOUND(HttpStatus.BAD_REQUEST, "언어를 찾을 수 없습니다."), + VISIBILITY_NOT_NULL(HttpStatus.BAD_REQUEST, "공개 여부는 null이 될 수 없습니다."), + + DETAIL_IS_NULL(HttpStatus.BAD_REQUEST, "DEFENSE DETAIL 값이 null입니다."), + PROBLEM_NUMBER_IS_NULL(HttpStatus.BAD_REQUEST, "문제 번호가 null입니다."), + + PROBLEM_NUMBER_IS_NEGATIVE(HttpStatus.BAD_REQUEST, "문제 번호가 음수입니다."), + VISIBILITY_IS_NULL(HttpStatus.BAD_REQUEST, "공개 여부가 null입니다."), + INVALID_VISIBILITY_VALUE(HttpStatus.BAD_REQUEST, "공개 여부는 OPEN 또는 CLOSE이어야 합니다."), + SOURCE_CODE_IS_NULL(HttpStatus.BAD_REQUEST, "제출 코드가 null일 수 없습니다."), + TRIAL_NUMBER_IS_NULL(HttpStatus.BAD_REQUEST, "시도 횟수가 null일 수 없습니다."), + TRIAL_NUMBER_IS_NEGATIVE(HttpStatus.BAD_REQUEST, "시도 횟수가 음수입니다."), + SUBMIT_DATE_TIME_IS_NULL(HttpStatus.BAD_REQUEST, "제출 시간이 null일 수 없습니다."); + + + + private final HttpStatus httpStatus; + private final String message; + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getMessage() { + return message; + } + + +} + diff --git a/src/main/java/kr/co/morandi/backend/judgement/domain/event/TempCodeSaveEvent.java b/src/main/java/kr/co/morandi/backend/judgement/domain/event/TempCodeSaveEvent.java new file mode 100644 index 00000000..775538a4 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/domain/event/TempCodeSaveEvent.java @@ -0,0 +1,12 @@ +package kr.co.morandi.backend.judgement.domain.event; + +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import lombok.AllArgsConstructor; +import lombok.Getter; + +public record TempCodeSaveEvent( + Long defenseSessionId, + Long problemNumber, + String sourceCode, + Language language +) {} diff --git a/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonCookie.java b/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonCookie.java new file mode 100644 index 00000000..f94efbc6 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonCookie.java @@ -0,0 +1,83 @@ +package kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.domain.error.BaekjoonCookieErrorCode; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BaekjoonCookie { + + @Column(name = "baekjoon_cookie") + private String value; + private LocalDateTime expiredAt; + + @Enumerated(EnumType.STRING) + private CookieStatus cookieStatus; + + public void updateCookie(String cookie, LocalDateTime nowDateTime) { + this.value = validateCookie(cookie); + this.expiredAt = calculateCookieExpiredAt(nowDateTime); + this.cookieStatus = CookieStatus.LOGGED_IN; + } + public void setLoggedOut(LocalDateTime nowDateTime){ + if(this.cookieStatus == CookieStatus.LOGGED_OUT) { + throw new MorandiException(BaekjoonCookieErrorCode.ALREADY_LOGGED_OUT); + } + this.expiredAt = nowDateTime; + this.cookieStatus = CookieStatus.LOGGED_OUT; + } + + protected boolean isValidCookie(LocalDateTime nowDateTime) { + if (cookieStatus == CookieStatus.LOGGED_OUT) { + return false; + } + if (nowDateTime.isBefore(expiredAt)) { + return true; + } + logOutCookie(); + return false; + } + + private void logOutCookie() { + this.cookieStatus = CookieStatus.LOGGED_OUT; + } + + public static BaekjoonCookie of(String cookie, LocalDateTime nowDateTime) { + return BaekjoonCookie.builder() + .cookie(cookie) + .nowDateTime(nowDateTime) + .build(); + } + + private String validateCookie(String cookie) { + if(cookie != null && !cookie.trim().isEmpty()) { + return cookie; + } + throw new MorandiException(BaekjoonCookieErrorCode.INVALID_COOKIE_VALUE); + } + + private LocalDateTime calculateCookieExpiredAt(LocalDateTime cookieCreatedAt) { + if(cookieCreatedAt == null) { + throw new MorandiException(BaekjoonCookieErrorCode.INVALID_COOKIE_VALUE); + } + return cookieCreatedAt.plusHours(6); + } + + @Builder + private BaekjoonCookie(String cookie, LocalDateTime nowDateTime) { + this.value = validateCookie(cookie); + this.expiredAt = calculateCookieExpiredAt(nowDateTime); + this.cookieStatus = CookieStatus.LOGGED_IN; + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonGlobalCookie.java b/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonGlobalCookie.java new file mode 100644 index 00000000..caf31c94 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonGlobalCookie.java @@ -0,0 +1,57 @@ +package kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie; + +import jakarta.persistence.*; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.domain.error.BaekjoonCookieErrorCode; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BaekjoonGlobalCookie { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long baekjoonCookieId; + + @Embedded + private BaekjoonCookie baekjoonCookie; + + private String globalUserId; + + // 백준의 Refresh Token을 저장하는 필드 + private String baekjoonRefreshToken; + + public boolean isValidCookie(LocalDateTime nowDateTime) { + return baekjoonCookie.isValidCookie(nowDateTime); + } + + public static BaekjoonGlobalCookie create(BaekjoonCookie baekjoonCookie, String globalUserId, String refreshToken) { + return new BaekjoonGlobalCookie(baekjoonCookie, globalUserId, refreshToken); + } + + @Builder + private BaekjoonGlobalCookie(BaekjoonCookie baekjoonCookie, String globalUserId, String baekjoonRefreshToken) { + validateGlobalUserId(globalUserId); + validateBaekjoonRefreshToken(baekjoonRefreshToken); + this.baekjoonCookie = baekjoonCookie; + this.globalUserId = globalUserId; + this.baekjoonRefreshToken = baekjoonRefreshToken; + } + + private void validateGlobalUserId(String globalUserId) { + if (globalUserId == null || globalUserId.trim().isEmpty()) { + throw new MorandiException(BaekjoonCookieErrorCode.INVALID_GLOBAL_USER_ID); + } + } + + private void validateBaekjoonRefreshToken(String baekjoonRefreshToken) { + if (baekjoonRefreshToken == null || baekjoonRefreshToken.trim().isEmpty()) { + throw new MorandiException(BaekjoonCookieErrorCode.INVALID_BAEKJOON_REFRESH_TOKEN); + } + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonMemberCookie.java b/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonMemberCookie.java new file mode 100644 index 00000000..63907ec2 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonMemberCookie.java @@ -0,0 +1,49 @@ +package kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie; + +import jakarta.persistence.*; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BaekjoonMemberCookie { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long baekjoonCookieId; + + @Embedded + private BaekjoonCookie baekjoonCookie; + + @OneToOne(fetch = FetchType.LAZY) + private Member member; + + public void updateCookie(String cookie, LocalDateTime nowDateTime) { + baekjoonCookie.updateCookie(cookie, nowDateTime); + } + public void setLoggedOut(LocalDateTime nowDateTime) { + baekjoonCookie.setLoggedOut(nowDateTime); + } + public boolean isValidCookie(LocalDateTime nowDateTime) { + return baekjoonCookie.isValidCookie(nowDateTime); + } + @Builder + private BaekjoonMemberCookie(String cookie, LocalDateTime nowDateTime, Member member) { + this(BaekjoonCookie.builder() + .cookie(cookie) + .nowDateTime(nowDateTime) + .build(), member); + } + + private BaekjoonMemberCookie(BaekjoonCookie baekjoonCookie, Member member) { + this.baekjoonCookie = baekjoonCookie; + this.member = member; + } + +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/CookieStatus.java b/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/CookieStatus.java new file mode 100644 index 00000000..c2c15165 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/CookieStatus.java @@ -0,0 +1,5 @@ +package kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie; + +public enum CookieStatus { + LOGGED_IN, LOGGED_OUT +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/result/BaekjoonJudgementResult.java b/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/result/BaekjoonJudgementResult.java new file mode 100644 index 00000000..0bbbf1b7 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/result/BaekjoonJudgementResult.java @@ -0,0 +1,76 @@ +package kr.co.morandi.backend.judgement.domain.model.baekjoon.result; + +import jakarta.persistence.Embeddable; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.domain.error.JudgementResultErrorCode; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BaekjoonJudgementResult { + + private Integer subtaskScore; + private Integer partialScore; + private Integer ac; + private Integer tot; + + private static final Integer INITIAL_SUBTASK_SCORE = 0; + private static final Integer INITIAL_PARTIAL_SCORE = 0; + private static final Integer INITIAL_AC = 0; + private static final Integer INITIAL_TOT = 0; + + public static BaekjoonJudgementResult defaultResult() { + return new BaekjoonJudgementResult(INITIAL_SUBTASK_SCORE, INITIAL_PARTIAL_SCORE, INITIAL_AC, INITIAL_TOT); + } + + public static BaekjoonJudgementResult subtaskScoreFrom(Integer subtaskScore) { + return new BaekjoonJudgementResult(subtaskScore, INITIAL_PARTIAL_SCORE, INITIAL_AC, INITIAL_TOT); + } + + public static BaekjoonJudgementResult partialScoreFrom(Integer partialScore) { + return new BaekjoonJudgementResult(INITIAL_SUBTASK_SCORE, partialScore, INITIAL_AC, INITIAL_TOT); + } + + public static BaekjoonJudgementResult acTotOf(Integer ac, Integer tot) { + return new BaekjoonJudgementResult(INITIAL_SUBTASK_SCORE, INITIAL_PARTIAL_SCORE, ac, tot); + } + + private BaekjoonJudgementResult(Integer subtaskScore, Integer partialScore, Integer ac, Integer tot) { + validateSubtaskScore(subtaskScore); + this.subtaskScore = subtaskScore; + + validatePartialScore(partialScore); + this.partialScore = partialScore; + + validateAcTot(ac, tot); + this.ac = ac; + this.tot = tot; + } + + private void validateSubtaskScore(Integer subtaskScore) { + if(subtaskScore == null) + throw new MorandiException(JudgementResultErrorCode.SUBTASK_SCORE_IS_NULL); + if(subtaskScore < 0) + throw new MorandiException(JudgementResultErrorCode.SUBTASK_SCORE_IS_NEGATIVE); + } + + private void validatePartialScore(Integer partialScore) { + if(partialScore == null) + throw new MorandiException(JudgementResultErrorCode.PARTIAL_SCORE_IS_NULL); + if(partialScore < 0) + throw new MorandiException(JudgementResultErrorCode.PARTIAL_SCORE_IS_NEGATIVE); + } + + private void validateAcTot(Integer ac, Integer tot) { + if(ac == null || tot == null) + throw new MorandiException(JudgementResultErrorCode.AC_OR_TOT_IS_NULL); + if(ac < 0 || tot < 0) + throw new MorandiException(JudgementResultErrorCode.AC_OR_TOT_IS_NEGATIVE); + if(ac > tot) + throw new MorandiException(JudgementResultErrorCode.AC_GREATER_THAN_TOT); + + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/result/BaekjoonResultType.java b/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/result/BaekjoonResultType.java new file mode 100644 index 00000000..ca1d1f90 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/result/BaekjoonResultType.java @@ -0,0 +1,45 @@ +package kr.co.morandi.backend.judgement.domain.model.baekjoon.result; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.domain.error.JudgementResultErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +@Getter +@RequiredArgsConstructor +public enum BaekjoonResultType { + CORRECT(4, "맞았습니다!!"), + WRONG_ANSWER(6, "틀렸습니다!!"), + RUNTIME_ERROR(10, "런타임 에러"), + COMPILE_ERROR(11, "컴파일 에러"), + PROGRESS(3, "채점 중"), + COMPILE_PROGRESS(2, "컴파일 중"), + TIME_LIMIT_EXCEEDED(7, "시간 초과"), + MEMORY_LIMIT_EXCEEDED(8, "메모리 초과"), + OUTPUT_LIMIT_EXCEEDED(9, "출력 초과"), + OUTPUT_FORMAT_ERROR(5, "출력 형식이 잘못되었습니다."), + SUBMITTED(-1, "제출됨"), + OTHER(0, "Other"); + + private final int code; + private final String description; + + private static final Map valueMap = Arrays.stream(values()) + .collect(Collectors.toMap(BaekjoonResultType::getCode, e -> e)); + @JsonValue + public int getCode() { + return code; + } + @JsonCreator + public static BaekjoonResultType fromCode(Integer code) { + if(code == null) + throw new MorandiException(JudgementResultErrorCode.RESULT_CODE_IS_NULL); + return valueMap.getOrDefault(code, OTHER); + } +} \ No newline at end of file diff --git a/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/submit/BaekjoonSubmit.java b/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/submit/BaekjoonSubmit.java new file mode 100644 index 00000000..4b969095 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/submit/BaekjoonSubmit.java @@ -0,0 +1,44 @@ +package kr.co.morandi.backend.judgement.domain.model.baekjoon.submit; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import kr.co.morandi.backend.defense_record.domain.model.record.Detail; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.result.BaekjoonJudgementResult; +import kr.co.morandi.backend.judgement.domain.model.submit.JudgementResult; +import kr.co.morandi.backend.judgement.domain.model.submit.Submit; +import kr.co.morandi.backend.judgement.domain.model.submit.SourceCode; +import kr.co.morandi.backend.judgement.domain.model.submit.SubmitVisibility; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@DiscriminatorValue("BaekjoonSubmit") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BaekjoonSubmit extends Submit { + + @Embedded + private BaekjoonJudgementResult baekjoonJudgementResult; + + public static BaekjoonSubmit submit(Member member, Detail detail, SourceCode sourceCode, + LocalDateTime submitDateTime, SubmitVisibility submitVisibility) { + return new BaekjoonSubmit(member, detail, sourceCode, submitDateTime, submitVisibility, null); + } + + public void updateJudgementResult(JudgementResult judgementResult, BaekjoonJudgementResult baekjoonJudgementResult) { + super.updateJudgementResult(judgementResult); + this.baekjoonJudgementResult = baekjoonJudgementResult; + } + @Builder + private BaekjoonSubmit(Member member, Detail detail, SourceCode sourceCode, + LocalDateTime submitDateTime, SubmitVisibility submitVisibility, BaekjoonJudgementResult baekjoonJudgementResult) { + super(member, detail, sourceCode, submitDateTime, submitVisibility); + this.baekjoonJudgementResult = baekjoonJudgementResult; + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/domain/model/submit/JudgementResult.java b/src/main/java/kr/co/morandi/backend/judgement/domain/model/submit/JudgementResult.java new file mode 100644 index 00000000..916dc0ed --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/domain/model/submit/JudgementResult.java @@ -0,0 +1,76 @@ +package kr.co.morandi.backend.judgement.domain.model.submit; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.domain.error.JudgementResultErrorCode; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class JudgementResult { + + @Enumerated(EnumType.STRING) + private JudgementStatus judgementStatus; + + @Column(name = "memory") + private Integer memory; + + @Column(name = "time") + private Integer time; + + private static final Integer INITIAL_MEMORY = 0; + private static final Integer INITIAL_TIME = 0; + + public boolean isAccepted() { + return judgementStatus.equals(JudgementStatus.ACCEPTED); + } + public static JudgementResult submit() { + return new JudgementResult(JudgementStatus.SUBMITTED, INITIAL_MEMORY, INITIAL_TIME); + } + public static JudgementResult accepted(Integer memory, Integer time) { + return new JudgementResult(JudgementStatus.ACCEPTED, memory, time); + } + public static JudgementResult rejected(JudgementStatus judgementStatus) { + return new JudgementResult(judgementStatus, INITIAL_MEMORY, INITIAL_TIME); + } + public void canUpdateJudgementResult() { + if(!judgementStatus.equals(JudgementStatus.SUBMITTED)) + throw new MorandiException(JudgementResultErrorCode.ALREADY_JUDGED); + } + @Builder + private JudgementResult(JudgementStatus judgementStatus, Integer memory, Integer time) { + if(judgementStatus == null) + throw new MorandiException(JudgementResultErrorCode.JUDGEMENT_RESULT_NOT_FOUND); + validateMemory(memory); + validateTime(time); + validateCanExistTimeAndMemory(judgementStatus, memory, time); + + this.judgementStatus = judgementStatus; + this.memory = memory; + this.time = time; + } + private void validateCanExistTimeAndMemory(JudgementStatus judgementStatus, Integer memory, Integer time) { + if(!judgementStatus.equals(JudgementStatus.ACCEPTED) && (memory != 0 || time != 0)) + throw new MorandiException(JudgementResultErrorCode.NOT_ACCEPTED_CANNOT_HAVE_MEMORY_AND_TIME); + } + + private void validateMemory(Integer memory) { + if(memory == null) + throw new MorandiException(JudgementResultErrorCode.MEMORY_IS_NULL); + if(memory < 0) + throw new MorandiException(JudgementResultErrorCode.MEMORY_IS_NEGATIVE); + } + private void validateTime(Integer time) { + if(time == null) + throw new MorandiException(JudgementResultErrorCode.TIME_IS_NULL); + if(time < 0) + throw new MorandiException(JudgementResultErrorCode.TIME_IS_NEGATIVE); + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/domain/model/submit/JudgementStatus.java b/src/main/java/kr/co/morandi/backend/judgement/domain/model/submit/JudgementStatus.java new file mode 100644 index 00000000..4c2a1c3b --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/domain/model/submit/JudgementStatus.java @@ -0,0 +1,38 @@ +package kr.co.morandi.backend.judgement.domain.model.submit; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import jakarta.persistence.Column; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum JudgementStatus { + + ACCEPTED("ACCEPTED"), + PARTIALLY_ACCEPTED("PARTIALLY_ACCEPTED"), + WRONG_ANSWER("WRONG_ANSWER"), + RUNTIME_ERROR("RUNTIME_ERROR"), + COMPILE_ERROR("COMPILE_ERROR"), + TIME_LIMIT_EXCEEDED("TIME_LIMIT_EXCEEDED"), + MEMORY_LIMIT_EXCEEDED("MEMORY_LIMIT_EXCEEDED"), + SUBMITTED("SUBMITTED"); + + @Column(name = "judgement_status") + private final String value; + + @JsonCreator + public static JudgementStatus fromValue(String value) { + for(JudgementStatus status : values()) { + if(status.getValue().equals(value)) { + return status; + } + } + return null; + } + @JsonValue + public String getValue() { + return value; + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/domain/model/submit/SourceCode.java b/src/main/java/kr/co/morandi/backend/judgement/domain/model/submit/SourceCode.java new file mode 100644 index 00000000..e0e158bb --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/domain/model/submit/SourceCode.java @@ -0,0 +1,45 @@ +package kr.co.morandi.backend.judgement.domain.model.submit; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.judgement.domain.error.SubmitErrorCode; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SourceCode { + + @Column(name = "source_code", columnDefinition = "TEXT", nullable = false) + private String sourceCode; + + @Enumerated(EnumType.STRING) + private Language language; + + public void updateSourceCode(String sourceCode) { + validateLength(sourceCode); + this.sourceCode = sourceCode; + } + public static SourceCode of(String sourceCode, Language language) { + return new SourceCode(sourceCode, language); + } + + private void validateLength(String sourceCode) { + if(sourceCode == null || sourceCode.isEmpty()) + throw new MorandiException(SubmitErrorCode.SOURCE_CODE_NOT_FOUND); + } + + @Builder + private SourceCode(String sourceCode, Language language) { + validateLength(sourceCode); + this.sourceCode = sourceCode; + this.language = language; + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/domain/model/submit/Submit.java b/src/main/java/kr/co/morandi/backend/judgement/domain/model/submit/Submit.java new file mode 100644 index 00000000..dc7bc497 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/domain/model/submit/Submit.java @@ -0,0 +1,89 @@ +package kr.co.morandi.backend.judgement.domain.model.submit; + +import jakarta.persistence.*; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.common.model.BaseEntity; +import kr.co.morandi.backend.defense_record.domain.model.record.Detail; +import kr.co.morandi.backend.judgement.domain.error.SubmitErrorCode; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DiscriminatorColumn +@Inheritance(strategy = InheritanceType.JOINED) +public abstract class Submit extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long submitId; + + @ManyToOne(fetch = FetchType.LAZY) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + private Detail detail; + + @Embedded + private SourceCode sourceCode; + + @Enumerated(EnumType.STRING) + private SubmitVisibility submitVisibility; + + @Embedded + private JudgementResult judgementResult; + + private LocalDateTime submitDateTime; + + public void trySolveProblem() { + this.detail.trySolveProblem(this.getSubmitId(), submitDateTime); + } + + protected void updateJudgementResult(JudgementResult judgementResult) { + // 제출 하나의 결과는 한 번 정해지면 변하지 않음 + this.judgementResult.canUpdateJudgementResult(); + this.judgementResult = judgementResult; + + if(judgementResult.isAccepted()) { + this.detail.trySolveProblem(this.getSubmitId(), submitDateTime); + } + } + protected Submit(Member member, Detail detail, SourceCode sourceCode, + LocalDateTime submitDateTime, SubmitVisibility submitVisibility) { + this.member = member; + this.submitDateTime = validateSubmitDateTime(submitDateTime); + this.detail = validateDetail(detail); + this.detail.increaseSubmitCount(); + this.sourceCode = validateSubmitCode(sourceCode); + this.submitVisibility = validateSubmitVisibility(submitVisibility); + this.judgementResult = JudgementResult.submit(); + } + private LocalDateTime validateSubmitDateTime(LocalDateTime submitDateTime) { + if(submitDateTime != null) { + return submitDateTime; + } + throw new MorandiException(SubmitErrorCode.SUBMIT_DATE_TIME_IS_NULL); + } + private Detail validateDetail(Detail detail) { + if(detail != null) { + return detail; + } + throw new MorandiException(SubmitErrorCode.DETAIL_IS_NULL); + } + private SourceCode validateSubmitCode(SourceCode sourceCode) { + if(sourceCode != null) { + return sourceCode; + } + throw new MorandiException(SubmitErrorCode.SOURCE_CODE_IS_NULL); + } + private SubmitVisibility validateSubmitVisibility(SubmitVisibility submitVisibility) { + if(submitVisibility != null) { + return submitVisibility; + } + throw new MorandiException(SubmitErrorCode.VISIBILITY_NOT_NULL); + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/domain/model/submit/SubmitVisibility.java b/src/main/java/kr/co/morandi/backend/judgement/domain/model/submit/SubmitVisibility.java new file mode 100644 index 00000000..a46534b7 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/domain/model/submit/SubmitVisibility.java @@ -0,0 +1,39 @@ +package kr.co.morandi.backend.judgement.domain.model.submit; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import jakarta.persistence.Column; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.domain.error.SubmitErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SubmitVisibility { + OPEN("OPEN"), + CLOSE("CLOSE"); + + @Column(name = "submit_visibility") + private final String value; + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static SubmitVisibility fromValue(String value) { + if(value == null || value.isEmpty()) { + throw new MorandiException(SubmitErrorCode.SUBMIT_VISIBILITY_NOT_FOUND); + } + for(SubmitVisibility submitVisibility : SubmitVisibility.values()) { + if (submitVisibility.value.equals(value.toUpperCase())) { + return submitVisibility; + } + } + throw new MorandiException(SubmitErrorCode.INVALID_VISIBILITY_VALUE); + } + + +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/domain/service/BaekjoonJudgementService.java b/src/main/java/kr/co/morandi/backend/judgement/domain/service/BaekjoonJudgementService.java new file mode 100644 index 00000000..af79be11 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/domain/service/BaekjoonJudgementService.java @@ -0,0 +1,55 @@ +package kr.co.morandi.backend.judgement.domain.service; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.application.port.out.BaekjoonSubmitPort; +import kr.co.morandi.backend.judgement.domain.error.JudgementResultErrorCode; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.result.BaekjoonJudgementResult; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.submit.BaekjoonSubmit; +import kr.co.morandi.backend.judgement.domain.model.submit.JudgementResult; +import kr.co.morandi.backend.judgement.domain.model.submit.JudgementStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BaekjoonJudgementService { + + private final BaekjoonSubmitPort baekjoonSubmitPort; + + /* + * Pusher의 결과를 받는 스레드를 블로킹하지 않기 위해 비동기로 처리하는 메서드입니다. + * TODO 실패 시 어떻게 해야할 지 + * Pusher가 단일 스레드가 아니라면 Async를 사용하지 않아도 괜찮습니다. (I/O 시 다른 스레드가 처리할 수 있기 때문) + * */ + @Async("updateJudgementStatusExecutor") + @Transactional + public void asyncUpdateJudgementStatus(final Long submitId, + final JudgementStatus judgementStatus, + final Integer memory, + final Integer time, + final BaekjoonJudgementResult baekjoonJudgementResult) { + + log.info("Update Judgement Status submitId: {}, judgementStatus: {}, memory: {}, time: {}", submitId, judgementStatus, memory, time); + + final BaekjoonSubmit submit = baekjoonSubmitPort.findSubmitJoinFetchDetailAndRecord(submitId) + .orElseThrow(() -> new MorandiException(JudgementResultErrorCode.SUBMIT_NOT_FOUND)); + + JudgementResult judgementResult = getJudgementResult(judgementStatus, memory, time); + + submit.updateJudgementResult(judgementResult, baekjoonJudgementResult); + + + baekjoonSubmitPort.save(submit); + } + private JudgementResult getJudgementResult(JudgementStatus judgementStatus, Integer memory, Integer time) { + if(judgementStatus.equals(JudgementStatus.ACCEPTED)) + return JudgementResult.accepted(memory, time); + return JudgementResult.rejected(judgementStatus); + } + + +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/infrastructure/adapter/out/BaekjoonSubmitAdapter.java b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/adapter/out/BaekjoonSubmitAdapter.java new file mode 100644 index 00000000..9edd66a8 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/adapter/out/BaekjoonSubmitAdapter.java @@ -0,0 +1,25 @@ +package kr.co.morandi.backend.judgement.infrastructure.adapter.out; + +import kr.co.morandi.backend.common.annotation.Adapter; +import kr.co.morandi.backend.judgement.application.port.out.BaekjoonSubmitPort; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.submit.BaekjoonSubmit; +import kr.co.morandi.backend.judgement.infrastructure.persistence.submit.BaekjoonSubmitRepository; +import lombok.RequiredArgsConstructor; + +import java.util.Optional; + +@Adapter +@RequiredArgsConstructor +public class BaekjoonSubmitAdapter implements BaekjoonSubmitPort { + + private final BaekjoonSubmitRepository baekjoonSubmitRepository; + @Override + public BaekjoonSubmit save(BaekjoonSubmit submit) { + return baekjoonSubmitRepository.save(submit); + } + + @Override + public Optional findSubmitJoinFetchDetailAndRecord(Long submitId) { + return baekjoonSubmitRepository.findSubmitJoinFetchDetailAndRecord(submitId); + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/cookie/MorandiBaekjoonCookieManager.java b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/cookie/MorandiBaekjoonCookieManager.java new file mode 100644 index 00000000..dfdbf3ec --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/cookie/MorandiBaekjoonCookieManager.java @@ -0,0 +1,19 @@ +package kr.co.morandi.backend.judgement.infrastructure.baekjoon.cookie; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +@Component +@RequiredArgsConstructor +public class MorandiBaekjoonCookieManager { + + private static final String BAEKJOON_URL = "https://www.acmicpc.net/"; + private static final String LOGIN_URL = "https://www.acmicpc.net/login"; + private final WebClient webClient; + + public String getMorandiManagedBaekjoonCookie() { + return "dummyCookie"; + } + +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/result/PusherService.java b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/result/PusherService.java new file mode 100644 index 00000000..7eb6738b --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/result/PusherService.java @@ -0,0 +1,36 @@ +package kr.co.morandi.backend.judgement.infrastructure.baekjoon.result; + +import com.pusher.client.Pusher; +import com.pusher.client.channel.Channel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.function.BiConsumer; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PusherService { + + private final Pusher pusher; + /* + * 채널에 대한 이벤트를 구독하는 로직 + * 채널에 대한 이벤트가 발생하면 콜백함수를 실행하도록 구현 + * BiConsumer를 이용하여 ChannelName과 Data를 파라미터로 받아서 콜백함수를 실행하도록 구현 + * pusher에서 bind를 수행하면, 내부의 Channel이 생기는 거고, WebSocket 연결이 추가되는 건 아닙니다. + * main WebSocket 연결은 Pusher 객체를 생성할 때 생성되는 것이고, Channel은 그 안에서 필터링 역할을 합니다. + * */ + public void subscribeJudgement(final String channelName, + final Long submitId, + final BiConsumer judgementCallback) { + Channel channel = pusher.subscribe(channelName); + // TODO : 끝난 채점은 Channel이 회수되지만, 너무 늦게 channel이 구독되어 아무 응답을 받지 못한 경우 여전히 회수되지 못하는 문제가 발생함 + channel.bind("update", pusherEvent -> + judgementCallback.accept(submitId, pusherEvent.getData())); + } + public void unsubscribeJudgement(final String channelName) { + pusher.unsubscribe(channelName); + + } +} \ No newline at end of file diff --git a/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitApiAdapter.java b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitApiAdapter.java new file mode 100644 index 00000000..6bc6f54e --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitApiAdapter.java @@ -0,0 +1,112 @@ +package kr.co.morandi.backend.judgement.infrastructure.baekjoon.submit; + +import kr.co.morandi.backend.common.annotation.Adapter; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.judgement.domain.error.SubmitErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.net.URI; + +import static org.springframework.http.HttpHeaders.COOKIE; +import static org.springframework.http.HttpHeaders.USER_AGENT; +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; + +@Adapter +@RequiredArgsConstructor +public class BaekjoonSubmitApiAdapter { + + private final WebClient webClient; + + /* + * 제출을 하고 솔루션 아이디를 가져오는 메소드 + */ + public String submitAndGetSolutionId(String baekjoonProblemId, String cookie, Language language, String sourceCode, String submitVisibility) { + String csrfKey = getCsrfKeyFromSubmitPage(cookie, baekjoonProblemId); + + final String languageCode = BaekjoonSubmitLanguageCode.getLanguageCode(language); + final String submitVisibilityCode = BaekjoonSubmitVisibility.getSubmitVisibilityCode(submitVisibility); + + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.add("problem_id", baekjoonProblemId); + parameters.add("language", languageCode); + parameters.add("source", sourceCode); + parameters.add("code_open", submitVisibilityCode); + parameters.add("csrf_key", csrfKey); + + + final String resultHtml = webClient.post() + .uri(String.format(BaekjoonSubmitConstants.BAEKJOON_SUBMIT_URL, baekjoonProblemId)) + .header(USER_AGENT, BaekjoonSubmitConstants.BAEKJOON_USER_AGENT) + .header(COOKIE, "OnlineJudge=" + cookie) + .contentType(APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(parameters)) + .exchangeToMono(response -> handleRedirection(response, baekjoonProblemId)) + .block(); + + return BaekjoonSubmitHtmlParser.parseSolutionIdFromHtml(resultHtml); + } + + /* + * 제출에 필요한 csrf_key를 가져오는 메소드 + * */ + private String getCsrfKeyFromSubmitPage(String cookie, String baekjoonProblemId) { + final String submitPageHtml = webClient.get() + .uri(String.format(BaekjoonSubmitConstants.BAEKJOON_SUBMIT_URL, baekjoonProblemId)) + .header(USER_AGENT, BaekjoonSubmitConstants.BAEKJOON_USER_AGENT) + .header(HttpHeaders.COOKIE, "OnlineJudge=" + cookie) + .retrieve() + .bodyToMono(String.class) + .block(); + + return BaekjoonSubmitHtmlParser.parseCsrfKeyInSubmitPage(submitPageHtml); + } + + + private Mono handleRedirection(ClientResponse initialResponse, String baekjoonProblemId) { + /* + * 제출 후 일반적으로 3xx redirection이 발생합니다. + * 3xx redirection이 발생하면 location을 가져와서 다시 요청합니다. + * */ + if (initialResponse.statusCode().is3xxRedirection()) { + URI locationUri = initialResponse.headers().asHttpHeaders().getLocation(); + + if (locationUri == null) { + throw new MorandiException(SubmitErrorCode.REDIRECTION_LOCATION_NOT_FOUND); + } + + String location = locationUri.toString(); + + if (location == null || location.isEmpty()) { + throw new MorandiException(SubmitErrorCode.REDIRECTION_LOCATION_NOT_FOUND); + } + + if (!location.startsWith("http")) { + location = URI.create(String.format(BaekjoonSubmitConstants.BAEKJOON_SUBMIT_URL, baekjoonProblemId)) + .resolve(location) + .toString(); + } + + /* + * 직접 Redirect할 경우에는 사용자 쿠키가 필요 없습니다. + * */ + return webClient.get() + .uri(location) + .header(USER_AGENT, BaekjoonSubmitConstants.BAEKJOON_USER_AGENT) + .retrieve() + .bodyToMono(String.class); + } + + return initialResponse + .bodyToMono(String.class); + } + + +} \ No newline at end of file diff --git a/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitConstants.java b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitConstants.java new file mode 100644 index 00000000..f526e2f6 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitConstants.java @@ -0,0 +1,9 @@ +package kr.co.morandi.backend.judgement.infrastructure.baekjoon.submit; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class BaekjoonSubmitConstants { + public static final String BAEKJOON_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36"; + public static final String BAEKJOON_SUBMIT_URL = "https://www.acmicpc.net/submit/%s"; +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitHtmlParser.java b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitHtmlParser.java new file mode 100644 index 00000000..effa5a0f --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitHtmlParser.java @@ -0,0 +1,60 @@ +package kr.co.morandi.backend.judgement.infrastructure.baekjoon.submit; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.domain.error.SubmitErrorCode; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.springframework.stereotype.Component; + +public class BaekjoonSubmitHtmlParser { + /* + * 제출 결과 페이지에서 솔루션 아이디를 추출하는 메소드 + * */ + public static String parseSolutionIdFromHtml(String html) { + // HTML을 파싱합니다. + Document doc = Jsoup.parse(html); + + // 테이블을 선택합니다. + Element table = doc.getElementById("status-table"); + if (table == null) { + throw new MorandiException(SubmitErrorCode.CANT_FIND_SOLUTION_ID); + } + + // 첫 번째 행을 선택합니다. + Element firstRow = table.select("tbody tr").first(); + if (firstRow == null) { + throw new MorandiException(SubmitErrorCode.CANT_FIND_SOLUTION_ID); + } + + // 첫 번째 행에서 solution-id를 추출합니다. + Element solutionIdElement = firstRow.select("td").first(); // 첫 번째 열에 있는 것이 solution-id 입니다. + + if (solutionIdElement != null && solutionIdElement.text().isEmpty()) { + throw new MorandiException(SubmitErrorCode.CANT_FIND_SOLUTION_ID); + } + + if (solutionIdElement == null) { + throw new MorandiException(SubmitErrorCode.CANT_FIND_SOLUTION_ID); + } + + return solutionIdElement.text(); + + } + + public static String parseCsrfKeyInSubmitPage(String response) { + if (response == null) { + throw new MorandiException(SubmitErrorCode.BAEKJOON_SUBMIT_PAGE_ERROR); + } + Document document = Jsoup.parse(response); + + String csrfKey = document.select("input[name=csrf_key]").attr("value"); + + if (csrfKey.isEmpty()) { + throw new MorandiException(SubmitErrorCode.CSRF_KEY_NOT_FOUND); + } + + return csrfKey; + } + +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitLanguageCode.java b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitLanguageCode.java new file mode 100644 index 00000000..bcf73790 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitLanguageCode.java @@ -0,0 +1,49 @@ +package kr.co.morandi.backend.judgement.infrastructure.baekjoon.submit; + +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum BaekjoonSubmitLanguageCode { + + JAVA("93"), + CPP("84"), + PYTHON("28"), + PYPY3("73"), + C99("0"), + RUBY("68"), + KOTLIN("69"), + SWIFT("74"), + TEXT("58"), + C_SHARP("86"), + NODE_JS("17"), + GO("12"), + D("29"), + RUST("94"), + C_PLUS17_CLANG("85"); + + private final String languageCode; + + public static String getLanguageCode(Language language) { + return BaekjoonSubmitLanguageCode.valueOf(language.name()).getLanguageCode(); + } + + +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitVisibility.java b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitVisibility.java new file mode 100644 index 00000000..236e70b5 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitVisibility.java @@ -0,0 +1,19 @@ +package kr.co.morandi.backend.judgement.infrastructure.baekjoon.submit; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum BaekjoonSubmitVisibility { + OPEN("open"),//공개 + CLOSE("close"),//비공개 + ONLY_ACCEPTED("onlyaccepted");//맞았을 때만 공개 + + private final String submitVisibilityCode; + + public static String getSubmitVisibilityCode(String submitVisibility) { + return BaekjoonSubmitVisibility.valueOf(submitVisibility.toUpperCase()).getSubmitVisibilityCode(); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/infrastructure/config/PusherConfig.java b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/config/PusherConfig.java new file mode 100644 index 00000000..754300e8 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/config/PusherConfig.java @@ -0,0 +1,22 @@ +package kr.co.morandi.backend.judgement.infrastructure.config; + +import com.pusher.client.Pusher; +import com.pusher.client.PusherOptions; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class PusherConfig { + + @Value("${pusher.appId}") + private String pusherAppId; + @Bean + public Pusher pusher() { + PusherOptions options = new PusherOptions(); + options.setCluster("ap1"); + Pusher pusher = new Pusher(pusherAppId, options); + pusher.connect(); + return pusher; + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/infrastructure/controller/BaekjoonSubmitController.java b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/controller/BaekjoonSubmitController.java new file mode 100644 index 00000000..f2992a24 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/controller/BaekjoonSubmitController.java @@ -0,0 +1,27 @@ +package kr.co.morandi.backend.judgement.infrastructure.controller; + +import jakarta.validation.Valid; +import kr.co.morandi.backend.common.web.MemberId; +import kr.co.morandi.backend.judgement.application.usecase.submit.BaekjoonSubmitUsecase; +import kr.co.morandi.backend.judgement.infrastructure.controller.request.BaekjoonJudgementRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class BaekjoonSubmitController { + + private final BaekjoonSubmitUsecase baekjoonSubmitUsecase; + + @PostMapping("/submit") + public ResponseEntity submit(@Valid @RequestBody BaekjoonJudgementRequest request, + @MemberId Long memberId) { + + baekjoonSubmitUsecase.judgement(request.toServiceRequest(memberId)); + return ResponseEntity.ok() + .build(); + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/infrastructure/controller/cookie/BaekjoonMemberCookieRequest.java b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/controller/cookie/BaekjoonMemberCookieRequest.java new file mode 100644 index 00000000..9d3072d3 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/controller/cookie/BaekjoonMemberCookieRequest.java @@ -0,0 +1,15 @@ +package kr.co.morandi.backend.judgement.infrastructure.controller.cookie; + +import jakarta.validation.constraints.NotEmpty; +import kr.co.morandi.backend.judgement.application.request.cookie.BaekjoonMemberCookieServiceRequest; + +import java.time.LocalDateTime; + +public record BaekjoonMemberCookieRequest( + @NotEmpty(message = "Cookie 값은 비어 있을 수 없습니다.") + String cookie +) { + public BaekjoonMemberCookieServiceRequest toServiceRequest(Long memberId, LocalDateTime nowDateTime) { + return new BaekjoonMemberCookieServiceRequest(cookie, memberId, nowDateTime); + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/infrastructure/controller/cookie/CookieController.java b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/controller/cookie/CookieController.java new file mode 100644 index 00000000..f661c7ab --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/controller/cookie/CookieController.java @@ -0,0 +1,28 @@ +package kr.co.morandi.backend.judgement.infrastructure.controller.cookie; + +import jakarta.validation.Valid; +import kr.co.morandi.backend.common.web.MemberId; +import kr.co.morandi.backend.judgement.application.service.baekjoon.cookie.BaekjoonMemberCookieService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; + +@RestController +@RequiredArgsConstructor +public class CookieController { + + private final BaekjoonMemberCookieService baekjoonMemberCookieService; + + @PostMapping("/cookie/baekjoon") + public ResponseEntity saveMemberBaekjoonCookie(@Valid @RequestBody BaekjoonMemberCookieRequest request, + @MemberId Long memberId) { + baekjoonMemberCookieService.saveMemberBaekjoonCookie(request.toServiceRequest(memberId, LocalDateTime.now())); + return ResponseEntity.ok() + .build(); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/infrastructure/controller/request/BaekjoonJudgementRequest.java b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/controller/request/BaekjoonJudgementRequest.java new file mode 100644 index 00000000..5e295e47 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/controller/request/BaekjoonJudgementRequest.java @@ -0,0 +1,54 @@ +package kr.co.morandi.backend.judgement.infrastructure.controller.request; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.judgement.application.request.JudgementServiceRequest; +import kr.co.morandi.backend.judgement.domain.model.submit.SubmitVisibility; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class BaekjoonJudgementRequest { + + @NotNull(message = "defenseSessionId가 존재해야 합니다.") + @Positive(message = "defenseSessionId는 양수여야 합니다.") + private Long defenseSessionId; + + @NotNull(message = "problemNumber가 존재해야 합니다.") + @Positive(message = "problemNumber가 양수여야 합니다.") + private Long problemNumber; + + @NotNull(message = "language가 존재해야 합니다.") + private Language language; + + @NotEmpty(message = "sourceCode가 존재해야 합니다.") + private String sourceCode; + + @NotNull(message = "submitVisibility가 존재해야 합니다.") + private SubmitVisibility submitVisibility; + + public JudgementServiceRequest toServiceRequest(Long memberId) { + return JudgementServiceRequest.builder() + .defenseSessionId(this.getDefenseSessionId()) + .memberId(memberId) + .problemNumber(this.getProblemNumber()) + .language(this.getLanguage()) + .sourceCode(this.getSourceCode()) + .submitVisibility(this.getSubmitVisibility()) + .build(); + } + + @Builder + private BaekjoonJudgementRequest(Long defenseSessionId, Long problemNumber, Language language, String sourceCode, SubmitVisibility submitVisibility) { + this.defenseSessionId = defenseSessionId; + this.problemNumber = problemNumber; + this.language = language; + this.sourceCode = sourceCode; + this.submitVisibility = submitVisibility; + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/infrastructure/helper/JudgementStatusMapper.java b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/helper/JudgementStatusMapper.java new file mode 100644 index 00000000..1de22ae8 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/helper/JudgementStatusMapper.java @@ -0,0 +1,22 @@ +package kr.co.morandi.backend.judgement.infrastructure.helper; + +import kr.co.morandi.backend.judgement.domain.model.baekjoon.result.BaekjoonResultType; +import kr.co.morandi.backend.judgement.domain.model.submit.JudgementStatus; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class JudgementStatusMapper { + + public static JudgementStatus mapToJudgementStatus(BaekjoonResultType baekjoonResultType) { + return switch (baekjoonResultType) { + case CORRECT -> JudgementStatus.ACCEPTED; + case WRONG_ANSWER -> JudgementStatus.WRONG_ANSWER; + case RUNTIME_ERROR -> JudgementStatus.RUNTIME_ERROR; + case COMPILE_ERROR -> JudgementStatus.COMPILE_ERROR; + case TIME_LIMIT_EXCEEDED -> JudgementStatus.TIME_LIMIT_EXCEEDED; + case MEMORY_LIMIT_EXCEEDED -> JudgementStatus.MEMORY_LIMIT_EXCEEDED; + default -> JudgementStatus.SUBMITTED; + }; + } +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/infrastructure/persistence/baekjoon/BaekjoonGlobalCookieRepository.java b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/persistence/baekjoon/BaekjoonGlobalCookieRepository.java new file mode 100644 index 00000000..e5ae8125 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/persistence/baekjoon/BaekjoonGlobalCookieRepository.java @@ -0,0 +1,18 @@ +package kr.co.morandi.backend.judgement.infrastructure.persistence.baekjoon; + +import kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie.BaekjoonGlobalCookie; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.time.LocalDateTime; +import java.util.List; + +public interface BaekjoonGlobalCookieRepository extends JpaRepository { + + @Query(""" + SELECT cookie + FROM BaekjoonGlobalCookie cookie + WHERE cookie.baekjoonCookie.expiredAt > :now + """) + List findValidGlobalCookies(LocalDateTime now); +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/infrastructure/persistence/baekjoon/BaekjoonMemberCookieRepository.java b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/persistence/baekjoon/BaekjoonMemberCookieRepository.java new file mode 100644 index 00000000..8a98ac4f --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/persistence/baekjoon/BaekjoonMemberCookieRepository.java @@ -0,0 +1,10 @@ +package kr.co.morandi.backend.judgement.infrastructure.persistence.baekjoon; + +import kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie.BaekjoonMemberCookie; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface BaekjoonMemberCookieRepository extends JpaRepository { + Optional findBaekjoonMemberCookieByMember_MemberId(Long memberId); +} diff --git a/src/main/java/kr/co/morandi/backend/judgement/infrastructure/persistence/submit/BaekjoonSubmitRepository.java b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/persistence/submit/BaekjoonSubmitRepository.java new file mode 100644 index 00000000..e75ba74a --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/judgement/infrastructure/persistence/submit/BaekjoonSubmitRepository.java @@ -0,0 +1,21 @@ +package kr.co.morandi.backend.judgement.infrastructure.persistence.submit; + +import kr.co.morandi.backend.judgement.domain.model.baekjoon.submit.BaekjoonSubmit; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface BaekjoonSubmitRepository extends JpaRepository { + + @Query(""" + SELECT s + FROM BaekjoonSubmit s + JOIN FETCH s.detail d + JOIN FETCH d.record + WHERE s.submitId = :submitId + """) + Optional findSubmitJoinFetchDetailAndRecord(Long submitId); +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/application/model/oauth/OAuthServiceFactory.java b/src/main/java/kr/co/morandi/backend/member_management/application/model/oauth/OAuthServiceFactory.java new file mode 100644 index 00000000..eff1d738 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/application/model/oauth/OAuthServiceFactory.java @@ -0,0 +1,21 @@ +package kr.co.morandi.backend.member_management.application.model.oauth; + +import kr.co.morandi.backend.member_management.application.service.oauth.OAuthService; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +public class OAuthServiceFactory { + private final Map serviceMap; + public OAuthServiceFactory(List oAuthServices) { + this.serviceMap = oAuthServices.stream() + .collect(Collectors.toMap(OAuthService::getType, Function.identity())); + } + public OAuthService getServiceByType(String type) { + return serviceMap.get(type); + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/application/port/in/oauth/AuthenticationUseCase.java b/src/main/java/kr/co/morandi/backend/member_management/application/port/in/oauth/AuthenticationUseCase.java new file mode 100644 index 00000000..14ce3dfc --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/application/port/in/oauth/AuthenticationUseCase.java @@ -0,0 +1,7 @@ +package kr.co.morandi.backend.member_management.application.port.in.oauth; + +import kr.co.morandi.backend.member_management.infrastructure.config.jwt.response.AuthenticationToken; + +public interface AuthenticationUseCase { + AuthenticationToken getAuthenticationToken(String type, String authenticationCode); +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/application/port/out/member/MemberPort.java b/src/main/java/kr/co/morandi/backend/member_management/application/port/out/member/MemberPort.java new file mode 100644 index 00000000..140b6ee7 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/application/port/out/member/MemberPort.java @@ -0,0 +1,13 @@ +package kr.co.morandi.backend.member_management.application.port.out.member; + +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.domain.model.member.SocialType; + +import java.util.Optional; + +public interface MemberPort { + Member saveMemberByEmail(String email, SocialType type); + Member findMemberById(Long memberId); + Optional findById(Long memberId); + Optional findMemberByEmail(String email); +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/application/service/jwt/MemberLoginService.java b/src/main/java/kr/co/morandi/backend/member_management/application/service/jwt/MemberLoginService.java new file mode 100644 index 00000000..306b5841 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/application/service/jwt/MemberLoginService.java @@ -0,0 +1,28 @@ +package kr.co.morandi.backend.member_management.application.service.jwt; + +import kr.co.morandi.backend.member_management.infrastructure.config.jwt.utils.JwtProvider; +import kr.co.morandi.backend.member_management.application.port.out.member.MemberPort; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.config.oauth.constants.OAuthUserInfo; +import kr.co.morandi.backend.member_management.infrastructure.config.jwt.response.AuthenticationToken; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MemberLoginService { + + private final JwtProvider jwtProvider; + + private final MemberPort memberPort; + public AuthenticationToken loginMember(OAuthUserInfo oAuthUserInfo) { + Optional maybeMember = memberPort.findMemberByEmail(oAuthUserInfo.getEmail()); + Member member = maybeMember.isPresent() + ? maybeMember.get() : memberPort.saveMemberByEmail(oAuthUserInfo.getEmail(), oAuthUserInfo.getType()); + return jwtProvider.getAuthenticationToken(member); + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/application/service/oauth/LoginService.java b/src/main/java/kr/co/morandi/backend/member_management/application/service/oauth/LoginService.java new file mode 100644 index 00000000..c158e86a --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/application/service/oauth/LoginService.java @@ -0,0 +1,26 @@ +package kr.co.morandi.backend.member_management.application.service.oauth; + +import kr.co.morandi.backend.member_management.application.port.in.oauth.AuthenticationUseCase; +import kr.co.morandi.backend.member_management.infrastructure.config.oauth.constants.OAuthUserInfo; +import kr.co.morandi.backend.member_management.application.model.oauth.OAuthServiceFactory; +import kr.co.morandi.backend.member_management.infrastructure.config.jwt.response.AuthenticationToken; +import kr.co.morandi.backend.member_management.application.service.jwt.MemberLoginService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LoginService implements AuthenticationUseCase { + + private final OAuthServiceFactory oAuthServiceFactory; + + private final MemberLoginService memberLoginService; + @Override + public AuthenticationToken getAuthenticationToken(String type, String authenticationCode) { + OAuthService oAuthService = oAuthServiceFactory.getServiceByType(type); + String oAuthAccessToken = oAuthService.getAccessToken(authenticationCode); + OAuthUserInfo oAuthUserInfo = oAuthService.getUserInfo(oAuthAccessToken); + AuthenticationToken authenticationToken = memberLoginService.loginMember(oAuthUserInfo); + return authenticationToken; + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/application/service/oauth/OAuthService.java b/src/main/java/kr/co/morandi/backend/member_management/application/service/oauth/OAuthService.java new file mode 100644 index 00000000..a3a8033c --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/application/service/oauth/OAuthService.java @@ -0,0 +1,8 @@ +package kr.co.morandi.backend.member_management.application.service.oauth; +import kr.co.morandi.backend.member_management.infrastructure.config.oauth.constants.OAuthUserInfo; + +public interface OAuthService { + String getType(); + String getAccessToken(String authorizationCode); + OAuthUserInfo getUserInfo(String accessToken); +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/application/service/oauth/google/GoogleService.java b/src/main/java/kr/co/morandi/backend/member_management/application/service/oauth/google/GoogleService.java new file mode 100644 index 00000000..0389020c --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/application/service/oauth/google/GoogleService.java @@ -0,0 +1,107 @@ +package kr.co.morandi.backend.member_management.application.service.oauth.google; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.member_management.infrastructure.config.oauth.constants.OAuthUserInfo; +import kr.co.morandi.backend.member_management.domain.model.member.SocialType; +import kr.co.morandi.backend.member_management.infrastructure.config.oauth.response.TokenResponse; +import kr.co.morandi.backend.member_management.infrastructure.config.oauth.constants.google.GoogleOAuthUserInfo; +import kr.co.morandi.backend.member_management.application.service.oauth.OAuthService; +import kr.co.morandi.backend.member_management.infrastructure.exception.OAuthErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; + +@Service +@RequiredArgsConstructor +@Slf4j +public class GoogleService implements OAuthService { + + @Value("${oauth2.google.client-id}") + private String googleClientId; + + @Value("${oauth2.google.client-secret}") + private String googleClientSecret; + + @Value("${oauth2.google.redirect-callback-url}") + private String googleClientRedirectUrl; + + @Value("${oauth2.google.api-token-url}") + private String googleApiTokenUrl; + + @Value("${oauth2.google.userinfo-url}") + private String googleUserInfoUrl; + + @Value("${oauth2.google.type}") + private String type; + + private final WebClient webClient; + @Override + public String getType() { + return type; + } + @Override + public String getAccessToken(String authorizationCode) { + LinkedMultiValueMap params = buildParams(authorizationCode); + TokenResponse tokenResponse = getTokenResponse(params); + + return tokenResponse.getAccess_token(); + } + private LinkedMultiValueMap buildParams(String authorizationCode) { + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + params.add("code", authorizationCode); + params.add("client_id", googleClientId); + params.add("client_secret", googleClientSecret); + params.add("grant_type", "authorization_code"); + params.add("redirect_uri", googleClientRedirectUrl); + return params; + } + private TokenResponse getTokenResponse(LinkedMultiValueMap params) { + TokenResponse tokenResponse = webClient.post() + .uri(googleApiTokenUrl) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromValue(params)) + .retrieve() + .bodyToMono(TokenResponse.class) + .retry(3) + .block(); + + if(tokenResponse == null) { + throw new MorandiException(OAuthErrorCode.GOOGLE_OAUTH_ERROR); + } + + return tokenResponse; + } + + @Override + public OAuthUserInfo getUserInfo(String accessToken) { + HttpHeaders headers = getBearerHeader(accessToken); + return getGoogleUserDto(headers); + } + private HttpHeaders getBearerHeader(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", "Bearer " + accessToken); + return headers; + } + private GoogleOAuthUserInfo getGoogleUserDto(HttpHeaders headers) { + GoogleOAuthUserInfo googleUserDto = webClient.get() + .uri(googleUserInfoUrl) + .headers(httpHeaders -> httpHeaders.addAll(headers)) + .retrieve() + .bodyToMono(GoogleOAuthUserInfo.class) + .retry(3) + .block(); + + if(googleUserDto == null) { + throw new MorandiException(OAuthErrorCode.GOOGLE_OAUTH_ERROR); + } + + googleUserDto.setSocialType(SocialType.GOOGLE); + return googleUserDto; + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/application/service/security/OAuthUserDetailsService.java b/src/main/java/kr/co/morandi/backend/member_management/application/service/security/OAuthUserDetailsService.java new file mode 100644 index 00000000..7aeb08ec --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/application/service/security/OAuthUserDetailsService.java @@ -0,0 +1,22 @@ +package kr.co.morandi.backend.member_management.application.service.security; + +import kr.co.morandi.backend.member_management.application.port.out.member.MemberPort; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.domain.model.security.OAuthDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class OAuthUserDetailsService implements UserDetailsService { + + private final MemberPort memberPort; + @Override + public UserDetails loadUserByUsername(String memberId) throws UsernameNotFoundException { + Member member = memberPort.findMemberById(Long.parseLong(memberId)); + return new OAuthDetails(memberId, member.getBaekjoonId()); + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/domain/model/error/MemberErrorCode.java b/src/main/java/kr/co/morandi/backend/member_management/domain/model/error/MemberErrorCode.java new file mode 100644 index 00000000..71fc148f --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/domain/model/error/MemberErrorCode.java @@ -0,0 +1,23 @@ +package kr.co.morandi.backend.member_management.domain.model.error; + +import kr.co.morandi.backend.common.exception.errorcode.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum MemberErrorCode implements ErrorCode { + MEMBER_NOT_FOUND(HttpStatus.BAD_REQUEST, "사용자를 찾을 수 없습니다"), + ; + + private final HttpStatus httpStatus; + private final String message; + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/domain/model/member/Member.java b/src/main/java/kr/co/morandi/backend/member_management/domain/model/member/Member.java new file mode 100644 index 00000000..b6d9ed7f --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/domain/model/member/Member.java @@ -0,0 +1,73 @@ +package kr.co.morandi.backend.member_management.domain.model.member; + +import jakarta.persistence.*; +import kr.co.morandi.backend.common.model.BaseEntity; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie.BaekjoonMemberCookie; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long memberId; + + @Column(unique = true) + private String nickname; + + private String baekjoonId; + + private String email; + + @Enumerated(EnumType.STRING) + private SocialType socialType; + + private String profileImageURL; + + private String description; + + @OneToOne(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.ALL) + private BaekjoonMemberCookie baekjoonMemberCookie; + + public void saveBaekjoonCookie(String cookie, LocalDateTime nowDateTime) { + if(baekjoonMemberCookie == null) { + baekjoonMemberCookie = BaekjoonMemberCookie.builder() + .cookie(cookie) + .nowDateTime(nowDateTime) + .member(this) + .build(); + return; + } + baekjoonMemberCookie.updateCookie(cookie, nowDateTime); + } + + @Builder + private Member(String nickname, String baekjoonId, String email, + SocialType socialType, String profileImageURL, String description) { + this.nickname = nickname; + this.baekjoonId = baekjoonId; + this.email = email; + this.socialType = socialType; + this.profileImageURL = profileImageURL; + this.description = description; + } + public static Member create(String nickname, String email, SocialType socialType, + String profileImageURL, String description) { + return Member.builder() + .nickname(nickname) + .email(email) + .socialType(socialType) + .profileImageURL(profileImageURL) + .description(description) + .build(); + } + public static Member create(String email, SocialType socialType) { + return Member.builder() + .email(email) + .socialType(socialType) + .build(); + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/domain/model/member/Role.java b/src/main/java/kr/co/morandi/backend/member_management/domain/model/member/Role.java new file mode 100644 index 00000000..5e91c88a --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/domain/model/member/Role.java @@ -0,0 +1,5 @@ +package kr.co.morandi.backend.member_management.domain.model.member; + +public enum Role { + USER, ADMIN +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/domain/model/member/SocialType.java b/src/main/java/kr/co/morandi/backend/member_management/domain/model/member/SocialType.java new file mode 100644 index 00000000..7c94f272 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/domain/model/member/SocialType.java @@ -0,0 +1,13 @@ +package kr.co.morandi.backend.member_management.domain.model.member; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +@Getter +@RequiredArgsConstructor +public enum SocialType { + GOOGLE("google"), + GITHUB("github"), + NAVER("naver"); + + private final String provider; +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/domain/model/security/OAuthDetails.java b/src/main/java/kr/co/morandi/backend/member_management/domain/model/security/OAuthDetails.java new file mode 100644 index 00000000..23d06887 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/domain/model/security/OAuthDetails.java @@ -0,0 +1,44 @@ +package kr.co.morandi.backend.member_management.domain.model.security; + +import lombok.AllArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; + +@AllArgsConstructor +public class OAuthDetails implements UserDetails { + + private String memberId; + + private String baekjoonId; + @Override + public Collection getAuthorities() { + return Collections.emptyList(); + } + @Override + public String getPassword() { + return null; + } + @Override + public String getUsername() { + return memberId; + } + @Override + public boolean isAccountNonExpired() { + return true; + } + @Override + public boolean isAccountNonLocked() { + return true; + } + @Override + public boolean isCredentialsNonExpired() { + return true; + } + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/adapter/member/MemberAdapter.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/adapter/member/MemberAdapter.java new file mode 100644 index 00000000..9f1f96cb --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/adapter/member/MemberAdapter.java @@ -0,0 +1,37 @@ +package kr.co.morandi.backend.member_management.infrastructure.adapter.member; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.member_management.infrastructure.exception.OAuthErrorCode; +import kr.co.morandi.backend.member_management.application.port.out.member.MemberPort; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.domain.model.member.SocialType; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class MemberAdapter implements MemberPort { + + private final MemberRepository memberRepository; + @Override + public Member saveMemberByEmail(String email, SocialType type) { + return memberRepository.save(Member.create(email, type)); + } + @Override + public Optional findMemberByEmail(String email) { + return memberRepository.findByEmail(email); + } + @Override + public Member findMemberById(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new MorandiException(OAuthErrorCode.MEMBER_NOT_FOUND)); + } + @Override + public Optional findById(Long memberId) { + return memberRepository.findById(memberId); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/cookie/utils/CookieUtils.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/cookie/utils/CookieUtils.java new file mode 100644 index 00000000..27b422a7 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/cookie/utils/CookieUtils.java @@ -0,0 +1,37 @@ +package kr.co.morandi.backend.member_management.infrastructure.config.cookie.utils; + +import jakarta.servlet.http.Cookie; +import kr.co.morandi.backend.member_management.infrastructure.config.jwt.constants.TokenType; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class CookieUtils { + + @Value("${oauth2.cookie.domain}") + private String domain; + + @Value("${oauth2.cookie.path}") + private String path; + + private final int COOKIE_AGE = 60 * 60 * 24 * 10; + private final Integer COOKIE_REMOVE_AGE = 0; + + public Cookie getCookie(TokenType type, String value) { + Cookie cookie = new Cookie(type.name(), value); + cookie.setHttpOnly(true); + cookie.setMaxAge(COOKIE_AGE); + cookie.setDomain(domain); + cookie.setPath(path); + return cookie; + } + + public Cookie removeCookie(TokenType type, String value) { + Cookie cookie = new Cookie(type.name(), value); + cookie.setHttpOnly(true); + cookie.setMaxAge(COOKIE_REMOVE_AGE); + cookie.setDomain(domain); + cookie.setPath(path); + return cookie; + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/jwt/constants/TokenType.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/jwt/constants/TokenType.java new file mode 100644 index 00000000..6fd2ff99 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/jwt/constants/TokenType.java @@ -0,0 +1,11 @@ +package kr.co.morandi.backend.member_management.infrastructure.config.jwt.constants; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum TokenType { + ACCESS_TOKEN("accessToken"), + REFRESH_TOKEN("refreshToken"); + + private final String name; +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/jwt/response/AuthenticationToken.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/jwt/response/AuthenticationToken.java new file mode 100644 index 00000000..6cc82376 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/jwt/response/AuthenticationToken.java @@ -0,0 +1,22 @@ +package kr.co.morandi.backend.member_management.infrastructure.config.jwt.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class AuthenticationToken { + + private String accessToken; + + private String refreshToken; + public static AuthenticationToken create(String accessToken, String refreshToken) { + return AuthenticationToken.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } +} + diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/jwt/utils/JwtProvider.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/jwt/utils/JwtProvider.java new file mode 100644 index 00000000..f3bdacea --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/jwt/utils/JwtProvider.java @@ -0,0 +1,95 @@ +package kr.co.morandi.backend.member_management.infrastructure.config.jwt.utils; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.domain.model.member.Role; +import kr.co.morandi.backend.member_management.infrastructure.config.jwt.constants.TokenType; +import kr.co.morandi.backend.member_management.infrastructure.config.jwt.response.AuthenticationToken; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.util.WebUtils; + +import java.security.PrivateKey; +import java.util.Date; + +@RequiredArgsConstructor +@Slf4j +@Component +public class JwtProvider { + + private final Long ACCESS_TOKEN_EXPIRATION = 60 * 60 * 3 * 1000L; // 3 hours + private final Long REFRESH_TOKEN_EXPIRATION = 60 * 60 * 24 * 7 * 1000L; // 7 days + + private final SecretKeyProvider secretKeyProvider; + + public AuthenticationToken getAuthenticationToken(Member member) { + String accessToken = generateAccessToken(member.getMemberId(), Role.USER); + String refreshToken = generateRefreshToken(member.getMemberId(), Role.USER); + return AuthenticationToken.create(accessToken, refreshToken); + } + + public String parseAccessToken(HttpServletRequest request) { + String accessToken = request.getHeader("Authorization"); + if (StringUtils.hasText(accessToken) && accessToken.startsWith("Bearer ")) { + return accessToken.substring(7); + } + return null; + } + public String parseRefreshToken(HttpServletRequest request) { + Cookie cookie = WebUtils.getCookie(request, "REFRESH_TOKEN"); + if(cookie==null) + return null; + return cookie.getValue(); + } + public String reissueAccessToken(String refreshToken) { + Long memberId = getMemberIdFromToken(refreshToken); + + return generateAccessToken(memberId, Role.USER); + } + + private String generateAccessToken(Long id, Role role) { + final Date issuedAt = new Date(); + final Date accessTokenExpiresIn = new Date(issuedAt.getTime() + ACCESS_TOKEN_EXPIRATION); + return buildAccessToken(id, issuedAt, accessTokenExpiresIn, role); + } + private String generateRefreshToken(Long id, Role role) { + final Date issuedAt = new Date(); + final Date refreshTokenExpiresIn = new Date(issuedAt.getTime() + REFRESH_TOKEN_EXPIRATION); + return buildRefreshToken(id, issuedAt, refreshTokenExpiresIn, role); + } + private String buildAccessToken(Long id, Date issuedAt, Date expiresIn, Role role) { + final PrivateKey encodedKey = secretKeyProvider.getPrivateKey(); + return jwtCreate(id, issuedAt, expiresIn, role, encodedKey, TokenType.ACCESS_TOKEN); + } + private String buildRefreshToken(Long id, Date issuedAt, Date expiresIn, Role role) { + final PrivateKey encodedKey = secretKeyProvider.getPrivateKey(); + + return jwtCreate(id, issuedAt, expiresIn, role, encodedKey, TokenType.REFRESH_TOKEN); + } + + private String jwtCreate(Long id, Date issuedAt, Date expiresIn, Role role, + PrivateKey encodedKey, TokenType tokenType) { + return Jwts.builder() + .setIssuer("MORANDI") + .setIssuedAt(issuedAt) + .setSubject(id.toString()) + .claim("type", tokenType) + .claim("role", role) + .setExpiration(expiresIn) + .signWith(encodedKey) + .compact(); + } + private Long getMemberIdFromToken(String token) { + Jws claimsJws = Jwts.parserBuilder().setSigningKey(secretKeyProvider.getPublicKey()) + .build() + .parseClaimsJws(token); + Claims claims = claimsJws.getBody(); + return Long.parseLong(claims.getSubject()); + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/jwt/utils/JwtValidator.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/jwt/utils/JwtValidator.java new file mode 100644 index 00000000..55458dbd --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/jwt/utils/JwtValidator.java @@ -0,0 +1,32 @@ +package kr.co.morandi.backend.member_management.infrastructure.config.jwt.utils; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.member_management.infrastructure.exception.OAuthErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +@RequiredArgsConstructor +public class JwtValidator { + + private final SecretKeyProvider secretKeyProvider; + public boolean validateToken(String token) { + if (!StringUtils.hasText(token)) + throw new MorandiException(OAuthErrorCode.INVALID_TOKEN); + try { + Jwts.parserBuilder() + .setSigningKey(secretKeyProvider.getPublicKey()) + .build() + .parseClaimsJws(token); + return true; + } catch (ExpiredJwtException e) { + return false; + } catch (JwtException e) { + throw new MorandiException(OAuthErrorCode.INVALID_TOKEN); + } + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/jwt/utils/SecretKeyProvider.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/jwt/utils/SecretKeyProvider.java new file mode 100644 index 00000000..3f4a4035 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/jwt/utils/SecretKeyProvider.java @@ -0,0 +1,68 @@ +package kr.co.morandi.backend.member_management.infrastructure.config.jwt.utils; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +@Component +@Getter +public class SecretKeyProvider { + + private final PublicKey publicKey; + + private final PrivateKey privateKey; + + public SecretKeyProvider(@Value("${security.publicKey}") String publicKey, + @Value("${security.privateKey}") String privateKey) { + this.publicKey = convertPEMToPublicKey(decoding(publicKey)); + this.privateKey = convertPEMToPrivateKey(decoding(privateKey)); + } + private String decoding(String key) { + byte[] decoded = Base64.getDecoder().decode(key); + return new String(decoded, StandardCharsets.UTF_8); + } + public PublicKey convertPEMToPublicKey(String publicKeyPemFile) { + String publicKeyPEM = extractPublicPemKeyContent(publicKeyPemFile); + byte[] encodedKey = Base64.getDecoder().decode(publicKeyPEM); + try { + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encodedKey); + return keyFactory.generatePublic(keySpec); + } + catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new RuntimeException(e); + } + } + private PrivateKey convertPEMToPrivateKey(String privateKeyPemFile) { + String privateKeyPEM = extractPrivatePemKeyContent(privateKeyPemFile); + byte[] encodedKey = Base64.getDecoder().decode(privateKeyPEM); + try { + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encodedKey); + return keyFactory.generatePrivate(keySpec); + } + catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new RuntimeException(e); + } + } + private String extractPublicPemKeyContent(String pemKey) { + return pemKey.replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s", ""); + } + + private String extractPrivatePemKeyContent(String pemKey) { + return pemKey.replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + } +} + diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/oauth/constants/OAuthUserInfo.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/oauth/constants/OAuthUserInfo.java new file mode 100644 index 00000000..c3dc0e87 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/oauth/constants/OAuthUserInfo.java @@ -0,0 +1,9 @@ +package kr.co.morandi.backend.member_management.infrastructure.config.oauth.constants; + +import kr.co.morandi.backend.member_management.domain.model.member.SocialType; + +public interface OAuthUserInfo { + SocialType getType(); + String getEmail(); + String getPicture(); +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/oauth/constants/google/GoogleOAuthUserInfo.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/oauth/constants/google/GoogleOAuthUserInfo.java new file mode 100644 index 00000000..cfe90c97 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/oauth/constants/google/GoogleOAuthUserInfo.java @@ -0,0 +1,40 @@ +package kr.co.morandi.backend.member_management.infrastructure.config.oauth.constants.google; + +import kr.co.morandi.backend.member_management.infrastructure.config.oauth.constants.OAuthUserInfo; +import kr.co.morandi.backend.member_management.domain.model.member.SocialType; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class GoogleOAuthUserInfo implements OAuthUserInfo { + + private String id; + private String email; + private String verified_email; + private String hd; + private String name; + private String given_name; + private String family_name; + private String picture; + private String locale; + private SocialType type; + + @Override + public SocialType getType() { + return type; + } + public void setSocialType(SocialType type) { + this.type = type; + } + + @Override + public String getEmail() { + return email; + } + + @Override + public String getPicture() { + return picture; + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/oauth/response/TokenResponse.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/oauth/response/TokenResponse.java new file mode 100644 index 00000000..83342eac --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/oauth/response/TokenResponse.java @@ -0,0 +1,21 @@ +package kr.co.morandi.backend.member_management.infrastructure.config.oauth.response; + +import lombok.Getter; + +@Getter +public class TokenResponse { + + public String token_type; + + public String access_token; + + public String id_token; + + public Integer expires_in; + + public String refresh_token; + + public Integer refresh_token_expires_in; + + public String scope; +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/security/SecurityConfig.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/security/SecurityConfig.java new file mode 100644 index 00000000..8b7d5b95 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/security/SecurityConfig.java @@ -0,0 +1,53 @@ +package kr.co.morandi.backend.member_management.infrastructure.config.security; + +import kr.co.morandi.backend.member_management.infrastructure.security.filter.entrypoint.JwtAuthenticationEntryPoint; +import kr.co.morandi.backend.member_management.infrastructure.security.filter.oauth.JwtAuthenticationFilter; +import kr.co.morandi.backend.member_management.infrastructure.security.filter.oauth.JwtExceptionFilter; +import kr.co.morandi.backend.member_management.infrastructure.security.filter.oauth.RequestCachingFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfigurationSource; + +import static org.springframework.http.HttpMethod.GET; + +@EnableWebSecurity +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtExceptionFilter jwtExceptionFilter; + private final RequestCachingFilter requestCachingFilter; + private final CorsConfigurationSource corsConfigurationSource; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{ + http + .httpBasic(HttpBasicConfigurer::disable) + .csrf(CsrfConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource)) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/oauths/**","/swagger-ui/**", "/swagger-resources/**", + "/v3/api-docs/**").permitAll() + .requestMatchers("/daily-record/rankings/**").permitAll() + .requestMatchers(GET, "/daily-defense/**").permitAll() + .anyRequest().authenticated()) + .exceptionHandling(exceptionHandling -> exceptionHandling + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + ) + .sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class) + .addFilterBefore(requestCachingFilter, JwtExceptionFilter.class); + + return http.build(); + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/security/utils/AuthenticationProvider.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/security/utils/AuthenticationProvider.java new file mode 100644 index 00000000..7ca291b5 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/security/utils/AuthenticationProvider.java @@ -0,0 +1,45 @@ +package kr.co.morandi.backend.member_management.infrastructure.config.security.utils; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import kr.co.morandi.backend.member_management.application.service.security.OAuthUserDetailsService; +import kr.co.morandi.backend.member_management.infrastructure.config.jwt.utils.SecretKeyProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class AuthenticationProvider { + + private final SecretKeyProvider secretKeyProvider; + + public void setAuthentication(String accessToken) { + Authentication authentication = getAuthentication(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + private Authentication getAuthentication(String accessToken) { + Long memberId = getMemberIdFromToken(accessToken); + + //TODO : authorities를 이후에 저장해야함 + List authorities = null;//getAuthoritiesFromToken(accessToken); + + return new UsernamePasswordAuthenticationToken(memberId, null, authorities); + } + private Long getMemberIdFromToken(String token) { + Jws claimsJws = Jwts.parserBuilder().setSigningKey(secretKeyProvider.getPublicKey()) + .build() + .parseClaimsJws(token); + Claims claims = claimsJws.getBody(); + return Long.parseLong(claims.getSubject()); + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/security/utils/IgnoredURIManager.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/security/utils/IgnoredURIManager.java new file mode 100644 index 00000000..b2fd8bf5 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/security/utils/IgnoredURIManager.java @@ -0,0 +1,23 @@ +package kr.co.morandi.backend.member_management.infrastructure.config.security.utils; + +import org.springframework.stereotype.Component; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +public class IgnoredURIManager { + private static final String[] IGNORED_URIS = { + "/oauths/", + "/swagger-ui/", + "/v3/api-docs/", + "/swagger-resources/", + "/daily-record/rankings" + }; + private String PATTERN_STRING = String.join("|", IGNORED_URIS); + public Pattern PATTERN = Pattern.compile(PATTERN_STRING); + public boolean isIgnoredURI(String uri) { + Matcher matcher = PATTERN.matcher(uri); + return matcher.find(); + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/security/utils/SecurityUtils.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/security/utils/SecurityUtils.java new file mode 100644 index 00000000..8bc4e1c2 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/config/security/utils/SecurityUtils.java @@ -0,0 +1,42 @@ +package kr.co.morandi.backend.member_management.infrastructure.config.security.utils; + +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; + +public class SecurityUtils { + public static Long getCurrentMemberId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated() || + authentication instanceof AnonymousAuthenticationToken) { + return null; + } + + Object principal = authentication.getPrincipal(); + + /* + * 일반적인 JWT 토큰을 사용할 때 발생. + * */ + if (principal instanceof Long) { + return (Long) principal; + } + /* + * @WithMockUser를 포함한 테스트나 기타 UserDetails 서비스를 사용할 때 발생한다. + * */ + if (principal instanceof UserDetails) { + /* + * principal이 UserDetails 타입인 경우, getUsername()에서 사용자 ID를 추출 + * 따라서 @WithMockUser를 사용할 때는 getUsername에 Member ID를 넣어줘야 한다. + */ + UserDetails userDetails = (UserDetails) principal; + try { + return Long.valueOf(userDetails.getUsername()); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/controller/oauth/OAuthController.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/controller/oauth/OAuthController.java new file mode 100644 index 00000000..e820d5ab --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/controller/oauth/OAuthController.java @@ -0,0 +1,43 @@ +package kr.co.morandi.backend.member_management.infrastructure.controller.oauth; + +import jakarta.servlet.http.HttpServletResponse; +import kr.co.morandi.backend.member_management.application.port.in.oauth.AuthenticationUseCase; +import kr.co.morandi.backend.member_management.infrastructure.config.cookie.utils.CookieUtils; +import kr.co.morandi.backend.member_management.infrastructure.config.jwt.response.AuthenticationToken; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; + +import static kr.co.morandi.backend.member_management.infrastructure.config.jwt.constants.TokenType.REFRESH_TOKEN; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/oauths") +@Slf4j +public class OAuthController { + + private final AuthenticationUseCase authenticationUseCase; + + private final CookieUtils cookieUtils; + + @Value("${morandi.redirect-url}") + private String redirectUrl; + + @GetMapping("/{type}/callback") + public ResponseEntity OAuthLogin(@PathVariable String type, + @RequestParam String code, + HttpServletResponse response) { + AuthenticationToken authenticationToken = authenticationUseCase.getAuthenticationToken(type, code); + response.setHeader("Authorization", "Bearer " + authenticationToken.getAccessToken()); + response.addCookie(cookieUtils.getCookie(REFRESH_TOKEN, authenticationToken.getRefreshToken())); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(redirectUrl)) + .build(); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/controller/oauth/OAuthURLController.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/controller/oauth/OAuthURLController.java new file mode 100644 index 00000000..0a4fe6d1 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/controller/oauth/OAuthURLController.java @@ -0,0 +1,20 @@ +package kr.co.morandi.backend.member_management.infrastructure.controller.oauth; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/oauths") +@RequiredArgsConstructor +public class OAuthURLController { + + @Value("${oauth2.google.redirect-url}") + private String googleRedirectUrl; + @GetMapping("/google") + public String googleRedirect() { + return "redirect:" + googleRedirectUrl; + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/exception/OAuthErrorCode.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/exception/OAuthErrorCode.java new file mode 100644 index 00000000..79311c88 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/exception/OAuthErrorCode.java @@ -0,0 +1,24 @@ +package kr.co.morandi.backend.member_management.infrastructure.exception; + + +import kr.co.morandi.backend.common.exception.errorcode.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum OAuthErrorCode implements ErrorCode { + + MEMBER_NOT_FOUND(HttpStatus.BAD_REQUEST,"사용자를 찾을 수 없습니다"), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED,"인증 시간이 만료된 토큰입니다. 다시 로그인하세요"), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED,"유효하지 않은 토큰입니다."), + TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "액세스 토큰을 찾을 수 없습니다"), + ACCESS_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "액세스 토큰을 찾을 수 없습니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "리프레시 토큰을 찾을 수 없습니다."), + UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,"알 수 없는 오류"), + GOOGLE_OAUTH_ERROR(HttpStatus.BAD_REQUEST,"구글 OAuth 인증을 실패했습니다."); + + private final HttpStatus httpStatus; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/persistence/member/MemberRepository.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/persistence/member/MemberRepository.java new file mode 100644 index 00000000..fcc2f4c5 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/persistence/member/MemberRepository.java @@ -0,0 +1,20 @@ +package kr.co.morandi.backend.member_management.infrastructure.persistence.member; + +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Boolean existsByNickname(String nickname); + Optional findByEmail(String email); + + @Query(""" + select m + from Member m + left join fetch m.baekjoonMemberCookie + where m.memberId = :memberId + """) + Optional findMemberJoinFetchCookie(Long memberId); +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/security/filter/entrypoint/JwtAuthenticationEntryPoint.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/security/filter/entrypoint/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..9f24f76c --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/security/filter/entrypoint/JwtAuthenticationEntryPoint.java @@ -0,0 +1,59 @@ +package kr.co.morandi.backend.member_management.infrastructure.security.filter.entrypoint; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.co.morandi.backend.common.exception.response.ErrorResponse; +import kr.co.morandi.backend.member_management.infrastructure.config.jwt.utils.JwtProvider; +import kr.co.morandi.backend.member_management.infrastructure.exception.OAuthErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + private final JwtProvider jwtProvider; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + /* + * AccessToken이 존재하지 않는 경우 + * */ + if(jwtProvider.parseAccessToken(request) == null) { + response.setStatus(OAuthErrorCode.ACCESS_TOKEN_NOT_FOUND.getHttpStatus().value()); + response.getWriter().write(objectMapper.writeValueAsString(ErrorResponse.of(OAuthErrorCode.ACCESS_TOKEN_NOT_FOUND))); + response.getWriter().flush(); + + return; + } + /* + * RefreshToken이 존재하지 않는 경우 + * */ + if(jwtProvider.parseRefreshToken(request) == null) { + response.setStatus(OAuthErrorCode.REFRESH_TOKEN_NOT_FOUND.getHttpStatus().value()); + response.setContentType("application/json"); + response.getWriter().write(objectMapper.writeValueAsString(ErrorResponse.of(OAuthErrorCode.REFRESH_TOKEN_NOT_FOUND))); + response.getWriter().flush(); + + return; + } + + /* + * RefreshToken이 만료된 경우 + * */ + response.setStatus(OAuthErrorCode.EXPIRED_TOKEN.getHttpStatus().value()); + response.getWriter().write(objectMapper.writeValueAsString(ErrorResponse.of(OAuthErrorCode.EXPIRED_TOKEN))); + + + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/security/filter/oauth/CachedBodyHttpServletWrapper.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/security/filter/oauth/CachedBodyHttpServletWrapper.java new file mode 100644 index 00000000..51c6231c --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/security/filter/oauth/CachedBodyHttpServletWrapper.java @@ -0,0 +1,59 @@ +package kr.co.morandi.backend.member_management.infrastructure.security.filter.oauth; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import lombok.SneakyThrows; +import org.springframework.util.StreamUtils; + +import java.io.*; +import java.nio.charset.StandardCharsets; + +public class CachedBodyHttpServletWrapper extends HttpServletRequestWrapper { + private final byte[] cachedBody; + + public CachedBodyHttpServletWrapper(HttpServletRequest request) throws IOException { + super(request); + InputStream requestInputStream = request.getInputStream(); + this.cachedBody = StreamUtils.copyToByteArray(requestInputStream); + } + + @Override + public ServletInputStream getInputStream() throws IOException { + return new CachedBodyServletInputStream(this.cachedBody); + } + + @Override + public BufferedReader getReader() throws IOException { + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.cachedBody); + return new BufferedReader(new InputStreamReader(byteArrayInputStream, StandardCharsets.UTF_8)); + } + public static class CachedBodyServletInputStream extends ServletInputStream { + + private final InputStream cachedBodyInputStream; + + public CachedBodyServletInputStream(byte[] cachedBody) { + this.cachedBodyInputStream = new ByteArrayInputStream(cachedBody); + } + @SneakyThrows + @Override + public boolean isFinished() { + return cachedBodyInputStream.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener listener) { + + } + @Override + public int read() throws IOException { + return cachedBodyInputStream.read(); + } + } +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/security/filter/oauth/JwtAuthenticationFilter.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/security/filter/oauth/JwtAuthenticationFilter.java new file mode 100644 index 00000000..e0c2aebd --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/security/filter/oauth/JwtAuthenticationFilter.java @@ -0,0 +1,76 @@ +package kr.co.morandi.backend.member_management.infrastructure.security.filter.oauth; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.co.morandi.backend.member_management.infrastructure.config.jwt.utils.JwtProvider; +import kr.co.morandi.backend.member_management.infrastructure.config.jwt.utils.JwtValidator; +import kr.co.morandi.backend.member_management.infrastructure.config.security.utils.AuthenticationProvider; +import kr.co.morandi.backend.member_management.infrastructure.config.security.utils.IgnoredURIManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + + private final JwtValidator jwtValidator; + + private final AuthenticationProvider authenticationProvider; + + private final IgnoredURIManager isIgnoredURIManager; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + /* + * 인증 필요 없는 URI인 경우 필터링하지 않고 바로 다음 필터로 넘어간다. + * */ + if (isIgnoredURIManager.isIgnoredURI(request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + + String accessToken = jwtProvider.parseAccessToken(request); + String refreshToken = jwtProvider.parseRefreshToken(request); + + if (accessToken != null && refreshToken != null) { + /* + * accessToken이 유효한 경우, accessToken을 이용하여 인증을 수행하고 다음 필터로 넘어간다. + * */ + if (jwtValidator.validateToken(accessToken)) { + authenticationProvider.setAuthentication(accessToken); + + filterChain.doFilter(request, response); + return; + } + /* + * accessToken의 유효 기간이 만료된 경우, refreshToken을 이용하여 accessToken을 재발급하고 다음 필터로 넘어간다. + * */ + else if (jwtValidator.validateToken(refreshToken)) { + // refreshToken이 유효할 경우 + accessToken = jwtProvider.reissueAccessToken(refreshToken); + response.setHeader("Authorization", "Bearer " + accessToken); + + authenticationProvider.setAuthentication(accessToken); + filterChain.doFilter(request, response); + return; + } + } + + /* + * accessToken이나 refreshToken이 없는 경우 다음 필터로 넘어가고 + * entryPoint에서 authentication되지 않은 요청에 대한 응답을 처리한다. + * */ + filterChain.doFilter(request, response); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/security/filter/oauth/JwtExceptionFilter.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/security/filter/oauth/JwtExceptionFilter.java new file mode 100644 index 00000000..3cc7de28 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/security/filter/oauth/JwtExceptionFilter.java @@ -0,0 +1,82 @@ +package kr.co.morandi.backend.member_management.infrastructure.security.filter.oauth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.member_management.infrastructure.exception.OAuthErrorCode; +import kr.co.morandi.backend.common.exception.errorcode.ErrorCode; +import kr.co.morandi.backend.common.exception.response.ErrorResponse; +import kr.co.morandi.backend.member_management.infrastructure.config.cookie.utils.CookieUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import static kr.co.morandi.backend.member_management.infrastructure.config.jwt.constants.TokenType.REFRESH_TOKEN; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtExceptionFilter extends OncePerRequestFilter { + + private final ObjectMapper objectMapper; + + private final CookieUtils cookieUtils; + + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws IOException { + /* + * 다음 필터인 JwtAuthenticationFilter에서 발생한 예외를 처리하기 위해 필터를 실행한다. + * */ + try { + filterChain.doFilter(request, response); + } catch (MorandiException e) { + /* + * JwtAuthenticationFilter에서 발생한 예외가 인증 오류인 경우, refreshToken을 제거하고 로그인 페이지로 리다이렉트한다. + * */ + if (isAuthError(e)) { + Cookie cookie = cookieUtils.removeCookie(REFRESH_TOKEN,null); + response.addCookie(cookie); + } + + /* + * JwtAuthenticationFilter에서 발생한 예외가 인증 오류가 아닌 경우, 예외에 해당하는 오류 응답을 반환한다. + */ + setErrorResponse(response, e.getErrorCode()); + } catch (Exception e) { + /* + * JwtAuthenticationFilter에서 발생한 예외가 MorandiException이 아닌 경우, 알 수 없는 오류 응답을 반환한다. + * */ + setErrorResponse(response, OAuthErrorCode.UNKNOWN_ERROR); + } + } + private boolean isAuthError(MorandiException e) { + return e.getErrorCode().getHttpStatus() == (HttpStatus.UNAUTHORIZED) || e.getErrorCode().getHttpStatus() == HttpStatus.FORBIDDEN; + } + private void setErrorResponse(HttpServletResponse response, ErrorCode errorCode) { + response.setStatus(errorCode.getHttpStatus().value()); + response.setCharacterEncoding("UTF-8"); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + ErrorResponse errorResponse = ErrorResponse.of(errorCode); + + try { + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + response.getWriter().flush(); + } catch (IOException e) { + log.error("IOException occurred while writing error response", e); + } + } + +} diff --git a/src/main/java/kr/co/morandi/backend/member_management/infrastructure/security/filter/oauth/RequestCachingFilter.java b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/security/filter/oauth/RequestCachingFilter.java new file mode 100644 index 00000000..8efd9bf6 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/member_management/infrastructure/security/filter/oauth/RequestCachingFilter.java @@ -0,0 +1,20 @@ +package kr.co.morandi.backend.member_management.infrastructure.security.filter.oauth; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class RequestCachingFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + CachedBodyHttpServletWrapper cachedBodyHttpServletWrapper = new CachedBodyHttpServletWrapper(request); + filterChain.doFilter(cachedBodyHttpServletWrapper, response); + } +} diff --git a/src/main/java/kr/co/morandi/backend/problem_information/application/port/out/problemcontent/ProblemContentPort.java b/src/main/java/kr/co/morandi/backend/problem_information/application/port/out/problemcontent/ProblemContentPort.java new file mode 100644 index 00000000..498a1896 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/problem_information/application/port/out/problemcontent/ProblemContentPort.java @@ -0,0 +1,11 @@ +package kr.co.morandi.backend.problem_information.application.port.out.problemcontent; + +import kr.co.morandi.backend.problem_information.application.response.problemcontent.ProblemContent; + +import java.util.List; +import java.util.Map; + +public interface ProblemContentPort { + + Map getProblemContents(List baekjoonProblemIds); +} diff --git a/src/main/java/kr/co/morandi/backend/problem_information/application/response/problemcontent/ProblemContent.java b/src/main/java/kr/co/morandi/backend/problem_information/application/response/problemcontent/ProblemContent.java new file mode 100644 index 00000000..1dd2ea5d --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/problem_information/application/response/problemcontent/ProblemContent.java @@ -0,0 +1,48 @@ +package kr.co.morandi.backend.problem_information.application.response.problemcontent; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.*; + +import java.util.List; + +@Getter +@JsonInclude(JsonInclude.Include.NON_NULL) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ProblemContent { + + private Long baekjoonProblemId; + private String title; + private String memoryLimit; + private String timeLimit; + private String description; + private String input; + private String output; + private List samples; + private String hint; + private List subtasks; + private String problemLimit; + private String additionalTimeLimit; + private String additionalJudgeInfo; + + // 오류 날 경우 error 필드만 반환됨 + private String error; + + + @Builder + private ProblemContent(Long baekjoonProblemId, String title, String memoryLimit, String timeLimit, String description, String input, String output, List samples, String hint, List subtasks, String problemLimit, String additionalTimeLimit, String additionalJudgeInfo, String error) { + this.baekjoonProblemId = baekjoonProblemId; + this.title = title; + this.memoryLimit = memoryLimit; + this.timeLimit = timeLimit; + this.description = description; + this.input = input; + this.output = output; + this.samples = samples; + this.hint = hint; + this.subtasks = subtasks; + this.problemLimit = problemLimit; + this.additionalTimeLimit = additionalTimeLimit; + this.additionalJudgeInfo = additionalJudgeInfo; + this.error = error; + } +} diff --git a/src/main/java/kr/co/morandi/backend/problem_information/application/response/problemcontent/SampleData.java b/src/main/java/kr/co/morandi/backend/problem_information/application/response/problemcontent/SampleData.java new file mode 100644 index 00000000..b532fecb --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/problem_information/application/response/problemcontent/SampleData.java @@ -0,0 +1,22 @@ +package kr.co.morandi.backend.problem_information.application.response.problemcontent; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SampleData { + + private String input; + private String output; + private String explanation; + + @Builder + private SampleData(String input, String output, String explanation) { + this.input = input; + this.output = output; + this.explanation = explanation; + } +} + diff --git a/src/main/java/kr/co/morandi/backend/problem_information/application/response/problemcontent/Subtask.java b/src/main/java/kr/co/morandi/backend/problem_information/application/response/problemcontent/Subtask.java new file mode 100644 index 00000000..899b9183 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/problem_information/application/response/problemcontent/Subtask.java @@ -0,0 +1,23 @@ +package kr.co.morandi.backend.problem_information.application.response.problemcontent; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.*; + +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Subtask { + + private String title; + private List conditions; + private String tableConditionsHtml; + + @Builder + private Subtask(String title, List conditions, String tableConditionsHtml) { + this.title = title; + this.conditions = conditions; + this.tableConditionsHtml = tableConditionsHtml; + } +} diff --git a/src/main/java/kr/co/morandi/backend/problem_information/domain/model/algorithm/Algorithm.java b/src/main/java/kr/co/morandi/backend/problem_information/domain/model/algorithm/Algorithm.java new file mode 100644 index 00000000..fc082c96 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/problem_information/domain/model/algorithm/Algorithm.java @@ -0,0 +1,30 @@ +package kr.co.morandi.backend.problem_information.domain.model.algorithm; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import kr.co.morandi.backend.common.model.BaseEntity; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Algorithm extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long algorithmId; + + private Integer bojTagId; + + private String algorithmKey; + + private String algorithmName; + + @Builder + private Algorithm(Integer bojTagId, String algorithmKey, String algorithmName) { + this.bojTagId = bojTagId; + this.algorithmKey = algorithmKey; + this.algorithmName = algorithmName; + } +} diff --git a/src/main/java/kr/co/morandi/backend/problem_information/domain/model/problem/Problem.java b/src/main/java/kr/co/morandi/backend/problem_information/domain/model/problem/Problem.java new file mode 100644 index 00000000..27c8d072 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/problem_information/domain/model/problem/Problem.java @@ -0,0 +1,48 @@ +package kr.co.morandi.backend.problem_information.domain.model.problem; + +import jakarta.persistence.*; +import kr.co.morandi.backend.common.model.BaseEntity; +import kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier; +import lombok.*; + +import static kr.co.morandi.backend.problem_information.domain.model.problem.ProblemStatus.INIT; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Problem extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long problemId; + + private Long baekjoonProblemId; + + @Enumerated(EnumType.STRING) + private ProblemTier problemTier; + + @Enumerated(EnumType.STRING) + private ProblemStatus problemStatus; + + private Long solvedCount; + + @Builder + private Problem(Long baekjoonProblemId, ProblemTier problemTier, ProblemStatus problemStatus, Long solvedCount) { + this.baekjoonProblemId = baekjoonProblemId; + this.problemTier = problemTier; + this.problemStatus = problemStatus; + this.solvedCount = solvedCount; + } + + public void activate() { + this.problemStatus = ProblemStatus.ACTIVE; + } + + public static Problem create(Long baekjoonProblemId, ProblemTier problemTier, Long solvedCount) { + return Problem.builder() + .baekjoonProblemId(baekjoonProblemId) + .problemTier(problemTier) + .problemStatus(INIT) + .solvedCount(solvedCount) + .build(); + } +} diff --git a/src/main/java/kr/co/morandi/backend/problem_information/domain/model/problem/ProblemAlgorithm.java b/src/main/java/kr/co/morandi/backend/problem_information/domain/model/problem/ProblemAlgorithm.java new file mode 100644 index 00000000..59b7194a --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/problem_information/domain/model/problem/ProblemAlgorithm.java @@ -0,0 +1,27 @@ +package kr.co.morandi.backend.problem_information.domain.model.problem; + +import jakarta.persistence.*; +import kr.co.morandi.backend.common.model.BaseEntity; +import kr.co.morandi.backend.problem_information.domain.model.algorithm.Algorithm; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProblemAlgorithm extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long problemAlgorithmId; + + @ManyToOne(fetch = FetchType.LAZY) + private Algorithm algorithm; + + @ManyToOne(fetch = FetchType.LAZY) + private Problem problem; + + @Builder + private ProblemAlgorithm(Algorithm algorithm, Problem problem) { + this.algorithm = algorithm; + this.problem = problem; + } +} diff --git a/src/main/java/kr/co/morandi/backend/problem_information/domain/model/problem/ProblemStatus.java b/src/main/java/kr/co/morandi/backend/problem_information/domain/model/problem/ProblemStatus.java new file mode 100644 index 00000000..c1d33cb9 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/problem_information/domain/model/problem/ProblemStatus.java @@ -0,0 +1,16 @@ +package kr.co.morandi.backend.problem_information.domain.model.problem; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ProblemStatus { + //배치 작업으로 문제가 자동으로 추가됐을 때, 관리자의 검토 없이 문제가 사용되는 것을 방지하기 위해 추가했습니다. + INIT("새롭게 추가되어 확인이 필요한 문제입니다."), + ACTIVE("활성화되어 사용 가능한 문제입니다."), + HOLD("홀드된 문제입니다. 문제가 활성화되기 전까지 사용할 수 없습니다."), + INACTIVE("비활성화된 문제입니다. 문제가 활성화되기 전까지 사용할 수 없습니다."); + + private final String info; +} diff --git a/src/main/java/kr/co/morandi/backend/problem_information/infrastructure/adapter/problemcontent/ProblemContentAdapter.java b/src/main/java/kr/co/morandi/backend/problem_information/infrastructure/adapter/problemcontent/ProblemContentAdapter.java new file mode 100644 index 00000000..8d2ebea0 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/problem_information/infrastructure/adapter/problemcontent/ProblemContentAdapter.java @@ -0,0 +1,70 @@ +package kr.co.morandi.backend.problem_information.infrastructure.adapter.problemcontent; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.morandi.backend.problem_information.application.port.out.problemcontent.ProblemContentPort; +import kr.co.morandi.backend.problem_information.application.response.problemcontent.ProblemContent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.util.retry.Retry; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class ProblemContentAdapter implements ProblemContentPort { + + private final WebClient webClient; + private final ObjectMapper objectMapper; + + private static final String PROBLEM_CONTENTS_API_URL = "https://n1bcmtru2j.execute-api.ap-northeast-2.amazonaws.com/default/getBaekjoonProblemContents?baekjoonProblemIds=%s"; + + @Override + public Map getProblemContents(List baekjoonProblemIds) { + + if(baekjoonProblemIds.isEmpty()) { + return Map.of(); + } + + if(baekjoonProblemIds.size() > 10) { + throw new IllegalArgumentException("문제 번호는 10개 이하로 요청해주세요."); + } + + String baekjoonProblemIdsParam = baekjoonProblemIds.stream() + .map(String::valueOf) + .collect(Collectors.joining(",")); + + String responseBody = webClient.get() + .uri(String.format(PROBLEM_CONTENTS_API_URL, baekjoonProblemIdsParam)) + .retrieve() + .bodyToMono(String.class) + .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)) + .maxBackoff(Duration.ofSeconds(5)) + .jitter(0.5)) + .block(); + + return parseResponse(responseBody); + } + + + private Map parseResponse(String responseBody) { + try { + objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); + List problemContents = objectMapper.readValue(responseBody, new TypeReference<>() { + }); + + return problemContents.stream() + .filter(content -> content.getError() == null && content.getBaekjoonProblemId() != null) + .collect(Collectors.toMap(ProblemContent::getBaekjoonProblemId, content -> content)); + + } catch (Exception e) { + throw new RuntimeException("Error parsing problem contents", e); + } + } + +} diff --git a/src/main/java/kr/co/morandi/backend/problem_information/infrastructure/initializer/AlgorithmInitializer.java b/src/main/java/kr/co/morandi/backend/problem_information/infrastructure/initializer/AlgorithmInitializer.java new file mode 100644 index 00000000..b16c66d9 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/problem_information/infrastructure/initializer/AlgorithmInitializer.java @@ -0,0 +1,51 @@ +package kr.co.morandi.backend.problem_information.infrastructure.initializer; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import org.springframework.core.io.Resource; +import org.springframework.beans.factory.annotation.Value; + +import kr.co.morandi.backend.problem_information.domain.model.algorithm.Algorithm; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.algorithm.AlgorithmRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class AlgorithmInitializer { + + private final AlgorithmRepository algorithmRepository; + private final ObjectMapper objectMapper; + + @Value("classpath:Algorithms.json") + private Resource algorithmsResource; + + @PostConstruct + @Transactional + public void init() throws IOException { + final List initialAlgorithms = objectMapper.readValue(algorithmsResource.getInputStream(), new TypeReference<>() {}); + final List uninitialized = collectUninitialized(initialAlgorithms); + + algorithmRepository.saveAll(uninitialized); + } + + private List collectUninitialized(List initialAlgorithms) { + final List findAll = algorithmRepository.findAll(); + + final Set collect = findAll.stream() + .map(Algorithm::getBojTagId) + .collect(Collectors.toSet()); + + return initialAlgorithms.stream() + .filter(algorithm -> !collect.contains(algorithm.getBojTagId())) + .toList(); + } + +} diff --git a/src/main/java/kr/co/morandi/backend/problem_information/infrastructure/persistence/algorithm/AlgorithmRepository.java b/src/main/java/kr/co/morandi/backend/problem_information/infrastructure/persistence/algorithm/AlgorithmRepository.java new file mode 100644 index 00000000..2f68fdd6 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/problem_information/infrastructure/persistence/algorithm/AlgorithmRepository.java @@ -0,0 +1,10 @@ +package kr.co.morandi.backend.problem_information.infrastructure.persistence.algorithm; + +import kr.co.morandi.backend.problem_information.domain.model.algorithm.Algorithm; +import org.springframework.data.jpa.repository.JpaRepository; + + +public interface AlgorithmRepository extends JpaRepository { + Boolean existsByBojTagIdOrAlgorithmKey(Integer bojTagId, String algorithmKey); + +} diff --git a/src/main/java/kr/co/morandi/backend/problem_information/infrastructure/persistence/problem/ProblemAlgorithmRepository.java b/src/main/java/kr/co/morandi/backend/problem_information/infrastructure/persistence/problem/ProblemAlgorithmRepository.java new file mode 100644 index 00000000..f1258f4d --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/problem_information/infrastructure/persistence/problem/ProblemAlgorithmRepository.java @@ -0,0 +1,7 @@ +package kr.co.morandi.backend.problem_information.infrastructure.persistence.problem; + +import kr.co.morandi.backend.problem_information.domain.model.problem.ProblemAlgorithm; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProblemAlgorithmRepository extends JpaRepository { +} diff --git a/src/main/java/kr/co/morandi/backend/problem_information/infrastructure/persistence/problem/ProblemRepository.java b/src/main/java/kr/co/morandi/backend/problem_information/infrastructure/persistence/problem/ProblemRepository.java new file mode 100644 index 00000000..006b61e1 --- /dev/null +++ b/src/main/java/kr/co/morandi/backend/problem_information/infrastructure/persistence/problem/ProblemRepository.java @@ -0,0 +1,29 @@ +package kr.co.morandi.backend.problem_information.infrastructure.persistence.problem; + +import kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.domain.model.problem.ProblemStatus; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface ProblemRepository extends JpaRepository { + + @Query(""" + SELECT p + FROM Problem p + WHERE p.problemStatus = 'ACTIVE' + AND p.problemTier IN :problemTiers + AND p.solvedCount >= :startSolvedCount + AND p.solvedCount <= :endSolvedCount + AND p NOT IN (SELECT ddp.problem + FROM DailyDefenseProblem ddp) + ORDER BY FUNCTION('RAND') + """) + List getDailyDefenseProblems(List problemTiers, Long startSolvedCount, Long endSolvedCount, Pageable pageable); + + List findAllByProblemStatus(ProblemStatus problemStatus); +} + diff --git a/src/main/resources/Algorithms.json b/src/main/resources/Algorithms.json new file mode 100644 index 00000000..bd9d1029 --- /dev/null +++ b/src/main/resources/Algorithms.json @@ -0,0 +1,1032 @@ +[ + { + "bojTagId": 1, + "algorithmKey": "2_sat", + "algorithmName": "2-sat" + }, + { + "bojTagId": 2, + "algorithmKey": "aho_corasick", + "algorithmName": "아호-코라식" + }, + { + "bojTagId": 3, + "algorithmKey": "polygon_area", + "algorithmName": "다각형의 넓이" + }, + { + "bojTagId": 4, + "algorithmKey": "articulation", + "algorithmName": "단절점과 단절선" + }, + { + "bojTagId": 5, + "algorithmKey": "backtracking", + "algorithmName": "백트래킹" + }, + { + "bojTagId": 6, + "algorithmKey": "combinatorics", + "algorithmName": "조합론" + }, + { + "bojTagId": 7, + "algorithmKey": "graphs", + "algorithmName": "그래프 이론" + }, + { + "bojTagId": 8, + "algorithmKey": "hashing", + "algorithmName": "해싱" + }, + { + "bojTagId": 9, + "algorithmKey": "primality_test", + "algorithmName": "소수 판정" + }, + { + "bojTagId": 10, + "algorithmKey": "bellman_ford", + "algorithmName": "벨만–포드" + }, + { + "bojTagId": 11, + "algorithmKey": "graph_traversal", + "algorithmName": "그래프 탐색" + }, + { + "bojTagId": 12, + "algorithmKey": "binary_search", + "algorithmName": "이분 탐색" + }, + { + "bojTagId": 13, + "algorithmKey": "bipartite_matching", + "algorithmName": "이분 매칭" + }, + { + "bojTagId": 14, + "algorithmKey": "bitmask", + "algorithmName": "비트마스킹" + }, + { + "bojTagId": 15, + "algorithmKey": "general_matching", + "algorithmName": "일반적인 매칭" + }, + { + "bojTagId": 16, + "algorithmKey": "burnside", + "algorithmName": "번사이드 보조정리" + }, + { + "bojTagId": 18, + "algorithmKey": "centroid_decomposition", + "algorithmName": "센트로이드 분할" + }, + { + "bojTagId": 19, + "algorithmKey": "crt", + "algorithmName": "중국인의 나머지 정리" + }, + { + "bojTagId": 20, + "algorithmKey": "convex_hull", + "algorithmName": "볼록 껍질" + }, + { + "bojTagId": 21, + "algorithmKey": "delaunay", + "algorithmName": "델로네 삼각분할" + }, + { + "bojTagId": 22, + "algorithmKey": "dijkstra", + "algorithmName": "데이크스트라" + }, + { + "bojTagId": 23, + "algorithmKey": "directed_mst", + "algorithmName": "유향 최소 신장 트리" + }, + { + "bojTagId": 24, + "algorithmKey": "divide_and_conquer", + "algorithmName": "분할 정복" + }, + { + "bojTagId": 25, + "algorithmKey": "dp", + "algorithmName": "다이나믹 프로그래밍" + }, + { + "bojTagId": 26, + "algorithmKey": "euclidean", + "algorithmName": "유클리드 호제법" + }, + { + "bojTagId": 27, + "algorithmKey": "extended_euclidean", + "algorithmName": "확장 유클리드 호제법" + }, + { + "bojTagId": 28, + "algorithmKey": "fft", + "algorithmName": "고속 푸리에 변환" + }, + { + "bojTagId": 29, + "algorithmKey": "flt", + "algorithmName": "페르마의 소정리" + }, + { + "bojTagId": 31, + "algorithmKey": "floyd_warshall", + "algorithmName": "플로이드–워셜" + }, + { + "bojTagId": 32, + "algorithmKey": "gaussian_elimination", + "algorithmName": "가우스 소거법" + }, + { + "bojTagId": 33, + "algorithmKey": "greedy", + "algorithmName": "그리디 알고리즘" + }, + { + "bojTagId": 34, + "algorithmKey": "hall", + "algorithmName": "홀의 결혼 정리" + }, + { + "bojTagId": 35, + "algorithmKey": "hld", + "algorithmName": "Heavy-light 분할" + }, + { + "bojTagId": 36, + "algorithmKey": "hungarian", + "algorithmName": "헝가리안" + }, + { + "bojTagId": 38, + "algorithmKey": "inclusion_and_exclusion", + "algorithmName": "포함 배제의 원리" + }, + { + "bojTagId": 39, + "algorithmKey": "exponentiation_by_squaring", + "algorithmName": "분할 정복을 이용한 거듭제곱" + }, + { + "bojTagId": 40, + "algorithmKey": "kmp", + "algorithmName": "KMP" + }, + { + "bojTagId": 41, + "algorithmKey": "lca", + "algorithmName": "최소 공통 조상" + }, + { + "bojTagId": 42, + "algorithmKey": "line_intersection", + "algorithmName": "선분 교차 판정" + }, + { + "bojTagId": 43, + "algorithmKey": "lis", + "algorithmName": "가장 긴 증가하는 부분 수열: O(n log n)" + }, + { + "bojTagId": 44, + "algorithmKey": "manacher", + "algorithmName": "매내처" + }, + { + "bojTagId": 45, + "algorithmKey": "flow", + "algorithmName": "최대 유량" + }, + { + "bojTagId": 46, + "algorithmKey": "mitm", + "algorithmName": "중간에서 만나기" + }, + { + "bojTagId": 47, + "algorithmKey": "miller_rabin", + "algorithmName": "밀러–라빈 소수 판별법" + }, + { + "bojTagId": 48, + "algorithmKey": "mcmf", + "algorithmName": "최소 비용 최대 유량" + }, + { + "bojTagId": 49, + "algorithmKey": "mst", + "algorithmName": "최소 스패닝 트리" + }, + { + "bojTagId": 50, + "algorithmKey": "mo", + "algorithmName": "mo\\'s" + }, + { + "bojTagId": 51, + "algorithmKey": "mobius_inversion", + "algorithmName": "뫼비우스 반전 공식" + }, + { + "bojTagId": 52, + "algorithmKey": "offline_dynamic_connectivity", + "algorithmName": "오프라인 동적 연결성 판정" + }, + { + "bojTagId": 53, + "algorithmKey": "palindrome_tree", + "algorithmName": "회문 트리" + }, + { + "bojTagId": 54, + "algorithmKey": "pbs", + "algorithmName": "병렬 이분 탐색" + }, + { + "bojTagId": 55, + "algorithmKey": "pst", + "algorithmName": "퍼시스턴트 세그먼트 트리" + }, + { + "bojTagId": 56, + "algorithmKey": "point_in_convex_polygon", + "algorithmName": "볼록 다각형 내부의 점 판정" + }, + { + "bojTagId": 57, + "algorithmKey": "point_in_non_convex_polygon", + "algorithmName": "오목 다각형 내부의 점 판정" + }, + { + "bojTagId": 58, + "algorithmKey": "pollard_rho", + "algorithmName": "폴라드 로" + }, + { + "bojTagId": 59, + "algorithmKey": "priority_queue", + "algorithmName": "우선순위 큐" + }, + { + "bojTagId": 60, + "algorithmKey": "pythagoras", + "algorithmName": "피타고라스 정리" + }, + { + "bojTagId": 61, + "algorithmKey": "rabin_karp", + "algorithmName": "라빈–카프" + }, + { + "bojTagId": 62, + "algorithmKey": "recursion", + "algorithmName": "재귀" + }, + { + "bojTagId": 63, + "algorithmKey": "regex", + "algorithmName": "정규 표현식" + }, + { + "bojTagId": 64, + "algorithmKey": "rotating_calipers", + "algorithmName": "회전하는 캘리퍼스" + }, + { + "bojTagId": 65, + "algorithmKey": "segtree", + "algorithmName": "세그먼트 트리" + }, + { + "bojTagId": 66, + "algorithmKey": "lazyprop", + "algorithmName": "느리게 갱신되는 세그먼트 트리" + }, + { + "bojTagId": 67, + "algorithmKey": "sieve", + "algorithmName": "에라토스테네스의 체" + }, + { + "bojTagId": 68, + "algorithmKey": "sliding_window", + "algorithmName": "슬라이딩 윈도우" + }, + { + "bojTagId": 69, + "algorithmKey": "splay_tree", + "algorithmName": "스플레이 트리" + }, + { + "bojTagId": 70, + "algorithmKey": "sprague_grundy", + "algorithmName": "스프라그–그런디 정리" + }, + { + "bojTagId": 71, + "algorithmKey": "stack", + "algorithmName": "스택" + }, + { + "bojTagId": 72, + "algorithmKey": "queue", + "algorithmName": "큐" + }, + { + "bojTagId": 73, + "algorithmKey": "deque", + "algorithmName": "덱" + }, + { + "bojTagId": 74, + "algorithmKey": "tree_set", + "algorithmName": "트리를 사용한 집합과 맵" + }, + { + "bojTagId": 75, + "algorithmKey": "stoer_wagner", + "algorithmName": "스토어–바그너" + }, + { + "bojTagId": 76, + "algorithmKey": "scc", + "algorithmName": "강한 연결 요소" + }, + { + "bojTagId": 77, + "algorithmKey": "suffix_array", + "algorithmName": "접미사 배열과 LCP 배열" + }, + { + "bojTagId": 78, + "algorithmKey": "topological_sorting", + "algorithmName": "위상 정렬" + }, + { + "bojTagId": 79, + "algorithmKey": "trie", + "algorithmName": "트라이" + }, + { + "bojTagId": 80, + "algorithmKey": "two_pointer", + "algorithmName": "두 포인터" + }, + { + "bojTagId": 81, + "algorithmKey": "disjoint_set", + "algorithmName": "분리 집합" + }, + { + "bojTagId": 82, + "algorithmKey": "voronoi", + "algorithmName": "보로노이 다이어그램" + }, + { + "bojTagId": 83, + "algorithmKey": "z", + "algorithmName": "z" + }, + { + "bojTagId": 84, + "algorithmKey": "sparse_table", + "algorithmName": "희소 배열" + }, + { + "bojTagId": 87, + "algorithmKey": "dp_bitfield", + "algorithmName": "비트필드를 이용한 다이나믹 프로그래밍" + }, + { + "bojTagId": 89, + "algorithmKey": "cht", + "algorithmName": "볼록 껍질을 이용한 최적화" + }, + { + "bojTagId": 90, + "algorithmKey": "knuth", + "algorithmName": "크누스 최적화" + }, + { + "bojTagId": 91, + "algorithmKey": "divide_and_conquer_optimization", + "algorithmName": "분할 정복을 사용한 최적화" + }, + { + "bojTagId": 92, + "algorithmKey": "dp_tree", + "algorithmName": "트리에서의 다이나믹 프로그래밍" + }, + { + "bojTagId": 93, + "algorithmKey": "eulerian_path", + "algorithmName": "오일러 경로" + }, + { + "bojTagId": 94, + "algorithmKey": "rb_tree", + "algorithmName": "레드-블랙 트리" + }, + { + "bojTagId": 95, + "algorithmKey": "number_theory", + "algorithmName": "정수론" + }, + { + "bojTagId": 96, + "algorithmKey": "parsing", + "algorithmName": "파싱" + }, + { + "bojTagId": 97, + "algorithmKey": "sorting", + "algorithmName": "정렬" + }, + { + "bojTagId": 98, + "algorithmKey": "link_cut_tree", + "algorithmName": "링크/컷 트리" + }, + { + "bojTagId": 100, + "algorithmKey": "geometry", + "algorithmName": "기하학" + }, + { + "bojTagId": 101, + "algorithmKey": "ternary_search", + "algorithmName": "삼분 탐색" + }, + { + "bojTagId": 102, + "algorithmKey": "implementation", + "algorithmName": "구현" + }, + { + "bojTagId": 103, + "algorithmKey": "linear_programming", + "algorithmName": "선형 계획법" + }, + { + "bojTagId": 104, + "algorithmKey": "matroid", + "algorithmName": "매트로이드" + }, + { + "bojTagId": 105, + "algorithmKey": "top_tree", + "algorithmName": "탑 트리" + }, + { + "bojTagId": 106, + "algorithmKey": "sweeping", + "algorithmName": "스위핑" + }, + { + "bojTagId": 107, + "algorithmKey": "dp_connection_profile", + "algorithmName": "커넥션 프로파일을 이용한 다이나믹 프로그래밍" + }, + { + "bojTagId": 108, + "algorithmKey": "dp_deque", + "algorithmName": "덱을 이용한 다이나믹 프로그래밍" + }, + { + "bojTagId": 109, + "algorithmKey": "ad_hoc", + "algorithmName": "애드 혹" + }, + { + "bojTagId": 110, + "algorithmKey": "berlekamp_massey", + "algorithmName": "벌리캠프–매시" + }, + { + "bojTagId": 111, + "algorithmKey": "calculus", + "algorithmName": "미적분학" + }, + { + "bojTagId": 112, + "algorithmKey": "kitamasa", + "algorithmName": "키타마사" + }, + { + "bojTagId": 113, + "algorithmKey": "lucas", + "algorithmName": "뤼카 정리" + }, + { + "bojTagId": 114, + "algorithmKey": "bayes", + "algorithmName": "베이즈 정리" + }, + { + "bojTagId": 115, + "algorithmKey": "randomization", + "algorithmName": "무작위화" + }, + { + "bojTagId": 116, + "algorithmKey": "physics", + "algorithmName": "물리학" + }, + { + "bojTagId": 117, + "algorithmKey": "arbitrary_precision", + "algorithmName": "임의 정밀도 / 큰 수 연산" + }, + { + "bojTagId": 119, + "algorithmKey": "euler_characteristic", + "algorithmName": "오일러 지표 (χ=V-E+F)" + }, + { + "bojTagId": 120, + "algorithmKey": "trees", + "algorithmName": "트리" + }, + { + "bojTagId": 121, + "algorithmKey": "arithmetic", + "algorithmName": "사칙연산" + }, + { + "bojTagId": 122, + "algorithmKey": "numerical_analysis", + "algorithmName": "수치해석" + }, + { + "bojTagId": 123, + "algorithmKey": "offline_queries", + "algorithmName": "오프라인 쿼리" + }, + { + "bojTagId": 124, + "algorithmKey": "math", + "algorithmName": "수학" + }, + { + "bojTagId": 125, + "algorithmKey": "bruteforcing", + "algorithmName": "브루트포스 알고리즘" + }, + { + "bojTagId": 126, + "algorithmKey": "bfs", + "algorithmName": "너비 우선 탐색" + }, + { + "bojTagId": 127, + "algorithmKey": "dfs", + "algorithmName": "깊이 우선 탐색" + }, + { + "bojTagId": 128, + "algorithmKey": "constructive", + "algorithmName": "해 구성하기" + }, + { + "bojTagId": 129, + "algorithmKey": "bidirectional_search", + "algorithmName": "양방향 탐색" + }, + { + "bojTagId": 130, + "algorithmKey": "sqrt_decomposition", + "algorithmName": "제곱근 분할법" + }, + { + "bojTagId": 131, + "algorithmKey": "geometry_3d", + "algorithmName": "3차원 기하학" + }, + { + "bojTagId": 132, + "algorithmKey": "geometry_hyper", + "algorithmName": "4차원 이상의 기하학" + }, + { + "bojTagId": 134, + "algorithmKey": "alien", + "algorithmName": "Aliens 트릭" + }, + { + "bojTagId": 135, + "algorithmKey": "dominator_tree", + "algorithmName": "도미네이터 트리" + }, + { + "bojTagId": 136, + "algorithmKey": "hash_set", + "algorithmName": "해시를 사용한 집합과 맵" + }, + { + "bojTagId": 137, + "algorithmKey": "case_work", + "algorithmName": "많은 조건 분기" + }, + { + "bojTagId": 138, + "algorithmKey": "tsp", + "algorithmName": "외판원 순회 문제" + }, + { + "bojTagId": 139, + "algorithmKey": "prefix_sum", + "algorithmName": "누적 합" + }, + { + "bojTagId": 140, + "algorithmKey": "game_theory", + "algorithmName": "게임 이론" + }, + { + "bojTagId": 141, + "algorithmKey": "simulation", + "algorithmName": "시뮬레이션" + }, + { + "bojTagId": 142, + "algorithmKey": "heuristics", + "algorithmName": "휴리스틱" + }, + { + "bojTagId": 143, + "algorithmKey": "cactus", + "algorithmName": "선인장" + }, + { + "bojTagId": 144, + "algorithmKey": "linear_algebra", + "algorithmName": "선형대수학" + }, + { + "bojTagId": 145, + "algorithmKey": "tree_isomorphism", + "algorithmName": "트리 동형 사상" + }, + { + "bojTagId": 146, + "algorithmKey": "discrete_log", + "algorithmName": "이산 로그" + }, + { + "bojTagId": 147, + "algorithmKey": "discrete_sqrt", + "algorithmName": "이산 제곱근" + }, + { + "bojTagId": 148, + "algorithmKey": "knapsack", + "algorithmName": "배낭 문제" + }, + { + "bojTagId": 149, + "algorithmKey": "discrete_kth_root", + "algorithmName": "이산 k제곱근" + }, + { + "bojTagId": 150, + "algorithmKey": "euler_tour_technique", + "algorithmName": "오일러 경로 테크닉" + }, + { + "bojTagId": 151, + "algorithmKey": "euler_phi", + "algorithmName": "오일러 피 함수" + }, + { + "bojTagId": 152, + "algorithmKey": "bitset", + "algorithmName": "비트 집합" + }, + { + "bojTagId": 153, + "algorithmKey": "biconnected_component", + "algorithmName": "이중 연결 요소" + }, + { + "bojTagId": 154, + "algorithmKey": "linked_list", + "algorithmName": "연결 리스트" + }, + { + "bojTagId": 155, + "algorithmKey": "merge_sort_tree", + "algorithmName": "머지 소트 트리" + }, + { + "bojTagId": 157, + "algorithmKey": "slope_trick", + "algorithmName": "함수 개형을 이용한 최적화" + }, + { + "bojTagId": 158, + "algorithmKey": "string", + "algorithmName": "문자열" + }, + { + "bojTagId": 159, + "algorithmKey": "rope", + "algorithmName": "로프" + }, + { + "bojTagId": 160, + "algorithmKey": "majority_vote", + "algorithmName": "보이어–무어 다수결 투표" + }, + { + "bojTagId": 161, + "algorithmKey": "coordinate_compression", + "algorithmName": "값 / 좌표 압축" + }, + { + "bojTagId": 162, + "algorithmKey": "min_enclosing_circle", + "algorithmName": "최소 외접원" + }, + { + "bojTagId": 163, + "algorithmKey": "hirschberg", + "algorithmName": "히르쉬버그" + }, + { + "bojTagId": 164, + "algorithmKey": "modular_multiplicative_inverse", + "algorithmName": "모듈로 곱셈 역원" + }, + { + "bojTagId": 165, + "algorithmKey": "monotone_queue_optimization", + "algorithmName": "단조 큐를 이용한 최적화" + }, + { + "bojTagId": 166, + "algorithmKey": "multi_segtree", + "algorithmName": "다차원 세그먼트 트리" + }, + { + "bojTagId": 167, + "algorithmKey": "mfmc", + "algorithmName": "최대 유량 최소 컷 정리" + }, + { + "bojTagId": 168, + "algorithmKey": "planar_graph", + "algorithmName": "평면 그래프" + }, + { + "bojTagId": 169, + "algorithmKey": "smaller_to_larger", + "algorithmName": "작은 집합에서 큰 집합으로 합치는 테크닉" + }, + { + "bojTagId": 170, + "algorithmKey": "parametric_search", + "algorithmName": "매개 변수 탐색" + }, + { + "bojTagId": 171, + "algorithmKey": "permutation_cycle_decomposition", + "algorithmName": "순열 사이클 분할" + }, + { + "bojTagId": 172, + "algorithmKey": "precomputation", + "algorithmName": "런타임 전의 전처리" + }, + { + "bojTagId": 173, + "algorithmKey": "dancing_links", + "algorithmName": "춤추는 링크" + }, + { + "bojTagId": 174, + "algorithmKey": "knuth_x", + "algorithmName": "크누스 X" + }, + { + "bojTagId": 175, + "algorithmKey": "data_structures", + "algorithmName": "자료 구조" + }, + { + "bojTagId": 176, + "algorithmKey": "0_1_bfs", + "algorithmName": "0-1 너비 우선 탐색" + }, + { + "bojTagId": 177, + "algorithmKey": "probability", + "algorithmName": "확률론" + }, + { + "bojTagId": 178, + "algorithmKey": "statistics", + "algorithmName": "통계학" + }, + { + "bojTagId": 179, + "algorithmKey": "linearity_of_expectation", + "algorithmName": "기댓값의 선형성" + }, + { + "bojTagId": 180, + "algorithmKey": "duality", + "algorithmName": "쌍대성" + }, + { + "bojTagId": 181, + "algorithmKey": "dual_graph", + "algorithmName": "쌍대 그래프" + }, + { + "bojTagId": 182, + "algorithmKey": "suffix_tree", + "algorithmName": "접미사 트리" + }, + { + "bojTagId": 183, + "algorithmKey": "green", + "algorithmName": "그린 정리" + }, + { + "bojTagId": 184, + "algorithmKey": "simulated_annealing", + "algorithmName": "담금질 기법" + }, + { + "bojTagId": 185, + "algorithmKey": "differential_cryptanalysis", + "algorithmName": "차분 공격" + }, + { + "bojTagId": 186, + "algorithmKey": "a_star", + "algorithmName": "a*" + }, + { + "bojTagId": 187, + "algorithmKey": "pick", + "algorithmName": "픽의 정리" + }, + { + "bojTagId": 188, + "algorithmKey": "centroid", + "algorithmName": "센트로이드" + }, + { + "bojTagId": 189, + "algorithmKey": "pigeonhole_principle", + "algorithmName": "비둘기집 원리" + }, + { + "bojTagId": 190, + "algorithmKey": "half_plane_intersection", + "algorithmName": "반평면 교집합" + }, + { + "bojTagId": 191, + "algorithmKey": "circulation", + "algorithmName": "서큘레이션" + }, + { + "bojTagId": 192, + "algorithmKey": "stable_marriage", + "algorithmName": "안정 결혼 문제" + }, + { + "bojTagId": 193, + "algorithmKey": "tree_compression", + "algorithmName": "트리 압축" + }, + { + "bojTagId": 196, + "algorithmKey": "multipoint_evaluation", + "algorithmName": "다중 대입값 계산" + }, + { + "bojTagId": 197, + "algorithmKey": "bipartite_graph", + "algorithmName": "이분 그래프" + }, + { + "bojTagId": 198, + "algorithmKey": "generating_function", + "algorithmName": "생성 함수" + }, + { + "bojTagId": 199, + "algorithmKey": "utf8", + "algorithmName": "utf-8 입력 처리" + }, + { + "bojTagId": 200, + "algorithmKey": "degree_sequence", + "algorithmName": "차수열" + }, + { + "bojTagId": 201, + "algorithmKey": "chordal_graph", + "algorithmName": "현 그래프" + }, + { + "bojTagId": 202, + "algorithmKey": "geometric_boolean_operations", + "algorithmName": "도형에서의 불 연산" + }, + { + "bojTagId": 203, + "algorithmKey": "birthday", + "algorithmName": "생일 문제" + }, + { + "bojTagId": 204, + "algorithmKey": "tree_decomposition", + "algorithmName": "트리 분할" + }, + { + "bojTagId": 205, + "algorithmKey": "hackenbush", + "algorithmName": "하켄부시 게임" + }, + { + "bojTagId": 206, + "algorithmKey": "cartesian_tree", + "algorithmName": "데카르트 트리" + }, + { + "bojTagId": 207, + "algorithmKey": "dp_sum_over_subsets", + "algorithmName": "부분집합의 합 다이나믹 프로그래밍" + }, + { + "bojTagId": 208, + "algorithmKey": "gradient_descent", + "algorithmName": "경사 하강법" + }, + { + "bojTagId": 209, + "algorithmKey": "polynomial_interpolation", + "algorithmName": "다항식 보간법" + }, + { + "bojTagId": 210, + "algorithmKey": "flood_fill", + "algorithmName": "플러드 필" + }, + { + "bojTagId": 211, + "algorithmKey": "functional_graph", + "algorithmName": "함수형 그래프" + }, + { + "bojTagId": 212, + "algorithmKey": "lte", + "algorithmName": "지수승강 보조정리" + }, + { + "bojTagId": 213, + "algorithmKey": "dag", + "algorithmName": "방향 비순환 그래프" + }, + { + "bojTagId": 214, + "algorithmKey": "lgv", + "algorithmName": "린드스트롬–게셀–비엔노 보조정리" + }, + { + "bojTagId": 215, + "algorithmKey": "shortest_path", + "algorithmName": "최단 경로" + }, + { + "bojTagId": 216, + "algorithmKey": "deque_trick", + "algorithmName": "덱을 이용한 구간 최댓값 트릭" + }, + { + "bojTagId": 217, + "algorithmKey": "dp_digit", + "algorithmName": "자릿수를 이용한 다이나믹 프로그래밍" + }, + { + "bojTagId": 218, + "algorithmKey": "floor_sum", + "algorithmName": "유리 등차수열의 내림 합" + } +] \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/ControllerTestSupport.java b/src/test/java/kr/co/morandi/backend/ControllerTestSupport.java new file mode 100644 index 00000000..f42bc799 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/ControllerTestSupport.java @@ -0,0 +1,76 @@ +package kr.co.morandi.backend; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.morandi.backend.defense_information.application.port.in.DailyDefenseUseCase; +import kr.co.morandi.backend.defense_information.infrastructure.controller.DailyDefenseController; +import kr.co.morandi.backend.defense_management.application.service.codesubmit.ExampleCodeSubmitService; +import kr.co.morandi.backend.defense_management.application.service.message.DefenseMessageService; +import kr.co.morandi.backend.defense_management.infrastructure.controller.ExampleCodeSubmitController; +import kr.co.morandi.backend.defense_management.infrastructure.controller.SessionConnectionController; +import kr.co.morandi.backend.defense_record.application.port.in.DailyRecordRankUseCase; +import kr.co.morandi.backend.defense_record.infrastructure.controller.DailyRecordController; +import kr.co.morandi.backend.judgement.application.service.baekjoon.cookie.BaekjoonMemberCookieService; +import kr.co.morandi.backend.judgement.application.usecase.submit.BaekjoonSubmitUsecase; +import kr.co.morandi.backend.judgement.infrastructure.controller.BaekjoonSubmitController; +import kr.co.morandi.backend.judgement.infrastructure.controller.cookie.CookieController; +import kr.co.morandi.backend.member_management.infrastructure.config.cookie.utils.CookieUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.filter.OncePerRequestFilter; + +@WebMvcTest(controllers = { + DailyDefenseController.class, + DailyRecordController.class, + SessionConnectionController.class, + CookieController.class, + BaekjoonSubmitController.class, + ExampleCodeSubmitController.class }, + excludeAutoConfiguration = SecurityAutoConfiguration.class, + excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = { + OncePerRequestFilter.class + }) + } +) +@ActiveProfiles("test") +public abstract class ControllerTestSupport { + + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected ObjectMapper objectMapper; + + // DailyDefenseController + @MockBean + protected DailyDefenseUseCase dailyDefenseUseCase; + + @MockBean + protected CookieUtils cookieUtils; + + // DailyRecordController + @MockBean + protected DailyRecordRankUseCase dailyRecordRankUseCase; + + //SessionConnectionController + @MockBean + protected DefenseMessageService defenseMessageService; + + // CookieController + @MockBean + protected BaekjoonMemberCookieService baekjoonMemberCookieService; + + // BaekjoonSubmitController + @MockBean + protected BaekjoonSubmitUsecase baekjoonSubmitUsecase; + + // ExampleCodeSubmitController + @MockBean + protected ExampleCodeSubmitService messagingQueueService; +} diff --git a/src/test/java/kr/co/morandi/backend/IntegrationTestSupport.java b/src/test/java/kr/co/morandi/backend/IntegrationTestSupport.java new file mode 100644 index 00000000..b316c65f --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/IntegrationTestSupport.java @@ -0,0 +1,15 @@ +package kr.co.morandi.backend; + +import kr.co.morandi.backend.config.WebClientTestConfig; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import com.amazonaws.services.sqs.AmazonSQS; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest +@Import({ + WebClientTestConfig.class +}) +public abstract class IntegrationTestSupport { +} diff --git a/src/test/java/kr/co/morandi/backend/NewMorandiApplicationTests.java b/src/test/java/kr/co/morandi/backend/NewMorandiApplicationTests.java index 15f09e85..2ac08c14 100644 --- a/src/test/java/kr/co/morandi/backend/NewMorandiApplicationTests.java +++ b/src/test/java/kr/co/morandi/backend/NewMorandiApplicationTests.java @@ -1,10 +1,8 @@ package kr.co.morandi.backend; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest -class NewMorandiApplicationTests { +class NewMorandiApplicationTests extends IntegrationTestSupport{ @Test void contextLoads() { diff --git a/src/test/java/kr/co/morandi/backend/config/WebClientTestConfig.java b/src/test/java/kr/co/morandi/backend/config/WebClientTestConfig.java new file mode 100644 index 00000000..d69edaff --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/config/WebClientTestConfig.java @@ -0,0 +1,33 @@ +package kr.co.morandi.backend.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.mockito.Mockito.mock; + +@TestConfiguration +public class WebClientTestConfig { + @Bean + @Primary + WebClient testWebClient(ExchangeFunction exchangeFunction) { + return WebClient.builder() + .exchangeFunction(exchangeFunction) + .build(); + } + + /* + * 중요 + * + * 내부에서 WebClient를 이용하는 통합 테스트에서는 ExchangeFunction의 exchange 메서드를 + * Stubbing하여 테스트를 진행합니다. + * + * 내부적으로 API가 호출되는 횟수만큼 Stubbing을 해주어야 합니다. + * */ + @Bean + ExchangeFunction exchangeFunction() { + return mock(ExchangeFunction.class); + } +} diff --git a/src/test/java/kr/co/morandi/backend/defense_information/application/mapper/dailydefense/DailyDefenseInfoMapperTest.java b/src/test/java/kr/co/morandi/backend/defense_information/application/mapper/dailydefense/DailyDefenseInfoMapperTest.java new file mode 100644 index 00000000..793009da --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/application/mapper/dailydefense/DailyDefenseInfoMapperTest.java @@ -0,0 +1,107 @@ +package kr.co.morandi.backend.defense_information.application.mapper.dailydefense; + +import kr.co.morandi.backend.defense_information.application.dto.response.DailyDefenseInfoResponse; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; + + +@ActiveProfiles("test") +class DailyDefenseInfoMapperTest { + + @DisplayName("시도한 적이 있는 DailyDefense Response DTO를 반환할 수 있다.") + @Test + void ofNonAttempted() { + // given + DailyDefense dailyDefense = createDailyDefense(); + + // when + DailyDefenseInfoResponse response = DailyDefenseInfoMapper.fromNonAttempted(dailyDefense); + + // then + assertThat(response) + .extracting("defenseName", "problemCount", "attemptCount") + .contains(dailyDefense.getContentName(), dailyDefense.getDailyDefenseProblems().size(), 0L); + + assertThat(response.getProblems()) + .extracting("problemNumber", "baekjoonProblemId", "difficulty", "solvedCount", "submitCount", "isSolved") + .containsExactlyInAnyOrder( + tuple(1L, 1L, B5, 0L, 0L, null), + tuple(2L, 2L, S5, 0L, 0L, null), + tuple(3L, 3L, G5, 0L, 0L, null) + ); + + + } + + @DisplayName("시도한 적이 있는 DailyDefense Response DTO를 반환할 수 있다.") + @Test + void ofAttempted() { + // given + DailyDefense dailyDefense = createDailyDefense(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + Member member = createMember("user"); + Map problems = getProblems(dailyDefense, 2L); + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, problems); + + + // when + DailyDefenseInfoResponse response = DailyDefenseInfoMapper.ofAttempted(dailyDefense, dailyRecord); + + // then + assertThat(response) + .extracting("defenseName", "problemCount", "attemptCount") + .contains(dailyDefense.getContentName(), dailyDefense.getDailyDefenseProblems().size(), 1L); + + assertThat(response.getProblems()) + .extracting("problemNumber", "baekjoonProblemId", "difficulty", "solvedCount", "submitCount", "isSolved") + .containsExactlyInAnyOrder( + tuple(1L, 1L, B5, 0L, 0L, false), + tuple(2L, 2L, S5, 0L, 0L, false), + tuple(3L, 3L, G5, 0L, 0L, false) + ); + } + + private Map getProblems(DailyDefense DailyDefense, Long problemNumber) { + return DailyDefense.getDailyDefenseProblems().stream() + .filter(p -> p.getProblemNumber().equals(problemNumber)) + .collect(Collectors.toMap(DailyDefenseProblem::getProblemNumber, DailyDefenseProblem::getProblem)); + } + + private DailyDefense createDailyDefense() { + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p -> problemNumber.getAndIncrement(), problem -> problem)); + LocalDate createdDate = LocalDate.of(2024, 3, 1); + return DailyDefense.create(createdDate, "오늘의 문제 테스트", problemMap); + } + + private List createProblems() { + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + Problem problem3 = Problem.create(3L, G5, 0L); + return List.of(problem1, problem2, problem3); + } + + private Member createMember(String name) { + return Member.create(name, name + "@gmail.com", GOOGLE, name, name); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/application/service/DailyDefenseUseCaseImplTest.java b/src/test/java/kr/co/morandi/backend/defense_information/application/service/DailyDefenseUseCaseImplTest.java new file mode 100644 index 00000000..37b13458 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/application/service/DailyDefenseUseCaseImplTest.java @@ -0,0 +1,141 @@ +package kr.co.morandi.backend.defense_information.application.service; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_information.application.dto.response.DailyDefenseInfoResponse; +import kr.co.morandi.backend.defense_information.application.port.in.DailyDefenseUseCase; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.service.defense.ProblemGenerationService; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseRepository; +import kr.co.morandi.backend.defense_record.application.port.out.dailyrecord.DailyRecordPort; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.factory.TestBaekjoonSubmitFactory; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.submit.BaekjoonSubmit; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; +import static org.junit.jupiter.api.Assertions.assertAll; + +@Transactional +class DailyDefenseUseCaseImplTest extends IntegrationTestSupport { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private DailyDefenseRepository dailyDefenseRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private DailyDefenseUseCase dailyDefenseUseCase; + + @Autowired + private ProblemGenerationService problemGenerationService; + + @Autowired + private DailyRecordPort dailyRecordPort; + + @DisplayName("사용자가 없을 때 DailyDefense 정보를 조회하면 isSolved는 null로 반환한다.") + @Test + void getDailyDefenseInfo() { + // given + createDailyDefense(LocalDate.of(2021, 10, 1), "오늘의 문제 테스트"); + + LocalDateTime requestTime = LocalDateTime.of(2021, 10, 1, 12, 0, 0); + + // when + final DailyDefenseInfoResponse response = dailyDefenseUseCase.getDailyDefenseInfo(null, requestTime); + + // then + assertAll( + () -> assertThat(response).isNotNull() + .extracting("defenseName", "problemCount", "attemptCount") + .contains("오늘의 문제 테스트", 3, 0L), + + () -> assertThat(response.getProblems()).hasSize(3) + .extracting("problemNumber", "baekjoonProblemId", "difficulty", "solvedCount", "submitCount", "isSolved") + .containsExactlyInAnyOrder( + tuple(1L, 1000L, B5, 0L, 0L, null), + tuple(2L, 2000L, S5, 0L, 0L, null), + tuple(3L, 3000L, G5, 0L, 0L, null) + ) + ); + } + @DisplayName("사용자가 있을 때 응시 기록도 있다면 DailyDefense 정보를 조회하면 isSolved는 True/False를 포함하여 반환한다.") + @Test + void getDailyDefenseInfoWithMemberAndRecord() { + // given + + final DailyDefense dailyDefense = createDailyDefense(LocalDate.of(2021, 10, 1), "오늘의 문제 테스트"); + Member member = createMember(); + LocalDateTime requestTime = LocalDateTime.of(2021, 10, 1, 12, 0, 0); + + final DailyRecord dailyRecord = createDailyRecord(dailyDefense, member, 2L, requestTime); + + BaekjoonSubmit 제출 = TestBaekjoonSubmitFactory.createSubmit(member, dailyRecord.getDetail(2L), requestTime.plusHours(1)); + 제출.trySolveProblem(); + dailyRecordPort.saveDailyRecord(dailyRecord); + + // when + final DailyDefenseInfoResponse response = dailyDefenseUseCase.getDailyDefenseInfo(member.getMemberId(), requestTime); + + // then + assertAll( + () -> assertThat(response).isNotNull() + .extracting("defenseName", "problemCount", "attemptCount") + .contains("오늘의 문제 테스트", 3, 1L), + + () -> assertThat(response.getProblems()).hasSize(3) + .extracting("problemNumber", "baekjoonProblemId", "difficulty", "solvedCount", "submitCount", "isSolved") + .containsExactlyInAnyOrder( + tuple(1L, 1000L, B5, 0L, 0L, false), + tuple(2L, 2000L, S5, 0L, 0L, true), + tuple(3L, 3000L, G5, 0L, 0L, false) + ) + ); + } + + // 시험에 응시하는 메소드 + private DailyRecord createDailyRecord(DailyDefense dailyDefense, Member member, Long problemNumber, LocalDateTime requestTIme) { + final Map tryingProblem = dailyDefense.getTryingProblem(problemNumber, problemGenerationService); + final DailyRecord dailyRecord = DailyRecord.tryDefense(requestTIme, dailyDefense, member, tryingProblem); + dailyRecord.tryMoreProblem(dailyDefense.getTryingProblem(3L, problemGenerationService)); + + return dailyRecordPort.saveDailyRecord(dailyRecord); + } + private DailyDefense createDailyDefense(LocalDate createdDate, String contentName) { + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + return dailyDefenseRepository.save(DailyDefense.create(createdDate, contentName, problemMap)); + } + private List createProblems() { + Problem problem1 = Problem.create(1000L, B5, 0L); + Problem problem2 = Problem.create(2000L, S5, 0L); + Problem problem3 = Problem.create(3000L, G5, 0L); + + return problemRepository.saveAll(List.of(problem1, problem2, problem3)); + } + private Member createMember() { + return memberRepository.save(Member.create("test", "test" + "@gmail.com", GOOGLE, "test", "test")); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/CustomDefenseTest.java b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/CustomDefenseTest.java new file mode 100644 index 00000000..9b839ceb --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/CustomDefenseTest.java @@ -0,0 +1,168 @@ +package kr.co.morandi.backend.defense_information.domain.model.customdefense; + +import kr.co.morandi.backend.defense_information.domain.model.customdefense.CustomDefense; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseTier.GOLD; +import static kr.co.morandi.backend.defense_information.domain.model.customdefense.Visibility.OPEN; +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.*; +import static org.springframework.test.web.servlet.result.StatusResultMatchersExtensionsKt.isEqualTo; + +@ActiveProfiles("test") +class CustomDefenseTest { + @DisplayName("커스텀 디펜스를 시작할 떄 끝나는 시간을 계산하면 시작 시간에 제한 시간을 더한 값이다.") + @Test + void getEndTime() { + // given + Member member = Member.create("test1", "test1", GOOGLE, "test1", "test1"); + + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + List problems = List.of(problem1, problem2); + + LocalDateTime now = LocalDateTime.of(2024, 2, 21, 0, 0, 0, 0); + + CustomDefense customDefense = CustomDefense.create(problems, member, "커스텀 디펜스1", "커스텀 디펜스1 설명", OPEN, GOLD, 60L, now); + + + // when + final LocalDateTime endTime = customDefense.getEndTime(now); + + + // then + assertThat(endTime) + .isEqualTo(now.plusMinutes(60L)); + } + + @DisplayName("커스텀 디펜스를 생성하면 등록 시간을 기록한다.") + @Test + void registeredWithDateTime() { + // given + Member member = Member.create("test1", "test1", GOOGLE, "test1", "test1"); + + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + List problems = List.of(problem1, problem2); + + LocalDateTime now = LocalDateTime.of(2024, 2, 21, 0, 0, 0, 0); + + // when + CustomDefense customDefense = CustomDefense.create(problems, member, "커스텀 디펜스1", "커스텀 디펜스1 설명", OPEN, GOLD, 60L, now); + + // then + assertThat(customDefense.getCreateDate()).isEqualTo(now); + + } + @DisplayName("커스텀 디펜스를 생성하면 컨텐츠 이름과 설명을 기록한다.") + @Test + void createCustomDefenseWithContentName() { + // given + Member member = Member.create("test1", "test1", GOOGLE, "test1", "test1"); + + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + List problems = List.of(problem1, problem2); + + LocalDateTime now = LocalDateTime.of(2024, 2, 21, 0, 0, 0, 0); + + // when + CustomDefense customDefense = CustomDefense.create(problems, member, "커스텀 디펜스1","커스텀 디펜스1 설명", OPEN, GOLD, 60L, now); + + // then + assertThat(customDefense) + .extracting("contentName", "description") + .containsExactlyInAnyOrder( + "커스텀 디펜스1", "커스텀 디펜스1 설명"); + } + + @DisplayName("커스텀 디펜스에 포함된 문제 개수를 조회할 수 있다.") + @Test + void createCustomDefenseProblemCount() { + // given + Member member = Member.create("test1", "test1", GOOGLE, "test1", "test1"); + + List problems = createProblems(); + + LocalDateTime now = LocalDateTime.of(2024, 2, 21, 0, 0, 0, 0); + + // when + CustomDefense customDefense = CustomDefense.create(problems, member, "커스텀 디펜스1", "커스텀 디펜스1 설명", OPEN, GOLD, 60L, now); + + // then + assertThat(customDefense.getCustomDefenseProblems()) + .hasSize(3) + .extracting("problem.baekjoonProblemId", "problem.problemTier") + .containsExactlyInAnyOrder( + tuple(1L, B5), + tuple(2L, S5), + tuple(3L, G5) + ); + } + @DisplayName("커스텀 디펜스를 빈 문제 리스트로 생성하면 예외가 발생한다") + @Test + void createCustomDefenseWithoutProblem() { + // given + Member member = Member.create("test1", "test1", GOOGLE, "test1", "test1"); + + List problems = Collections.emptyList(); + + LocalDateTime now = LocalDateTime.of(2024, 2, 21, 0, 0, 0, 0); + + // when & then + assertThatThrownBy( () -> CustomDefense.create(problems, member, "커스텀 디펜스1", "커스텀 디펜스1 설명", OPEN, GOLD, 60L, now)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("커스텀 디펜스에는 최소 한 개의 문제가 포함되어야 합니다."); + + } + + @DisplayName("커스텀 디펜스 제한 시간을 0으로 설정하면 예외를 발생한다.") + @Test + void createCustomDefenseWithZeroTimeLimit() { + // given + Member member = Member.create("test1", "test1", GOOGLE, "test1", "test1"); + + List problems = createProblems(); + + LocalDateTime now = LocalDateTime.of(2024, 2, 21, 0, 0, 0, 0); + + // when & then + assertThatThrownBy( () -> CustomDefense.create(problems, member, "커스텀 디펜스1", "커스텀 디펜스1 설명", OPEN, GOLD, -1L, now)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("커스텀 디펜스 제한 시간은 0보다 커야 합니다."); + } + + @DisplayName("커스텀 디펜스 제한 시간을 0 미만의 값으로 설정하면 예외를 발생한다.") + @Test + void createCustomDefenseWithNegativeTimeLimit() { + // given + Member member = Member.create("test1", "test1", GOOGLE, "test1", "test1"); + + List problems = createProblems(); + + LocalDateTime now = LocalDateTime.of(2024, 2, 21, 0, 0, 0, 0); + + // when & then + assertThatThrownBy( () -> CustomDefense.create(problems, member, "커스텀 디펜스1", "커스텀 디펜스1 설명", OPEN, GOLD, -1L, now)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("커스텀 디펜스 제한 시간은 0보다 커야 합니다."); + + } + + private List createProblems() { + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + Problem problem3 = Problem.create(3L, G5, 0L); + return List.of(problem1, problem2, problem3); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/CustomDetailTest.java b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/CustomDetailTest.java new file mode 100644 index 00000000..5909ae1b --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/customdefense/CustomDetailTest.java @@ -0,0 +1,85 @@ +package kr.co.morandi.backend.defense_information.domain.model.customdefense; + +import kr.co.morandi.backend.defense_information.domain.model.customdefense.CustomDefense; +import kr.co.morandi.backend.defense_information.domain.model.customdefense.CustomDefenseProblem; +import kr.co.morandi.backend.defense_record.domain.model.customdefense_record.CustomDetail; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.defense_record.domain.model.customdefense_record.CustomRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseTier.GOLD; +import static kr.co.morandi.backend.defense_information.domain.model.customdefense.Visibility.OPEN; +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.B5; +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.S5; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + + +@ActiveProfiles("test") +class CustomDetailTest { + + @DisplayName("CustomDefenseProblemRecord를 생성할 수 있다.") + @Test + void create() { + // given + CustomDefense customDefense = createCustomDefense(); + Member member = createMember("member"); + Problem problem = customDefense.getCustomDefenseProblems().stream() + .map(CustomDefenseProblem::getProblem) + .findFirst() + .orElse(null); + + CustomRecord customDefenseRecord = mock(CustomRecord.class); + + // when + CustomDetail customDefenseProblemRecord = CustomDetail.create(member, 1L, problem, customDefenseRecord, customDefense); + + // then + assertThat(customDefenseProblemRecord).isNotNull() + .extracting("member", "problem", "defense", "record") + .containsExactly( + member, problem, customDefense, customDefenseRecord + ); + } + @DisplayName("CustomDefenseProblemRecord가 생성됐을 때 solvedTime은 0이다.") + @Test + void initialSolvedTimeIsOne() { + // given + CustomDefense customDefense = createCustomDefense(); + Member member = createMember("member"); + Problem problem = customDefense.getCustomDefenseProblems().stream() + .map(CustomDefenseProblem::getProblem) + .findFirst() + .orElse(null); + + CustomRecord customDefenseRecord = mock(CustomRecord.class); + + // when + CustomDetail customDefenseProblemRecord = CustomDetail.create(member, 1L, problem, customDefenseRecord, customDefense); + + // then + assertThat(customDefenseProblemRecord.getSolvedTime()) + .isEqualTo(0L); + } + private CustomDefense createCustomDefense() { + Member member = createMember("author"); + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + List problems = List.of(problem1, problem2); + + LocalDateTime now = LocalDateTime.of(2024, 2, 26, 0, 0, 0, 0); + + return CustomDefense.create(problems, member, "custom_defense", + "custom_defense", OPEN, GOLD, 60L, now); + } + private Member createMember(String name) { + return Member.create(name, name + "@gmail.com", GOOGLE, name, name); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/domain/model/dailydefense/DailyDefenseProblemTest.java b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/dailydefense/DailyDefenseProblemTest.java new file mode 100644 index 00000000..976bb6e7 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/dailydefense/DailyDefenseProblemTest.java @@ -0,0 +1,63 @@ +package kr.co.morandi.backend.defense_information.domain.model.dailydefense; + +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles("test") +class DailyDefenseProblemTest { + @DisplayName("오늘의 문제가 만들어졌을 때, 초기의 문제 제출횟수는 0이어야 한다.") + @Test + void submitCountIsZero() { + // given + LocalDateTime now = LocalDateTime.of(2021, 1, 1, 0, 0, 0); + + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + + // when + DailyDefense dailyDefense = DailyDefense.create(now.toLocalDate(), "오늘의 문제 테스트", problemMap); + + // then + assertThat(dailyDefense.getDailyDefenseProblems()) + .extracting("submitCount") + .containsExactlyInAnyOrder(0L, 0L, 0L); + } + + @DisplayName("오늘의 문제가 만들어졌을 때, 초기 정답자 수는 0이어야 한다.") + @Test + void solvedCountIsZero() { + // given + LocalDateTime now = LocalDateTime.of(2021, 1, 1, 0, 0, 0); + + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + + // when + DailyDefense dailyDefense = DailyDefense.create(now.toLocalDate(), "오늘의 문제 테스트", problemMap); + + // then + assertThat(dailyDefense.getDailyDefenseProblems()) + .extracting("solvedCount") + .containsExactlyInAnyOrder(0L, 0L, 0L); + } + private List createProblems() { + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + Problem problem3 = Problem.create(3L, G5, 0L); + return List.of(problem1, problem2, problem3); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/domain/model/dailydefense/DailyDefenseTest.java b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/dailydefense/DailyDefenseTest.java new file mode 100644 index 00000000..4cab7fb9 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/dailydefense/DailyDefenseTest.java @@ -0,0 +1,127 @@ +package kr.co.morandi.backend.defense_information.domain.model.dailydefense; + +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.service.defense.ProblemGenerationService; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ActiveProfiles("test") +class DailyDefenseTest { + + @DisplayName("오늘의 문제세트에 포함된 문제를 가져올 수 있다.") + @Test + void getTryingProblem() { + // given + LocalDateTime now = LocalDateTime.of(2021, 1, 1, 0, 0, 0); + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + final DailyDefense dailyDefense = DailyDefense.create(now.toLocalDate(), "오늘의 문제 테스트", problemMap); + + Map expectedProblems = Map.of( + 1L, problemMap.get(1L), + 2L, problemMap.get(2L), + 3L, problemMap.get(3L) + ); + final ProblemGenerationService problemGenerationService = mock(ProblemGenerationService.class); + + when(problemGenerationService.getDefenseProblems(dailyDefense)).thenReturn(expectedProblems); + + // when + final Map tryingProblem = dailyDefense.getTryingProblem(1L, problemGenerationService); + + // then + assertThat(tryingProblem.entrySet()).isNotEmpty() + .extracting(Map.Entry::getKey, Map.Entry::getValue) + .containsExactlyInAnyOrder( + tuple(1L, problemMap.get(1L)) + ); + + } + @DisplayName("오늘의 문제를 응시할 때 끝나는 시간은 오늘의 문제 날짜 직전까지이다.") + @Test + void getEndTime() { + // given + LocalDateTime now = LocalDateTime.of(2021, 1, 1, 0, 0, 0); + + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + + // when + DailyDefense dailyDefense = DailyDefense.create(now.toLocalDate(), "오늘의 문제 테스트", problemMap); + + // then + assertThat(dailyDefense.getEndTime(now)) + .isEqualTo(now.toLocalDate().atTime(23, 59, 59)); + } + @DisplayName("오늘의 문제 세트가 만들어진 시점에서 시도한 사람의 수는 0명 이어야 한다.") + @Test + void attemptCountIsZero() { + // given + LocalDateTime now = LocalDateTime.of(2021, 1, 1, 0, 0, 0); + + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + + // when + DailyDefense dailyDefense = DailyDefense.create(now.toLocalDate(), "오늘의 문제 테스트", problemMap); + + // then + assertThat(dailyDefense.getAttemptCount()).isZero(); + } + @DisplayName("오늘의 문제가 만들어진 시점에 등록된 날짜는 만들어진 시점과 같아야 한다.") + @Test + void testDateEqualNow() { + // given + LocalDate now = LocalDate.of(2021, 1, 1); + + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + + // when + DailyDefense dailyDefense = DailyDefense.create(now, "오늘의 문제 테스트", problemMap); + + // then + assertThat(dailyDefense.getDate()).isEqualTo(now); + } + @DisplayName("오늘의 문제가 만들어진 이름은 일치해야한다.") + @Test + void contentNameIsEqual() { + // given + LocalDateTime now = LocalDateTime.of(2021, 1, 1, 0, 0, 0); + + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + + // when + DailyDefense dailyDefense = DailyDefense.create(now.toLocalDate(), "오늘의 문제 테스트", problemMap); + + // then + assertThat(dailyDefense.getContentName()).isEqualTo("오늘의 문제 테스트"); + } + private List createProblems() { + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + Problem problem3 = Problem.create(3L, G5, 0L); + return List.of(problem1, problem2, problem3); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/domain/model/dailydefense/DailyDetailTest.java b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/dailydefense/DailyDetailTest.java new file mode 100644 index 00000000..7b041c81 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/dailydefense/DailyDetailTest.java @@ -0,0 +1,140 @@ +package kr.co.morandi.backend.defense_information.domain.model.dailydefense; + +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyDetail; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.B5; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static kr.co.morandi.backend.problem_information.domain.model.problem.ProblemStatus.ACTIVE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +@ActiveProfiles("test") +class DailyDetailTest { + @DisplayName("DailyDefenseProblemRecord를 만들 수 있다.") + @Test + void create() { + // given + DailyDefense DailyDefense = createDailyDefense(); + DailyRecord DailyDefenseRecord = mock(DailyRecord.class); + Problem problem = DailyDefense.getDailyDefenseProblems().stream() + .map(DailyDefenseProblem::getProblem) + .findFirst() + .orElse(null); + Member member = createMember(); + + // when + DailyDetail DailyDefenseProblemRecord = DailyDetail.create(member, 1L, problem, DailyDefenseRecord, DailyDefense); + + // then + assertThat(DailyDefenseProblemRecord).isNotNull() + .extracting("member", "problem", "defense", "record") + .contains(member, problem, DailyDefense, DailyDefenseRecord); + } + @DisplayName("DailyDefenseProblemRecord가 생성되면 isSolved는 false이다") + @Test + void initialIsSolvedFalse() { + // given + DailyDefense DailyDefense = createDailyDefense(); + DailyRecord DailyDefenseRecord = mock(DailyRecord.class); + Problem problem = DailyDefense.getDailyDefenseProblems().stream() + .map(DailyDefenseProblem::getProblem) + .findFirst() + .orElse(null); + Member member = createMember(); + + // when + DailyDetail DailyDefenseProblemRecord = DailyDetail.create(member, 1L, problem, DailyDefenseRecord, DailyDefense); + + // then + assertThat(DailyDefenseProblemRecord.getIsSolved()).isFalse(); + } + @DisplayName("DailyDefenseProblemRecord가 생성되면 submitCount는 0이다") + @Test + void initialSubmitCountIsZero() { + // given + DailyDefense DailyDefense = createDailyDefense(); + DailyRecord DailyDefenseRecord = mock(DailyRecord.class); + Problem problem = DailyDefense.getDailyDefenseProblems().stream() + .map(DailyDefenseProblem::getProblem) + .findFirst() + .orElse(null); + Member member = createMember(); + + // when + DailyDetail DailyDefenseProblemRecord = DailyDetail.create(member, 1L, problem, DailyDefenseRecord, DailyDefense); + + // then + assertThat(DailyDefenseProblemRecord.getSubmitCount()).isZero(); + } + @DisplayName("DailyDefenseProblemRecord가 생성되면 correctSubmit은 null이다") + @Test + void initialSolvedCodeIsSetToNull() { + // given + DailyDefense DailyDefense = createDailyDefense(); + DailyRecord DailyDefenseRecord = mock(DailyRecord.class); + Problem problem = DailyDefense.getDailyDefenseProblems().stream() + .map(DailyDefenseProblem::getProblem) + .findFirst() + .orElse(null); + Member member = createMember(); + + // when + DailyDetail DailyDefenseProblemRecord = DailyDetail.create(member, 1L, problem, DailyDefenseRecord, DailyDefense); + + // then + assertThat(DailyDefenseProblemRecord.getCorrectSubmitId()) + .isNull(); + } + + private DailyDefense createDailyDefense() { + LocalDate createdDate = LocalDate.of(2024, 3, 1); + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblem().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + return DailyDefense.create(createdDate, "오늘의 문제 테스트", problemMap); + } + private List createProblem() { + return List.of( + Problem.builder() + .baekjoonProblemId(1L) + .problemTier(B5) + .problemStatus(ACTIVE) + .solvedCount(0L) + .build(), + Problem.builder() + .baekjoonProblemId(2L) + .problemTier(B5) + .problemStatus(ACTIVE) + .solvedCount(0L) + .build(), + Problem.builder() + .baekjoonProblemId(3L) + .problemTier(B5) + .problemStatus(ACTIVE) + .solvedCount(0L) + .build() + ); + } + private Member createMember() { + return Member.builder() + .email("user" + "@gmail.com") + .socialType(GOOGLE) + .nickname("nickname") + .description("description") + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/domain/model/defense/DifficultyRangeTest.java b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/defense/DifficultyRangeTest.java new file mode 100644 index 00000000..1f352ed5 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/defense/DifficultyRangeTest.java @@ -0,0 +1,59 @@ +package kr.co.morandi.backend.defense_information.domain.model.defense; + +import kr.co.morandi.backend.defense_information.domain.model.defense.RandomCriteria; +import kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.B1; +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.B5; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ActiveProfiles("test") +class DifficultyRangeTest { + + @DisplayName("같은 난이도로 DifficultyRange를 생성할 수 있다.") + @Test + void startAndEndDifficultySameException() { + // given + ProblemTier start = B1; + ProblemTier end = B1; + + // when + RandomCriteria.DifficultyRange difficultyRange = RandomCriteria.DifficultyRange.of(start, end); + + // then + assertThat(difficultyRange) + .extracting("startDifficulty", "endDifficulty") + .containsExactly(start, end); + + } + + @DisplayName("최소 난이도가 최대 난이도보다 큰 값으로 DifficultyRange를 생성하려고 하면 예외가 발생한다.") + @Test + void startDifficultyGreaterThanEndDifficultyException() { + // given + ProblemTier start = B1; + ProblemTier end = B5; + + // when & then + assertThatThrownBy(() -> RandomCriteria.DifficultyRange.of(start, end)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Start difficulty must be less than or equal to end difficulty"); + } + + @DisplayName("시작 난이도나 끝 난이도가 null로 DifficultyRange를 생성하려고 하면 예외가 발생한다.") + @Test + void startOrEndDifficultyNullException() { + // given + ProblemTier start = null; + ProblemTier end = B5; + + // when & then + assertThatThrownBy(() -> RandomCriteria.DifficultyRange.of(start, end)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("DifficultyRange must not be null"); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/domain/model/defense/RandomCriteriaTest.java b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/defense/RandomCriteriaTest.java new file mode 100644 index 00000000..10b5112a --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/defense/RandomCriteriaTest.java @@ -0,0 +1,57 @@ +package kr.co.morandi.backend.defense_information.domain.model.defense; + +import kr.co.morandi.backend.defense_information.domain.model.defense.RandomCriteria; +import kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ActiveProfiles("test") +class RandomCriteriaTest { + @DisplayName("출제 기준에서 최소 푼 사람 수와 최대 푼 사람 수이 0보다 작으면 예외가 발생한다.") + @Test + void minAndMaxSolvedCountZeroOrMoreException() { + // given + long minSolvedCount = -2L; + long maxSolvedCount = -1L; + RandomCriteria.DifficultyRange difficultyRange = RandomCriteria.DifficultyRange.of(ProblemTier.B5, ProblemTier.B1); + + + // when & then + assertThatThrownBy(() -> RandomCriteria.of(difficultyRange, minSolvedCount, maxSolvedCount)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Solved count must be greater than or equal to 0"); + } + + @DisplayName("최소 푼 사람 수가 최대 푼 사람 수보다 크면 예외가 발생한다.") + @Test + void minSolvedCountLessThanMaxSolvedCountException() { + // given + long minSolvedCount = 100L; + long maxSolvedCount = 50L; + RandomCriteria.DifficultyRange difficultyRange = RandomCriteria.DifficultyRange.of(ProblemTier.B5, ProblemTier.B1); + + // when & then + assertThatThrownBy(() -> RandomCriteria.of(difficultyRange, minSolvedCount, maxSolvedCount)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Min solved count must be less than or equal to max solved count"); + + } + + @DisplayName("푼 사람 수의 범위가 같으면 예외가 발생한다.") + @Test + void minAndMaxSolvedCountSameException() { + // given + long minSolvedCount = 100L; + long maxSolvedCount = 100L; + RandomCriteria.DifficultyRange difficultyRange = RandomCriteria.DifficultyRange.of(ProblemTier.B5, ProblemTier.B1); + + // when & then + assertThatThrownBy(() -> RandomCriteria.of(difficultyRange, minSolvedCount, maxSolvedCount)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Min solved count must be less than or equal to max solved count"); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/domain/model/randomdefense/RandomDefenseTest.java b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/randomdefense/RandomDefenseTest.java new file mode 100644 index 00000000..42245902 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/randomdefense/RandomDefenseTest.java @@ -0,0 +1,76 @@ +package kr.co.morandi.backend.defense_information.domain.model.randomdefense; + +import kr.co.morandi.backend.defense_information.domain.model.randomdefense.model.RandomDefense; +import kr.co.morandi.backend.defense_information.domain.model.defense.RandomCriteria; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.B1; +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.B5; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ActiveProfiles("test") +class RandomDefenseTest { + @DisplayName("랜덤 디펜스 응시 시 끝나는 시간을 계산하면 시작 시간에 제한 시간을 더한 시간이어야 한다.") + @Test + void getEndTime() { + // given + RandomCriteria.DifficultyRange bronzeRange = RandomCriteria.DifficultyRange.of(B5, B1); + RandomCriteria randomCriteria = RandomCriteria.of(bronzeRange, 100L, 200L); + RandomDefense randomDefense = RandomDefense.create(randomCriteria, 4, 120L, "브론즈 랜덤 디펜스"); + LocalDateTime startTime = LocalDateTime.of(2021, 1, 1, 0, 0); + + // when + final LocalDateTime endTime = randomDefense.getEndTime(startTime); + + // then + assertThat(endTime) + .isEqualTo(startTime.plusMinutes(120L)); + } + + @DisplayName("랜덤 디펜스를 생성할 때 등록한 정보가 올바르게 저장되어야 한다.") + @Test + void createRandomDefense() { + // given + RandomCriteria.DifficultyRange bronzeRange = RandomCriteria.DifficultyRange.of(B5, B1); + RandomCriteria randomCriteria = RandomCriteria.of(bronzeRange, 100L, 200L); + + // when + RandomDefense randomDefense = RandomDefense.create(randomCriteria, 4, 120L, "브론즈 랜덤 디펜스"); + + // then + assertThat(randomDefense) + .extracting("randomCriteria.DifficultyRange.startDifficulty", "randomCriteria.DifficultyRange.endDifficulty", + "problemCount", "timeLimit", "contentName", "RandomCriteria.minSolvedCount", "RandomCriteria.maxSolvedCount") + .containsExactly(B5, B1, 4, 120L, "브론즈 랜덤 디펜스", 100L, 200L); + } + + @DisplayName("랜덤 디펜스를 생성할 때 시간 제한이 0분 이하로 설정되면 예외가 발생한다.") + @Test + void timeLimitGreatherThanZero() { + // given + RandomCriteria.DifficultyRange bronzeRange = RandomCriteria.DifficultyRange.of(B5, B1); + RandomCriteria randomCriteria = RandomCriteria.of(bronzeRange, 100L, 200L); + + // when & then + assertThatThrownBy(() -> RandomDefense.create(randomCriteria, 4, 0L, "브론즈 랜덤 디펜스")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("랜덤 디펜스 제한 시간은 0보다 커야 합니다."); + } + @DisplayName("랜덤 디펜스를 생성할 때 문제 개수가 0개 이하로 설정되면 예외가 발생한다.") + @Test + void problemCountGreatherThanZero() { + // given + RandomCriteria.DifficultyRange bronzeRange = RandomCriteria.DifficultyRange.of(B5, B1); + RandomCriteria randomCriteria = RandomCriteria.of(bronzeRange, 100L, 200L); + + // when & then + assertThatThrownBy(() -> RandomDefense.create(randomCriteria, 0, 120L, "브론즈 랜덤 디펜스")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("랜덤 디펜스 문제 수는 1문제 이상 이어야 합니다."); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/domain/model/randomdefense/RandomDetailTest.java b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/randomdefense/RandomDetailTest.java new file mode 100644 index 00000000..3ecc1b92 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/randomdefense/RandomDetailTest.java @@ -0,0 +1,104 @@ +package kr.co.morandi.backend.defense_information.domain.model.randomdefense; + +import kr.co.morandi.backend.defense_information.domain.model.randomdefense.model.RandomDefense; +import kr.co.morandi.backend.defense_record.domain.model.randomdefense_record.RandomDetail; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.defense_record.domain.model.randomdefense_record.RandomRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.B5; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +@ActiveProfiles("test") +class RandomDetailTest { + @DisplayName("RandomDefenseProblemRecord를 생성한다.") + @Test + void create() { + // given + RandomDefense randomDefense = mock(RandomDefense.class); + RandomRecord randomDefenseRecord = mock(RandomRecord.class); + Problem problem = createProblem(); + Member member = createMember("test"); + + // when + RandomDetail randomDefenseProblemRecord = RandomDetail.create(member, 1L, problem, randomDefenseRecord, randomDefense); + + // then + assertThat(randomDefenseProblemRecord).isNotNull() + .extracting("member", "problem", "defense", "record") + .contains(member, problem, randomDefense, randomDefenseRecord); + + } + @DisplayName("RandomDefenseProblemRecord가 생성되면 solvedTime은 0이다") + @Test + void initialSolvedTimeIsZero() { + // given + RandomDefense randomDefense = mock(RandomDefense.class); + RandomRecord randomDefenseRecord = mock(RandomRecord.class); + Problem problem = createProblem(); + Member member = createMember("test"); + + // when + RandomDetail randomDefenseProblemRecord = RandomDetail.create(member, 1L, problem, randomDefenseRecord, randomDefense); + + // then + assertThat(randomDefenseProblemRecord.getSolvedTime()).isZero(); + } + @DisplayName("RandomDefenseProblemRecord가 생성되면 isSolved는 false이다") + @Test + void initialIsSolvedFalse() { + // given + RandomDefense randomDefense = mock(RandomDefense.class); + RandomRecord randomDefenseRecord = mock(RandomRecord.class); + Problem problem = createProblem(); + Member member = createMember("test"); + + // when + RandomDetail randomDefenseProblemRecord = RandomDetail.create(member,1L, problem, randomDefenseRecord, randomDefense); + + // then + assertThat(randomDefenseProblemRecord.getIsSolved()).isFalse(); + } + @DisplayName("RandomDefenseProblemRecord가 생성되면 submitCount는 0이다") + @Test + void initialSubmitCountIsZero() { + // given + RandomDefense randomDefense = mock(RandomDefense.class); + RandomRecord randomDefenseRecord = mock(RandomRecord.class); + Problem problem = createProblem(); + Member member = createMember("test"); + + // when + RandomDetail randomDefenseProblemRecord = RandomDetail.create(member, 1L, problem, randomDefenseRecord, randomDefense); + // then + assertThat(randomDefenseProblemRecord.getSubmitCount()).isZero(); + } + @DisplayName("RandomDefenseProblemRecord가 생성되면 correctSubmit은 null이다") + @Test + void initialSolvedCodeIsSetToNull() { + // given + RandomDefense randomDefense = mock(RandomDefense.class); + RandomRecord randomDefenseRecord = mock(RandomRecord.class); + Problem problem = createProblem(); + Member member = createMember("test"); + + // when + RandomDetail randomDefenseProblemRecord = RandomDetail.create(member, 1L, problem, randomDefenseRecord, randomDefense); + + // then + assertThat(randomDefenseProblemRecord.getCorrectSubmitId()) + .isNull(); + } + + private Problem createProblem() { + return Problem.create(1L, B5, 0L); + } + private Member createMember(String name) { + return Member.create(name, name + "@gmail.com", GOOGLE, name, name); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/domain/model/stagedefense/StageDefenseTest.java b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/stagedefense/StageDefenseTest.java new file mode 100644 index 00000000..a7e922d7 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/stagedefense/StageDefenseTest.java @@ -0,0 +1,91 @@ +package kr.co.morandi.backend.defense_information.domain.model.stagedefense; + +import kr.co.morandi.backend.defense_information.domain.model.defense.RandomCriteria; +import kr.co.morandi.backend.defense_information.domain.model.stagedefense.model.StageDefense; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.B1; +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.B5; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ActiveProfiles("test") +class StageDefenseTest { + @DisplayName("스테이지 모드를 시작할 때 끝나는 시간은 시작 시간에 제한 시간을 더한 값이어야 한다.") + @Test + void getEndTime() { + // given + RandomCriteria.DifficultyRange bronzeRange = RandomCriteria.DifficultyRange.of(B5, B1); + RandomCriteria randomCriteria = RandomCriteria.of(bronzeRange, 100L, 200L); + StageDefense randomStageDefense = StageDefense.create(randomCriteria, 120L, "브론즈 스테이지 모드"); + LocalDateTime startTime = LocalDateTime.of(2021, 1, 1, 0, 0, 0); + + // when + final LocalDateTime endTime = randomStageDefense.getEndTime(startTime); + + // then + assertThat(endTime) + .isEqualTo(startTime.plusMinutes(120L)); + + } + + @DisplayName("스테이지 모드를 처음 만들 때 정보가 올바르게 저장되어야 한다.") + @Test + void createRamdomStageDefense() { + // given + RandomCriteria.DifficultyRange bronzeRange = RandomCriteria.DifficultyRange.of(B5, B1); + RandomCriteria randomCriteria = RandomCriteria.of(bronzeRange, 100L, 200L); + + // when + StageDefense randomStageDefense = StageDefense.create(randomCriteria, 120L, "브론즈 스테이지 모드"); + + // then + assertThat(randomStageDefense) + .extracting("randomCriteria.minSolvedCount", "randomCriteria.maxSolvedCount", "timeLimit", + "randomCriteria.difficultyRange.startDifficulty", "randomCriteria.difficultyRange.endDifficulty", + "contentName") + .containsExactly(100L, 200L, 120L, B5, B1, "브론즈 스테이지 모드"); + } + @DisplayName("스테이지 모드를 처음 만들 때 평균 스테이지 수는 0으로 설정되어 있어야 한다.") + @Test + void averageStageIsZero() { + // given + RandomCriteria.DifficultyRange bronzeRange = RandomCriteria.DifficultyRange.of(B5, B1); + RandomCriteria randomCriteria = RandomCriteria.of(bronzeRange, 100L, 200L); + + // when + StageDefense randomStageDefense = StageDefense.create(randomCriteria, 120L, "브론즈 스테이지 모드"); + + // then + assertThat(randomStageDefense.getAverageStage()).isZero(); + } + @DisplayName("스테이지 모드를 처음 만들 때 시도한 사람 수는 0명 이어야 한다.") + @Test + void attemptCountIsZero() { + // given + RandomCriteria.DifficultyRange bronzeRange = RandomCriteria.DifficultyRange.of(B5, B1); + RandomCriteria randomCriteria = RandomCriteria.of(bronzeRange, 100L, 200L); + + // when + StageDefense randomStageDefense = StageDefense.create(randomCriteria, 120L, "브론즈 스테이지 모드"); + + // then + assertThat(randomStageDefense.getAttemptCount()).isZero(); + } + @DisplayName("스테이지 모드를 처음 만들 때 시간 제한은 0분 미만일 경우 예외가 발생한다.") + @Test + void timeLimitGreatherThanZero() { + // given + RandomCriteria.DifficultyRange bronzeRange = RandomCriteria.DifficultyRange.of(B5, B1); + RandomCriteria randomCriteria = RandomCriteria.of(bronzeRange, 100L, 200L); + + // when & then + assertThatThrownBy(() -> StageDefense.create(randomCriteria, 0L, "브론즈 스테이지 모드")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("스테이지 모드 제한 시간은 0보다 커야 합니다."); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/domain/model/stagedefense/StageDetailTest.java b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/stagedefense/StageDetailTest.java new file mode 100644 index 00000000..97047f50 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/domain/model/stagedefense/StageDetailTest.java @@ -0,0 +1,136 @@ +package kr.co.morandi.backend.defense_information.domain.model.stagedefense; + +import kr.co.morandi.backend.defense_information.domain.model.stagedefense.model.StageDefense; +import kr.co.morandi.backend.defense_record.domain.model.stagedefense_record.StageDetail; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.defense_record.domain.model.stagedefense_record.StageRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.B5; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static kr.co.morandi.backend.problem_information.domain.model.problem.ProblemStatus.ACTIVE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +@ActiveProfiles("test") +class StageDetailTest { + @DisplayName("스테이지 문제 기록이 생성되면 초기 정답 시간은 0이다.") + @Test + void initialSolvedTimeIsZero() { + // given + StageDefense randomStageDefense = mock(StageDefense.class); + StageRecord stageDefenseRecord = mock(StageRecord.class); + Problem problem = createProblem(); + Member member = createMember(); + + // when + StageDetail stageDefenseProblemRecord = StageDetail.create(member,1L, problem, stageDefenseRecord, randomStageDefense); + + // then + assertThat(stageDefenseProblemRecord.getSolvedTime()).isZero(); + + } + @DisplayName("원하는 스테이지 번호에 따른 스테이지 문제 기록을 만들 수 있다.") + @Test + void createStageDefenseProblemRecordWithStageNumber() { + // given + StageDefense randomStageDefense = mock(StageDefense.class); + StageRecord stageDefenseRecord = mock(StageRecord.class); + Problem problem = createProblem(); + Member member = createMember(); + + // when + StageDetail stageDefenseProblemRecord = StageDetail.create(member, 1L, problem, stageDefenseRecord, randomStageDefense); + // then + assertThat(stageDefenseProblemRecord.getStageNumber()).isEqualTo(1L); + + } + @DisplayName("스테이지 문제 기록이 생성되면 초기 정답 여부는 false이다.") + @Test + void initialIsSolvedIsFalse() { + // given + StageDefense randomStageDefense = mock(StageDefense.class); + StageRecord stageDefenseRecord = mock(StageRecord.class); + Problem problem = createProblem(); + Member member = createMember(); + + // when + StageDetail stageDefenseProblemRecord = StageDetail.create(member, 1L, problem, stageDefenseRecord, randomStageDefense); + + // then + assertThat(stageDefenseProblemRecord) + .extracting("isSolved") + .isEqualTo(false); + + } + @DisplayName("스테이지 문제 기록이 생성되면 submitCount는 0이다.") + @Test + void initialSubmitCountIsZero() { + // given + StageDefense randomStageDefense = mock(StageDefense.class); + StageRecord stageDefenseRecord = mock(StageRecord.class); + Problem problem = createProblem(); + Member member = createMember(); + + // when + StageDetail stageDefenseProblemRecord = StageDetail.create(member, 1L, problem, stageDefenseRecord, randomStageDefense); + // then + assertThat(stageDefenseProblemRecord) + .extracting("submitCount") + .isEqualTo(0L); + + } + @DisplayName("스테이지 문제 기록이 생성되면 초기 정답 코드는 null이다.") + @Test + void initialSolvedCodeIsNull() { + // given + StageDefense randomStageDefense = mock(StageDefense.class); + StageRecord stageDefenseRecord = mock(StageRecord.class); + Problem problem = createProblem(); + Member member = createMember(); + + // when + StageDetail stageDefenseProblemRecord = StageDetail.create(member, 1L, problem, stageDefenseRecord, randomStageDefense); + + // then + assertThat(stageDefenseProblemRecord) + .extracting("correctSubmitId") + .isNull(); + + } + @DisplayName("스테이지 문제 기록을 생성할 수 있다.") + @Test + void createStageDefenseProblemRecord() { + // given + StageDefense randomStageDefense = mock(StageDefense.class); + StageRecord stageDefenseRecord = mock(StageRecord.class); + Problem problem = createProblem(); + Member member = createMember(); + + // when + StageDetail stageDefenseProblemRecord = StageDetail.create(member, 1L, problem, stageDefenseRecord, randomStageDefense); + // then + assertThat(stageDefenseProblemRecord) + .extracting("member", "problem", "record", "defense") + .contains(member, problem, stageDefenseRecord, randomStageDefense); + } + private Member createMember() { + return Member.builder() + .email("user" + "@gmail.com") + .socialType(GOOGLE) + .nickname("nickname") + .description("description") + .build(); + } + private Problem createProblem() { + return Problem.builder() + .baekjoonProblemId(1L) + .problemTier(B5) + .problemStatus(ACTIVE) + .solvedCount(0L) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/domain/service/dailydefense/DailyDefenseGenerationServiceTest.java b/src/test/java/kr/co/morandi/backend/defense_information/domain/service/dailydefense/DailyDefenseGenerationServiceTest.java new file mode 100644 index 00000000..ab243707 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/domain/service/dailydefense/DailyDefenseGenerationServiceTest.java @@ -0,0 +1,69 @@ +package kr.co.morandi.backend.defense_information.domain.service.dailydefense; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_information.application.port.out.dailydefense.DailyDefensePort; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType.DAILY; +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +class DailyDefenseGenerationServiceTest extends IntegrationTestSupport { + + @Autowired + private DailyDefenseGenerationService dailyDefenseGenerationService; + + @Autowired + private DailyDefensePort dailyDefensePort; + + @Autowired + private ProblemRepository problemRepository; + + @DisplayName("오늘의 문제를 생성할 수 있다.") + @Test + void generateDailyDefense() { + // given + final List problems = createProblems(); + LocalDateTime requestTIme = LocalDateTime.of(2021, 10, 1, 0, 0, 0); + + // when + final boolean result = dailyDefenseGenerationService.generateDailyDefense(requestTIme); + + // then + assertThat(result).isTrue(); + + final DailyDefense nextDaysDailyDefense = dailyDefensePort.findDailyDefense(DAILY, requestTIme.plusDays(1L).toLocalDate()); + + assertThat(nextDaysDailyDefense).isNotNull(); + assertThat(nextDaysDailyDefense.getDailyDefenseProblems()).hasSize(5); + } + + private List createProblems() { + Problem problem1 = Problem.create(1L, B3, 1500L); + Problem problem2 = Problem.create(2L, S4, 1500L); + Problem problem3 = Problem.create(3L, S2, 1500L); + Problem problem4 = Problem.create(4L, G4, 1500L); + Problem problem5 = Problem.create(5L, G2, 1500L); + Problem problem6 = Problem.create(6L, B3, 1500L); + Problem problem7 = Problem.create(7L, S4, 1500L); + Problem problem8 = Problem.create(8L, S2, 1500L); + Problem problem9 = Problem.create(9L, G4, 1500L); + Problem problem10 = Problem.create(10L, G2, 1500L); + + final List problemList = List.of(problem1, problem2, problem3, problem4, problem5, problem6, problem7, problem8, problem9, problem10); + problemList.forEach(Problem::activate); + return problemRepository.saveAll(problemList); + + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/domain/service/defense/ProblemGenerationServiceTest.java b/src/test/java/kr/co/morandi/backend/defense_information/domain/service/defense/ProblemGenerationServiceTest.java new file mode 100644 index 00000000..24448372 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/domain/service/defense/ProblemGenerationServiceTest.java @@ -0,0 +1,85 @@ +package kr.co.morandi.backend.defense_information.domain.service.defense; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.service.defense.ProblemGenerationService; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseProblemRepository; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseRepository; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; + +class ProblemGenerationServiceTest extends IntegrationTestSupport { + + @Autowired + private ProblemGenerationService problemGenerationService; + + @Autowired + private DailyDefenseRepository dailyDefenseRepository; + + @Autowired + private DailyDefenseProblemRepository dailyDefenseProblemRepository; + + @Autowired + private ProblemRepository problemRepository; + + @AfterEach + void tearDown() { + dailyDefenseProblemRepository.deleteAllInBatch(); + problemRepository.deleteAllInBatch(); + dailyDefenseRepository.deleteAllInBatch(); + } + + @DisplayName("DailyDefense의 Problem목록을 가져올 수 있다.") + @Test + void getDefenseProblems() { + // given + LocalDate defenseDate = LocalDate.of(2021, 1, 1); + DailyDefense dailyDefense = createDailyDefense(defenseDate); + + // when + Map defenseProblems = problemGenerationService.getDefenseProblems(dailyDefense); + + // then + assertThat(defenseProblems.values()).hasSize(3) + .extracting(Problem::getBaekjoonProblemId, Problem::getProblemTier, Problem::getSolvedCount) + .containsExactlyInAnyOrder( + tuple(1L, B5, 0L), + tuple(2L, S5, 0L), + tuple(3L, G5, 0L) + ); + + } + + private DailyDefense createDailyDefense(LocalDate date) { + + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + + return dailyDefenseRepository.save(DailyDefense.create(date, "오늘의 문제 테스트", problemMap)); + } + + private List createProblems() { + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + Problem problem3 = Problem.create(3L, G5, 0L); + return problemRepository.saveAll(List.of(problem1, problem2, problem3)); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/adapter/dailydefense/DailyDefenseProblemAdapterTest.java b/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/adapter/dailydefense/DailyDefenseProblemAdapterTest.java new file mode 100644 index 00000000..c455d76a --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/adapter/dailydefense/DailyDefenseProblemAdapterTest.java @@ -0,0 +1,122 @@ +package kr.co.morandi.backend.defense_information.infrastructure.adapter.dailydefense; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.model.defense.RandomCriteria; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseProblemRepository; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseRepository; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; + +class DailyDefenseProblemAdapterTest extends IntegrationTestSupport { + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private DailyDefenseProblemAdapter dailyDefenseProblemAdapter; + + @Autowired + private DailyDefenseRepository dailyDefenseRepository; + + @Autowired + private DailyDefenseProblemRepository dailyDefenseProblemRepository; + + @AfterEach + void tearDown() { + dailyDefenseProblemRepository.deleteAllInBatch(); + dailyDefenseRepository.deleteAllInBatch(); + problemRepository.deleteAllInBatch(); + } + + @DisplayName("오늘의 문제로 출제된 적이 있는 문제는 출제되지 않는다.") + @Test + void getDailyDefenseProblemWithAlreadySolved() { + // given + createProblems(); + RandomCriteria randomCriteria1 = RandomCriteria.builder() + .minSolvedCount(500L) + .maxSolvedCount(1500L) + .difficultyRange(RandomCriteria.DifficultyRange.of(B5, B1)) + .build(); + RandomCriteria randomCriteria2 = RandomCriteria.builder() + .minSolvedCount(1500L) + .maxSolvedCount(3500L) + .difficultyRange(RandomCriteria.DifficultyRange.of(S5, G1)) + .build(); + Map request = Map.of(1L, randomCriteria2, 2L, randomCriteria1); + final Map dailyDefenseProblem = dailyDefenseProblemAdapter.getDailyDefenseProblem(request); + + LocalDate yesterday = LocalDate.of(2021, 1, 1); + final DailyDefense dailyDefense = DailyDefense.create(yesterday, "어제 오늘의 문제", dailyDefenseProblem); + dailyDefenseRepository.save(dailyDefense); + + Map newRequest = Map.of(1L, randomCriteria2); + + + // when + final Map dailyDefenseProblem2 = dailyDefenseProblemAdapter.getDailyDefenseProblem(newRequest); + + // then + assertThat(dailyDefenseProblem2).hasSize(1); + + // 같은 범위에 2개의 문제가 있는데, 출제되면 서로 다른 문제가 출제될 테니깐 + assertThat(dailyDefenseProblem.get(1L).getProblemTier()).isNotEqualTo(dailyDefenseProblem2.get(1L).getProblemTier()); + } + @DisplayName("오늘의 문제에 포함되는 문제들을 의도하는 조건대로 출제할 수 있다.") + @Test + void getDailyDefenseProblem() { + // given + createProblems(); + RandomCriteria randomCriteria1 = RandomCriteria.builder() + .minSolvedCount(500L) + .maxSolvedCount(1500L) + .difficultyRange(RandomCriteria.DifficultyRange.of(B5, B1)) + .build(); + RandomCriteria randomCriteria2 = RandomCriteria.builder() + .minSolvedCount(1500L) + .maxSolvedCount(2500L) + .difficultyRange(RandomCriteria.DifficultyRange.of(S5, S1)) + .build(); + + Map request = Map.of(1L, randomCriteria1, 2L, randomCriteria2); + + // when + final Map dailyDefenseProblem = dailyDefenseProblemAdapter.getDailyDefenseProblem(request); + + // then + assertThat(dailyDefenseProblem).hasSize(2); + assertThat(dailyDefenseProblem.entrySet()) + .extracting(Map.Entry::getKey, entry -> entry.getValue().getProblemTier(), entry -> entry.getValue().getSolvedCount()) + .containsExactlyInAnyOrder( + tuple(2L, S5, 2000L), + tuple(1L, B5, 1000L) + ); + } + + private void createProblems() { + Problem problem1 = Problem.create(1L, B5, 1000L); + problem1.activate(); + + Problem problem2 = Problem.create(2L, S5, 2000L); + problem2.activate(); + + Problem problem3 = Problem.create(3L, G5, 3000L); + problem3.activate(); + + problemRepository.saveAll(List.of(problem1, problem2, problem3)); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/controller/DailyDefenseControllerTest.java b/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/controller/DailyDefenseControllerTest.java new file mode 100644 index 00000000..1a2d851f --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/controller/DailyDefenseControllerTest.java @@ -0,0 +1,43 @@ +package kr.co.morandi.backend.defense_information.infrastructure.controller; + +import kr.co.morandi.backend.ControllerTestSupport; +import kr.co.morandi.backend.defense_information.application.dto.response.DailyDefenseInfoResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +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; + +class DailyDefenseControllerTest extends ControllerTestSupport { + @DisplayName("DailyDefense 정보를 로그인하지 않은 상태에서 가져올 수 있다.") + @Test + void getDailyDefenseInfo() throws Exception { + // given + when(dailyDefenseUseCase.getDailyDefenseInfo(any(), any())) + .thenReturn(DailyDefenseInfoResponse.builder() + .problems(List.of()) + .defenseName("test") + .attemptCount(1L) + .problemCount(5) + .build()); + + // when + final ResultActions perform = mockMvc.perform(get("/daily-defense")); + + // then + perform + .andExpect(status().isOk()) + .andExpect(jsonPath("$.problems").isArray()) + .andExpect(jsonPath("$.defenseName").isString()) + .andExpect(jsonPath("$.attemptCount").isNumber()) + .andExpect(jsonPath("$.problemCount").isNumber()); + + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/algorithm/AlgorithmRepositoryTest.java b/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/algorithm/AlgorithmRepositoryTest.java new file mode 100644 index 00000000..836a5485 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/algorithm/AlgorithmRepositoryTest.java @@ -0,0 +1,53 @@ +package kr.co.morandi.backend.defense_information.infrastructure.persistence.algorithm; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.problem_information.domain.model.algorithm.Algorithm; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.algorithm.AlgorithmRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + + +class AlgorithmRepositoryTest extends IntegrationTestSupport { + + @Autowired + private AlgorithmRepository algorithmRepository; + @AfterEach + void tearDown() { + algorithmRepository.deleteAllInBatch(); + } + + @DisplayName("알고리즘 초기 데이터가 존재하는지 확인한다.") + @Test + void existsByBojTagIdOrAlgorithmKey() { + // given + Algorithm algorithm1 = Algorithm.builder() + .bojTagId(1) + .algorithmKey("key1") + .algorithmName("name1") + .build(); + + Algorithm algorithm2 = Algorithm.builder() + .bojTagId(2) + .algorithmKey("key2") + .algorithmName("name2") + .build(); + + algorithmRepository.saveAll(List.of(algorithm1, algorithm2)); + + // when + boolean exists1 = algorithmRepository.existsByBojTagIdOrAlgorithmKey(1, "key1"); + boolean exists2 = algorithmRepository.existsByBojTagIdOrAlgorithmKey(2, "key2"); + + + // then + assertThat(exists1).isTrue(); + assertThat(exists2).isTrue(); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/customdefense/CustomDefenseRepositoryTest.java b/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/customdefense/CustomDefenseRepositoryTest.java new file mode 100644 index 00000000..69b07601 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/customdefense/CustomDefenseRepositoryTest.java @@ -0,0 +1,80 @@ +package kr.co.morandi.backend.defense_information.infrastructure.persistence.customdefense; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_information.domain.model.customdefense.CustomDefense; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; +import java.util.List; + +import static kr.co.morandi.backend.defense_information.domain.model.customdefense.Visibility.CLOSE; +import static kr.co.morandi.backend.defense_information.domain.model.customdefense.Visibility.OPEN; +import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseTier.*; +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +class CustomDefenseRepositoryTest extends IntegrationTestSupport { + + @Autowired + private CustomDefenseRepository customDefenseRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private CustomDefenseProblemRepository customDefenseProblemsRepository; + @AfterEach + void tearDown() { + customDefenseProblemsRepository.deleteAllInBatch(); + customDefenseRepository.deleteAllInBatch(); + problemRepository.deleteAllInBatch(); + memberRepository.deleteAllInBatch(); + } + + @DisplayName("공개 상태의 커스텀 디펜스를 조회할 수 있다.") + @Test + void findAllByVisibility() { + // given + Member member = Member.create("test1", "test1", GOOGLE, "test1", "test1"); + memberRepository.save(member); + + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + Problem problem3 = Problem.create(3L, G5, 0L); + problemRepository.saveAll(List.of(problem1, problem2, problem3)); + + LocalDateTime now = LocalDateTime.of(2024, 2, 21, 0, 0, 0, 0); + + CustomDefense customDefense1 = CustomDefense.create(List.of(problem1,problem2), member, "커스텀 디펜스1","커스텀 디펜스1 설명", OPEN, BRONZE, 60L, now); + CustomDefense customDefense2 = CustomDefense.create(List.of(problem1,problem3), member, "커스텀 디펜스2","커스텀 디펜스2 설명", OPEN, SILVER, 60L, now); + CustomDefense customDefense3 = CustomDefense.create(List.of(problem2,problem3), member, "커스텀 디펜스3","커스텀 디펜스3 설명", CLOSE, GOLD, 60L, now); + + customDefenseRepository.saveAll(List.of(customDefense1, customDefense2, customDefense3)); + + // when + List customDefenses = customDefenseRepository.findAllByVisibility(OPEN); + + // then + assertThat(customDefenses) + .hasSize(2) + .extracting("contentName","description","visibility") + .containsExactlyInAnyOrder( + tuple("커스텀 디펜스1","커스텀 디펜스1 설명",OPEN), + tuple("커스텀 디펜스2","커스텀 디펜스2 설명",OPEN) + ); + + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/dailydefense/DailyDefenseProblemRepositoryTest.java b/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/dailydefense/DailyDefenseProblemRepositoryTest.java new file mode 100644 index 00000000..4cdcc50d --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/dailydefense/DailyDefenseProblemRepositoryTest.java @@ -0,0 +1,74 @@ +package kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import kr.co.morandi.backend.defense_information.domain.model.defense.Defense; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; + +class DailyDefenseProblemRepositoryTest extends IntegrationTestSupport { + + @Autowired + private DailyDefenseProblemRepository dailyDefenseProblemRepository; + @Autowired + private DailyDefenseRepository dailyDefenseRepository; + @Autowired + private ProblemRepository problemRepository; + + @AfterEach + void tearDown() { + dailyDefenseProblemRepository.deleteAllInBatch(); + dailyDefenseRepository.deleteAllInBatch(); + problemRepository.deleteAllInBatch(); + } + + @DisplayName("defense 타입으로 DailyDefenseProblem을 가져올 수 있다.") + @Test + void findAllProblemsContainsDefenseId() { + // given + LocalDate defenseDate = LocalDate.of(2021, 1, 1); + Defense defense = createDailyDefense(defenseDate); + + // when + List dailyDefenseProblems = dailyDefenseProblemRepository.findAllProblemsContainsDefenseId(defense.getDefenseId()); + + // then + assertThat(dailyDefenseProblems).hasSize(3) + .extracting("problem.baekjoonProblemId", "problem.problemTier", "problem.solvedCount") + .containsExactlyInAnyOrder( + tuple(1L, B5, 0L), + tuple(2L, S5, 0L), + tuple(3L, G5, 0L) + ); + + } + private DailyDefense createDailyDefense(LocalDate date) { + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + return dailyDefenseRepository.save(DailyDefense.create(date, "오늘의 문제 테스트", problemMap)); + } + + private List createProblems() { + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + Problem problem3 = Problem.create(3L, G5, 0L); + return problemRepository.saveAll(List.of(problem1, problem2, problem3)); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/randomdefense/RandomDefenseRepositoryTest.java b/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/randomdefense/RandomDefenseRepositoryTest.java new file mode 100644 index 00000000..bc9cd7b8 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_information/infrastructure/persistence/randomdefense/RandomDefenseRepositoryTest.java @@ -0,0 +1,88 @@ +package kr.co.morandi.backend.defense_information.infrastructure.persistence.randomdefense; + + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_information.domain.model.defense.RandomCriteria; +import kr.co.morandi.backend.defense_information.domain.model.randomdefense.model.RandomDefense; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +class RandomDefenseRepositoryTest extends IntegrationTestSupport { + + @Autowired + private RandomDefenseRepository randomDefenseRepository; + @AfterEach + void tearDown() { + randomDefenseRepository.deleteAllInBatch(); + } + @Test + @DisplayName("저장된 랜덤 디펜스의 정보를 조회할 수 있다.") + void findByContentName() { + // given + List randomDefenses = createRandomDefense(); + randomDefenseRepository.saveAll(randomDefenses); + + // when + RandomDefense findRandomDefense = randomDefenseRepository.findById(randomDefenses.get(0).getDefenseId()).get(); + + // then + assertThat(findRandomDefense) + .extracting("randomCriteria.maxSolvedCount", "randomCriteria.minSolvedCount", "timeLimit", "problemCount", "randomCriteria.difficultyRange.startDifficulty", "randomCriteria.difficultyRange.endDifficulty") + .containsExactly(200L, 100L, 1000L, 4, B5, B1); + } + @DisplayName("랜덤 디펜스들을 모두 조회하여 가져올 수 있다.") + @Test + void findAllRandomDefense(){ + // given + List randomDefenses = createRandomDefense(); + randomDefenseRepository.saveAll(randomDefenses); + + // when + List findRandomDefenses = randomDefenseRepository.findAll(); + + // then + assertThat(findRandomDefenses).hasSize(3) + .extracting("randomCriteria.maxSolvedCount", "randomCriteria.minSolvedCount", "timeLimit", "problemCount", "randomCriteria.difficultyRange.startDifficulty", "randomCriteria.difficultyRange.endDifficulty") + .containsExactlyInAnyOrder( + tuple(200L, 100L, 1000L, 4, B5, B1), + tuple(200L, 100L, 1000L, 4, S5, S1), + tuple(200L, 100L, 1000L, 4, G5, G1) + ); + } + private List createRandomDefense() { + RandomCriteria.DifficultyRange bronzeRange = RandomCriteria.DifficultyRange.of(B5, B1); + RandomCriteria.DifficultyRange silverRange = RandomCriteria.DifficultyRange.of(S5, S1); + RandomCriteria.DifficultyRange goldRange = RandomCriteria.DifficultyRange.of(G5, G1); + + RandomDefense bronzeDefense = RandomDefense.builder() + .timeLimit(1000L) + .problemCount(4) + .contentName("브론즈 랜덤 디펜스") + .randomCriteria(RandomCriteria.of(bronzeRange,100L,200L)) + .build(); + + RandomDefense silverDefense = RandomDefense.builder() + .timeLimit(1000L) + .problemCount(4) + .contentName("실버 랜덤 디펜스") + .randomCriteria(RandomCriteria.of(silverRange,100L,200L)) + .build(); + + RandomDefense goldDefense = RandomDefense.builder() + .timeLimit(1000L) + .problemCount(4) + .contentName("골드 랜덤 디펜스") + .randomCriteria(RandomCriteria.of(goldRange,100L,200L)) + .build(); + + return List.of(bronzeDefense, silverDefense, goldDefense); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/application/mapper/tempcode/TempCodeMapperTest.java b/src/test/java/kr/co/morandi/backend/defense_management/application/mapper/tempcode/TempCodeMapperTest.java new file mode 100644 index 00000000..ef04a710 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/application/mapper/tempcode/TempCodeMapperTest.java @@ -0,0 +1,67 @@ +package kr.co.morandi.backend.defense_management.application.mapper.tempcode; + +import kr.co.morandi.backend.defense_management.application.response.tempcode.TempCodeResponse; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.TempCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Set; + +import static kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; + +@ActiveProfiles("test") +class TempCodeMapperTest { + + @DisplayName("아무 것도 포함하지 않고 생성하면 초기화된 TempCodeResponses를 반환한다.") + @Test + void createTempCodeResponses() { + // given + Set tempCodes = Set.of(); + + // when + final Set responses = TempCodeMapper.createTempCodeResponses(tempCodes); + + // then + assertThat(responses).hasSize(3) + .extracting("language", "code") + .containsExactlyInAnyOrder( + tuple(JAVA, JAVA.getInitialCode()), + tuple(PYTHON, PYTHON.getInitialCode()), + tuple(CPP, CPP.getInitialCode()) + ); + + } + + @DisplayName("일부 언어를 포함하고 나머지는 초기화된 TempCodeResponses를 반환한다.") + @Test + void createTempCodeResponsesWithSomeTempCodes() { + //given + Set tempCodes = Set.of( + TempCode.builder() + .language(JAVA) + .code("java code") + .build(), + TempCode.builder() + .language(PYTHON) + .code("python code") + .build() + ); + + // when + final Set responses = TempCodeMapper.createTempCodeResponses(tempCodes); + + // then + assertThat(responses).hasSize(3) + .extracting("language", "code") + .containsExactlyInAnyOrder( + tuple(JAVA, "java code"), + tuple(PYTHON, "python code"), + tuple(CPP, CPP.getInitialCode()) + ); + + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/application/port/out/session/DefenseSessionPortTest.java b/src/test/java/kr/co/morandi/backend/defense_management/application/port/out/session/DefenseSessionPortTest.java new file mode 100644 index 00000000..ce1d4802 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/application/port/out/session/DefenseSessionPortTest.java @@ -0,0 +1,112 @@ +package kr.co.morandi.backend.defense_management.application.port.out.session; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseProblemRepository; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseRepository; +import kr.co.morandi.backend.defense_management.domain.model.session.DefenseSession; +import kr.co.morandi.backend.defense_management.infrastructure.persistence.session.DefenseSessionRepository; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType.DAILY; +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; + +class DefenseSessionPortTest extends IntegrationTestSupport { + + @Autowired + private DefenseSessionPort defenseSessionPort; + + @Autowired + private DailyDefenseRepository dailyDefenseRepository; + + @Autowired + private DailyDefenseProblemRepository dailyDefenseProblemRepository; + + @Autowired + private DefenseSessionRepository defenseSessionRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private MemberRepository memberRepository; + + @AfterEach + void tearDown() { + defenseSessionRepository.deleteAll(); + dailyDefenseProblemRepository.deleteAllInBatch(); + dailyDefenseRepository.deleteAllInBatch(); + problemRepository.deleteAllInBatch(); + memberRepository.deleteAllInBatch(); + } + + @DisplayName("DailyDefense 세션을 조회할 수 있다.") + @Test + void findDailyDefenseSession() { + // given + final Member member = createMember(); + final DailyDefense dailyDefense = createDailyDefense(); + LocalDateTime startTime = LocalDateTime.of(2021, 10, 1, 0, 0); + + Long recordId = 1L; + final DefenseSession defenseSession = DefenseSession.startSession(member, recordId, DAILY, Set.of(2L), startTime, dailyDefense.getEndTime(startTime)); + defenseSessionPort.saveDefenseSession(defenseSession); + + // when + final Optional maybeDefenseSession = defenseSessionPort.findTodaysDailyDefenseSession(member, startTime.plusMinutes(1)); + + // then + assertThat(maybeDefenseSession).isPresent() + .get() + .extracting(DefenseSession::getRecordId) + .isEqualTo(recordId); + + final DefenseSession session = maybeDefenseSession.get(); + assertThat(session.getSessionDetails()).hasSize(1) + .extracting("problemNumber") + .contains( + 2L + ); + + } + private Map getProblem(DailyDefense dailyDefense, Long problemNumber) { + return dailyDefense.getDailyDefenseProblems().stream() + .filter(problem -> Objects.equals(problem.getProblemNumber(), problemNumber)) + .collect(Collectors.toMap(DailyDefenseProblem::getProblemNumber, DailyDefenseProblem::getProblem)); + } + + private DailyDefense createDailyDefense() { + LocalDate createdDate = LocalDate.of(2021, 10, 1); + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + return dailyDefenseRepository.save(DailyDefense.create(createdDate, "오늘의 문제 테스트", problemMap)); + } + private List createProblems() { + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + Problem problem3 = Problem.create(3L, G5, 0L); + + return problemRepository.saveAll(List.of(problem1, problem2, problem3)); + } + private Member createMember() { + return memberRepository.save(Member.create("test", "test" + "@gmail.com", GOOGLE, "test", "test")); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/application/service/codesubmit/ExampleCodeSubscriberTest.java b/src/test/java/kr/co/morandi/backend/defense_management/application/service/codesubmit/ExampleCodeSubscriberTest.java new file mode 100644 index 00000000..21ba1632 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/application/service/codesubmit/ExampleCodeSubscriberTest.java @@ -0,0 +1,117 @@ +package kr.co.morandi.backend.defense_management.application.service.codesubmit; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_management.application.port.out.defensemessaging.DefenseMessagePort; +import kr.co.morandi.backend.defense_management.application.response.codesubmit.MessageResponse; +import kr.co.morandi.backend.defense_management.infrastructure.exception.RedisMessageErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.DefaultMessage; +import org.springframework.data.redis.connection.Message; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +@ActiveProfiles("test") +@DisabledIfEnvironmentVariable(named = "redis.enabled", matches = "false") +class ExampleCodeSubscriberTest extends IntegrationTestSupport { + + @MockBean + private DefenseMessagePort defenseMessagePort; + + @Autowired + private ExampleCodeSubscriber redisMessageSubscriber; + + @Autowired + private ObjectMapper objectMapper; + + @DisplayName("Redis pub/sub에 메시지가 도착하면 메시지가 정상적으로 SSE에 전송되어야 한다.") + @Test + void correctOnMessage() throws JsonProcessingException { + // given + MessageResponse messageResponse + = MessageResponse.create("성공", 0.2, "Hello world", "123"); + + String json = objectMapper.writeValueAsString(messageResponse); + String channel = "channel"; + Message message = new DefaultMessage(channel.getBytes(), json.getBytes()); + + when(defenseMessagePort.sendMessage(anyLong(), anyString())) + .thenReturn(true); + + // when + redisMessageSubscriber.onMessage(message, null); + + // then + verify(defenseMessagePort).sendMessage(eq(123L), any(String.class)); + } + + @DisplayName("Redis 메시지 전송 시에 예외가 발생하더라도 3번 이내에 재전송에 성공하면 정상적으로 전송된다.") + @Test + void retrySendMessageTest() throws JsonProcessingException { + // given + MessageResponse messageResponse + = MessageResponse.create("성공", 0.2, "Hello world", "123"); + + String json = objectMapper.writeValueAsString(messageResponse); + String channel = "channel"; + Message message = new DefaultMessage(channel.getBytes(), json.getBytes()); + + when(defenseMessagePort.sendMessage(anyLong(), anyString())) + .thenThrow(new RuntimeException("Error")) + .thenThrow(new RuntimeException("Error")) + .thenReturn(true); + + // when + redisMessageSubscriber.onMessage(message, null); + + // then + verify(defenseMessagePort, times(1 + 2)).sendMessage(eq(123L), any(String.class)); + } + + @DisplayName("Redis 메시지 재전송 로직이 3번 실패하면 예외가 발생한다.") + @Test + void retrySendMessageFailTest() throws JsonProcessingException { + // given + MessageResponse messageResponse + = MessageResponse.create("성공", 0.2, "Hello world", "123"); + + String json = objectMapper.writeValueAsString(messageResponse); + String channel = "channel"; + Message message = new DefaultMessage(channel.getBytes(), json.getBytes()); + + when(defenseMessagePort.sendMessage(anyLong(), anyString())) + .thenThrow(new RuntimeException("Error")); + + // when + MorandiException exception = assertThrows(MorandiException.class, + () -> redisMessageSubscriber.onMessage(message, null)); + + // then + assertEquals(RedisMessageErrorCode.MESSAGE_SEND_ERROR, exception.getErrorCode()); + verify(defenseMessagePort, times(1 + 3)).sendMessage(eq(123L), any(String.class)); + } + + @DisplayName("Redis pub/sub에 형식에 맞지 않는 메시지가 오면 예외를 발생시켜야 한다.") + @Test + void incorrectOnMessage() { + // when & then + MorandiException morandiException = assertThrows(MorandiException.class, + () -> redisMessageSubscriber.onMessage(null, null)); + assertEquals(RedisMessageErrorCode.MESSAGE_PARSE_ERROR, morandiException.getErrorCode()); + assertEquals("Redis의 메시지를 파싱하지 못했습니다.", morandiException.getMessage()); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/application/service/codesubmit/SQSCodeSubmitServiceTest.java b/src/test/java/kr/co/morandi/backend/defense_management/application/service/codesubmit/SQSCodeSubmitServiceTest.java new file mode 100644 index 00000000..0417e173 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/application/service/codesubmit/SQSCodeSubmitServiceTest.java @@ -0,0 +1,77 @@ +package kr.co.morandi.backend.defense_management.application.service.codesubmit; + +import com.amazonaws.services.sqs.AmazonSQS; +import com.amazonaws.services.sqs.model.SendMessageRequest; +import com.amazonaws.services.sqs.model.SendMessageResult; +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_management.infrastructure.exception.SQSMessageErrorCode; +import kr.co.morandi.backend.defense_management.infrastructure.request.codesubmit.CodeRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SQSCodeSubmitServiceTest extends IntegrationTestSupport { + + @Autowired + private SQSCodeSubmitService sqsService; + + @MockBean + private AmazonSQS amazonSQS; + + @DisplayName("사용자가 소스코드를 제출하면 AWS SQS에 JSON 메시지가 올바른 주소에 전송된다.") + @Test + void correctSendMessage() { + // given + CodeRequest 코드_요청_정보 = CodeRequest.create("Hello world", "Python", "", "123"); + + SendMessageResult 전송_결과물 = new SendMessageResult().withMessageId("12345"); + when(amazonSQS.sendMessage(any(SendMessageRequest.class))).thenReturn(전송_결과물); + + // when & then + assertDoesNotThrow(() -> sqsService.submitCodeToQueue(코드_요청_정보)); + } + + @DisplayName("AWS SQS 메시지 전송 시에 예외가 발생하더라도 3번 이내에 재전송에 성공하면 정상적으로 전송된다.") + @Test + void retrySendMessageTest() { + // given + CodeRequest 코드_요청_정보 = CodeRequest.create("Hello world", "Python", "", "123"); + SendMessageResult 전송_결과물 = new SendMessageResult().withMessageId("12345"); + + when(amazonSQS.sendMessage(any(SendMessageRequest.class))) + .thenThrow(new RuntimeException("SQS Exception")) + .thenThrow(new RuntimeException("SQS Exception")) + .thenReturn(전송_결과물); + + // when & then + assertDoesNotThrow(() -> sqsService.submitCodeToQueue(코드_요청_정보)); + verify(amazonSQS, times(1 + 2)).sendMessage(any(SendMessageRequest.class)); + } + + @DisplayName("AWS SQS 메시지 재전송 로직이 3번 실패하면 예외가 발생한다.") + @Test + void retrySendMessageFailTest() { + // given + CodeRequest 코드_요청_정보 = CodeRequest.create("Hello world", "Python", "", "123"); + + when(amazonSQS.sendMessage(any(SendMessageRequest.class))) + .thenThrow(new RuntimeException("SQS Exception")); + + // when & then + MorandiException exception = assertThrows(MorandiException.class, () -> { + sqsService.submitCodeToQueue(코드_요청_정보); + }); + + assertEquals(SQSMessageErrorCode.MESSAGE_SEND_FAILED, exception.getErrorCode()); + verify(amazonSQS, times(1 + 3)).sendMessage(any(SendMessageRequest.class)); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/application/service/message/DefenseMessageServiceTest.java b/src/test/java/kr/co/morandi/backend/defense_management/application/service/message/DefenseMessageServiceTest.java new file mode 100644 index 00000000..1cd12d65 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/application/service/message/DefenseMessageServiceTest.java @@ -0,0 +1,184 @@ +package kr.co.morandi.backend.defense_management.application.service.message; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseRepository; +import kr.co.morandi.backend.defense_management.application.request.session.StartDailyDefenseServiceRequest; +import kr.co.morandi.backend.defense_management.application.response.session.StartDailyDefenseResponse; +import kr.co.morandi.backend.defense_management.application.usecase.session.DailyDefenseManagementUsecase; +import kr.co.morandi.backend.defense_management.domain.model.session.DefenseSession; +import kr.co.morandi.backend.defense_management.infrastructure.persistence.session.DefenseSessionRepository; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import reactor.core.publisher.Mono; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Transactional +class DefenseMessageServiceTest extends IntegrationTestSupport { + + @Autowired + private DefenseMessageService defenseMessageService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private DailyDefenseRepository dailyDefenseRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private DailyDefenseManagementUsecase dailyDefenseManagementService; + + @Autowired + private DefenseSessionRepository defenseSessionRepository; + + + /* + * 내부에서 WebClient를 이용하는 통합 테스트에서는 ExchangeFunction의 exchange 메서드를 + * Stubbing하여 테스트를 진행합니다. + * + * 내부적으로 API가 호출되는 횟수만큼 Stubbing을 해주어야 합니다. + * */ + @Autowired + private ExchangeFunction exchangeFunction; + + @BeforeEach + void setUp() { + Mockito.when(exchangeFunction.exchange(Mockito.any())) + .thenReturn(Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body(""" + [ + { + "baekjoonProblemId": 1000, + "title": "A+B" + }, + { + "baekjoonProblemId": 1001, + "title": "A-B" + } + ]""") + .build())); + } + + @DisplayName("다른 사람의 디펜스 세션에 연결을 요청하면 예외가 발생한다.") + @Test + void getConnectionToOtherUserDefense() { + // given + Member 시험_시작_사용자 = createMember(); + createDailyDefense(LocalDate.of(2021, 10, 1), "오늘의 문제 테스트"); + LocalDateTime 요청_시각 = LocalDateTime.of(2021, 10, 1, 0, 0, 0); + + StartDailyDefenseServiceRequest 시험_시작_요청 = StartDailyDefenseServiceRequest.builder() + .problemNumber(2L) + .build(); + final StartDailyDefenseResponse 오늘의_문제_응답 = dailyDefenseManagementService.startDailyDefense(시험_시작_요청, 시험_시작_사용자.getMemberId(), 요청_시각); + + Long 디펜스_세션_아이디 = 오늘의_문제_응답.getDefenseSessionId(); + + Long 다른_사용자_아이디 = 100L; + + // when & then + assertThatThrownBy(() -> defenseMessageService.getConnection(디펜스_세션_아이디, 다른_사용자_아이디)) + .isInstanceOf(MorandiException.class) + .hasMessage("사용자의 시험 세션이 아닙니다."); + + } + + @DisplayName("시작된 디펜스에 연결을 요청하면 성공한다.") + @Test + void getConnection() { + // given + Member 시험_시작_사용자 = createMember(); + createDailyDefense(LocalDate.of(2021, 10, 1), "오늘의 문제 테스트"); + + LocalDateTime 요청_시각 = LocalDateTime.of(2021, 10, 1, 0, 0, 0); + + StartDailyDefenseServiceRequest 시험_시작_요청 = StartDailyDefenseServiceRequest.builder() + .problemNumber(2L) + .build(); + final StartDailyDefenseResponse 오늘의_문제_응답 = dailyDefenseManagementService.startDailyDefense(시험_시작_요청, 시험_시작_사용자.getMemberId(), 요청_시각); + + Long 디펜스_세션_아이디 = 오늘의_문제_응답.getDefenseSessionId(); + + // when + final SseEmitter 연결 = defenseMessageService.getConnection(디펜스_세션_아이디, 시험_시작_사용자.getMemberId()); + + // then + assertThat(연결).isNotNull(); + } + + @DisplayName("이미 종료된 디펜스에 연결을 요청하면 성공한다.") + @Test + void getConnectionToTerminatedDefense() { + // given + Member 시험_시작_사용자 = createMember(); + createDailyDefense(LocalDate.of(2021, 10, 1), "오늘의 문제 테스트"); + + LocalDateTime 요청_시각 = LocalDateTime.of(2021, 10, 1, 0, 0, 0); + + StartDailyDefenseServiceRequest 시험_시작_요청 = StartDailyDefenseServiceRequest.builder() + .problemNumber(2L) + .build(); + final StartDailyDefenseResponse 오늘의_문제_응답 = dailyDefenseManagementService.startDailyDefense(시험_시작_요청, 시험_시작_사용자.getMemberId(), 요청_시각); + + Long 디펜스_세션_아이디 = 오늘의_문제_응답.getDefenseSessionId(); + + DefenseSession 디펜스_세션 = defenseSessionRepository.findById(디펜스_세션_아이디).get(); + 디펜스_세션.terminateSession(); + defenseSessionRepository.save(디펜스_세션); + + + // when & then + assertThatThrownBy(() -> defenseMessageService.getConnection(디펜스_세션_아이디, 시험_시작_사용자.getMemberId())) + .isInstanceOf(MorandiException.class) + .hasMessage("이미 종료된 디펜스 세션입니다."); + + } + + private DailyDefense createDailyDefense(LocalDate createdDate, String contentName) { + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + return dailyDefenseRepository.save(DailyDefense.create(createdDate, contentName, problemMap)); + } + private List createProblems() { + Problem problem1 = Problem.create(1000L, B5, 0L); + Problem problem2 = Problem.create(2000L, S5, 0L); + Problem problem3 = Problem.create(3000L, G5, 0L); + + return problemRepository.saveAll(List.of(problem1, problem2, problem3)); + } + private Member createMember() { + return memberRepository.save(Member.create("test", "test" + "@gmail.com", GOOGLE, "test", "test")); + } + + + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/application/usecase/dailydefense/DailyDefenseManagementUsecaseTest.java b/src/test/java/kr/co/morandi/backend/defense_management/application/usecase/dailydefense/DailyDefenseManagementUsecaseTest.java new file mode 100644 index 00000000..07a75f4b --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/application/usecase/dailydefense/DailyDefenseManagementUsecaseTest.java @@ -0,0 +1,412 @@ +package kr.co.morandi.backend.defense_management.application.usecase.dailydefense; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseProblemRepository; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseRepository; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDetailRepository; +import kr.co.morandi.backend.defense_management.application.port.out.defensemessaging.DefenseMessagePort; +import kr.co.morandi.backend.defense_management.application.request.session.StartDailyDefenseServiceRequest; +import kr.co.morandi.backend.defense_management.application.response.session.StartDailyDefenseResponse; +import kr.co.morandi.backend.defense_management.application.service.timer.DefenseTimerService; +import kr.co.morandi.backend.defense_management.application.usecase.session.DailyDefenseManagementUsecase; +import kr.co.morandi.backend.defense_management.domain.event.CreateDefenseMessageEvent; +import kr.co.morandi.backend.defense_management.domain.event.DefenseStartTimerEvent; +import kr.co.morandi.backend.defense_management.infrastructure.persistence.session.DefenseSessionRepository; +import kr.co.morandi.backend.defense_management.infrastructure.persistence.session.SessionDetailRepository; +import kr.co.morandi.backend.defense_record.infrastructure.persistence.dailydefense_record.DailyRecordRepository; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import kr.co.morandi.backend.problem_information.application.response.problemcontent.ProblemContent; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.adapter.problemcontent.ProblemContentAdapter; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType.DAILY; +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.*; + + +@RecordApplicationEvents +class DailyDefenseManagementUsecaseTest extends IntegrationTestSupport { + + @Autowired + private DailyDefenseManagementUsecase dailyDefenseManagementUsecase; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private DailyDefenseRepository dailyDefenseRepository; + + @Autowired + private DailyDefenseProblemRepository dailyDefenseProblemRepository; + + @Autowired + private DailyRecordRepository dailyRecordRepository; + + @Autowired + private DailyDetailRepository dailyDetailRepository; + + @Autowired + private DefenseSessionRepository defenseSessionRepository; + + @Autowired + private SessionDetailRepository sessionDetailRepository; + + @MockBean + private ProblemContentAdapter problemContentAdapter; + + @Autowired + private ApplicationEvents applicationEvents; + + @Autowired + private TransactionTemplate transactionTemplate; + + @MockBean + private DefenseTimerService defenseTimerService; + + @MockBean + private DefenseMessagePort defenseMessagePort; + + @BeforeEach + void setUp() { + Map problemContentMap = Map.of( + 1L, ProblemContent.builder() + .baekjoonProblemId(1000L) + .title("test") + .build(), + 2L, ProblemContent.builder() + .baekjoonProblemId(2000L) + .title("test2") + .build(), + 3L, ProblemContent.builder() + .baekjoonProblemId(3000L) + .title("test3") + .build() + ); + Mockito.when(problemContentAdapter.getProblemContents(anyList())) + .thenReturn(problemContentMap); + } + + @AfterEach + void tearDown() { + sessionDetailRepository.deleteAll(); + defenseSessionRepository.deleteAllInBatch(); + dailyDetailRepository.deleteAllInBatch(); + dailyRecordRepository.deleteAllInBatch(); + dailyDefenseProblemRepository.deleteAllInBatch(); + dailyDefenseRepository.deleteAllInBatch(); + problemRepository.deleteAllInBatch(); + memberRepository.deleteAllInBatch(); + } + + @DisplayName("오늘의 문제가 시작되다가 롤백되면 Sse연결 이벤트가 실행되지 않는다.") + @Test + void eventPublishWhenStartDailyDefenseWhenRollbackSse() { + // given + Member 시험_시작_사용자 = createMember(); + createDailyDefense(LocalDate.of(2021, 10, 1), "오늘의 문제 테스트"); + + LocalDateTime 요청_시각 = LocalDateTime.of(2021, 10, 1, 0, 0, 0); + + StartDailyDefenseServiceRequest 시험_시작_요청 = StartDailyDefenseServiceRequest.builder() + .problemNumber(2L) + .build(); + + // when + transactionTemplate.execute(status -> { + dailyDefenseManagementUsecase.startDailyDefense(시험_시작_요청, 시험_시작_사용자.getMemberId(), 요청_시각); + status.setRollbackOnly(); // Force rollback + return null; + }); + + // then + assertThat(applicationEvents.stream(CreateDefenseMessageEvent.class)) + .hasSize(1) + .anySatisfy(event -> { + assertAll( + () -> assertThat(event.getDefenseSessionId()).isNotNull() + ); + }); + + verify(defenseMessagePort, never()).createConnection(any()); + } + + @DisplayName("오늘의 문제가 시작되면 Sse연결 이벤트가 실행된다.") + @Test + void eventPublishWhenStartDailyDefenseSse() { + // given + Member 시험_시작_사용자 = createMember(); + createDailyDefense(LocalDate.of(2021, 10, 1), "오늘의 문제 테스트"); + + LocalDateTime 요청_시각 = LocalDateTime.of(2021, 10, 1, 0, 0, 0); + + StartDailyDefenseServiceRequest 시험_시작_요청 = StartDailyDefenseServiceRequest.builder() + .problemNumber(2L) + .build(); + + // when + dailyDefenseManagementUsecase.startDailyDefense(시험_시작_요청, 시험_시작_사용자.getMemberId(), 요청_시각); + + // then + assertThat(applicationEvents.stream(CreateDefenseMessageEvent.class)) + .hasSize(1) + .anySatisfy(event -> { + assertAll( + () -> assertThat(event.getDefenseSessionId()).isNotNull() + ); + }); + + verify(defenseMessagePort, times(1)).createConnection(any()); + } + + @DisplayName("오늘의 문제가 시작되다가 롤백되면 타이머 이벤트가 실행되지 않는다.") + @Test + void eventPublishWhenStartDailyDefenseWhenRollback() { + // given + Member member = createMember(); + createDailyDefense(LocalDate.of(2021, 10, 1), "오늘의 문제 테스트"); + + LocalDateTime requestTime = LocalDateTime.of(2021, 10, 1, 0, 0, 0); + + StartDailyDefenseServiceRequest request = StartDailyDefenseServiceRequest.builder() + .problemNumber(2L) + .build(); + + // when + transactionTemplate.execute(status -> { + dailyDefenseManagementUsecase.startDailyDefense(request, member.getMemberId(), requestTime); + status.setRollbackOnly(); // Force rollback + return null; + }); + + // then + assertThat(applicationEvents.stream(DefenseStartTimerEvent.class)) + .hasSize(1) + .anySatisfy(event -> { + assertAll( + () -> assertThat(event.getSessionId()).isNotNull(), + () -> assertThat(event.getStartDateTime()).isNotNull(), + () -> assertThat(event.getEndDateTime()).isNotNull() + ); + }); + + verify(defenseTimerService, never()).startDefenseTimer(any(), any(), any()); + } + + @DisplayName("오늘의 문제가 시작될 때 타이머 이벤트를 발행한다.") + @Test + void eventPublishWhenStartDailyDefense() { + // given + Member member = createMember(); + createDailyDefense(LocalDate.of(2021, 10, 1), "오늘의 문제 테스트"); + + LocalDateTime requestTime = LocalDateTime.of(2021, 10, 1, 0, 0, 0); + + StartDailyDefenseServiceRequest request = StartDailyDefenseServiceRequest.builder() + .problemNumber(2L) + .build(); + + // when + dailyDefenseManagementUsecase.startDailyDefense(request, member.getMemberId(), requestTime); + + // then + assertThat(applicationEvents.stream(DefenseStartTimerEvent.class)) + .hasSize(1) + .anySatisfy(event -> { + assertAll( + () -> assertThat(event.getSessionId()).isNotNull(), + () -> assertThat(event.getStartDateTime()).isNotNull(), + () -> assertThat(event.getEndDateTime()).isNotNull() + ); + }); + verify(defenseTimerService, times(1)).startDefenseTimer(any(), any(), any()); + } + @DisplayName("전날 시작했던 시험이 안 끝났더라도 오늘의 문제를 시도하면 해당하는 날짜의 문제를 제공한다.") + @Test + void retryDailyDefenseWhenDayPassed() { + // given + Member member = createMember(); + createDailyDefense(LocalDate.of(2021, 10, 1), "10월 1일 오늘의 문제 테스트"); + createDailyDefense(LocalDate.of(2021, 10, 2), "10월 2일 오늘의 문제 테스트"); + + LocalDateTime requestTime = LocalDateTime.of(2021, 10, 1, 0, 0, 0); + + StartDailyDefenseServiceRequest request = StartDailyDefenseServiceRequest.builder() + .problemNumber(1L) + .build(); + dailyDefenseManagementUsecase.startDailyDefense(request, member.getMemberId(), requestTime); + + StartDailyDefenseServiceRequest retryRequest = StartDailyDefenseServiceRequest.builder() + .problemNumber(2L) + .build(); + + LocalDateTime retryRequestTime = LocalDateTime.of(2021, 10, 2, 12, 0, 0); + + // when + final StartDailyDefenseResponse response = dailyDefenseManagementUsecase.startDailyDefense(retryRequest, member.getMemberId(), retryRequestTime); + + + // then + assertAll( + () -> assertThat(response).isNotNull() + .extracting("lastAccessTime", "contentName", "defenseType") + .contains(retryRequestTime, "10월 2일 오늘의 문제 테스트", DAILY), + + () -> assertThat(response.getDefenseProblems()).hasSize(1) + .extracting("problemNumber", "baekjoonProblemId", "isCorrect") + .contains( + tuple(2L, 2000L, false) + ) + ); + + } + + @DisplayName("시도하던 오늘의 문제 외 다른 문제를 시도하면 새로 시도한 문제를 제공한다.") + @Test + void retryDailyDefenseWithOtherProblem() { + // given + Member member = createMember(); + createDailyDefense(LocalDate.of(2021, 10, 1), "오늘의 문제 테스트"); + + LocalDateTime requestTime = LocalDateTime.of(2021, 10, 1, 0, 0, 0); + + StartDailyDefenseServiceRequest request = StartDailyDefenseServiceRequest.builder() + .problemNumber(1L) + .build(); + dailyDefenseManagementUsecase.startDailyDefense(request, member.getMemberId(), requestTime); + + StartDailyDefenseServiceRequest retryRequest = StartDailyDefenseServiceRequest.builder() + .problemNumber(2L) + .build(); + + LocalDateTime retryRequestTime = LocalDateTime.of(2021, 10, 1, 12, 0, 0); + // when + final StartDailyDefenseResponse response = dailyDefenseManagementUsecase.startDailyDefense(retryRequest, member.getMemberId(), retryRequestTime); + + // then + assertAll( + () -> assertThat(response).isNotNull() + .extracting("lastAccessTime") + .isEqualTo(retryRequestTime), + + () -> assertThat(response.getDefenseProblems()).hasSize(1) + .extracting("problemNumber", "baekjoonProblemId", "isCorrect") + .contains( + tuple(2L, 2000L, false) + ) + ); + + } + + @DisplayName("시도하던 오늘의 문제에 대해 다시 시도하면 기존의 문제를 다시 제공한다. (다른 문제 시도나, tempCode 등의 기록이 없으면 기존 시간 그대로 기록돼있음") + @Test + void retryDailyDefense() { + // given + Member member = createMember(); + createDailyDefense(LocalDate.of(2021, 10, 1), "오늘의 문제 테스트"); + + LocalDateTime requestTime = LocalDateTime.of(2021, 10, 1, 0, 0, 0); + + StartDailyDefenseServiceRequest request = StartDailyDefenseServiceRequest.builder() + .problemNumber(2L) + .build(); + dailyDefenseManagementUsecase.startDailyDefense(request, member.getMemberId(), requestTime); + + + LocalDateTime retryRequestTime = LocalDateTime.of(2021, 10, 1, 12, 0, 0); + // when + final StartDailyDefenseResponse response = dailyDefenseManagementUsecase.startDailyDefense(request, member.getMemberId(), retryRequestTime); + + // then + assertAll( + () -> assertThat(response).isNotNull() + .extracting("lastAccessTime") + .isEqualTo(requestTime), + + () -> assertThat(response.getDefenseProblems()).hasSize(1) + .extracting("problemNumber", "baekjoonProblemId", "isCorrect") + .contains( + tuple(2L, 2000L, false) + ) + ); + } + + @DisplayName("오늘의 문제를 시작할 수 있다.") + @Test + void startDailyDefense() { + // given + Member member = createMember(); + createDailyDefense(LocalDate.of(2021, 10, 1), "오늘의 문제 테스트"); + + LocalDateTime requestTime = LocalDateTime.of(2021, 10, 1, 0, 0, 0); + + StartDailyDefenseServiceRequest request = StartDailyDefenseServiceRequest.builder() + .problemNumber(2L) + .build(); + + // when + final StartDailyDefenseResponse response = dailyDefenseManagementUsecase.startDailyDefense(request, member.getMemberId(), requestTime); + + // then + + assertAll( + () -> assertThat(response).isNotNull() + .extracting("lastAccessTime") + .isEqualTo(requestTime), + + () -> assertThat(response.getDefenseProblems()).hasSize(1) + .extracting("problemNumber", "baekjoonProblemId", "isCorrect") + .contains( + tuple(2L, 2000L, false) + ) + ); + + } + + private DailyDefense createDailyDefense(LocalDate createdDate, String contentName) { + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + return dailyDefenseRepository.save(DailyDefense.create(createdDate, contentName, problemMap)); + } + private List createProblems() { + Problem problem1 = Problem.create(1000L, B5, 0L); + Problem problem2 = Problem.create(2000L, S5, 0L); + Problem problem3 = Problem.create(3000L, G5, 0L); + + return problemRepository.saveAll(List.of(problem1, problem2, problem3)); + } + private Member createMember() { + return memberRepository.save(Member.create("test", "test" + "@gmail.com", GOOGLE, "test", "test")); + } + + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/domain/model/judgement/JudgementResultSubscriberTest.java b/src/test/java/kr/co/morandi/backend/defense_management/domain/model/judgement/JudgementResultSubscriberTest.java new file mode 100644 index 00000000..1e2b8625 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/domain/model/judgement/JudgementResultSubscriberTest.java @@ -0,0 +1,29 @@ +package kr.co.morandi.backend.defense_management.domain.model.judgement; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.judgement.application.service.baekjoon.result.JudgementResultSubscriber; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class JudgementResultSubscriberTest extends IntegrationTestSupport { + + @Autowired + private JudgementResultSubscriber judgementResultSubscriber; + + @DisplayName("") + @Test + void test() throws InterruptedException { + // given + + // when +// for(int i = 79196920;i<79197000;i++){ +// judgementResultService.subscribeJudgement(String.valueOf(i), ); + + + // then +// Thread.sleep(100000L); + + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/domain/model/session/DefenseSessionTest.java b/src/test/java/kr/co/morandi/backend/defense_management/domain/model/session/DefenseSessionTest.java new file mode 100644 index 00000000..2677e933 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/domain/model/session/DefenseSessionTest.java @@ -0,0 +1,375 @@ +package kr.co.morandi.backend.defense_management.domain.model.session; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import kr.co.morandi.backend.defense_management.domain.error.SessionErrorCode; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.TempCode; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.factory.TestDefenseFactory; +import kr.co.morandi.backend.factory.TestMemberFactory; +import kr.co.morandi.backend.factory.TestProblemFactory; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.domain.model.member.SocialType; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language.CPP; +import static kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language.JAVA; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ActiveProfiles("test") +class DefenseSessionTest { + + @DisplayName("DefenseSession에서 해당하는 문제의 TempCode를 업데이트할 수 있다.") + @Test + void updateTempCode() { + // given + Member 사용자 = TestMemberFactory.createMember(); + + Map 문제 = TestProblemFactory.createProblems(5); + + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord 오늘의_문제_기록 = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + + DefenseSession 디펜스_세션 = DefenseSession.builder() + .member(사용자) + .defenseType(오늘의_문제.getDefenseType()) + .problemNumbers(Set.of(1L)) + .recordId(오늘의_문제_기록.getRecordId()) + .startDateTime(LocalDateTime.of(2021, 1, 1, 0, 0)) + .endDateTime(LocalDateTime.of(2021, 1, 1, 1, 0)) + .build(); + + // when + 디펜스_세션.updateTempCode(1L, JAVA, "code"); + + // then + assertThat(디펜스_세션.getSessionDetail(1L) + .getTempCode(JAVA)) + .extracting("code", "language") + .containsExactly("code", JAVA); + + } + + @DisplayName("끝난 DefenseSession에서 TempCode를 업데이트하려고 하면 예외를 발생한다.") + @Test + void updateTempCodeWhenTerminated() { + // given + Member 사용자 = TestMemberFactory.createMember(); + + Map 문제 = TestProblemFactory.createProblems(5); + + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord 오늘의_문제_기록 = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + + DefenseSession 디펜스_세션 = DefenseSession.builder() + .member(사용자) + .defenseType(오늘의_문제.getDefenseType()) + .problemNumbers(Set.of(1L)) + .recordId(오늘의_문제_기록.getRecordId()) + .startDateTime(LocalDateTime.of(2021, 1, 1, 0, 0)) + .endDateTime(LocalDateTime.of(2021, 1, 1, 1, 0)) + .build(); + + 디펜스_세션.terminateSession(); + + // when & then + assertThatThrownBy(() -> 디펜스_세션.updateTempCode(1L, JAVA, "code")) + .isInstanceOf(MorandiException.class) + .hasMessage(SessionErrorCode.SESSION_ALREADY_ENDED.getMessage()); + } + + @DisplayName("세션 소유자일 경우 아무 예외가 발생하지 않는다.") + @Test + void validateSessionOwner() { + // given + final Member member = mock(Member.class); + when(member.getMemberId()) + .thenReturn(1L); + + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + DailyDefense dailyDefense = createDailyDefense(startTime.toLocalDate()); + Map problems = getProblems(dailyDefense, 1L); + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, problems); + + DefenseSession defenseSession = DefenseSession.startSession(member, dailyRecord.getRecordId(), dailyDefense.getDefenseType(), problems.keySet(), startTime, dailyDefense.getEndTime(startTime)); + + + // when & then + defenseSession.validateSessionOwner(member.getMemberId()); + } + + @DisplayName("세션 소유자가 아닐 경우 예외를 발생한다.") + @Test + void throwWhenNotSessionOwner() { + // given + final Member member = mock(Member.class); + when(member.getMemberId()) + .thenReturn(1L); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + DailyDefense dailyDefense = createDailyDefense(startTime.toLocalDate()); + Map problems = getProblems(dailyDefense, 1L); + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, problems); + + DefenseSession defenseSession = DefenseSession.startSession(member, dailyRecord.getRecordId(), dailyDefense.getDefenseType(), problems.keySet(), startTime, dailyDefense.getEndTime(startTime)); + + + // when & then + assertThatThrownBy(() -> defenseSession.validateSessionOwner(2L)) + .isInstanceOf(MorandiException.class) + .hasMessage("사용자의 시험 세션이 아닙니다."); + } + @DisplayName("세션을 종료할 수 있다.") + @Test + void terminateSession() { + // given + Member member = createMember(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + DailyDefense dailyDefense = createDailyDefense(startTime.toLocalDate()); + Map problems = getProblems(dailyDefense, 1L); + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, problems); + + DefenseSession defenseSession = DefenseSession.startSession(member, dailyRecord.getRecordId(), dailyDefense.getDefenseType(), problems.keySet(), startTime, dailyDefense.getEndTime(startTime)); + + + // when + final boolean result = defenseSession.terminateSession(); + + // then + assertThat(result).isTrue(); + + } + + @DisplayName("세션이 종료상태일 때 종료하려하면 false를 반환한다.") + @Test + void terminateSessionWhenAlreadyTerminated() { + // given + Member member = createMember(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + DailyDefense dailyDefense = createDailyDefense(startTime.toLocalDate()); + Map problems = getProblems(dailyDefense, 1L); + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, problems); + + DefenseSession defenseSession = DefenseSession.startSession(member, dailyRecord.getRecordId(), dailyDefense.getDefenseType(), problems.keySet(), startTime, dailyDefense.getEndTime(startTime)); + defenseSession.terminateSession(); + + // when & then + assertThatThrownBy( + () -> defenseSession.terminateSession() + ) + .isInstanceOf(MorandiException.class) + .hasMessage("이미 종료된 세션입니다."); + + } + + @DisplayName("문제 번호를 가지고 있는지 확인한다.") + @Test + void hasTriedProblem() { + // given + Member member = createMember(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + DailyDefense dailyDefense = createDailyDefense(startTime.toLocalDate()); + Map problems = getProblems(dailyDefense, 1L); + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, problems); + + DefenseSession defenseSession = DefenseSession.startSession(member, dailyRecord.getRecordId(), dailyDefense.getDefenseType(), problems.keySet(), startTime, dailyDefense.getEndTime(startTime)); + + // when + final boolean result = defenseSession.hasTriedProblem(1L); + + // then + assertThat(result).isTrue(); + + } + + @DisplayName("문제 번호를 가지고 있지 않으면 false를 반환한다.") + @Test + void hasNotTriedProblem() { + // given + Member member = createMember(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + DailyDefense dailyDefense = createDailyDefense(startTime.toLocalDate()); + Map problems = getProblems(dailyDefense, 1L); + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, problems); + + DefenseSession defenseSession = DefenseSession.startSession(member, dailyRecord.getRecordId(), dailyDefense.getDefenseType(), problems.keySet(), startTime, dailyDefense.getEndTime(startTime)); + + // when + final boolean result = defenseSession.hasTriedProblem(2L); + + // then + assertThat(result).isFalse(); + + } + + @DisplayName("Session을 생성하면 TempCode까지 생성된다.") + @Test + void sessionWithTempCode() { + // given + Member member = createMember(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + DailyDefense dailyDefense = createDailyDefense(startTime.toLocalDate()); + + Map problems = getProblems(dailyDefense, 1L); + + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, problems); + + // when + DefenseSession defenseSession = DefenseSession.startSession(member, dailyRecord.getRecordId(), dailyDefense.getDefenseType(), problems.keySet(), startTime, dailyDefense.getEndTime(startTime)); + + + // then + assertThat(defenseSession).isNotNull(); + assertThat(defenseSession.getSessionDetails()).hasSize(1) + .extracting("problemNumber") + .containsExactly(1L); + + SessionDetail sessionDetail = defenseSession.getSessionDetails().iterator().next(); + + assertThat(sessionDetail.getTempCodes()) + .isNotEmpty(); + + TempCode tempCode = sessionDetail.getTempCodes().iterator().next(); + assertThat(tempCode.getLanguage()) + .isEqualTo(CPP); + } + + @DisplayName("문제를 추가하면 Detail이 추가된다.") + @Test + void tryMoreProblem() { + // given + Member member = createMember(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + DailyDefense dailyDefense = createDailyDefense(startTime.toLocalDate()); + + Map problems = getProblems(dailyDefense, 2L); + + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, problems); + + DefenseSession defenseSession = DefenseSession.startSession(member, dailyRecord.getRecordId(), dailyDefense.getDefenseType(), problems.keySet(), startTime, dailyDefense.getEndTime(startTime)); + + // when + defenseSession.tryMoreProblem(3L, startTime.plusMinutes(1)); + + // then + assertThat(defenseSession.getSessionDetails()).hasSize(2) + .extracting("problemNumber") + .containsExactlyInAnyOrder(2L, 3L); + + } + + @DisplayName("Record에 따라 시험 세션을 시작하면 마지막 접근 문제 번호가 가장 첫 번째 숫자로 저장된다.") + @Test + void startSessionWithRecord() { + // given + Member member = createMember(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + DailyDefense dailyDefense = createDailyDefense(startTime.toLocalDate()); + + Map problems = getProblems(dailyDefense, 2L); + + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, problems); + + // when + DefenseSession defenseSession = DefenseSession.startSession(member, dailyRecord.getRecordId(), dailyDefense.getDefenseType(), problems.keySet(), startTime, dailyDefense.getEndTime(startTime)); + + // then + assertThat(defenseSession.getLastAccessProblemNumber()).isEqualTo(2L); + } + + @DisplayName("문제 번호 없이 Session을 시작하려 하면 예외를 발생한다.") + @Test + void startSessionWithoutProblemNumber() { + // given + Member member = createMember(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + DailyDefense dailyDefense = createDailyDefense(startTime.toLocalDate()); + + Map problems = getProblems(dailyDefense, null); + + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, problems); + + // when & then + assertThatThrownBy(() -> DefenseSession.startSession(member, dailyRecord.getRecordId(), dailyDefense.getDefenseType(), new HashSet<>(), startTime, dailyDefense.getEndTime(startTime))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("문제 번호가 없습니다."); + + } + + @DisplayName("Record에 따라 시험 세션을 시작할 수 있다.") + @Test + void startSession() { + // given + Member member = createMember(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + DailyDefense dailyDefense = createDailyDefense(startTime.toLocalDate()); + + Map problems = getProblems(dailyDefense, 2L); + + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, problems); + + // when + DefenseSession defenseSession = DefenseSession.startSession(member, dailyRecord.getRecordId(), dailyDefense.getDefenseType(), problems.keySet(), startTime, dailyDefense.getEndTime(startTime)); + + // then + assertThat(defenseSession).isNotNull(); + } + + private Member createMember() { + return Member.builder() + .nickname("nickname") + .email("email") + .socialType(SocialType.GOOGLE) + .build(); + } + private DailyDefense createDailyDefense(LocalDate createdDate) { + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + return DailyDefense.create(createdDate, "오늘의 문제 테스트", problemMap); + } + private List createProblems() { + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + Problem problem3 = Problem.create(3L, G5, 0L); + return List.of(problem1, problem2, problem3); + } + private Map getProblems(DailyDefense DailyDefense, Long problemNumber) { + return DailyDefense.getDailyDefenseProblems().stream() + .filter(p -> p.getProblemNumber().equals(problemNumber)) + .collect(Collectors.toMap(DailyDefenseProblem::getProblemNumber, DailyDefenseProblem::getProblem)); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/domain/model/session/SessionDetailTest.java b/src/test/java/kr/co/morandi/backend/defense_management/domain/model/session/SessionDetailTest.java new file mode 100644 index 00000000..e28d6447 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/domain/model/session/SessionDetailTest.java @@ -0,0 +1,133 @@ +package kr.co.morandi.backend.defense_management.domain.model.session; + +import kr.co.morandi.backend.defense_management.domain.model.session.DefenseSession; +import kr.co.morandi.backend.defense_management.domain.model.session.SessionDetail; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.TempCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import static kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language.CPP; +import static kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language.JAVA; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.Mockito.mock; + +@ActiveProfiles("test") +class SessionDetailTest { + @DisplayName("처음 SessionDetail을 생성하면 tempCode 언어는 CPP로 생성된다.") + @Test + void createSessionDetailWithInitialLanguageType() { + // given + DefenseSession defenseSession = mock(DefenseSession.class); + + // when + final SessionDetail sessionDetail = SessionDetail.create(defenseSession, 1L); + + // then + assertThat(sessionDetail.getTempCodes()) + .hasSize(1) + .extracting("language", "sessionDetail") + .contains( + tuple(CPP, sessionDetail) + ); + + } + @DisplayName("SessionDetail에 저장된 언어로 tempCode를 조회할 수 있다.") + @Test + void getTempCode() { + // given + DefenseSession defenseSession = mock(DefenseSession.class); + final SessionDetail sessionDetail = SessionDetail.create(defenseSession, 1L); + + // when + final TempCode tempCode = sessionDetail.getTempCode(CPP); + + // then + assertThat(tempCode) + .extracting("language","sessionDetail") + .contains(CPP, sessionDetail); + } + + @DisplayName("SessionDetail에 저장되지 않은 언어로 tempCode를 조회하면 새로 생성된다.") + @Test + void getTempCodeWhenNotExists() { + // given + DefenseSession defenseSession = mock(DefenseSession.class); + final SessionDetail sessionDetail = SessionDetail.create(defenseSession, 1L); + + // when + final TempCode tempCode = sessionDetail.getTempCode(JAVA); + + // then + assertThat(tempCode) + .extracting("language","sessionDetail") + .contains(JAVA, sessionDetail); + assertThat(sessionDetail.getTempCodes()).hasSize(2) + .extracting("language","sessionDetail") + .contains( + tuple(CPP, sessionDetail), + tuple(JAVA, sessionDetail) + ); + } + + @DisplayName("SessionDetail에 저장된 언어로 tempCode에 접근하여 수정할 수 있다.") + @Test + void updateTempCode() { + // given + DefenseSession defenseSession = mock(DefenseSession.class); + final SessionDetail sessionDetail = SessionDetail.create(defenseSession, 1L); + + // when + sessionDetail.updateTempCode(CPP, "newCode"); + + // then + assertThat(sessionDetail.getTempCode(CPP) + .getCode()) + .isEqualTo("newCode"); + + } + + @DisplayName("SessionDetail에 저장되지 않은 언어로 tempCode를 수정하면 새로 생성된다.") + @Test + void updateTempCodeWhenNotExists() { + // given + DefenseSession defenseSession = mock(DefenseSession.class); + final SessionDetail sessionDetail = SessionDetail.create(defenseSession, 1L); + + // when + sessionDetail.updateTempCode(JAVA, "newCode"); + + // then + assertThat(sessionDetail.getTempCodes()).hasSize(2) + .extracting("language","sessionDetail") + .contains( + tuple(CPP, sessionDetail), + tuple(JAVA, sessionDetail) + ); + + assertThat(sessionDetail.getTempCode(JAVA) + .getCode()) + .isEqualTo("newCode"); + + } + @DisplayName("SessionDetail에 tempCode를 원하는 언어로 추가할 수 있다.") + @Test + void addTempCode() { + // given + DefenseSession defenseSession = mock(DefenseSession.class); + final SessionDetail sessionDetail = SessionDetail.create(defenseSession, 1L); + + // when + sessionDetail.addTempCode(JAVA, JAVA.getInitialCode()); + + // then + assertThat(sessionDetail.getTempCodes()).hasSize(2) + .extracting("language","sessionDetail") + .contains( + tuple(CPP, sessionDetail), + tuple(JAVA, sessionDetail) + ); + + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/domain/model/tempcode/model/LanguageTest.java b/src/test/java/kr/co/morandi/backend/defense_management/domain/model/tempcode/model/LanguageTest.java new file mode 100644 index 00000000..a9fa3534 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/domain/model/tempcode/model/LanguageTest.java @@ -0,0 +1,63 @@ +package kr.co.morandi.backend.defense_management.domain.model.tempcode.model; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_management.domain.error.LanguageErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LanguageTest { + @DisplayName("CPP에 해당하는 Language 객체를 반환한다.") + @Test + void fromCpp() { + // given + String value = "CPP"; + + // when + Language language = Language.from(value); + + // then + assertThat(language).isEqualTo(Language.CPP); + } + + @DisplayName("JAVA 해당하는 Language 객체를 반환한다.") + @Test + void fromJava() { + // given + String value = "JAVA"; + + // when + Language language = Language.from(value); + + // then + assertThat(language).isEqualTo(Language.JAVA); + } + + @DisplayName("PYTHON 해당하는 Language 객체를 반환한다.") + @Test + void fromPython() { + // given + String value = "PYTHON"; + + // when + Language language = Language.from(value); + + // then + assertThat(language).isEqualTo(Language.PYTHON); + } + + @DisplayName("적절하지 않은 Language 값을 입력하면 예외를 반환한다.") + @Test + void fromInvalidValue() { + // given + String value = "JAVAGOOD"; + + // when & then + assertThatThrownBy(() -> Language.from(value)) + .isInstanceOf(MorandiException.class) + .hasMessageContaining(LanguageErrorCode.LANGUAGE_NOT_FOUND.getMessage()); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/domain/service/DefenseEventServiceTest.java b/src/test/java/kr/co/morandi/backend/defense_management/domain/service/DefenseEventServiceTest.java new file mode 100644 index 00000000..3347b6be --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/domain/service/DefenseEventServiceTest.java @@ -0,0 +1,71 @@ +package kr.co.morandi.backend.defense_management.domain.service; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_management.application.service.timer.DefenseTimerService; +import kr.co.morandi.backend.defense_management.domain.event.DefenseStartTimerEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; + +import static org.mockito.Mockito.*; + +class DefenseEventServiceTest extends IntegrationTestSupport { + + @Autowired + private ApplicationEventPublisher publisher; + + @MockBean + private DefenseTimerService defenseTimerService; + + @Autowired + private DefenseEventService defenseEventService; + + @Autowired + private TransactionTemplate transactionTemplate; + + + @DisplayName("DefenseStartTimerEvent가 발생하면 DefenseTimerService의 startDefenseTimer가 호출된다.") + @Test + void onDefenseStartTimerEvent() { + // given + LocalDateTime startDateTime = LocalDateTime.of(2021, 10, 1, 0, 0, 0); + LocalDateTime endDateTime = LocalDateTime.of(2021, 10, 1, 0, 1, 0); + DefenseStartTimerEvent event = new DefenseStartTimerEvent(1L, startDateTime, endDateTime); + + // when + transactionTemplate.execute(status -> { + publisher.publishEvent(event); + return null; + }); + + // then + verify(defenseTimerService, times(1)) + .startDefenseTimer(1L, startDateTime, endDateTime); + } + + @DisplayName("DefenseStartTimerEvent 발행 후 rollback되면 DefenseTimerService의 startDefenseTimer가 호출되지 않는다.") + @Test + void onDefenseStartTimerEventRollback() { + // given + LocalDateTime startDateTime = LocalDateTime.of(2021, 10, 1, 0, 0, 0); + LocalDateTime endDateTime = LocalDateTime.of(2021, 10, 1, 0, 1, 0); + DefenseStartTimerEvent event = new DefenseStartTimerEvent(1L, startDateTime, endDateTime); + + // when + transactionTemplate.execute(status -> { + publisher.publishEvent(event); + status.setRollbackOnly(); + return null; + }); + + // then + verify(defenseTimerService, never()) + .startDefenseTimer(1L, startDateTime, endDateTime); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/domain/service/SessionServiceTest.java b/src/test/java/kr/co/morandi/backend/defense_management/domain/service/SessionServiceTest.java new file mode 100644 index 00000000..6ff350d4 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/domain/service/SessionServiceTest.java @@ -0,0 +1,209 @@ +package kr.co.morandi.backend.defense_management.domain.service; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseRepository; +import kr.co.morandi.backend.defense_management.domain.model.session.DefenseSession; +import kr.co.morandi.backend.defense_management.domain.model.session.ExamStatus; +import kr.co.morandi.backend.defense_management.infrastructure.persistence.session.DefenseSessionRepository; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyDetail; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.defense_record.domain.model.record.RecordStatus; +import kr.co.morandi.backend.defense_record.infrastructure.persistence.dailydefense_record.DailyRecordRepository; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +@Transactional +class SessionServiceTest extends IntegrationTestSupport { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private DailyRecordRepository dailyRecordRepository; + + @Autowired + private DailyDefenseRepository dailyDefenseRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private DefenseSessionRepository defenseSessionRepository; + + @Autowired + private SessionService sessionService; + + + @DisplayName("DailyDefense를 시작했을 때 세션과 Record를 종료할 수 있다.") + @Test + void terminateDefense() { + // given + LocalDateTime today = LocalDateTime.of(2021, 10, 1, 0, 0); + Member member = createMember(); + DailyRecord dailyRecord = tryDailyDefense(today, member); + DefenseSession session = DefenseSession.builder() + .recordId(dailyRecord.getRecordId()) + .problemNumbers(Set.of(2L)) + .startDateTime(today) + .endDateTime(today.plusMinutes(1)) + .build(); + + final DefenseSession defenseSession = defenseSessionRepository.save(session); + + // when + sessionService.terminateDefense(defenseSession.getDefenseSessionId()); + + // then + defenseSessionRepository.findById(defenseSession.getDefenseSessionId()) + .ifPresent(s -> assertEquals(s.getExamStatus(), ExamStatus.COMPLETED)); + + } + + @DisplayName("Session이 종료된 상태에서 세션을 종료하려고 하면 예외가 발생한다.") + @Test + void terminateDefenseWhenSessionTerminated() { + // given + LocalDateTime today = LocalDateTime.of(2021, 10, 1, 0, 0); + Member member = createMember(); + DailyRecord dailyRecord = tryDailyDefense(today, member); + DefenseSession session = DefenseSession.builder() + .recordId(dailyRecord.getRecordId()) + .problemNumbers(Set.of(2L)) + .startDateTime(today) + .endDateTime(today.plusMinutes(1)) + .build(); + + final DefenseSession defenseSession = defenseSessionRepository.save(session); + defenseSession.terminateSession(); + + // when & then + assertThatThrownBy(() -> sessionService.terminateDefense(defenseSession.getDefenseSessionId())) + .isInstanceOf(MorandiException.class) + .hasMessageContaining("이미 종료된 세션입니다."); + } + + @DisplayName("없는 Session ID로 세션을 종료하려고 하면 예외가 발생한다.") + @Test + void terminateDefenseWhenSessionNotFound() { + // given + Long notFoundSessionId = 1L; + + + // when & then + assertThatThrownBy(() -> sessionService.terminateDefense(notFoundSessionId)) + .isInstanceOf(MorandiException.class) + .hasMessageContaining("세션을 찾을 수 없습니다."); + } + + @DisplayName("Session이 종료된 상태에서 세션을 종료하려고 하면 예외가 발생한다.") + @Test + void terminateDefenseWhenRecordTerminated() { + // given + LocalDateTime today = LocalDateTime.of(2021, 10, 1, 0, 0); + Member member = createMember(); + DailyRecord dailyRecord = tryDailyDefense(today, member); + DefenseSession session = DefenseSession.builder() + .recordId(dailyRecord.getRecordId()) + .problemNumbers(Set.of(2L)) + .startDateTime(today) + .endDateTime(today.plusMinutes(1)) + .build(); + dailyRecord.terminteDefense(); + + final DefenseSession defenseSession = defenseSessionRepository.save(session); + + + + // when & then + assertThatThrownBy(() -> sessionService.terminateDefense(defenseSession.getDefenseSessionId())) + .isInstanceOf(MorandiException.class) + .hasMessageContaining("이미 종료된 시험 기록입니다."); + + } + + private DailyRecord tryDailyDefense(LocalDateTime today, Member member) { + final DailyDefense dailyDefense = createDailyDefense(today.toLocalDate()); + final Map problem = getProblem(dailyDefense, 2L); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(today) + .defense(dailyDefense) + .member(member) + .problems(problem) + .build(); + + final DailyRecord savedDailyRecord = dailyRecordRepository.save(dailyRecord); + + final DailyDetail dailyDetail = DailyDetail.builder() + .member(member) + .problemNumber(1L) + .problem(problem.get(1L)) + .records(savedDailyRecord) + .defense(dailyDefense) + .build(); + + savedDailyRecord.getDetails().add(dailyDetail); + + return dailyRecordRepository.save(savedDailyRecord); + } + + private Map getProblem(DailyDefense dailyDefense, Long problemNumber) { + return dailyDefense.getDailyDefenseProblems().stream() + .filter(problem -> Objects.equals(problem.getProblemNumber(), problemNumber)) + .collect(Collectors.toMap(DailyDefenseProblem::getProblemNumber, DailyDefenseProblem::getProblem)); + } + + private DailyDefense createDailyDefense(LocalDate createdDate) { + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + + return dailyDefenseRepository.save(DailyDefense.create(createdDate, "오늘의 문제 테스트", problemMap)); + } + private List createProblems() { + Problem problem1 = Problem.builder() + .baekjoonProblemId(1L) + .problemTier(B5) + .build(); + + Problem problem2 = Problem.builder() + .baekjoonProblemId(2L) + .problemTier(S5) + .build(); + + Problem problem3 = Problem.builder() + .baekjoonProblemId(3L) + .problemTier(G5) + .build(); + + return problemRepository.saveAll(List.of(problem1, problem2, problem3)); + } + private Member createMember() { + return memberRepository.save(Member.builder() + .email("test") + .build()); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/domain/service/TempCodeSaveServiceTest.java b/src/test/java/kr/co/morandi/backend/defense_management/domain/service/TempCodeSaveServiceTest.java new file mode 100644 index 00000000..b8b66c16 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/domain/service/TempCodeSaveServiceTest.java @@ -0,0 +1,7 @@ +package kr.co.morandi.backend.defense_management.domain.service; + +class TempCodeSaveServiceTest { + + + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/infrastructure/adapter/session/DefenseSessionAdapterTest.java b/src/test/java/kr/co/morandi/backend/defense_management/infrastructure/adapter/session/DefenseSessionAdapterTest.java new file mode 100644 index 00000000..e0682886 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/infrastructure/adapter/session/DefenseSessionAdapterTest.java @@ -0,0 +1,73 @@ +package kr.co.morandi.backend.defense_management.infrastructure.adapter.session; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_management.application.port.out.session.DefenseSessionPort; +import kr.co.morandi.backend.defense_management.domain.model.session.DefenseSession; +import kr.co.morandi.backend.defense_management.infrastructure.persistence.session.DefenseSessionRepository; +import kr.co.morandi.backend.defense_management.infrastructure.persistence.session.SessionDetailRepository; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.domain.model.member.SocialType; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.Set; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType.DAILY; +import static org.assertj.core.api.Assertions.assertThat; + +class DefenseSessionAdapterTest extends IntegrationTestSupport { + + @Autowired + private DefenseSessionPort defenseSessionPort; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private DefenseSessionRepository defenseSessionRepository; + + @Autowired + private SessionDetailRepository sessionDetailRepository; + @AfterEach + void tearDown() { + sessionDetailRepository.deleteAll(); + defenseSessionRepository.deleteAllInBatch(); + memberRepository.deleteAll(); + } + + @DisplayName("DailyDefense 세션을 조회할 수 있다.") + @Test + void findDailyDefenseSession() { + // given + Long recordId = 1L; + Member member = createMember(); + + Set problemNumbers = Set.of(2L); + LocalDateTime startDateTime = LocalDateTime.of(2021, 10, 1, 12, 0); + LocalDateTime endDateTime = LocalDateTime.of(2021, 10, 1, 23, 59); + + final DefenseSession session = DefenseSession.startSession(member, recordId, DAILY, problemNumbers, startDateTime, endDateTime); + defenseSessionPort.saveDefenseSession(session); + + // when + final Optional dailyDefenseSession = defenseSessionPort.findTodaysDailyDefenseSession(member, startDateTime); + + // then + assertThat(dailyDefenseSession).isPresent() + .get() + .extracting("lastAccessDateTime", "lastAccessProblemNumber", "endDateTime", "defenseType") + .containsExactly(startDateTime, 2L, endDateTime, DAILY); + + } + + + private Member createMember() { + return memberRepository.save(Member.create("test", "test" + "@gmail.com", SocialType.GOOGLE, "test", "test")); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/infrastructure/controller/DefenseMangementControllerTest.java b/src/test/java/kr/co/morandi/backend/defense_management/infrastructure/controller/DefenseMangementControllerTest.java new file mode 100644 index 00000000..3b92d9b8 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/infrastructure/controller/DefenseMangementControllerTest.java @@ -0,0 +1,10 @@ +package kr.co.morandi.backend.defense_management.infrastructure.controller; + +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.junit.jupiter.api.Assertions.*; + +class DefenseMangementControllerTest { + + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/infrastructure/controller/ExampleCodeSubmitControllerTest.java b/src/test/java/kr/co/morandi/backend/defense_management/infrastructure/controller/ExampleCodeSubmitControllerTest.java new file mode 100644 index 00000000..6261a290 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/infrastructure/controller/ExampleCodeSubmitControllerTest.java @@ -0,0 +1,33 @@ +package kr.co.morandi.backend.defense_management.infrastructure.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.morandi.backend.ControllerTestSupport; +import kr.co.morandi.backend.defense_management.infrastructure.request.codesubmit.CodeRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.ResultActions; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ExampleCodeSubmitControllerTest extends ControllerTestSupport { + + @Autowired + private ObjectMapper objectMapper; + + @DisplayName("소스코드를 제출했을 때 정상적인 요청이라면 200 OK 실행이 된다.") + @Test + public void testSubmitCodeRequest() throws Exception { + // given + CodeRequest codeRequest = CodeRequest.create("Hello world", "Python", "", "123"); + + // when & then + ResultActions perform = mockMvc.perform(post("/submit/example") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(codeRequest))); + + perform.andExpect(status().isOk()); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/infrastructure/controller/SessionConnectionControllerTest.java b/src/test/java/kr/co/morandi/backend/defense_management/infrastructure/controller/SessionConnectionControllerTest.java new file mode 100644 index 00000000..5ae84942 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/infrastructure/controller/SessionConnectionControllerTest.java @@ -0,0 +1,34 @@ +package kr.co.morandi.backend.defense_management.infrastructure.controller; + +import kr.co.morandi.backend.ControllerTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import static org.mockito.ArgumentMatchers.anyLong; +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.status; + +class SessionConnectionControllerTest extends ControllerTestSupport { + + @DisplayName("[GET] 메세지를 받기 위한 SSE를 연결한다.") + @WithMockUser + @Test + void connectSession() throws Exception { + Long 세션_아이디 = 1L; + + SseEmitter expectedEmitter = new SseEmitter(); + expectedEmitter.send("test"); + + when(defenseMessageService.getConnection(anyLong(), anyLong())) + .thenReturn(expectedEmitter); + + mockMvc.perform(get("/session/{sessionId}/connect", 세션_아이디) + .accept(MediaType.TEXT_EVENT_STREAM)) + .andExpect(status().isOk()); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_management/infrastructure/persistence/session/DefenseSessionRepositoryTest.java b/src/test/java/kr/co/morandi/backend/defense_management/infrastructure/persistence/session/DefenseSessionRepositoryTest.java new file mode 100644 index 00000000..8539dab1 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_management/infrastructure/persistence/session/DefenseSessionRepositoryTest.java @@ -0,0 +1,97 @@ +package kr.co.morandi.backend.defense_management.infrastructure.persistence.session; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseRepository; +import kr.co.morandi.backend.defense_management.domain.model.session.DefenseSession; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.defense_record.infrastructure.persistence.dailydefense_record.DailyRecordRepository; +import kr.co.morandi.backend.factory.TestDefenseFactory; +import kr.co.morandi.backend.factory.TestMemberFactory; +import kr.co.morandi.backend.factory.TestProblemFactory; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language.JAVA; +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +class DefenseSessionRepositoryTest extends IntegrationTestSupport { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private DailyDefenseRepository dailyDefenseRepository; + + @Autowired + private DailyRecordRepository dailyRecordRepository; + + @Autowired + private DefenseSessionRepository defenseSessionRepository; + + @DisplayName("디펜스 세션을 FetchJoin을 통해 TempCode까지 한 번에 조회할 수 있다.") + @Test + void findDefenseSessionJoinFetch() { + // given + Member 사용자 = TestMemberFactory.createMember(); + memberRepository.save(사용자); + + Map 문제 = TestProblemFactory.createProblems(5); + problemRepository.saveAll(문제.values()); + + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + dailyDefenseRepository.save(오늘의_문제); + + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord 오늘의_문제_기록 = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + dailyRecordRepository.save(오늘의_문제_기록); + + DefenseSession 디펜스_세션 = DefenseSession.builder() + .member(사용자) + .defenseType(오늘의_문제.getDefenseType()) + .problemNumbers(Set.of(1L)) + .recordId(오늘의_문제_기록.getRecordId()) + .startDateTime(LocalDateTime.of(2021, 1, 1, 0, 0)) + .endDateTime(LocalDateTime.of(2021, 1, 1, 1, 0)) + .build(); + + 디펜스_세션.updateTempCode(1L, JAVA, "exampleCode"); + + defenseSessionRepository.save(디펜스_세션); + + // when + Optional defenseSession = defenseSessionRepository.findDefenseSessionJoinFetchTempCode(디펜스_세션.getDefenseSessionId()); + + // then + assertThat(defenseSession).isPresent() + .get() + .extracting("defenseSessionId", "defenseType") + .contains(디펜스_세션.getDefenseSessionId(), 오늘의_문제.getDefenseType()); + assertThat(defenseSession.get().getSessionDetail(1L).getTempCode(JAVA)) + .extracting("language", "code") + .contains(JAVA, "exampleCode"); + + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_record/application/usecase/DailyRecordRankUseCaseTest.java b/src/test/java/kr/co/morandi/backend/defense_record/application/usecase/DailyRecordRankUseCaseTest.java new file mode 100644 index 00000000..cd0a0e8a --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_record/application/usecase/DailyRecordRankUseCaseTest.java @@ -0,0 +1,145 @@ +package kr.co.morandi.backend.defense_record.application.usecase; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseRepository; +import kr.co.morandi.backend.defense_record.application.dto.DailyDefenseRankPageResponse; +import kr.co.morandi.backend.defense_record.application.port.in.DailyRecordRankUseCase; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.defense_record.infrastructure.persistence.dailydefense_record.DailyRecordRepository; +import kr.co.morandi.backend.factory.TestBaekjoonSubmitFactory; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.submit.BaekjoonSubmit; +import kr.co.morandi.backend.judgement.infrastructure.persistence.submit.BaekjoonSubmitRepository; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; + +@Transactional +class DailyRecordRankUseCaseTest extends IntegrationTestSupport { + + @Autowired + private DailyRecordRankUseCase dailyRecordRankUseCase; + + @Autowired + private DailyRecordRepository dailyRecordRepository; + + @Autowired + private DailyDefenseRepository dailyDefenseRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private BaekjoonSubmitRepository baekjoonSubmitRepository; + + @DisplayName("특정 시점 DailyRecord의 순위를 조회할 수 있다.") + @Test + void getDailyRecordsRankByDate() { + // given + LocalDate today = LocalDate.of(2021, 10, 1); + final DailyDefense dailyDefense = createDailyDefense(today); + + final Member member1 = createMember("userA", "userA"); + final Member member2 = createMember("userB", "userB"); + final Member member3 = createMember("userC", "userC"); + final Member member4= createMember("userD", "userD"); + final Member member5 = createMember("userE", "userE"); + + final DailyRecord dailyRecord1 = DailyRecord.tryDefense(LocalDateTime.of(2021, 10, 1, 0, 0), dailyDefense, member1, getProblem(dailyDefense, 1L)); + final DailyRecord dailyRecord2 = DailyRecord.tryDefense(LocalDateTime.of(2021, 10, 1, 0, 0), dailyDefense, member2, getProblem(dailyDefense, 2L)); + final DailyRecord dailyRecord3 = DailyRecord.tryDefense(LocalDateTime.of(2021, 10, 1, 0, 0), dailyDefense, member3, getProblem(dailyDefense, 3L)); + final DailyRecord dailyRecord4 = DailyRecord.tryDefense(LocalDateTime.of(2021, 10, 1, 0, 0), dailyDefense, member4, getProblem(dailyDefense, 3L)); + final DailyRecord dailyRecord5 = DailyRecord.tryDefense(LocalDateTime.of(2021, 10, 1, 0, 0), dailyDefense, member5, getProblem(dailyDefense, 3L)); + + /* + * member1: 한 문제 해결 일찍 + * member2 : 두 문제 해결 + * member3: 한 문제 해결 1보다 늦게 + * + * -> 등수 = 2 -> 1 -> 3 + * */ + BaekjoonSubmit 제출1 = TestBaekjoonSubmitFactory.createSubmit(member1, dailyRecord1.getDetail(1L), LocalDateTime.of(2021, 10, 1, 0, 15)); + final BaekjoonSubmit 저장된_제출1 = baekjoonSubmitRepository.save(제출1); + 저장된_제출1.trySolveProblem(); + + BaekjoonSubmit 제출2 = TestBaekjoonSubmitFactory.createSubmit(member2, dailyRecord2.getDetail(2L), LocalDateTime.of(2021, 10, 1, 0, 30)); + final BaekjoonSubmit 저장된_제출2 = baekjoonSubmitRepository.save(제출2); + 저장된_제출2.trySolveProblem(); + + dailyRecord2.tryMoreProblem(getProblem(dailyDefense, 3L)); + + BaekjoonSubmit 제출3 = TestBaekjoonSubmitFactory.createSubmit(member2, dailyRecord2.getDetail(3L), LocalDateTime.of(2021, 10, 1, 0, 45)); + final BaekjoonSubmit 저장된_제출3 = baekjoonSubmitRepository.save(제출3); + 저장된_제출3.trySolveProblem(); + + BaekjoonSubmit 제출4 = TestBaekjoonSubmitFactory.createSubmit(member3, dailyRecord3.getDetail(3L), LocalDateTime.of(2021, 10, 1, 1, 0)); + final BaekjoonSubmit 저장된_제출4 = baekjoonSubmitRepository.save(제출4); + 저장된_제출4.trySolveProblem(); + + dailyRecordRepository.saveAll(List.of(dailyRecord1, dailyRecord2, dailyRecord3, dailyRecord4, dailyRecord5)); + + + // when + LocalDateTime requestTime = LocalDateTime.of(2021, 10, 1, 2, 0); + final DailyDefenseRankPageResponse dailyRecordRank = dailyRecordRankUseCase.getDailyRecordRank(requestTime, 0, 5); + + // then + assertThat(dailyRecordRank.getDailyRecords()).hasSize(5) + .extracting("rank", "nickname", "solvedCount", "totalSolvedTime") + .containsExactly( + tuple(1L, "userB", 2L, "01:15:00"), + tuple(2L, "userA", 1L, "00:15:00"), + tuple(3L, "userC", 1L, "01:00:00"), + //TODO 동점자 처리 로직 반영 X + tuple(4L, "userD", 0L, "00:00:00"), + tuple(5L, "userE", 0L, "00:00:00") + + ); + } + + private Map getProblem(DailyDefense dailyDefense, Long problemNumber) { + return dailyDefense.getDailyDefenseProblems().stream() + .filter(problem -> Objects.equals(problem.getProblemNumber(), problemNumber)) + .collect(Collectors.toMap(DailyDefenseProblem::getProblemNumber, DailyDefenseProblem::getProblem)); + } + + private DailyDefense createDailyDefense(LocalDate createdDate) { + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + return dailyDefenseRepository.save(DailyDefense.create(createdDate, "오늘의 문제 테스트", problemMap)); + } + private List createProblems() { + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + Problem problem3 = Problem.create(3L, G5, 0L); + + return problemRepository.saveAll(List.of(problem1, problem2, problem3)); + } + private Member createMember(String nickname, String email) { + return memberRepository.save(Member.create(nickname, email + "@gmail.com", GOOGLE, "test", "test")); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_record/application/util/TimeFormatHelperTest.java b/src/test/java/kr/co/morandi/backend/defense_record/application/util/TimeFormatHelperTest.java new file mode 100644 index 00000000..a76c8f74 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_record/application/util/TimeFormatHelperTest.java @@ -0,0 +1,27 @@ +package kr.co.morandi.backend.defense_record.application.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles("test") +class TimeFormatHelperTest { + + @DisplayName("시간을 문자열로 변환한다") + @Test + void solvedTimeToString() { + // given + // 1시간 15분 37초를 초로 변환 + Long time = 3600L + (15L * 60L) + 37L; + + // when + String result = TimeFormatHelper.solvedTimeToString(time); + + // then + assertThat(result).isEqualTo("01:15:37"); + + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_record/domain/model/customdefense_record/CustomRecordTest.java b/src/test/java/kr/co/morandi/backend/defense_record/domain/model/customdefense_record/CustomRecordTest.java new file mode 100644 index 00000000..7ff54fe2 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_record/domain/model/customdefense_record/CustomRecordTest.java @@ -0,0 +1,196 @@ +package kr.co.morandi.backend.defense_record.domain.model.customdefense_record; + +import kr.co.morandi.backend.defense_information.domain.model.customdefense.CustomDefense; +import kr.co.morandi.backend.defense_information.domain.model.customdefense.CustomDefenseProblem; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.defense_record.domain.model.customdefense_record.CustomRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseTier.GOLD; +import static kr.co.morandi.backend.defense_information.domain.model.customdefense.Visibility.OPEN; +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.B5; +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.S5; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles("test") +class CustomRecordTest { + @DisplayName("커스텀 디펜스 기록이 만들어 졌을 때 시험 날짜는 시작한 시점과 같아야 한다.") + @Test + void testDateIsEqualNow() { + // given + CustomDefense customDefense = createCustomDefense(); + Map problems = getCustomDefenseProblems(customDefense); + + Member member = Member.create("user", "user@gmail.com", GOOGLE, "user", "user"); + LocalDateTime startTime = LocalDateTime.of(2024, 2, 26, 0, 0, 0, 0); + + // when + CustomRecord customDefenseRecord = CustomRecord.create(customDefense, member, startTime, problems); + + // then + assertThat(customDefenseRecord.getTestDate()).isEqualTo(startTime); + } + @DisplayName("커스텀 디펜스 기록이 만들어졌을 때 맞춘 문제 수는 0으로 설정되어야 한다.") + @Test + void solvedCountIsZero() { + // given + CustomDefense customDefense = createCustomDefense(); + Map problems = getCustomDefenseProblems(customDefense); + + Member member = Member.create("user", "user@gmail.com", GOOGLE, "user", "user"); + LocalDateTime startTime = LocalDateTime.of(2024, 2, 26, 0, 0, 0, 0); + + // when + CustomRecord customDefenseRecord = CustomRecord.create(customDefense, member, startTime, problems); + + // then + assertThat(customDefenseRecord.getTotalSolvedCount()).isZero(); + } + @DisplayName("커스텀 디펜스 기록이 만들어졌을 때 총 문제 수 기록은 커스텀 디펜스 문제 수와 같아야 한다.") + @Test + void problemCountIsEqual() { + // given + CustomDefense customDefense = createCustomDefense(); + Map problems = getCustomDefenseProblems(customDefense); + + Member member = Member.create("user", "user@gmail.com", GOOGLE, "user", "user"); + LocalDateTime startTime = LocalDateTime.of(2024, 2, 26, 0, 0, 0, 0); + + // when + CustomRecord customDefenseRecord = CustomRecord.create(customDefense, member, startTime, problems); + + // then + assertThat(customDefenseRecord.getProblemCount()).isEqualTo(customDefense.getProblemCount()); + } + @DisplayName("커스텀 디펜스 기록이 만들어졌을 때 초기 전체 소요 시간은 0분 이어야 한다.") + @Test + void totalSolvedTimeIsZero() { + // given + CustomDefense customDefense = createCustomDefense(); + Map problems = getCustomDefenseProblems(customDefense); + + Member member = Member.create("user", "user@gmail.com", GOOGLE, "user", "user"); + LocalDateTime startTime = LocalDateTime.of(2024, 2, 26, 0, 0, 0, 0); + + // when + CustomRecord customDefenseRecord = CustomRecord.create(customDefense, member, startTime, problems); + + // then + assertThat(customDefenseRecord.getTotalSolvedTime()).isZero(); + } + @DisplayName("커스텀 디펜스 기록이 만들어졌을 때 세부 문제 기록의 정답 여부는 모두 오답이어야 한다.") + @Test + void isSolvedFalse() { + // given + CustomDefense customDefense = createCustomDefense(); + Map problems = getCustomDefenseProblems(customDefense); + + Member member = createMember("user"); + LocalDateTime startTime = LocalDateTime.of(2024, 2, 26, 0, 0, 0, 0); + + // when + CustomRecord customDefenseRecord = CustomRecord.create(customDefense, member, startTime, problems); + + // then + assertThat(customDefenseRecord.getDetails()) + .extracting("isSolved") + .containsExactlyInAnyOrder( + false, + false + ); + } + + @DisplayName("커스텀 디펜스 기록이 만들어졌을 때 세부 문제 기록의 문제 기록의 소요 시간은 0분이어야 한다.") + @Test + void solvedTimeIsZero() { + // given + CustomDefense customDefense = createCustomDefense(); + Map problems = getCustomDefenseProblems(customDefense); + Member member = createMember("user"); + LocalDateTime startTime = LocalDateTime.of(2024, 2, 26, 0, 0, 0, 0); + + // when + CustomRecord customDefenseRecord = CustomRecord.create(customDefense, member, startTime, problems); + + // then + assertThat(customDefenseRecord.getDetails()) + .extracting("solvedTime") + .containsExactlyInAnyOrder( + 0L, + 0L + ); + } + + @DisplayName("커스텀 디펜스 기록이 만들어졌을 때 세부 문제 기록의 제출 횟수는 0회 이어야 한다.") + @Test + void submitCountIsZero() { + // given + CustomDefense customDefense = createCustomDefense(); + Map problems = getCustomDefenseProblems(customDefense); + Member member = createMember("user"); + LocalDateTime startTime = LocalDateTime.of(2024, 2, 26, 0, 0, 0, 0); + + // when + CustomRecord customDefenseRecord = CustomRecord.create(customDefense, member, startTime, problems); + + // when & then + assertThat(customDefenseRecord.getDetails()) + .extracting("submitCount") + .containsExactlyInAnyOrder( + 0L, + 0L + ); + } + + @DisplayName("커스텀 디펜스 기록이 만들어졌을 때 세부 문제 기록의 정답 제출 id는 null 값 이어야 한다.") + @Test + void solvedCodeIsNull() { + // given + CustomDefense customDefense = createCustomDefense(); + Map problems = getCustomDefenseProblems(customDefense); + Member member = createMember("user"); + LocalDateTime startTime = LocalDateTime.of(2024, 2, 26, 0, 0, 0, 0); + + // when + CustomRecord customDefenseRecord = CustomRecord.create(customDefense, member, startTime, problems); + + // when & then + assertThat(customDefenseRecord.getDetails()) + .extracting("correctSubmitId") + .containsExactlyInAnyOrder( + null, + null + ); + + } + + private CustomDefense createCustomDefense() { + Member member = createMember("author"); + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + List problems = List.of(problem1, problem2); + + LocalDateTime now = LocalDateTime.of(2024, 2, 26, 0, 0, 0, 0); + + return CustomDefense.create(problems, member, "custom_defense", + "custom_defense", OPEN, GOLD, 60L, now); + } + private Map getCustomDefenseProblems(CustomDefense customDefense) { + List customDefenseProblems = customDefense.getCustomDefenseProblems(); + + return customDefenseProblems.stream() + .collect(Collectors.toMap(CustomDefenseProblem::getProblemNumber, CustomDefenseProblem::getProblem)); + } + private Member createMember(String name) { + return Member.create(name, name + "@gmail.com", GOOGLE, name, name); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_record/domain/model/dailydefense_record/DailyRecordTest.java b/src/test/java/kr/co/morandi/backend/defense_record/domain/model/dailydefense_record/DailyRecordTest.java new file mode 100644 index 00000000..ec0ae0f5 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_record/domain/model/dailydefense_record/DailyRecordTest.java @@ -0,0 +1,328 @@ +package kr.co.morandi.backend.defense_record.domain.model.dailydefense_record; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import kr.co.morandi.backend.factory.TestBaekjoonSubmitFactory; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.submit.BaekjoonSubmit; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@ActiveProfiles("test") +class DailyRecordTest { + + @DisplayName("시험 기록(Record)를 종료하면 종료 상태로 변경된다.") + @Test + void terminateDefense() { + // given + DailyDefense dailyDefense = createDailyDefense(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + Member member = createMember("user"); + Map triedProblem = getProblems(dailyDefense, 2L); + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, triedProblem); + + + // when + final boolean result = dailyRecord.terminteDefense(); + + // then + assertThat(result).isTrue(); + + } + + @DisplayName("시험 기록(Record)가 종료된 상태에서 다시 종료하려고 하면 false를 반환한다.") + @Test + void terminateDefenseWhenAlreadyTerminated() { + // given + DailyDefense dailyDefense = createDailyDefense(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + Member member = createMember("user"); + Map triedProblem = getProblems(dailyDefense, 2L); + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, triedProblem); + dailyRecord.terminteDefense(); + + // when & then + assertThatThrownBy(() -> dailyRecord.terminteDefense()) + .isInstanceOf(MorandiException.class); + } + @DisplayName("오늘의 문제를 정답처리 하면 푼 total 문제 수가 증가하고, 푼 시간이 기록된다.") + @Test + void solveProblem() { + // given + DailyDefense dailyDefense = createDailyDefense(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + Member member = createMember("user"); + Map triedProblem = getProblems(dailyDefense, 2L); + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, triedProblem); + + // when + BaekjoonSubmit 제출 = TestBaekjoonSubmitFactory.createSubmit(member, dailyRecord.getDetail(2L), LocalDateTime.of(2024, 3, 1, 12, 15, 0)); + 제출.trySolveProblem(); + + // then + assertThat(dailyRecord) + .extracting("totalSolvedTime", "totalSolvedCount") + .contains( + 15 * 60L, 1L + ); + assertThat(dailyRecord.getDetails()).hasSize(1) + .extracting("isSolved", "solvedTime") + .contains( + tuple(true, 15 * 60L) + ); + } + @DisplayName("이미 정답처리된 문제를 정답 solved하려하면 바뀌지 않는다.") + @Test + void solveProblemWhenAlreadySolved() { + // given + DailyDefense dailyDefense = createDailyDefense(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + Member member = createMember("user"); + Map triedProblem = getProblems(dailyDefense, 2L); + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, triedProblem); + + BaekjoonSubmit 제출 = TestBaekjoonSubmitFactory.createSubmit(member, dailyRecord.getDetail(2L), LocalDateTime.of(2024, 3, 1, 12, 15, 0)); + 제출.trySolveProblem(); + + // when + BaekjoonSubmit 제출2 = TestBaekjoonSubmitFactory.createSubmit(member, dailyRecord.getDetail(2L), LocalDateTime.of(2024, 3, 1, 12, 20, 0)); + 제출2.trySolveProblem(); + + + // then + assertThat(dailyRecord) + .extracting("totalSolvedTime", "totalSolvedCount") + .contains( + 15 * 60L, 1L + ); + assertThat(dailyRecord.getDetails()).hasSize(1) + .extracting("isSolved", "solvedTime") + .contains( + tuple(true, 15 * 60L) + ); + } + @DisplayName("풀어낸 문제들에 대한 문제번호 목록을 반환할 수 있다.") + @Test + void getSolvedProblemNumbers() { + // given + DailyDefense dailyDefense = createDailyDefense(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + Member member = createMember("user"); + Map triedProblem = getProblems(dailyDefense, 2L); + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, triedProblem); + dailyRecord.tryMoreProblem(getProblems(dailyDefense, 3L)); + + BaekjoonSubmit 제출 = TestBaekjoonSubmitFactory.createSubmit(member, dailyRecord.getDetail(2L), LocalDateTime.of(2024, 3, 1, 12, 15, 0)); + 제출.trySolveProblem(); + + // when + final Set solvedProblemNumbers = dailyRecord.getSolvedProblemNumbers(); + + + // then + assertThat(solvedProblemNumbers).hasSize(1) + .contains(2L); + + } + + @DisplayName("시험에 응시하면 오늘의 문제 attemptCount가 1 증가한다.") + @Test + void increaseAttempCountWhenTryDefense() { + // given + DailyDefense dailyDefense = createDailyDefense(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + Member member = createMember("user"); + Map triedProblem = getProblems(dailyDefense, 2L); + + // when + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, triedProblem); + + // then + assertThat(dailyDefense.getAttemptCount()).isEqualTo(1L); + + } + + @DisplayName("오늘의 문제 기록에서 세부 문제의 정답 여부를 확인할 수 있다.") + @Test + void isSolvedProblem() { + // given + DailyDefense dailyDefense = createDailyDefense(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + Member member = createMember("user"); + Map triedProblem = getProblems(dailyDefense, 2L); + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, triedProblem); + + // when + final boolean solvedProblem = dailyRecord.isSolvedProblem(2L); + + // then + assertThat(solvedProblem).isFalse(); + + } + @DisplayName("오늘의 문제 기록이 이미 있을 때, 같은 문제를 다시 시도하면 기존 문제 기록을 반환한다.") + @Test + void tryExistDetailThenReturnExistDetail() { + // given + DailyDefense dailyDefense = createDailyDefense(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + Member member = createMember("user"); + Map triedProblem = getProblems(dailyDefense, 2L); + DailyRecord dailyRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, triedProblem); + + // when + dailyRecord.tryMoreProblem(triedProblem); + + // then + assertThat(dailyRecord.getDetails()).hasSize(1) + .extracting("problem") + .contains(triedProblem.get(2L)); + } + + @DisplayName("오늘의 문제 기록이 만들어졌을 때 푼 문제 수는 0문제 이어야 한다.") + @Test + void solvedCountIsZero() { + // given + DailyDefense dailyDefense = createDailyDefense(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + Member member = createMember("user"); + Map problems = getProblems(dailyDefense, 2L); + + // when + DailyRecord dailyDefenseRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, problems); + + // then + assertThat(dailyDefenseRecord.getTotalSolvedCount()).isZero(); + } + + @DisplayName("오늘의 문제 기록이 만들어진 시점이 문제가 출제된 시점에서 하루 이상 넘어가면 예외가 발생한다.") + @Test + void recordCreateExceptionWhenOverOneDay() { + // given + LocalDate createdDate = LocalDate.of(2024, 3, 1); + DailyDefense DailyDefense = createDailyDefense(createdDate); + + Member member = createMember("user"); + Map problems = getProblems(DailyDefense, 2L); + + LocalDateTime startTime = LocalDateTime.of(2024, 3, 2, 0, 0, 0); + + // when & then + assertThatThrownBy(() -> DailyRecord.tryDefense(startTime, DailyDefense, member, problems)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("오늘의 문제 기록은 출제 날짜와 같은 날에 생성되어야 합니다."); + } + @DisplayName("오늘의 문제 기록이 만들어진 시점이 문제가 출제된 시점에서 하루 이상 넘어가지 않으면 정상적으로 등록된다.") + @Test + void recordCreatedWithinOneDay() { + // given + LocalDate createdDate = LocalDate.of(2024, 3, 1); + DailyDefense dailyDefense = createDailyDefense(createdDate); + + Member member = createMember("user"); + Map problems = getProblems(dailyDefense, 2L); + + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 23, 59, 59); + + // when + DailyRecord dailyDefenseRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, problems); + + // then + assertNotNull(dailyDefenseRecord); + } + @DisplayName("오늘의 문제 테스트 기록이 만들어졌을 때 세부 문제들의 정답 여부는 모두 오답 상태여야 한다.") + @Test + void isSolvedIsFalse() { + // given + DailyDefense dailyDefense = createDailyDefense(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + Member member = createMember("user"); + Map problems = getProblems(dailyDefense, 2L); + + // when + DailyRecord dailyDefenseRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, problems); + + // then + assertThat(dailyDefenseRecord.getDetails()) + .extracting("isSolved") + .contains(false); + } + @DisplayName("오늘의 문제 테스트 기록이 만들어졌을 때 세부 문제들의 제출 횟수는 모두 0회여야 한다.") + @Test + void submitCountIsZero() { + // given + DailyDefense dailyDefense = createDailyDefense(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + Member member = createMember("user"); + Map problems = getProblems(dailyDefense, 2L); + + // when + DailyRecord dailyDefenseRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, problems); + + // then + assertThat(dailyDefenseRecord.getDetails()) + .extracting("submitCount") + .containsExactlyInAnyOrder(0L); + } + @DisplayName("오늘의 문제 테스트 기록이 만들어졌을 때 세부 문제들의 정답 제출은 null 이어야 한다.") + @Test + void solvedCodeIsNull() { + // given + DailyDefense dailyDefense = createDailyDefense(); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + Member member = createMember("user"); + Map problems = getProblems(dailyDefense,2L); + + // when + DailyRecord dailyDefenseRecord = DailyRecord.tryDefense(startTime, dailyDefense, member, problems); + + // then + assertThat(dailyDefenseRecord.getDetails()) + .extracting("correctSubmitId") + .contains((String)null); + } + private Map getProblems(DailyDefense DailyDefense, Long problemNumber) { + return DailyDefense.getDailyDefenseProblems().stream() + .filter(p -> p.getProblemNumber().equals(problemNumber)) + .collect(Collectors.toMap(DailyDefenseProblem::getProblemNumber, DailyDefenseProblem::getProblem)); + } + private DailyDefense createDailyDefense() { + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + LocalDate createdDate = LocalDate.of(2024, 3, 1); + return DailyDefense.create(createdDate, "오늘의 문제 테스트", problemMap); + } + private DailyDefense createDailyDefense(LocalDate createdDate) { + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + return DailyDefense.create(createdDate, "오늘의 문제 테스트", problemMap); + } + private List createProblems() { + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + Problem problem3 = Problem.create(3L, G5, 0L); + return List.of(problem1, problem2, problem3); + } + private Member createMember(String name) { + return Member.create(name, name + "@gmail.com", GOOGLE, name, name); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_record/domain/model/randomdefense_record/RandomRecordTest.java b/src/test/java/kr/co/morandi/backend/defense_record/domain/model/randomdefense_record/RandomRecordTest.java new file mode 100644 index 00000000..3a8c55ad --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_record/domain/model/randomdefense_record/RandomRecordTest.java @@ -0,0 +1,198 @@ +package kr.co.morandi.backend.defense_record.domain.model.randomdefense_record; + +import kr.co.morandi.backend.defense_information.domain.model.randomdefense.model.RandomDefense; +import kr.co.morandi.backend.defense_information.domain.model.defense.RandomCriteria; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.defense_record.domain.model.randomdefense_record.RandomRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.Map; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles("test") +class RandomRecordTest { + @DisplayName("랜덤 디펜스 기록이 만들어졌을 때 맞춘 문제수는 0문제 이어야 한다.") + @Test + void solvedCountIsZero() { + // given + RandomDefense randomDefense = createRandomDefense(); + Map problems = getProblemsByRandom(); + + Member member = createMember("user"); + LocalDateTime now = LocalDateTime.of(2024, 3, 1, 0,0,0); + + // when + RandomRecord randomDefenseRecord = RandomRecord.create(randomDefense, member, now, problems); + + // then + assertThat(randomDefenseRecord.getTotalSolvedCount()).isZero(); + } + @DisplayName("랜덤 디펜스 기록이 만들어졌을 때 총 문제 수는 랜덤 디펜스 문제 개수와 같아야 한다.") + @Test + void problemCountIsEqual() { + // given + RandomDefense randomDefense = createRandomDefense(); + Map problems = getProblemsByRandom(); + + Member member = createMember("user"); + LocalDateTime now = LocalDateTime.of(2024, 3, 1, 0,0,0); + + // when + RandomRecord randomDefenseRecord = RandomRecord.create(randomDefense, member, now, problems); + + // then + assertThat(randomDefenseRecord.getProblemCount()).isEqualTo(randomDefense.getProblemCount()); + } + + @DisplayName("랜덤 디펜스 기록이 만들어졌을 때 전체 소요 시간은 0분 이어야 한다.") + @Test + void totalSolvedTimeIsZero() { + // given + RandomDefense randomDefense = createRandomDefense(); + Map problems = getProblemsByRandom(); + + Member member = createMember("user"); + LocalDateTime now = LocalDateTime.of(2024, 3, 1, 0,0,0); + + // when + RandomRecord randomDefenseRecord = RandomRecord.create(randomDefense, member, now, problems); + + // then + assertThat(randomDefenseRecord.getTotalSolvedTime()).isZero(); + } + @DisplayName("랜덤 디펜스 기록이 만들어졌을 때 시점과 랜덤 디펜스 시험 날짜는 같아야 한다.") + @Test + void testDateIsEqualTestDate() { + // given + RandomDefense randomDefense = createRandomDefense(); + Map problems = getProblemsByRandom(); + + Member member = createMember("user"); + LocalDateTime now = LocalDateTime.of(2024, 3, 1, 0,0,0); + + // when + RandomRecord randomDefenseRecord = RandomRecord.create(randomDefense, member, now, problems); + + // then + assertThat(randomDefenseRecord.getTestDate()).isEqualTo(now); + } + @DisplayName("랜덤 디펜스 기록이 만들어졌을 때 세부 문제 기록의 정답 여부는 모두 오답이어야 한다.") + @Test + void isSolvedIsFalse() { + // given + RandomDefense randomDefense = createRandomDefense(); + Map problems = getProblemsByRandom(); + + Member member = createMember("user"); + LocalDateTime now = LocalDateTime.of(2024, 3, 1, 0,0,0); + + // when + RandomRecord randomDefenseRecord = RandomRecord.create(randomDefense, member, now, problems); + + // then + assertThat(randomDefenseRecord.getDetails()) + .extracting("isSolved") + .containsExactly( + false, + false, + false, + false + ); + } + @DisplayName("랜덤 디펜스 기록이 만들어졌을 때 세부 문제 기록의 제출 횟수는 모두 0회여야 한다.") + @Test + void submitCountIsZero() { + // given + RandomDefense randomDefense = createRandomDefense(); + Map problems = getProblemsByRandom(); + + Member member = createMember("user"); + LocalDateTime now = LocalDateTime.of(2024, 3, 1, 0,0,0); + + // when + RandomRecord randomDefenseRecord = RandomRecord.create(randomDefense, member, now, problems); + + // then + assertThat(randomDefenseRecord.getDetails()) + .extracting("submitCount") + .containsExactly( + 0L, + 0L, + 0L, + 0L + ); + } + @DisplayName("랜덤 디펜스 기록이 만들어졌을 때 세부 문제 기록의 정답 제출 id는 모두 null 이어야 한다.") + @Test + void solvedCodeIsNull() { + // given + RandomDefense randomDefense = createRandomDefense(); + Map problems = getProblemsByRandom(); + + Member member = createMember("user"); + LocalDateTime now = LocalDateTime.of(2024, 3, 1, 0,0,0); + + // when + RandomRecord randomDefenseRecord = RandomRecord.create(randomDefense, member, now, problems); + + // then + assertThat(randomDefenseRecord.getDetails()) + .extracting("correctSubmitId") + .containsExactly( + null, + null, + null, + null + ); + } + @DisplayName("랜덤 디펜스 기록이 만들어졌을 때 세부 문제 기록의 소요 시간은 모두 0분 이어야 한다.") + @Test + void solvedTimeIsZero() { + // given + RandomDefense randomDefense = createRandomDefense(); + Map problems = getProblemsByRandom(); + Member member = createMember("user"); + LocalDateTime now = LocalDateTime.of(2024, 3, 1, 0,0,0); + + // when + RandomRecord randomDefenseRecord = RandomRecord.create(randomDefense, member, now, problems); + + // then + assertThat(randomDefenseRecord.getDetails()) + .extracting("solvedTime") + .containsExactly( + 0L, + 0L, + 0L, + 0L + ); + } + private RandomDefense createRandomDefense() { + RandomCriteria.DifficultyRange bronzeRange = RandomCriteria.DifficultyRange.of(B5, B1); + RandomCriteria randomCriteria = RandomCriteria.of(bronzeRange, 100L, 200L); + return RandomDefense.create(randomCriteria, 4, 120L, "브론즈 랜덤 디펜스"); + } + private Member createMember(String name) { + return Member.create(name, name + "@gmail.com", GOOGLE, name, name); + } + private Map getProblemsByRandom() { + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + Problem problem3 = Problem.create(3L, G5, 0L); + Problem problem4 = Problem.create(4L, P5, 0L); + + return Map.of( + 1L, problem1, + 2L, problem2, + 3L, problem3, + 4L, problem4 + ); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_record/domain/model/record/RecordTest.java b/src/test/java/kr/co/morandi/backend/defense_record/domain/model/record/RecordTest.java new file mode 100644 index 00000000..ef879b54 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_record/domain/model/record/RecordTest.java @@ -0,0 +1,152 @@ +package kr.co.morandi.backend.defense_record.domain.model.record; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyDetail; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ActiveProfiles("test") +class RecordTest { + + @DisplayName("ProblemNumber로 Problem을 찾아올 수 있다.") + @Test + void getProblem() { + DailyDefense 오늘의_문제 = createDailyDefense(); + LocalDateTime 시험_시작_시간 = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + Member 시험_보려는_사용자 = createMember("user"); + + Long 문제_번호 = 1L; + Map problems = getProblems(오늘의_문제, 문제_번호); + + final DailyRecord dailyRecord = DailyRecord.tryDefense(시험_시작_시간, 오늘의_문제, 시험_보려는_사용자, problems); + + // when + final Problem problem = dailyRecord.getProblem(문제_번호); + + // then + assertThat(problem.getBaekjoonProblemId()) + .isEqualTo(문제_번호); + + } + + @DisplayName("Invalid ProblemNumber로 Problem을 찾으려고 하면 예외가 발생한다.") + @Test + void getProblemWithInvalidProblemNumber() { + // given + DailyDefense 오늘의_문제 = createDailyDefense(); + LocalDateTime 시험_시작_시간 = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + Member 시험_보려는_사용자 = createMember("user"); + Long 문제_번호 = 1L; + Map problems = getProblems(오늘의_문제, 문제_번호); + + final DailyRecord dailyRecord = DailyRecord.tryDefense(시험_시작_시간, 오늘의_문제, 시험_보려는_사용자, problems); + + Long 잘못된_문제_번호 = 2L; + + // when & then + assertThatThrownBy(() -> dailyRecord.getProblem(잘못된_문제_번호)) + .isInstanceOf(MorandiException.class) + .hasMessageContaining("해당 번호의 문제 풀이 기록을 찾을 수 없습니다."); + + } + @DisplayName("ProblemNumber로 Detail을 찾아올 수 있다.") + @Test + void getDetail() { + // given + DailyDefense 오늘의_문제 = createDailyDefense(); + LocalDateTime 시험_시작_시간 = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + Member 시험_보려는_사용자 = createMember("user"); + + Long 문제_번호 = 1L; + Map problems = getProblems(오늘의_문제, 문제_번호); + + final DailyRecord dailyRecord = DailyRecord.tryDefense(시험_시작_시간, 오늘의_문제, 시험_보려는_사용자, problems); + + // when + final DailyDetail detail = dailyRecord.getDetail(문제_번호); + + // then + assertThat(detail.getProblem().getBaekjoonProblemId()) + .isEqualTo(문제_번호); + + } + + @DisplayName("Invalid ProblemNumber로 Detail을 찾으려고 하면 예외가 발생한다.") + @Test + void getDetailWithInvalidProblemNumber() { + // given + DailyDefense 오늘의_문제 = createDailyDefense(); + LocalDateTime 시험_시작_시간 = LocalDateTime.of(2024, 3, 1, 12, 0, 0); + Member 시험_보려는_사용자 = createMember("user"); + Long 문제_번호 = 1L; + Map problems = getProblems(오늘의_문제, 문제_번호); + + final DailyRecord dailyRecord = DailyRecord.tryDefense(시험_시작_시간, 오늘의_문제, 시험_보려는_사용자, problems); + + Long 잘못된_문제_번호 = 2L; + + // when & then + assertThatThrownBy(() -> dailyRecord.getDetail(잘못된_문제_번호)) + .isInstanceOf(MorandiException.class) + .hasMessageContaining("해당 번호의 문제 풀이 기록을 찾을 수 없습니다."); + + } + + private Map getProblems(DailyDefense DailyDefense, Long problemNumber) { + return DailyDefense.getDailyDefenseProblems().stream() + .filter(p -> p.getProblemNumber().equals(problemNumber)) + .collect(Collectors.toMap(DailyDefenseProblem::getProblemNumber, DailyDefenseProblem::getProblem)); + } + private DailyDefense createDailyDefense() { + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p -> problemNumber.getAndIncrement(), problem -> problem)); + LocalDate createdDate = LocalDate.of(2024, 3, 1); + return DailyDefense.create(createdDate, "오늘의 문제 테스트", problemMap); + } + private List createProblems() { + Problem problem1 = Problem.builder() + .baekjoonProblemId(1L) + .problemTier(B5) + .solvedCount(0L) + .build(); + Problem problem2 = Problem.builder() + .baekjoonProblemId(2L) + .problemTier(S5) + .solvedCount(0L) + .build(); + Problem problem3 = Problem.builder() + .baekjoonProblemId(3L) + .problemTier(G5) + .solvedCount(0L) + .build(); + return List.of(problem1, problem2, problem3); + } + private Member createMember(String name) { + return Member.builder() + .email(name + "@gmail.com") + .socialType(GOOGLE) + .nickname(name) + .build(); + + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_record/domain/model/stagedefense_record/StageRecordTest.java b/src/test/java/kr/co/morandi/backend/defense_record/domain/model/stagedefense_record/StageRecordTest.java new file mode 100644 index 00000000..80bac8b5 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_record/domain/model/stagedefense_record/StageRecordTest.java @@ -0,0 +1,167 @@ +package kr.co.morandi.backend.defense_record.domain.model.stagedefense_record; + +import kr.co.morandi.backend.defense_information.domain.model.defense.RandomCriteria; +import kr.co.morandi.backend.defense_information.domain.model.stagedefense.model.StageDefense; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.defense_record.domain.model.stagedefense_record.StageRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.B1; +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.B5; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles("test") +class StageRecordTest { + @DisplayName("스테이지 기록이 만들어졌을 때 포함된 문제 수는 1개다.") + @Test + void stageCountIsOne() { + // given + StageDefense randomStageDefense = createRandomStageDefense(); + + Problem problem = Problem.create(1L, B5, 100L); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 0, 0, 0); + Member member = createMember("user"); + + // when + StageRecord stageDefenseRecord = StageRecord.create(randomStageDefense, startTime, member, problem); + + // then + assertThat(stageDefenseRecord.getStageCount()).isOne(); + } + @DisplayName("스테이지 기록이 만들어졌을 때 만들어진 첫번째 문제 기록의 스테이지 번호는 1번이어야 한다.") + @Test + void initialStageNumberIsSetToOne() { + // given + StageDefense randomStageDefense = createRandomStageDefense(); + + Problem problem = Problem.create(1L, B5, 100L); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 0, 0, 0); + Member member = createMember("user"); + + // when + StageRecord stageDefenseRecord = StageRecord.create(randomStageDefense, startTime, member, problem); + + // then + assertThat(stageDefenseRecord.getDetails()) + .extracting("stageNumber") + .containsExactly(1L); + } + @DisplayName("스테이지 기록이 만들어졌을 때 전체 소요 시간은 0분 이어야 한다.") + @Test + void totalSolvedTimeIsZero() { + // given + StageDefense randomStageDefense = createRandomStageDefense(); + + Problem problem = Problem.create(1L, B5, 100L); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 0, 0, 0); + Member member = createMember("user"); + + // when + StageRecord stageDefenseRecord = StageRecord.create(randomStageDefense, startTime, member, problem); + + // then + assertThat(stageDefenseRecord.getTotalSolvedTime()).isZero(); + } + @DisplayName("스테이지 기록이 만들어졌을 때 시험 날짜는 시작한 시점과 같아야 한다.") + @Test + void testDateEqualNow() { + // given + StageDefense randomStageDefense = createRandomStageDefense(); + + Problem problem = Problem.create(1L, B5, 100L); + LocalDateTime startTime = LocalDateTime.of(2024, 3, 1, 0, 0, 0); + Member member = createMember("user"); + + // when + StageRecord stageDefenseRecord = StageRecord.create(randomStageDefense, startTime, member, problem); + + // then + assertThat(stageDefenseRecord.getTestDate()).isEqualTo(startTime); + } + @DisplayName("스테이지 기록이 만들어졌을 때 만들어진 첫번째 문제 기록의 소요 시간은 0분 이어야 한다.") + @Test + void solvedTimeIsZero() { + // given + StageDefense randomStageDefense = createRandomStageDefense(); + + Problem problem = Problem.create(1L, B5, 100L); + LocalDateTime now = LocalDateTime.of(2024, 3, 1, 0, 0, 0); + Member member = createMember("user"); + + // when + StageRecord stageDefenseRecord = StageRecord.create(randomStageDefense, now, member, problem); + + // then + assertThat(stageDefenseRecord.getDetails()) + .extracting("solvedTime") + .containsExactly(0L); + } + @DisplayName("스테이지 기록이 만들어졌을 때 만들어진 첫번째 문제 기록의 정답 여부는 오답이어야 한다.") + @Test + void isSolvedIsFalse() { + // given + StageDefense randomStageDefense = createRandomStageDefense(); + + Problem problem = Problem.create(1L, B5, 100L); + LocalDateTime now = LocalDateTime.of(2024, 3, 1, 0, 0, 0); + Member member = createMember("user"); + + // when + StageRecord stageDefenseRecord = StageRecord.create(randomStageDefense, now, member, problem); + + // then + assertThat(stageDefenseRecord.getDetails()) + .extracting("isSolved") + .containsExactly(false); + } + @DisplayName("스테이지 기록이 만들어졌을 때 만들어진 첫번째 문제 기록의 제출 횟수는 0회 이어야한다.") + @Test + void submitCountIsZero() { + // given + StageDefense randomStageDefense = createRandomStageDefense(); + + Problem problem = Problem.create(1L, B5, 100L); + LocalDateTime now = LocalDateTime.of(2024, 3, 1, 0, 0, 0); + Member member = createMember("user"); + + // when + StageRecord stageDefenseRecord = StageRecord.create(randomStageDefense, now, member, problem); + + // then + assertThat(stageDefenseRecord.getDetails()) + .extracting("submitCount") + .containsExactly(0L); + } + @DisplayName("스테이지 기록이 만들어졌을 때 만들어진 첫번째 문제 기록의 정답 코드는 null 이어야 한다.") + @Test + void solvedCodeIsNull() { + // given + StageDefense randomStageDefense = createRandomStageDefense(); + + Problem problem = Problem.create(1L, B5, 100L); + LocalDateTime now = LocalDateTime.of(2024, 3, 1, 0, 0, 0); + Member member = createMember("user"); + + // when + StageRecord stageDefenseRecord = StageRecord.create(randomStageDefense, now, member, problem); + + // then + assertThat(stageDefenseRecord.getDetails()) + .extracting("correctSubmitId") + .contains((String) null); + } + private StageDefense createRandomStageDefense() { + RandomCriteria.DifficultyRange bronzeRange = RandomCriteria.DifficultyRange.of(B5, B1); + RandomCriteria randomCriteria = RandomCriteria.of(bronzeRange, 100L, 200L); + return StageDefense.create(randomCriteria, 120L, "브론즈 스테이지 모드"); + } + private Member createMember(String name) { + return Member.create(name, name + "@gmail.com", GOOGLE, name, name); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_record/infrastructure/adapter/dailydefense_record/DailyRecordAdapterTest.java b/src/test/java/kr/co/morandi/backend/defense_record/infrastructure/adapter/dailydefense_record/DailyRecordAdapterTest.java new file mode 100644 index 00000000..80a5ec0b --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_record/infrastructure/adapter/dailydefense_record/DailyRecordAdapterTest.java @@ -0,0 +1,123 @@ +package kr.co.morandi.backend.defense_record.infrastructure.adapter.dailydefense_record; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseRepository; +import kr.co.morandi.backend.defense_record.application.port.out.dailyrecord.DailyRecordPort; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.defense_record.infrastructure.persistence.dailydefense_record.DailyRecordRepository; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; + +class DailyRecordAdapterTest extends IntegrationTestSupport { + + @Autowired + private DailyRecordPort dailyRecordPort; + + @Autowired + private DailyDefenseRepository dailyDefenseRepository; + + @Autowired + private DailyRecordRepository dailyRecordRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private MemberRepository memberRepository; + + @AfterEach + void tearDown() { + dailyRecordRepository.deleteAll(); + dailyDefenseRepository.deleteAll(); + problemRepository.deleteAll(); + memberRepository.deleteAll(); + } + + @DisplayName("오늘 날짜에 해당하는 DailyRecord가 존재할 때 찾아올 수 있다.") + @Test + void findDailyRecordByMemberAndDate() { + // given + LocalDateTime today = LocalDateTime.of(2021, 10, 1, 0, 0); + + final Member member = createMember(); + tryDailyDefense(today, member); + + // when + Optional foundDailyRecord = dailyRecordPort.findDailyRecord(member, today.toLocalDate()); + + // then + assertThat(foundDailyRecord).isPresent() + .get() + .extracting("testDate", "problemCount") + .contains(today, 1); + + } + + @DisplayName("오늘 날짜에 해당하는 DailyRecord가 없을 때 null을 반한한다.") + @Test + void nullWhenDailyRecordNotExists() { + // given + LocalDateTime today = LocalDateTime.of(2021, 10, 1, 0, 0); + + final Member member = createMember(); + + // when + Optional foundDailyRecord = dailyRecordPort.findDailyRecord(member, today.toLocalDate()); + + // then + assertThat(foundDailyRecord).isNotPresent(); + + } + + private void tryDailyDefense(LocalDateTime today, Member member) { + final DailyDefense dailyDefense = createDailyDefense(today.toLocalDate()); + dailyDefenseRepository.save(dailyDefense); + + DailyRecord dailyRecord = DailyRecord.tryDefense(today, dailyDefense, member, getProblem(dailyDefense, 2L)); + dailyRecordRepository.save(dailyRecord); + } + + private Map getProblem(DailyDefense dailyDefense, Long problemNumber) { + return dailyDefense.getDailyDefenseProblems().stream() + .filter(problem -> Objects.equals(problem.getProblemNumber(), problemNumber)) + .collect(Collectors.toMap(DailyDefenseProblem::getProblemNumber, DailyDefenseProblem::getProblem)); + } + + private DailyDefense createDailyDefense(LocalDate createdDate) { + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + return DailyDefense.create(createdDate, "오늘의 문제 테스트", problemMap); + } + private List createProblems() { + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + Problem problem3 = Problem.create(3L, G5, 0L); + + return problemRepository.saveAll(List.of(problem1, problem2, problem3)); + } + private Member createMember() { + return memberRepository.save(Member.create("test", "test" + "@gmail.com", GOOGLE, "test", "test")); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_record/infrastructure/adapter/record/RecordAdapterTest.java b/src/test/java/kr/co/morandi/backend/defense_record/infrastructure/adapter/record/RecordAdapterTest.java new file mode 100644 index 00000000..3493090b --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_record/infrastructure/adapter/record/RecordAdapterTest.java @@ -0,0 +1,152 @@ +package kr.co.morandi.backend.defense_record.infrastructure.adapter.record; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseRepository; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyDetail; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.defense_record.domain.model.record.Detail; +import kr.co.morandi.backend.defense_record.domain.model.record.Record; +import kr.co.morandi.backend.defense_record.infrastructure.persistence.dailydefense_record.DailyRecordRepository; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +class RecordAdapterTest extends IntegrationTestSupport { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private DailyDefenseRepository dailyDefenseRepository; + + @Autowired + private RecordAdapter recordAdapter; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private DailyRecordRepository dailyRecordRepository; + + @DisplayName("RecordId로 Record를 찾아올 수 있다. (Fetch Join)") + @Test + void findRecordByIdFetchDetails() { + LocalDateTime today = LocalDateTime.of(2021, 10, 1, 0, 0); + + final Member member = createMember(); + final DailyRecord dailyRecord = tryDailyDefense(today, member); + + // when + final Optional> record = recordAdapter.findRecordFetchJoinWithDetail(dailyRecord.getRecordId()); + + // then + assertThat(record).isPresent() + .get() + .extracting("recordId", "defense.contentName", "details") + .contains(dailyRecord.getRecordId(), "오늘의 문제 테스트", dailyRecord.getDetails()); + } + + @DisplayName("RecordId로 Record를 찾아올 수 있다.") + @Test + void findRecordById() { + // given + LocalDateTime today = LocalDateTime.of(2021, 10, 1, 0, 0); + + final Member member = createMember(); + final DailyRecord dailyRecord = tryDailyDefense(today, member); + + // when + final Optional> record = recordAdapter.findRecordById(dailyRecord.getRecordId()); + + // then + assertThat(record).isPresent() + .get() + .extracting("recordId", "defense.contentName") + .contains(dailyRecord.getRecordId(), "오늘의 문제 테스트"); + + } + + private DailyRecord tryDailyDefense(LocalDateTime today, Member member) { + final DailyDefense dailyDefense = createDailyDefense(today.toLocalDate()); + final Map problem = getProblem(dailyDefense, 2L); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(today) + .defense(dailyDefense) + .member(member) + .problems(problem) + .build(); + + final DailyRecord savedDailyRecord = dailyRecordRepository.save(dailyRecord); + + final DailyDetail dailyDetail = DailyDetail.builder() + .member(member) + .problemNumber(1L) + .problem(problem.get(1L)) + .records(savedDailyRecord) + .defense(dailyDefense) + .build(); + + savedDailyRecord.getDetails().add(dailyDetail); + + return dailyRecordRepository.save(savedDailyRecord); + } + + private Map getProblem(DailyDefense dailyDefense, Long problemNumber) { + return dailyDefense.getDailyDefenseProblems().stream() + .filter(problem -> Objects.equals(problem.getProblemNumber(), problemNumber)) + .collect(Collectors.toMap(DailyDefenseProblem::getProblemNumber, DailyDefenseProblem::getProblem)); + } + + private DailyDefense createDailyDefense(LocalDate createdDate) { + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + + return dailyDefenseRepository.save(DailyDefense.create(createdDate, "오늘의 문제 테스트", problemMap)); + } + private List createProblems() { + Problem problem1 = Problem.builder() + .baekjoonProblemId(1L) + .problemTier(B5) + .build(); + + Problem problem2 = Problem.builder() + .baekjoonProblemId(2L) + .problemTier(S5) + .build(); + + Problem problem3 = Problem.builder() + .baekjoonProblemId(3L) + .problemTier(G5) + .build(); + + return problemRepository.saveAll(List.of(problem1, problem2, problem3)); + } + private Member createMember() { + return memberRepository.save(Member.builder() + .email("test") + .build()); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_record/infrastructure/controller/DailyRecordControllerTest.java b/src/test/java/kr/co/morandi/backend/defense_record/infrastructure/controller/DailyRecordControllerTest.java new file mode 100644 index 00000000..017492bc --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_record/infrastructure/controller/DailyRecordControllerTest.java @@ -0,0 +1,51 @@ +package kr.co.morandi.backend.defense_record.infrastructure.controller; + +import kr.co.morandi.backend.ControllerTestSupport; +import kr.co.morandi.backend.defense_record.application.dto.DailyDefenseRankPageResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +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; + +class DailyRecordControllerTest extends ControllerTestSupport { + + @DisplayName("[GET] DailyDefense 순위를 조회한다.") + @Test + void getDailyRecordRank() throws Exception { + // given + int page = 0; + int size = 5; + when(dailyRecordRankUseCase.getDailyRecordRank(any(), anyInt(), anyInt())) + .thenReturn(DailyDefenseRankPageResponse.builder() + .totalPage(1) + .currentPage(0) + .dailyRecords(List.of()) + .build()); + + + // when + ResultActions perform = mockMvc.perform( + get("/daily-record/rankings") + .param("page", String.valueOf(page)) + .param("size", String.valueOf(size) + )); + + + // then + perform + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalPage").isNumber()) + .andExpect(jsonPath("$.currentPage").isNumber()) + .andExpect(jsonPath("$.dailyRecords").isArray()); + } + + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/defense_record/infrastructure/persistence/dailydefense_record/DailyRecordRepositoryTest.java b/src/test/java/kr/co/morandi/backend/defense_record/infrastructure/persistence/dailydefense_record/DailyRecordRepositoryTest.java new file mode 100644 index 00000000..28d86ac0 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/defense_record/infrastructure/persistence/dailydefense_record/DailyRecordRepositoryTest.java @@ -0,0 +1,218 @@ +package kr.co.morandi.backend.defense_record.infrastructure.persistence.dailydefense_record; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseRepository; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.defense_record.domain.model.record.Detail; +import kr.co.morandi.backend.factory.TestBaekjoonSubmitFactory; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.submit.BaekjoonSubmit; +import kr.co.morandi.backend.judgement.domain.model.submit.SourceCode; +import kr.co.morandi.backend.judgement.domain.model.submit.Submit; +import kr.co.morandi.backend.judgement.infrastructure.persistence.submit.BaekjoonSubmitRepository; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language.JAVA; +import static kr.co.morandi.backend.judgement.domain.model.submit.SubmitVisibility.CLOSE; +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@Transactional +class DailyRecordRepositoryTest extends IntegrationTestSupport { + + @Autowired + private DailyRecordRepository dailyRecordRepository; + + @Autowired + private DailyDefenseRepository dailyDefenseRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private BaekjoonSubmitRepository baekjoonSubmitRepository; + + + + @DisplayName("특정 시점 DailyRecord의 순위를 조회할 수 있다.") + @Test + void getDailyRecordsRankByDate() { + // given + LocalDate today = LocalDate.of(2021, 10, 1); + final DailyDefense dailyDefense = createDailyDefense(today); + + final Member member1 = createMember("userA", "userA"); + final Member member2 = createMember("userB", "userB"); + final Member member3 = createMember("userC", "userC"); + + final DailyRecord dailyRecord1 = DailyRecord.tryDefense(LocalDateTime.of(2021, 10, 1, 0, 0), dailyDefense, member1, getProblem(dailyDefense, 1L)); + final DailyRecord dailyRecord2 = DailyRecord.tryDefense(LocalDateTime.of(2021, 10, 1, 0, 0), dailyDefense, member2, getProblem(dailyDefense, 2L)); + final DailyRecord dailyRecord3 = DailyRecord.tryDefense(LocalDateTime.of(2021, 10, 1, 0, 0), dailyDefense, member3, getProblem(dailyDefense, 3L)); + + /* + * member1: 한 문제 해결 일찍 + * member2 : 두 문제 해결 + * member3: 한 문제 해결 1보다 늦게 + * + * -> 등수 = 2 -> 1 -> 3 + * */ + + BaekjoonSubmit 제출1 = TestBaekjoonSubmitFactory.createSubmit(member1, dailyRecord1.getDetail(1L), LocalDateTime.of(2021, 10, 1, 0, 15)); + final BaekjoonSubmit 저장된_제출1 = baekjoonSubmitRepository.save(제출1); + 저장된_제출1.trySolveProblem(); + + BaekjoonSubmit 제출2 = TestBaekjoonSubmitFactory.createSubmit(member2, dailyRecord2.getDetail(2L), LocalDateTime.of(2021, 10, 1, 0, 30)); + final BaekjoonSubmit 저장된_제출2 = baekjoonSubmitRepository.save(제출2); + 저장된_제출2.trySolveProblem(); + + dailyRecord2.tryMoreProblem(getProblem(dailyDefense, 3L)); + + BaekjoonSubmit 제출3 = TestBaekjoonSubmitFactory.createSubmit(member2, dailyRecord2.getDetail(3L), LocalDateTime.of(2021, 10, 1, 0, 45)); + final BaekjoonSubmit 저장된_제출3 = baekjoonSubmitRepository.save(제출3); + 저장된_제출3.trySolveProblem(); + + BaekjoonSubmit 제출4 = TestBaekjoonSubmitFactory.createSubmit(member3, dailyRecord3.getDetail(3L), LocalDateTime.of(2021, 10, 1, 1, 0)); + final BaekjoonSubmit 저장된_제출4 = baekjoonSubmitRepository.save(제출4); + 저장된_제출4.trySolveProblem(); + + dailyRecordRepository.saveAll(List.of(dailyRecord1, dailyRecord2, dailyRecord3)); + + // when + Pageable pageable = PageRequest.of(0, 5); + Page dailyRecords = dailyRecordRepository.getDailyRecordsRankByDate(today, pageable); + + // then + assertThat(dailyRecords).hasSize(3) + .extracting(DailyRecord::getMember, DailyRecord::getTotalSolvedCount, DailyRecord::getTotalSolvedTime) + .containsExactly(// 푼 시간은 초단위 + tuple(member2, 2L, 75L * 60), + tuple(member1, 1L, 15L * 60), + tuple(member3, 1L, 60L * 60) + ); + + } + + @DisplayName("원하는 recordId에 해당하는 DailyRecord가 존재할 때 찾아올 수 있다.") + @Test + void findDailyRecordWithRecordId() { + // given + LocalDateTime today = LocalDateTime.of(2021, 10, 1, 0, 0); + + final Member member = createMember(); + final DailyRecord dailyRecord = tryDailyDefense(today, member); + + // when + Optional maybeDailyRecord = dailyRecordRepository.findDailyRecordByRecordId(member, dailyRecord.getRecordId(), today.toLocalDate()); + + // then + assertThat(maybeDailyRecord).isPresent() + .get() + .extracting("testDate", "problemCount") + .contains(today, 1); + + } + + + // TODO fetch join이 정상적으로 되는지 확인하는 테스트코드 작성 + @DisplayName("오늘 날짜에 해당하는 DailyRecord가 존재할 때 문제 리스트까지 함께 가져올 수 있다.") + @Test + void findDailyRecordByMemberAndDateWithFetchJoin() { + // given + LocalDateTime today = LocalDateTime.of(2021, 10, 1, 0, 0); + + final Member member = createMember(); + tryDailyDefense(today, member); + + // when + DailyRecord dailyRecord = dailyRecordRepository.findDailyRecordByMemberAndDate(member, today.toLocalDate()) + .orElse(null); + + // then + assertThat(dailyRecord).isNotNull(); + assertThat(dailyRecord.getDetails()).hasSize(1) + .extracting("problemNumber","problem.baekjoonProblemId") + .contains( + tuple(2L,2L) + ); + } + @DisplayName("오늘 날짜에 해당하는 DailyRecord가 존재할 때 찾아올 수 있다.") + @Test + void findDailyRecordByMemberAndDate() { + // given + LocalDateTime today = LocalDateTime.of(2021, 10, 1, 0, 0); + + final Member member = createMember(); + tryDailyDefense(today, member); + + // when + Optional maybeDailyRecord = dailyRecordRepository.findDailyRecordByMemberAndDate(member, today.toLocalDate()); + + // then + assertThat(maybeDailyRecord).isPresent() + .get() + .extracting("testDate", "problemCount") + .contains(today, 1); + + } + + private DailyRecord tryDailyDefense(LocalDateTime today, Member member) { + final DailyDefense dailyDefense = createDailyDefense(today.toLocalDate()); + + DailyRecord dailyRecord = DailyRecord.tryDefense(today, dailyDefense, member, getProblem(dailyDefense, 2L)); + return dailyRecordRepository.save(dailyRecord); + } + + private Map getProblem(DailyDefense dailyDefense, Long problemNumber) { + return dailyDefense.getDailyDefenseProblems().stream() + .filter(problem -> Objects.equals(problem.getProblemNumber(), problemNumber)) + .collect(Collectors.toMap(DailyDefenseProblem::getProblemNumber, DailyDefenseProblem::getProblem)); + } + + private DailyDefense createDailyDefense(LocalDate createdDate) { + AtomicLong problemNumber = new AtomicLong(1L); + Map problemMap = createProblems().stream() + .collect(Collectors.toMap(p-> problemNumber.getAndIncrement(), problem -> problem)); + return dailyDefenseRepository.save(DailyDefense.create(createdDate, "오늘의 문제 테스트", problemMap)); + } + private List createProblems() { + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + Problem problem3 = Problem.create(3L, G5, 0L); + + return problemRepository.saveAll(List.of(problem1, problem2, problem3)); + } + private Member createMember() { + return memberRepository.save(Member.create("test", "test" + "@gmail.com", GOOGLE, "test", "test")); + } + private Member createMember(String nickname, String email) { + return memberRepository.save(Member.create(nickname, email + "@gmail.com", GOOGLE, "test", "test")); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/docs/RestDocsSupport.java b/src/test/java/kr/co/morandi/backend/docs/RestDocsSupport.java new file mode 100644 index 00000000..e35b275d --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/docs/RestDocsSupport.java @@ -0,0 +1,33 @@ +package kr.co.morandi.backend.docs; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; + +@ExtendWith(RestDocumentationExtension.class) +public abstract class RestDocsSupport { + + protected MockMvc mockMvc; + protected ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + @BeforeEach + void setUp(RestDocumentationContextProvider provider) { + this.mockMvc = MockMvcBuilders.standaloneSetup(initController()) + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .apply(documentationConfiguration(provider)) + .build(); + } + + protected abstract Object initController(); +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/docs/cookie/CookieControllerDocsTest.java b/src/test/java/kr/co/morandi/backend/docs/cookie/CookieControllerDocsTest.java new file mode 100644 index 00000000..e59c37f3 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/docs/cookie/CookieControllerDocsTest.java @@ -0,0 +1,54 @@ +package kr.co.morandi.backend.docs.cookie; + +import kr.co.morandi.backend.docs.RestDocsSupport; +import kr.co.morandi.backend.judgement.application.service.baekjoon.cookie.BaekjoonMemberCookieService; +import kr.co.morandi.backend.judgement.infrastructure.controller.cookie.BaekjoonMemberCookieRequest; +import kr.co.morandi.backend.judgement.infrastructure.controller.cookie.CookieController; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class CookieControllerDocsTest extends RestDocsSupport { + + private final BaekjoonMemberCookieService cookieService = mock(BaekjoonMemberCookieService.class); + @Override + protected Object initController() { + return new CookieController(cookieService); + } + + @DisplayName("백준 쿠키를 저장하는 API") + @Test + void saveMemberBaekjoonCookie() throws Exception { + BaekjoonMemberCookieRequest request = new BaekjoonMemberCookieRequest("cookie"); + + doNothing().when(cookieService).saveMemberBaekjoonCookie(any()); + + final ResultActions perform = mockMvc.perform(post("/cookie/baekjoon") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + perform + .andExpect(status().isOk()) + .andDo(document("save-member-baekjoon-cookie", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("cookie").type(JsonFieldType.STRING) + .description("백준 쿠키") + ) + )); + } +} diff --git a/src/test/java/kr/co/morandi/backend/docs/dailydefense/DailyDefenseControllerDocsTest.java b/src/test/java/kr/co/morandi/backend/docs/dailydefense/DailyDefenseControllerDocsTest.java new file mode 100644 index 00000000..f4b3189e --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/docs/dailydefense/DailyDefenseControllerDocsTest.java @@ -0,0 +1,96 @@ +package kr.co.morandi.backend.docs.dailydefense; + +import kr.co.morandi.backend.defense_information.application.dto.response.DailyDefenseInfoResponse; +import kr.co.morandi.backend.defense_information.application.dto.response.DailyDefenseProblemInfoResponse; +import kr.co.morandi.backend.defense_information.application.port.in.DailyDefenseUseCase; +import kr.co.morandi.backend.defense_information.infrastructure.controller.DailyDefenseController; +import kr.co.morandi.backend.docs.RestDocsSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.List; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.S5; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +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; + +class DailyDefenseControllerDocsTest extends RestDocsSupport { + + private final DailyDefenseUseCase dailyDefenseUseCase = mock(DailyDefenseUseCase.class); + @Override + protected Object initController() { + return new DailyDefenseController(dailyDefenseUseCase); + } + + @DisplayName("DailyDefense 정보를 가져오는 API") + @Test + void getDailyDefenseInfo() throws Exception { + + final DailyDefenseProblemInfoResponse problem = DailyDefenseProblemInfoResponse.builder() + .problemNumber(1L) + .problemId(1L) + .baekjoonProblemId(1000L) + .difficulty(S5) + .solvedCount(1L) + .submitCount(1L) + .isSolved(true) + .build(); + + when(dailyDefenseUseCase.getDailyDefenseInfo(any(), any())) + .thenReturn(DailyDefenseInfoResponse.builder() + .problems(List.of(problem)) + .defenseName("test") + .attemptCount(1L) + .problemCount(5) + .build()); + + final ResultActions perform = mockMvc.perform(get("/daily-defense")); + + perform + .andExpect(status().isOk()) + .andExpect(jsonPath("$.problems").isArray()) + .andExpect(jsonPath("$.defenseName").isString()) + .andExpect(jsonPath("$.attemptCount").isNumber()) + .andExpect(jsonPath("$.problemCount").isNumber()) + .andDo(document("daily-defense-info", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("defenseName").type(JsonFieldType.STRING) + .description("디펜스 이름"), + fieldWithPath("problemCount").type(JsonFieldType.NUMBER) + .description("총 문제 수"), + fieldWithPath("attemptCount").type(JsonFieldType.NUMBER) + .description("디펜스 시도 횟수"), + fieldWithPath("problems").type(JsonFieldType.ARRAY) + .description("디펜스 문제 목록"), + fieldWithPath("problems[].problemNumber").type(JsonFieldType.NUMBER) + .description("시도하는 문제 번호"), + fieldWithPath("problems[].problemId").type(JsonFieldType.NUMBER) + .description("시도하는 문제의 PK"), + fieldWithPath("problems[].baekjoonProblemId").type(JsonFieldType.NUMBER) + .description("백준 문제 번호"), + fieldWithPath("problems[].difficulty").type(JsonFieldType.STRING) + .description("문제 난이도 ex) SILVER"), + fieldWithPath("problems[].solvedCount").type(JsonFieldType.NUMBER) + .description("정답자 수"), + fieldWithPath("problems[].submitCount").type(JsonFieldType.NUMBER) + .description("제출한 사람 수"), + fieldWithPath("problems[].isSolved").type(JsonFieldType.BOOLEAN) + .optional() + .description("해당 사용자가 정답을 맞췄는지 여부, 이 필드가 없으면 아직 시도하지 않은 문제") + ) + )); + + } +} diff --git a/src/test/java/kr/co/morandi/backend/docs/dailydefense/DailyRecordControllerDocsTest.java b/src/test/java/kr/co/morandi/backend/docs/dailydefense/DailyRecordControllerDocsTest.java new file mode 100644 index 00000000..9d460330 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/docs/dailydefense/DailyRecordControllerDocsTest.java @@ -0,0 +1,141 @@ +package kr.co.morandi.backend.docs.dailydefense; + +import kr.co.morandi.backend.defense_record.application.dto.DailyDefenseRankPageResponse; +import kr.co.morandi.backend.defense_record.application.dto.DailyDetailRankResponse; +import kr.co.morandi.backend.defense_record.application.dto.DailyRecordRankResponse; +import kr.co.morandi.backend.defense_record.application.port.in.DailyRecordRankUseCase; +import kr.co.morandi.backend.defense_record.application.util.TimeFormatHelper; +import kr.co.morandi.backend.defense_record.infrastructure.controller.DailyRecordController; +import kr.co.morandi.backend.docs.RestDocsSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class DailyRecordControllerDocsTest extends RestDocsSupport { + + private final DailyRecordRankUseCase dailyRecordRankUseCase = mock(DailyRecordRankUseCase.class); + + @Override + protected Object initController() { + return new DailyRecordController(dailyRecordRankUseCase); + } + + @DisplayName("오늘의 문제 랭킹 반환 API") + @Test + void getDailyRecordRank() throws Exception { + // 5개 문제에 대한 세부 정보 생성 + Map allDetails = new HashMap<>(); + for (long i = 1; i <= 5; i++) { // 5개의 문제 + allDetails.put(i, DailyDetailRankResponse.builder() + .problemNumber(i) + .isSolved(i % 2 == 0) // 홀수 문제는 해결하지 못함, 짝수 문제는 해결함 + .solvedTime(TimeFormatHelper.solvedTimeToString(i * 60000)) // 문제별로 1, 2, 3, 4, 5분 소요 + .build()); + } + + + // 각 유저가 5개의 문제에 대한 세부 정보를 갖도록 설정 + List records = new ArrayList<>(); + for (long i = 1; i <= 5; i++) { // 5명의 유저 + List details = allDetails.values().stream() + .collect(Collectors.toList()); + + records.add(DailyRecordRankResponse.builder() + .nickname("test" + i) + .rank(i) + .solvedCount(details.stream().filter(DailyDetailRankResponse::getIsSolved).count()) // 해결한 문제 수 + .updatedAt(LocalDateTime.now()) + .totalSolvedTime(TimeFormatHelper.solvedTimeToString(details.stream() + .mapToLong(detail -> TimeFormatHelper.stringToSolvedTime(detail.getSolvedTime())) + .sum())) + .rankDetails(details) + .build()); + + } + + DailyDefenseRankPageResponse response = DailyDefenseRankPageResponse.builder() + .dailyRecords(records) + .totalPage(1) + .currentPage(0) + .build(); + + when(dailyRecordRankUseCase.getDailyRecordRank(any(), anyInt(), anyInt())) + .thenReturn(response); + + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("page", "0"); + params.add("size", "5"); + + + final ResultActions perform = mockMvc.perform(get("/daily-record/rankings") + .params(params)); + + perform + .andExpect(status().isOk()) + .andDo(document("daily-defense-ranking", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("dailyRecords") + .type(JsonFieldType.ARRAY) + .description("오늘의 문제 순위 기록 목록"), + fieldWithPath("dailyRecords[].nickname") + .type(JsonFieldType.STRING) + .description("사용자 닉네임"), + fieldWithPath("dailyRecords[].rank") + .type(JsonFieldType.NUMBER) + .description("사용자의 순위"), + fieldWithPath("dailyRecords[].solvedCount") + .type(JsonFieldType.NUMBER) + .description("해결한 문제 수"), + fieldWithPath("dailyRecords[].updatedAt") + .type(JsonFieldType.STRING) + .description("최근 업데이트 시간"), + fieldWithPath("dailyRecords[].totalSolvedTime") + .type(JsonFieldType.STRING) + .description("총 해결 시간"), + fieldWithPath("dailyRecords[].rankDetails") + .type(JsonFieldType.ARRAY) + .description("문제별 세부 순위 정보"), + fieldWithPath("dailyRecords[].rankDetails[].problemNumber") + .type(JsonFieldType.NUMBER) + .description("문제 번호"), + fieldWithPath("dailyRecords[].rankDetails[].isSolved") + .type(JsonFieldType.BOOLEAN) + .description("해결 여부"), + fieldWithPath("dailyRecords[].rankDetails[].solvedTime") + .type(JsonFieldType.STRING) + .description("해결 시간"), + fieldWithPath("totalPage") + .type(JsonFieldType.NUMBER) + .description("전체 페이지 수"), + fieldWithPath("currentPage") + .type(JsonFieldType.NUMBER) + .description("현재 페이지 번호") + ))); + + + } +} diff --git a/src/test/java/kr/co/morandi/backend/docs/dailydefense/DefenseManagementControllerDocsTest.java b/src/test/java/kr/co/morandi/backend/docs/dailydefense/DefenseManagementControllerDocsTest.java new file mode 100644 index 00000000..91f817dc --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/docs/dailydefense/DefenseManagementControllerDocsTest.java @@ -0,0 +1,209 @@ +package kr.co.morandi.backend.docs.dailydefense; + +import kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType; +import kr.co.morandi.backend.defense_management.application.response.session.DefenseProblemResponse; +import kr.co.morandi.backend.defense_management.application.response.session.StartDailyDefenseResponse; +import kr.co.morandi.backend.defense_management.application.response.tempcode.TempCodeResponse; +import kr.co.morandi.backend.defense_management.application.usecase.session.DailyDefenseManagementUsecase; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.defense_management.infrastructure.controller.DefenseMangementController; +import kr.co.morandi.backend.defense_management.infrastructure.request.dailydefense.StartDailyDefenseRequest; +import kr.co.morandi.backend.docs.RestDocsSupport; +import kr.co.morandi.backend.problem_information.application.response.problemcontent.ProblemContent; +import kr.co.morandi.backend.problem_information.application.response.problemcontent.SampleData; +import kr.co.morandi.backend.problem_information.application.response.problemcontent.Subtask; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class DefenseManagementControllerDocsTest extends RestDocsSupport { + + private final DailyDefenseManagementUsecase dailyDefenseManagementService = mock(DailyDefenseManagementUsecase.class); + + @Override + protected Object initController() { + return new DefenseMangementController(dailyDefenseManagementService); + } + + @DisplayName("DailyDefense를 시작하는 API") + @Test + void getDailyDefenseInfo() throws Exception { + Subtask subtask = Subtask.builder() + .title("Basic Cases") + .conditions(List.of("Input range 1-100", "No negative numbers")) + .tableConditionsHtml("
Condition
Input range 1-100
No negative numbers
") + .build(); + + SampleData sampleData = SampleData.builder() + .input("1 2") + .output("3") + .explanation("The output 3 is the sum of 1 and 2.") + .build(); + + ProblemContent problemContent = ProblemContent.builder() + .baekjoonProblemId(1001L) + .title("A+B Problem") + .memoryLimit("128MB") + .timeLimit("1s") + .description("Calculate A + B.") + .input("Two integers A and B.") + .output("Output of A + B.") + .samples(List.of(sampleData)) + .hint("Use simple addition.") + .subtasks(List.of(subtask)) + .problemLimit("No specific limits.") + .additionalTimeLimit("None") + .additionalJudgeInfo("Standard problem.") + .error(null) + .build(); + + TempCodeResponse java = TempCodeResponse.builder() + .language(Language.JAVA) + .code("public class Main { public static void main(String[] args) { System.out.println(1 + 1); } }") + .build(); + + TempCodeResponse cpp = TempCodeResponse.builder() + .language(Language.CPP) + .code("#include \nint main() { std::cout << 1 + 1 << std::endl; return 0; }") + .build(); + + TempCodeResponse python = TempCodeResponse.builder() + .language(Language.PYTHON) + .code("print(1 + 1)") + .build(); + + DefenseProblemResponse defenseProblemResponse = DefenseProblemResponse.builder() + .problemId(100L) + .problemNumber(1L) + .baekjoonProblemId(1001L) + .content(problemContent) + .isCorrect(true) + .lastAccessLanguage(Language.JAVA) + .tempCodes(Set.of(java, cpp, python)) + .build(); + final StartDailyDefenseResponse startDailyDefenseResponse = StartDailyDefenseResponse.builder() + .defenseSessionId(101L) + .contentName("Daily Challenge") + .defenseType(DefenseType.DAILY) + .lastAccessTime(LocalDateTime.of(2021, 4, 27, 0, 0, 0)) + .defenseProblems(List.of(defenseProblemResponse)) + .build(); + + when(dailyDefenseManagementService.startDailyDefense(any(), any(), any())) + .thenReturn(startDailyDefenseResponse); + + final StartDailyDefenseRequest request = StartDailyDefenseRequest.builder() + .problemNumber(1L) + .build(); + + final ResultActions perform = mockMvc.perform(post("/daily-defense") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + perform + .andExpect(status().isOk()) + .andDo(document("daily-defense-start", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + responseFields( + // StartDailyDefenseResponse + fieldWithPath("defenseSessionId").type(JsonFieldType.NUMBER) + .description("오늘의 문제 세션의 고유 ID"), + fieldWithPath("contentName").type(JsonFieldType.STRING) + .description("오늘의 문제 이름"), + fieldWithPath("defenseType").type(JsonFieldType.STRING) + .description("디펜스 유형"), + fieldWithPath("lastAccessTime").type(JsonFieldType.STRING) + .description("마지막으로 콘텐츠에 접근한 시간"), + fieldWithPath("defenseProblems").type(JsonFieldType.ARRAY) + .description("오늘의 문제의 문제 목록[]"), + + // DefenseProblemResponse - Array Element + fieldWithPath("defenseProblems[].problemId").type(JsonFieldType.NUMBER) + .description("문제의 고유 ID"), + fieldWithPath("defenseProblems[].problemNumber").type(JsonFieldType.NUMBER) + .description("문제 번호"), + fieldWithPath("defenseProblems[].baekjoonProblemId").type(JsonFieldType.NUMBER) + .description("백준 문제 ID"), + fieldWithPath("defenseProblems[].content").type(JsonFieldType.OBJECT) + .description("문제의 내용 상세"), + fieldWithPath("defenseProblems[].isCorrect").type(JsonFieldType.BOOLEAN) + .description("문제가 올바르게 해결되었는지 여부"), + fieldWithPath("defenseProblems[].lastAccessLanguage").type(JsonFieldType.STRING) + .description("사용된 마지막 프로그래밍 언어"), + fieldWithPath("defenseProblems[].tempCodes").type(JsonFieldType.ARRAY) + .description("문제에 대해 작성된 임시 코드[]"), + + // ProblemContent within DefenseProblemResponse + fieldWithPath("defenseProblems[].content.baekjoonProblemId").type(JsonFieldType.NUMBER) + .description("백준 문제 ID"), + fieldWithPath("defenseProblems[].content.title").type(JsonFieldType.STRING) + .description("문제의 제목"), + fieldWithPath("defenseProblems[].content.memoryLimit").type(JsonFieldType.STRING) + .description("문제의 메모리 제한"), + fieldWithPath("defenseProblems[].content.timeLimit").type(JsonFieldType.STRING) + .description("문제의 시간 제한"), + fieldWithPath("defenseProblems[].content.description").type(JsonFieldType.STRING) + .description("문제의 설명"), + fieldWithPath("defenseProblems[].content.input").type(JsonFieldType.STRING) + .description("문제의 입력 형식"), + fieldWithPath("defenseProblems[].content.output").type(JsonFieldType.STRING) + .description("문제의 출력 형식"), + fieldWithPath("defenseProblems[].content.samples").type(JsonFieldType.ARRAY) + .description("문제의 샘플 입력/출력 값 배열"), + fieldWithPath("defenseProblems[].content.samples[].input").type(JsonFieldType.STRING) + .description("문제의 샘플 입력값"), + fieldWithPath("defenseProblems[].content.samples[].output").type(JsonFieldType.STRING) + .description("문제의 샘플 출력값"), + fieldWithPath("defenseProblems[].content.samples[].explanation").type(JsonFieldType.STRING) + .optional() + .description("입출력 예제에 대한 설명"), + fieldWithPath("defenseProblems[].content.hint").type(JsonFieldType.STRING) + .optional() + .description("문제를 해결하기 위한 힌트"), + fieldWithPath("defenseProblems[].content.subtasks").type(JsonFieldType.ARRAY) + .description("서브테스크 목록"), + fieldWithPath("defenseProblems[].content.subtasks[].title").type(JsonFieldType.STRING) + .description("부분 작업의 제목"), + fieldWithPath("defenseProblems[].content.subtasks[].conditions").type(JsonFieldType.ARRAY) + .description("부분 작업의 조건 목록 (일반적으로 conditions 와 tableConditionsHtml 중 1개가 주어짐)"), + fieldWithPath("defenseProblems[].content.subtasks[].tableConditionsHtml").type(JsonFieldType.STRING) + .description("HTML 형식의 조건 표"), + fieldWithPath("defenseProblems[].content.problemLimit").type(JsonFieldType.STRING) + .optional() + .description("문제 제한"), + fieldWithPath("defenseProblems[].content.additionalTimeLimit").type(JsonFieldType.STRING) + .optional() + .description("추가 시간 제한"), + fieldWithPath("defenseProblems[].content.additionalJudgeInfo").type(JsonFieldType.STRING) + .optional() + .description("추가 채점 정보"), + fieldWithPath("defenseProblems[].content.error").type(JsonFieldType.STRING) + .optional() + .description("문제 발생 시 반환되는 오류 필드만 반환됨"), + + // TempCodeResponse within DefenseProblemResponse + fieldWithPath("defenseProblems[].tempCodes[].language").type(JsonFieldType.STRING) + .description("임시 저장된 코드의 프로그래밍 언어"), + fieldWithPath("defenseProblems[].tempCodes[].code").type(JsonFieldType.STRING) + .description("임시 저장된 코드 스니펫") + ) + )); + + } +} diff --git a/src/test/java/kr/co/morandi/backend/docs/dailydefense/SessionConnectionControllerDocsTest.java b/src/test/java/kr/co/morandi/backend/docs/dailydefense/SessionConnectionControllerDocsTest.java new file mode 100644 index 00000000..78afbc73 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/docs/dailydefense/SessionConnectionControllerDocsTest.java @@ -0,0 +1,44 @@ +package kr.co.morandi.backend.docs.dailydefense; + +import kr.co.morandi.backend.defense_management.application.service.message.DefenseMessageService; +import kr.co.morandi.backend.defense_management.infrastructure.controller.SessionConnectionController; +import kr.co.morandi.backend.docs.RestDocsSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +public class SessionConnectionControllerDocsTest extends RestDocsSupport { + + private final DefenseMessageService defenseMessageService = mock(DefenseMessageService.class); + @Override + protected Object initController() { + return new SessionConnectionController(defenseMessageService); + } + + @DisplayName("SSE 세션을 연결하는 API") + @Test + void connectSession() throws Exception { + Long 세션_아이디 = 1L; + + SseEmitter expectedEmitter = new SseEmitter(); + + when(defenseMessageService.getConnection(anyLong(), anyLong())) + .thenReturn(expectedEmitter); + + + mockMvc.perform(get("/session/{sessionId}/connect", 세션_아이디) + .accept(MediaType.TEXT_EVENT_STREAM)) + .andDo(document("connect-session", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()) + )); + } +} diff --git a/src/test/java/kr/co/morandi/backend/docs/submit/BaekjoonSubmitControllerDocsTest.java b/src/test/java/kr/co/morandi/backend/docs/submit/BaekjoonSubmitControllerDocsTest.java new file mode 100644 index 00000000..3aee45fd --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/docs/submit/BaekjoonSubmitControllerDocsTest.java @@ -0,0 +1,78 @@ +package kr.co.morandi.backend.docs.submit; + +import com.fasterxml.jackson.core.JsonProcessingException; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.docs.RestDocsSupport; +import kr.co.morandi.backend.judgement.application.usecase.submit.BaekjoonSubmitUsecase; +import kr.co.morandi.backend.judgement.domain.model.submit.SubmitVisibility; +import kr.co.morandi.backend.judgement.infrastructure.controller.BaekjoonSubmitController; +import kr.co.morandi.backend.judgement.infrastructure.controller.request.BaekjoonJudgementRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class BaekjoonSubmitControllerDocsTest extends RestDocsSupport { + + private final BaekjoonSubmitUsecase baekjoonSubmitUsecase = mock(BaekjoonSubmitUsecase.class); + @Override + protected Object initController() { + return new BaekjoonSubmitController(baekjoonSubmitUsecase); + } + + @DisplayName("백준 제출 API") + @Test + void submit() throws Exception { + Long defenseSessionId = 1L; + Long problemNumber = 1L; + Language language = Language.JAVA; + String sourceCode = "sourceCode"; + SubmitVisibility submitVisibility = SubmitVisibility.OPEN; + BaekjoonJudgementRequest request = BaekjoonJudgementRequest.builder() + .defenseSessionId(defenseSessionId) + .problemNumber(problemNumber) + .language(language) + .sourceCode(sourceCode) + .submitVisibility(submitVisibility) + .build(); + + doNothing().when(baekjoonSubmitUsecase).judgement(any()); + + + // when + final ResultActions perform = mockMvc.perform(post("/submit") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + perform + .andExpect(status().isOk()) + .andDo(document("submit-for-judgement", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("defenseSessionId").type(JsonFieldType.NUMBER) + .description("디펜스 세션 아이디"), + fieldWithPath("problemNumber").type(JsonFieldType.NUMBER) + .description("문제 번호"), + fieldWithPath("language").type(JsonFieldType.STRING) + .description("사용 언어 [JAVA/CPP/PYTHON] "), + fieldWithPath("sourceCode").type(JsonFieldType.STRING) + .description("제출 소스 코드"), + fieldWithPath("submitVisibility").type(JsonFieldType.STRING) + .description("제출 공개 여부 [OPEN/CLOSE]") + ))); + } + +} diff --git a/src/test/java/kr/co/morandi/backend/factory/TestBaekjoonSubmitFactory.java b/src/test/java/kr/co/morandi/backend/factory/TestBaekjoonSubmitFactory.java new file mode 100644 index 00000000..917a01a8 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/factory/TestBaekjoonSubmitFactory.java @@ -0,0 +1,26 @@ +package kr.co.morandi.backend.factory; + +import kr.co.morandi.backend.defense_record.domain.model.record.Detail; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.submit.BaekjoonSubmit; +import kr.co.morandi.backend.judgement.domain.model.submit.SourceCode; +import kr.co.morandi.backend.member_management.domain.model.member.Member; + +import java.time.LocalDateTime; + +import static kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language.JAVA; +import static kr.co.morandi.backend.judgement.domain.model.submit.SubmitVisibility.CLOSE; + +public class TestBaekjoonSubmitFactory { + public static BaekjoonSubmit createSubmit(Member member, Detail detail, LocalDateTime submitTime) { + return BaekjoonSubmit.builder() + .submitDateTime(submitTime) + .submitVisibility(CLOSE) + .member(member) + .detail(detail) + .sourceCode(SourceCode.builder() + .sourceCode("sourceCode") + .language(JAVA) + .build()) + .build(); + } +} diff --git a/src/test/java/kr/co/morandi/backend/factory/TestDefenseFactory.java b/src/test/java/kr/co/morandi/backend/factory/TestDefenseFactory.java new file mode 100644 index 00000000..0d4dd953 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/factory/TestDefenseFactory.java @@ -0,0 +1,20 @@ +package kr.co.morandi.backend.factory; + +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; + +import java.time.LocalDate; +import java.util.Map; + +public class TestDefenseFactory { + + public static DailyDefense createDailyDefense(Map problems) { + + return DailyDefense.builder() + .problems(problems) + .date(LocalDate.of(2021, 1, 1)) + .contentName("contentName") + .build(); + } +} diff --git a/src/test/java/kr/co/morandi/backend/factory/TestMemberFactory.java b/src/test/java/kr/co/morandi/backend/factory/TestMemberFactory.java new file mode 100644 index 00000000..6bbb5038 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/factory/TestMemberFactory.java @@ -0,0 +1,14 @@ +package kr.co.morandi.backend.factory; + +import kr.co.morandi.backend.member_management.domain.model.member.Member; + +public class TestMemberFactory { + public static Member createMember() { + return Member.builder() + .nickname("nickname") + .baekjoonId("baekjoonId") + .email("email") + .build(); + } +} + diff --git a/src/test/java/kr/co/morandi/backend/factory/TestProblemFactory.java b/src/test/java/kr/co/morandi/backend/factory/TestProblemFactory.java new file mode 100644 index 00000000..3f4bb83d --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/factory/TestProblemFactory.java @@ -0,0 +1,42 @@ +package kr.co.morandi.backend.factory; + +import kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.domain.model.problem.ProblemStatus; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class TestProblemFactory { + + public static Problem createProblem() { + return Problem.builder() + .problemStatus(ProblemStatus.ACTIVE) + .baekjoonProblemId(1000L) + .problemTier(ProblemTier.S5) + .solvedCount(0L) + .build(); + } + + static Map tierMap = Map.of(1, ProblemTier.S5, + 2, ProblemTier.G4, + 3, ProblemTier.G3, + 4, ProblemTier.G1, + 5, ProblemTier.B4); + + public static Map createProblems(int count) { + Map problems = new HashMap(); + for (int i = 0; i < count; i++) { + problems.put((long) (i + 1), Problem.builder() + .problemStatus(ProblemStatus.ACTIVE) + .baekjoonProblemId(1000L + i) + .problemTier(tierMap.get(i % 5 + 1)) + .solvedCount(0L) + .build()); + } + return problems; + } + +} diff --git a/src/test/java/kr/co/morandi/backend/judgement/application/service/baekjoon/cookie/BaekjoonMemberCookieServiceTest.java b/src/test/java/kr/co/morandi/backend/judgement/application/service/baekjoon/cookie/BaekjoonMemberCookieServiceTest.java new file mode 100644 index 00000000..f78d378f --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/application/service/baekjoon/cookie/BaekjoonMemberCookieServiceTest.java @@ -0,0 +1,84 @@ +package kr.co.morandi.backend.judgement.application.service.baekjoon.cookie; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.factory.TestMemberFactory; +import kr.co.morandi.backend.judgement.application.request.cookie.BaekjoonMemberCookieServiceRequest; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie.BaekjoonMemberCookie; +import kr.co.morandi.backend.judgement.infrastructure.persistence.baekjoon.BaekjoonMemberCookieRepository; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +class BaekjoonMemberCookieServiceTest extends IntegrationTestSupport { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private BaekjoonMemberCookieService baekjoonMemberCookieService; + + @Autowired + private BaekjoonMemberCookieRepository baekjoonMemberCookieRepository; + + @DisplayName("이미 쿠키가 저장돼있는 경우에도 쿠키를 갱신할 수 있다.") + @Test + void saveMemberBaekjoonCookie_이미_쿠키가_저장돼있는_경우() { + // given + LocalDateTime 과거시각 = LocalDateTime.of(2021, 1, 1, 0, 0, 0); + String 과거_쿠키 = "dummyCookie"; + Member 사용자 = TestMemberFactory.createMember(); + 사용자.saveBaekjoonCookie(과거_쿠키, 과거시각); + final Member 저장된_사용자 = memberRepository.save(사용자); + + String 새로운_쿠키 = "newDummyCookie"; + LocalDateTime 현재시각 = LocalDateTime.of(2021, 1, 2, 0, 0, 0); + + final BaekjoonMemberCookieServiceRequest 서비스_요청 = new BaekjoonMemberCookieServiceRequest(새로운_쿠키, 저장된_사용자.getMemberId(), 현재시각); + + // when + baekjoonMemberCookieService.saveMemberBaekjoonCookie(서비스_요청); + + // then + final Optional 저장된_쿠키 = baekjoonMemberCookieRepository.findBaekjoonMemberCookieByMember_MemberId(저장된_사용자.getMemberId()); + + assertThat(저장된_쿠키).isPresent() + .get() + .extracting("baekjoonCookie.value", "baekjoonCookie.expiredAt") + .contains("newDummyCookie", 현재시각.plusHours(6)); + + } + + @DisplayName("사용자의 쿠키를 저장할 수 있다.") + @Test + void saveMemberBaekjoonCookie() { + // given + LocalDateTime 현재시각 = LocalDateTime.of(2021, 1, 1, 0, 0, 0); + String 쿠키 = "dummyCookie"; + Member 사용자 = TestMemberFactory.createMember(); + + final Member 저장된_사용자 = memberRepository.save(사용자); + + final BaekjoonMemberCookieServiceRequest 서비스_요청 = new BaekjoonMemberCookieServiceRequest(쿠키, 저장된_사용자.getMemberId(), 현재시각); + + // when + baekjoonMemberCookieService.saveMemberBaekjoonCookie(서비스_요청); + + // then + final Optional 저장된_쿠키 = baekjoonMemberCookieRepository.findBaekjoonMemberCookieByMember_MemberId(저장된_사용자.getMemberId()); + + assertThat(저장된_쿠키).isPresent() + .get() + .extracting("baekjoonCookie.value", "baekjoonCookie.expiredAt") + .contains(쿠키, 현재시각.plusHours(6)); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/application/service/baekjoon/result/BaekjoonJudgementStatusTest.java b/src/test/java/kr/co/morandi/backend/judgement/application/service/baekjoon/result/BaekjoonJudgementStatusTest.java new file mode 100644 index 00000000..4a60bce8 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/application/service/baekjoon/result/BaekjoonJudgementStatusTest.java @@ -0,0 +1,278 @@ +package kr.co.morandi.backend.judgement.application.service.baekjoon.result; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.result.BaekjoonResultType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.junit.jupiter.api.Assertions.*; + +class BaekjoonJudgementStatusTest extends IntegrationTestSupport { + + @Autowired + private ObjectMapper objectMapper; + + @DisplayName("컴파일 에러 테스트") + @Test + void testDeserializationCompileError() throws JsonProcessingException { + String json = "{\"result\":11,\"solution_id\":79195594}"; + + BaekjoonJudgementStatus result = objectMapper.readValue(json, BaekjoonJudgementStatus.class); + + assertEquals(BaekjoonResultType.COMPILE_ERROR, result.getResult()); + assertNull(result.getProgress()); + assertNull(result.getMemory()); + assertNull(result.getTime()); + assertNull(result.getSubtaskScore()); + assertNull(result.getPartialScore()); + assertNull(result.getAc()); + assertNull(result.getTot()); + assertNull(result.getFeedback()); + assertNull(result.getRteReason()); + assertNull(result.getRemain()); + } + + @DisplayName("런타임 에러 테스트") + @Test + void testDeserializationRuntimeError() throws JsonProcessingException { + String json = "{\"result\":10,\"solution_id\":79195594}"; + + BaekjoonJudgementStatus result = objectMapper.readValue(json, BaekjoonJudgementStatus.class); + + assertEquals(BaekjoonResultType.RUNTIME_ERROR, result.getResult()); + assertNull(result.getProgress()); + assertNull(result.getMemory()); + assertNull(result.getTime()); + assertNull(result.getSubtaskScore()); + assertNull(result.getPartialScore()); + assertNull(result.getAc()); + assertNull(result.getTot()); + assertNull(result.getFeedback()); + assertNull(result.getRteReason()); + assertNull(result.getRemain()); + } + + @DisplayName("프로그레스 97% 테스트") + @Test + void testDeserializationProgress97() throws JsonProcessingException { + String json = "{\"progress\":97,\"result\":3,\"solution_id\":79195594}"; + + BaekjoonJudgementStatus result = objectMapper.readValue(json, BaekjoonJudgementStatus.class); + + assertEquals(BaekjoonResultType.PROGRESS, result.getResult()); + assertEquals(97, result.getProgress()); + assertNull(result.getMemory()); + assertNull(result.getTime()); + assertNull(result.getSubtaskScore()); + assertNull(result.getPartialScore()); + assertNull(result.getAc()); + assertNull(result.getTot()); + assertNull(result.getFeedback()); + assertNull(result.getRteReason()); + assertNull(result.getRemain()); + } + + @DisplayName("정답입니다 테스트") + @Test + void testDeserializationFinalResult() throws JsonProcessingException { + String json = "{\"memory\":739436,\"result\":4,\"solution_id\":79195594,\"time\":3944}"; + + BaekjoonJudgementStatus result = objectMapper.readValue(json, BaekjoonJudgementStatus.class); + + assertEquals(BaekjoonResultType.CORRECT, result.getResult()); + assertEquals(739436, result.getMemory()); + assertEquals(3944, result.getTime()); + assertNull(result.getProgress()); + assertNull(result.getSubtaskScore()); + assertNull(result.getPartialScore()); + assertNull(result.getAc()); + assertNull(result.getTot()); + assertNull(result.getFeedback()); + assertNull(result.getRteReason()); + assertNull(result.getRemain()); + } + + @DisplayName("결과가 WRONG_ANSWER일 때 isRejected가 true를 반환해야 한다") + @Test + void givenWrongAnswer_whenCheckingIsRejected_thenShouldReturnTrue() { + // given + BaekjoonJudgementStatus judgementStatus = BaekjoonJudgementStatus.builder() + .result(BaekjoonResultType.WRONG_ANSWER) + .build(); + + // when + boolean isRejected = judgementStatus.isRejected(); + + // then + assertTrue(isRejected); + } + + @DisplayName("결과가 RUNTIME_ERROR일 때 isRejected가 true를 반환해야 한다") + @Test + void givenRuntimeError_whenCheckingIsRejected_thenShouldReturnTrue() { + // given + BaekjoonJudgementStatus judgementStatus = BaekjoonJudgementStatus.builder() + .result(BaekjoonResultType.RUNTIME_ERROR) + .build(); + + // when + boolean isRejected = judgementStatus.isRejected(); + + // then + assertTrue(isRejected); + } + + @DisplayName("결과가 COMPILE_ERROR일 때 isRejected가 true를 반환해야 한다") + @Test + void givenCompileError_whenCheckingIsRejected_thenShouldReturnTrue() { + // given + BaekjoonJudgementStatus judgementStatus = BaekjoonJudgementStatus.builder() + .result(BaekjoonResultType.COMPILE_ERROR) + .build(); + + // when + boolean isRejected = judgementStatus.isRejected(); + + // then + assertTrue(isRejected); + } + + @DisplayName("결과가 TIME_LIMIT_EXCEEDED일 때 isRejected가 true를 반환해야 한다") + @Test + void givenTimeLimitExceeded_whenCheckingIsRejected_thenShouldReturnTrue() { + // given + BaekjoonJudgementStatus judgementStatus = BaekjoonJudgementStatus.builder() + .result(BaekjoonResultType.TIME_LIMIT_EXCEEDED) + .build(); + + // when + boolean isRejected = judgementStatus.isRejected(); + + // then + assertTrue(isRejected); + } + + @DisplayName("결과가 OTHER일 때 isRejected가 true를 반환해야 한다") + @Test + void givenOther_whenCheckingIsRejected_thenShouldReturnTrue() { + // given + BaekjoonJudgementStatus judgementStatus = BaekjoonJudgementStatus.builder() + .result(BaekjoonResultType.OTHER) + .build(); + + // when + boolean isRejected = judgementStatus.isRejected(); + + // then + assertTrue(isRejected); + } + + @DisplayName("결과가 CORRECT일 때 isRejected가 false를 반환해야 한다") + @Test + void givenCorrect_whenCheckingIsRejected_thenShouldReturnFalse() { + // given + BaekjoonJudgementStatus judgementStatus = BaekjoonJudgementStatus.builder() + .result(BaekjoonResultType.CORRECT) + .build(); + + // when + boolean isRejected = judgementStatus.isRejected(); + + // then + assertFalse(isRejected); + } + + @DisplayName("결과가 CORRECT일 때 isFinalResult가 true를 반환해야 한다") + @Test + void givenCorrect_whenCheckingIsFinalResult_thenShouldReturnTrue() { + // given + BaekjoonJudgementStatus judgementStatus = BaekjoonJudgementStatus.builder() + .result(BaekjoonResultType.CORRECT) + .build(); + + // when + boolean isFinalResult = judgementStatus.isFinalResult(); + + // then + assertTrue(isFinalResult); + } + + @DisplayName("결과가 WRONG_ANSWER일 때 isFinalResult가 true를 반환해야 한다") + @Test + void givenWrongAnswer_whenCheckingIsFinalResult_thenShouldReturnTrue() { + // given + BaekjoonJudgementStatus judgementStatus = BaekjoonJudgementStatus.builder() + .result(BaekjoonResultType.WRONG_ANSWER) + .build(); + + // when + boolean isFinalResult = judgementStatus.isFinalResult(); + + // then + assertTrue(isFinalResult); + } + + @DisplayName("결과가 RUNTIME_ERROR일 때 isFinalResult가 true를 반환해야 한다") + @Test + void givenRuntimeError_whenCheckingIsFinalResult_thenShouldReturnTrue() { + // given + BaekjoonJudgementStatus judgementStatus = BaekjoonJudgementStatus.builder() + .result(BaekjoonResultType.RUNTIME_ERROR) + .build(); + + // when + boolean isFinalResult = judgementStatus.isFinalResult(); + + // then + assertTrue(isFinalResult); + } + + @DisplayName("결과가 COMPILE_ERROR일 때 isFinalResult가 true를 반환해야 한다") + @Test + void givenCompileError_whenCheckingIsFinalResult_thenShouldReturnTrue() { + // given + BaekjoonJudgementStatus judgementStatus = BaekjoonJudgementStatus.builder() + .result(BaekjoonResultType.COMPILE_ERROR) + .build(); + + // when + boolean isFinalResult = judgementStatus.isFinalResult(); + + // then + assertTrue(isFinalResult); + } + + @DisplayName("결과가 TIME_LIMIT_EXCEEDED일 때 isFinalResult가 true를 반환해야 한다") + @Test + void givenTimeLimitExceeded_whenCheckingIsFinalResult_thenShouldReturnTrue() { + // given + BaekjoonJudgementStatus judgementStatus = BaekjoonJudgementStatus.builder() + .result(BaekjoonResultType.TIME_LIMIT_EXCEEDED) + .build(); + + // when + boolean isFinalResult = judgementStatus.isFinalResult(); + + // then + assertTrue(isFinalResult); + } + + @DisplayName("결과가 OTHER일 때 isFinalResult가 true를 반환해야 한다") + @Test + void givenOther_whenCheckingIsFinalResult_thenShouldReturnTrue() { + // given + BaekjoonJudgementStatus judgementStatus = BaekjoonJudgementStatus.builder() + .result(BaekjoonResultType.OTHER) + .build(); + + // when + boolean isFinalResult = judgementStatus.isFinalResult(); + + // then + assertTrue(isFinalResult); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/application/service/baekjoon/result/BaekjoonResultTypeTest.java b/src/test/java/kr/co/morandi/backend/judgement/application/service/baekjoon/result/BaekjoonResultTypeTest.java new file mode 100644 index 00000000..1fc78730 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/application/service/baekjoon/result/BaekjoonResultTypeTest.java @@ -0,0 +1,117 @@ +package kr.co.morandi.backend.judgement.application.service.baekjoon.result; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.domain.error.JudgementResultErrorCode; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.result.BaekjoonResultType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BaekjoonResultTypeTest { + + @DisplayName("Null이 들어올 때 예외가 발생해야 한다.") + @Test + void fromCodeWithNullCode() { + // given + Integer code = null; + + // when & then + assertThatThrownBy(() -> BaekjoonResultType.fromCode(code)) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.RESULT_CODE_IS_NULL.getMessage()); + } + + @DisplayName("정답입니다 테스트") + @Test + void fromCode4() { + // given + int code = 4; + + // when + BaekjoonResultType result = BaekjoonResultType.fromCode(code); + + // then + assertThat(result) + .extracting("code", "description") + .contains(4, "맞았습니다!!"); + } + + @DisplayName("틀렸습니다 테스트") + @Test + void fromCode6() { + // given + int code = 6; + + // when + BaekjoonResultType result = BaekjoonResultType.fromCode(code); + + // then + assertThat(result) + .extracting("code", "description") + .contains(6, "틀렸습니다!!"); + + } + @DisplayName("런타임 에러 테스트") + @Test + void fromCode10() { + // given + int code = 10; + + // when + BaekjoonResultType result = BaekjoonResultType.fromCode(code); + + // then + assertThat(result) + .extracting("code", "description") + .contains(10, "런타임 에러"); + } + + @DisplayName("컴파일 에러 테스트") + @Test + void fromCode11() { + // given + int code = 11; + + // when + BaekjoonResultType result = BaekjoonResultType.fromCode(code); + + // then + assertThat(result) + .extracting("code", "description") + .contains(11, "컴파일 에러"); + + } + + @DisplayName("채점 중 테스트") + @Test + void fromCode3() { + // given + int code = 3; + + // when + BaekjoonResultType result = BaekjoonResultType.fromCode(code); + + // then + assertThat(result) + .extracting("code", "description") + .contains(3, "채점 중"); + } + + @DisplayName("Other 테스트") + @Test + void fromCode0() { + // given + int code = 0; + + // when + BaekjoonResultType result = BaekjoonResultType.fromCode(code); + + // then + assertThat(result) + .extracting("code", "description") + .contains(0, "Other"); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonCookieTest.java b/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonCookieTest.java new file mode 100644 index 00000000..3a6d8b42 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonCookieTest.java @@ -0,0 +1,280 @@ +package kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.domain.error.BaekjoonCookieErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BaekjoonCookieTest { + + @DisplayName("백준 쿠키를 빈 값으로 업데이트 할 수 없다.") + @Test + void updateCookieWithEmptyValue() { + // given + String 쿠키 = "testCookie"; + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + BaekjoonCookie baekjoonCookie = BaekjoonCookie.builder() + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + + String 새로운_쿠키 = " "; + LocalDateTime 다시_로그인_시간 = LocalDateTime.of(2021, 1, 1, 3, 0); + + // when & then + assertThatThrownBy(() -> baekjoonCookie.updateCookie(새로운_쿠키, 다시_로그인_시간)) + .isInstanceOf(MorandiException.class) + .hasMessage(BaekjoonCookieErrorCode.INVALID_COOKIE_VALUE.getMessage()); + + } + + @DisplayName("백준 쿠키를 null 값으로 업데이트 할 수 없다.") + @Test + void updateCookieWithNullValue() { + String 쿠키 = "testCookie"; + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + BaekjoonCookie baekjoonCookie = BaekjoonCookie.builder() + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + + String 새로운_쿠키 = null; + LocalDateTime 다시_로그인_시간 = LocalDateTime.of(2021, 1, 1, 3, 0); + + // when & then + assertThatThrownBy(() -> baekjoonCookie.updateCookie(새로운_쿠키, 다시_로그인_시간)) + .isInstanceOf(MorandiException.class) + .hasMessage(BaekjoonCookieErrorCode.INVALID_COOKIE_VALUE.getMessage()); + } + + @DisplayName("백준 쿠키를 업데이트하는 시간이 null인 경우 업데이트 할 수 없다.") + @Test + void updateCookieWithNullNowDateTime() { + String 쿠키 = "testCookie"; + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + BaekjoonCookie baekjoonCookie = BaekjoonCookie.builder() + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + + String 새로운_쿠키 = "newCookie"; + LocalDateTime 다시_로그인_시간 = null; + // when & then + assertThatThrownBy(() -> baekjoonCookie.updateCookie(새로운_쿠키, 다시_로그인_시간)) + .isInstanceOf(MorandiException.class) + .hasMessage(BaekjoonCookieErrorCode.INVALID_COOKIE_VALUE.getMessage()); + } + + @DisplayName("백준 쿠키 상태를 로그아웃 상태로 변경할 수 있다.") + @Test + void setLoggedOut() { + // given + String 쿠키 = "testCookie"; + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + BaekjoonCookie baekjoonCookie = BaekjoonCookie.builder() + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + + LocalDateTime 로그아웃_시간 = LocalDateTime.of(2021, 1, 1, 6, 0); + + // when + baekjoonCookie.setLoggedOut(로그아웃_시간); + + // then + assertThat(baekjoonCookie) + .extracting("cookieStatus", "expiredAt") + .contains(CookieStatus.LOGGED_OUT, 로그아웃_시간); + + } + + @DisplayName("이미 로그아웃 상태인 백준 쿠키는 다시 로그아웃 상태로 변경할 수 없다.") + @Test + void setLoggedOutWhenAlreadyLoggedOut() { + // given + String 쿠키 = "testCookie"; + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + BaekjoonCookie baekjoonCookie = BaekjoonCookie.builder() + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + + LocalDateTime 로그아웃_시간 = LocalDateTime.of(2021, 1, 1, 6, 0); + baekjoonCookie.setLoggedOut(로그아웃_시간); + + // when & then + assertThatThrownBy(() -> baekjoonCookie.setLoggedOut(로그아웃_시간)) + .isInstanceOf(MorandiException.class) + .hasMessage(BaekjoonCookieErrorCode.ALREADY_LOGGED_OUT.getMessage()); + } + + @DisplayName("백준 쿠키를 만료된 상태에서 유효한 상태로 변경할 수 있다.") + @Test + void updateCookieWhenExpired() { + // given + String 쿠키 = "testCookie"; + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + BaekjoonCookie baekjoonCookie = BaekjoonCookie.builder() + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + baekjoonCookie.setLoggedOut(LocalDateTime.of(2021, 1, 1, 6, 0)); + + String 새로운_쿠키 = "newCookie"; + LocalDateTime 다시_로그인_시간 = LocalDateTime.of(2021, 1, 1, 3, 0); + + + // when + baekjoonCookie.updateCookie(새로운_쿠키, 다시_로그인_시간); + + // then + assertThat(baekjoonCookie) + .extracting("cookieStatus", "expiredAt") + .contains(CookieStatus.LOGGED_IN, 다시_로그인_시간.plusHours(6)); + } + + @DisplayName("백준 쿠키를 만료된 상태에서 유효한 상태로 변경할 수 있다.") + @Test + void updateCookie() { + // given + String 쿠키 = "testCookie"; + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + BaekjoonCookie baekjoonCookie = BaekjoonCookie.builder() + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + + String 새로운_쿠키 = "newCookie"; + LocalDateTime 다시_로그인_시간 = LocalDateTime.of(2021, 1, 1, 3, 0); + + + // when + baekjoonCookie.updateCookie(새로운_쿠키, 다시_로그인_시간); + + // then + assertThat(baekjoonCookie) + .extracting("cookieStatus", "expiredAt") + .contains(CookieStatus.LOGGED_IN, 다시_로그인_시간.plusHours(6)); + + } + + @DisplayName("백준 쿠키가 유효한지 확인할 수 있다.") + @Test + void validateCookie() { + // given + String 쿠키 = "testCookie"; + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + BaekjoonCookie baekjoonCookie = BaekjoonCookie.builder() + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + + // when + boolean isValidCookie = baekjoonCookie.isValidCookie(LocalDateTime.of(2021, 1, 1, 3, 0)); + + // then + assertThat(isValidCookie).isTrue(); + + } + @DisplayName("로그아웃된 백준 쿠키는 Valid하지 않다.") + @Test + void validateCookieWhenLoggedOut() { + // given + String 쿠키 = "testCookie"; + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + BaekjoonCookie 백준_쿠키 = BaekjoonCookie.builder() + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + + LocalDateTime 만료된_시간 = LocalDateTime.of(2021, 1, 1, 6, 0); + 백준_쿠키.setLoggedOut(만료된_시간); + + + // when + final boolean validCookie = 백준_쿠키.isValidCookie(LocalDateTime.of(2021, 1, 1, 3, 0)); + + // then + assertThat(validCookie).isFalse(); + + } + + @DisplayName("만료 시간이 지난 경우 백준 쿠키는 Valid하지 않다.") + @Test + void validateCookieWhenExpired() { + // given + String 쿠키 = "testCookie"; + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + BaekjoonCookie baekjoonCookie = BaekjoonCookie.builder() + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + + LocalDateTime 만료된_시간 = LocalDateTime.of(2021, 1, 1, 6, 0); + + // when + final boolean validCookie = baekjoonCookie.isValidCookie(만료된_시간); + + // then + assertThat(validCookie).isFalse(); + + } + + @DisplayName("빈 쿠키값으로 백준 쿠키를 생성할 수 없다.") + @Test + void createBaekjoonCookieWithInvalidCookieValue() { + // given + String 쿠키 = " "; + + // when & then + assertThatThrownBy(() -> BaekjoonCookie.builder() + .cookie(쿠키) + .nowDateTime(LocalDateTime.of(2021, 1, 1, 0, 0)) + .build() + ) + .isInstanceOf(MorandiException.class) + .hasMessage(BaekjoonCookieErrorCode.INVALID_COOKIE_VALUE.getMessage()); + } + + @DisplayName("null인 쿠키값으로 백준 쿠키를 생성할 수 없다.") + @Test + void createBaekjoonCookieWithNullCookieValue() { + // given + String 쿠키 = null; + + // when & then + assertThatThrownBy(() -> BaekjoonCookie.builder() + .cookie(쿠키) + .nowDateTime(LocalDateTime.of(2021, 1, 1, 0, 0)) + .build() + ) + .isInstanceOf(MorandiException.class) + .hasMessage(BaekjoonCookieErrorCode.INVALID_COOKIE_VALUE.getMessage()); + } + + @DisplayName("현재 시간이 null인 경우 백준 쿠키를 생성할 수 없다.") + @Test + void createBaekjoonCookieWithNullNowDateTime() { + // given + String 쿠키 = "testCookie"; + LocalDateTime 등록된_시간 = null; + + // when & then + assertThatThrownBy(() -> BaekjoonCookie.builder() + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build() + ) + .isInstanceOf(MorandiException.class) + .hasMessage(BaekjoonCookieErrorCode.INVALID_COOKIE_VALUE.getMessage()); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonGlobalCookieTest.java b/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonGlobalCookieTest.java new file mode 100644 index 00000000..bc611d88 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonGlobalCookieTest.java @@ -0,0 +1,106 @@ +package kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.domain.error.BaekjoonCookieErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BaekjoonGlobalCookieTest { + + @DisplayName("BaekjoonGlobalCookie를 생성할 때, globalUserId가 null이면 예외를 던진다.") + @Test + void validateGlobalUserIdWhenNull() { + // given + BaekjoonCookie 백준_쿠키 = BaekjoonCookie.builder() + .cookie("dummyCookie") + .nowDateTime(LocalDateTime.of(2021, 1, 1, 0, 0, 0)) + .build(); + + String 백준_아이디 = null; + String 리프레시_토큰 = "testRefreshToken"; + + + // when & then + assertThatThrownBy(() -> BaekjoonGlobalCookie.builder() + .baekjoonCookie(백준_쿠키) + .globalUserId(백준_아이디) + .baekjoonRefreshToken(리프레시_토큰) + .build()) + .isInstanceOf(MorandiException.class) + .hasMessage(BaekjoonCookieErrorCode.INVALID_GLOBAL_USER_ID.getMessage()); + } + + @DisplayName("BaekjoonGlobalCookie를 생성할 때, globalUserId가 빈 문자열이면 예외를 던진다.") + @Test + void validateGlobalUserIdWithEmptyString() { + // given + BaekjoonCookie 백준_쿠키 = BaekjoonCookie.builder() + .cookie("dummyCookie") + .nowDateTime(LocalDateTime.of(2021, 1, 1, 0, 0, 0)) + .build(); + + String 백준_아이디 = " "; + String 리프레시_토큰 = "testRefreshToken"; + + + // when & then + assertThatThrownBy(() -> BaekjoonGlobalCookie.builder() + .baekjoonCookie(백준_쿠키) + .globalUserId(백준_아이디) + .baekjoonRefreshToken(리프레시_토큰) + .build()) + .isInstanceOf(MorandiException.class) + .hasMessage(BaekjoonCookieErrorCode.INVALID_GLOBAL_USER_ID.getMessage()); + } + + @DisplayName("BaekjoonGlobalCookie를 생성할 때, refreshToken이 빈 문자열이면 예외를 던진다.") + @Test + void validateRefreshTokenWithEmptyString() { + // given + BaekjoonCookie 백준_쿠키 = BaekjoonCookie.builder() + .cookie("dummyCookie") + .nowDateTime(LocalDateTime.of(2021, 1, 1, 0, 0, 0)) + .build(); + + String 백준_아이디 = "testGlobalUserId"; + String 리프레시_토큰 = " "; + + + // when & then + assertThatThrownBy(() -> BaekjoonGlobalCookie.builder() + .baekjoonCookie(백준_쿠키) + .globalUserId(백준_아이디) + .baekjoonRefreshToken(리프레시_토큰) + .build()) + .isInstanceOf(MorandiException.class) + .hasMessage(BaekjoonCookieErrorCode.INVALID_BAEKJOON_REFRESH_TOKEN.getMessage()); + } + + @DisplayName("BaekjoonGlobalCookie를 생성할 때, refreshToken이 null이면 예외를 던진다.") + @Test + void validateRefreshTokenWithNull() { + // given + BaekjoonCookie 백준_쿠키 = BaekjoonCookie.builder() + .cookie("dummyCookie") + .nowDateTime(LocalDateTime.of(2021, 1, 1, 0, 0, 0)) + .build(); + + String 백준_아이디 = "testGlobalUserId"; + String 리프레시_토큰 = null; + + + // when & then + assertThatThrownBy(() -> BaekjoonGlobalCookie.builder() + .baekjoonCookie(백준_쿠키) + .globalUserId(백준_아이디) + .baekjoonRefreshToken(리프레시_토큰) + .build()) + .isInstanceOf(MorandiException.class) + .hasMessage(BaekjoonCookieErrorCode.INVALID_BAEKJOON_REFRESH_TOKEN.getMessage()); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonMemberCookieTest.java b/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonMemberCookieTest.java new file mode 100644 index 00000000..a38cea0a --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/cookie/BaekjoonMemberCookieTest.java @@ -0,0 +1,237 @@ +package kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.factory.TestMemberFactory; +import kr.co.morandi.backend.judgement.domain.error.BaekjoonCookieErrorCode; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BaekjoonMemberCookieTest { + @DisplayName("백준 쿠키를 빈 값으로 업데이트 할 수 없다.") + @Test + void updateCookieWithEmptyValue() { + // given + String 쿠키 = "testCookie"; + final Member 사용자 = TestMemberFactory.createMember(); + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + BaekjoonMemberCookie 백준_사용자_쿠키 = BaekjoonMemberCookie.builder() + .cookie(쿠키) + .nowDateTime(등록된_시간) + .member(사용자) + .build(); + + String 새로운_쿠키 = " "; + LocalDateTime 다시_로그인_시간 = LocalDateTime.of(2021, 1, 1, 3, 0); + + // when & then + assertThatThrownBy(() -> 백준_사용자_쿠키.updateCookie(새로운_쿠키, 다시_로그인_시간)) + .isInstanceOf(MorandiException.class) + .hasMessage(BaekjoonCookieErrorCode.INVALID_COOKIE_VALUE.getMessage()); + + } + + @DisplayName("백준 쿠키를 null 값으로 업데이트 할 수 없다.") + @Test + void updateCookieWithNullValue() { + String 쿠키 = "testCookie"; + Member 사용자 = TestMemberFactory.createMember(); + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + + BaekjoonMemberCookie 백준_사용자_쿠키 = BaekjoonMemberCookie.builder() + .member(사용자) + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + + String 새로운_쿠키 = null; + LocalDateTime 다시_로그인_시간 = LocalDateTime.of(2021, 1, 1, 3, 0); + + // when & then + assertThatThrownBy(() -> 백준_사용자_쿠키.updateCookie(새로운_쿠키, 다시_로그인_시간)) + .isInstanceOf(MorandiException.class) + .hasMessage(BaekjoonCookieErrorCode.INVALID_COOKIE_VALUE.getMessage()); + } + + @DisplayName("백준 쿠키를 업데이트하는 시간이 null인 경우 업데이트 할 수 없다.") + @Test + void updateCookieWithNullNowDateTime() { + String 쿠키 = "testCookie"; + Member 사용자 = TestMemberFactory.createMember(); + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + BaekjoonMemberCookie 백준_사용자_쿠키 = BaekjoonMemberCookie.builder() + .member(사용자) + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + + String 새로운_쿠키 = "newCookie"; + LocalDateTime 다시_로그인_시간 = null; + // when & then + assertThatThrownBy(() -> 백준_사용자_쿠키.updateCookie(새로운_쿠키, 다시_로그인_시간)) + .isInstanceOf(MorandiException.class) + .hasMessage(BaekjoonCookieErrorCode.INVALID_COOKIE_VALUE.getMessage()); + } + + @DisplayName("백준 쿠키 상태를 로그아웃 상태로 변경할 수 있다.") + @Test + void setLoggedOut() { + // given + String 쿠키 = "testCookie"; + Member 사용자 = TestMemberFactory.createMember(); + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + BaekjoonMemberCookie 백준_사용자_쿠키 = BaekjoonMemberCookie.builder() + .member(사용자) + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + + LocalDateTime 로그아웃_시간 = LocalDateTime.of(2021, 1, 1, 6, 0); + + // when + 백준_사용자_쿠키.setLoggedOut(로그아웃_시간); + + // then + assertThat(백준_사용자_쿠키) + .extracting("baekjoonCookie.cookieStatus", "baekjoonCookie.expiredAt") + .contains(CookieStatus.LOGGED_OUT, 로그아웃_시간); + + } + + @DisplayName("이미 로그아웃 상태인 백준 쿠키는 다시 로그아웃 상태로 변경할 수 없다.") + @Test + void setLoggedOutWhenAlreadyLoggedOut() { + // given + String 쿠키 = "testCookie"; + Member 사용자 = TestMemberFactory.createMember(); + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + BaekjoonMemberCookie 백준_사용자_쿠키 = BaekjoonMemberCookie.builder() + .member(사용자) + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + + LocalDateTime 로그아웃_시간 = LocalDateTime.of(2021, 1, 1, 6, 0); + 백준_사용자_쿠키.setLoggedOut(로그아웃_시간); + + // when & then + assertThatThrownBy(() -> 백준_사용자_쿠키.setLoggedOut(로그아웃_시간)) + .isInstanceOf(MorandiException.class) + .hasMessage(BaekjoonCookieErrorCode.ALREADY_LOGGED_OUT.getMessage()); + } + + @DisplayName("백준 쿠키를 만료된 상태에서 유효한 상태로 변경할 수 있다.") + @Test + void updateCookieWhenExpired() { + // given + String 쿠키 = "testCookie"; + Member 사용자 = TestMemberFactory.createMember(); + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + BaekjoonMemberCookie 백준_사용자_쿠키 = BaekjoonMemberCookie.builder() + .member(사용자) + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + + 백준_사용자_쿠키.setLoggedOut(LocalDateTime.of(2021, 1, 1, 6, 0)); + + String 새로운_쿠키 = "newCookie"; + LocalDateTime 다시_로그인_시간 = LocalDateTime.of(2021, 1, 1, 3, 0); + + + // when + 백준_사용자_쿠키.updateCookie(새로운_쿠키, 다시_로그인_시간); + + // then + assertThat(백준_사용자_쿠키) + .extracting("baekjoonCookie.cookieStatus", "baekjoonCookie.expiredAt") + .contains(CookieStatus.LOGGED_IN, 다시_로그인_시간.plusHours(6)); + } + + @DisplayName("백준 쿠키를 만료된 상태에서 유효한 상태로 변경할 수 있다.") + @Test + void updateCookie() { + // given + String 쿠키 = "testCookie"; + Member 사용자 = TestMemberFactory.createMember(); + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + BaekjoonMemberCookie 백준_사용자_쿠키 = BaekjoonMemberCookie.builder() + .member(사용자) + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + + + String 새로운_쿠키 = "newCookie"; + LocalDateTime 다시_로그인_시간 = LocalDateTime.of(2021, 1, 1, 3, 0); + + + // when + 백준_사용자_쿠키.updateCookie(새로운_쿠키, 다시_로그인_시간); + + // then + assertThat(백준_사용자_쿠키) + .extracting("baekjoonCookie.cookieStatus", "baekjoonCookie.expiredAt") + .contains(CookieStatus.LOGGED_IN, 다시_로그인_시간.plusHours(6)); + + } + + @DisplayName("백준 쿠키가 유효한지 확인할 수 있다.") + @Test + void validateCookie() { + // given + String 쿠키 = "testCookie"; + Member 사용자 = TestMemberFactory.createMember(); + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + BaekjoonMemberCookie 백준_사용자_쿠키 = BaekjoonMemberCookie.builder() + .member(사용자) + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + + // when + boolean isValidCookie = 백준_사용자_쿠키.isValidCookie(LocalDateTime.of(2021, 1, 1, 3, 0)); + + // then + assertThat(isValidCookie).isTrue(); + + } + + @DisplayName("만료 시간이 지난 경우 백준 쿠키는 Valid하지 않다.") + @Test + void validateCookieWhenExpired() { + // given + String 쿠키 = "testCookie"; + Member 사용자 = TestMemberFactory.createMember(); + LocalDateTime 등록된_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + BaekjoonMemberCookie 백준_사용자_쿠키 = BaekjoonMemberCookie.builder() + .member(사용자) + .cookie(쿠키) + .nowDateTime(등록된_시간) + .build(); + + LocalDateTime 만료된_시간 = LocalDateTime.of(2021, 1, 1, 6, 0); + + // when + final boolean validCookie = 백준_사용자_쿠키.isValidCookie(만료된_시간); + + // then + assertThat(validCookie).isFalse(); + + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/result/BaekjoonJudgementResultTest.java b/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/result/BaekjoonJudgementResultTest.java new file mode 100644 index 00000000..51a3e9f9 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/result/BaekjoonJudgementResultTest.java @@ -0,0 +1,199 @@ +package kr.co.morandi.backend.judgement.domain.model.baekjoon.result; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.domain.error.JudgementResultErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BaekjoonJudgementResultTest { + + @DisplayName("BaekjoonCorrectInfo default 객체 생성 테스트") + @Test + void createDefaultCorrectInfo() { + // when + BaekjoonJudgementResult defaultResult = BaekjoonJudgementResult.defaultResult(); + + // then + assertThat(defaultResult) + .extracting("subtaskScore", "partialScore", "ac", "tot") + .contains(0, 0, 0, 0); + } + + @DisplayName("BaekjoonCorrectInfo 객체 생성 시 subtaskScore null인 경우 예외 발생 테스트") + @Test + void createCorrectInfoWithNullSubtaskScore() { + // given + Integer subtaskScore = null; + + // when, then + assertThatThrownBy(() -> BaekjoonJudgementResult.subtaskScoreFrom(subtaskScore)) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.SUBTASK_SCORE_IS_NULL.getMessage()); + } + + @DisplayName("BaekjoonCorrectInfo 객체 생성 시 subtaskScore 음수인 경우 예외 발생 테스트") + @Test + void createCorrectInfoWithNegativeSubtaskScore() { + // given + Integer subtaskScore = -1; + + // when, then + assertThatThrownBy(() -> BaekjoonJudgementResult.subtaskScoreFrom(subtaskScore)) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.SUBTASK_SCORE_IS_NEGATIVE.getMessage()); + } + @DisplayName("BaekjoonCorrectInfo 객체 생성 시 partialScore가 null인 경우 예외 발생 테스트") + @Test + void createCorrectInfoWithNullPartialScore() { + // given + Integer partialScore = null; + + // when, then + assertThatThrownBy(() -> BaekjoonJudgementResult.partialScoreFrom(partialScore)) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.PARTIAL_SCORE_IS_NULL.getMessage()); + } + + @DisplayName("BaekjoonCorrectInfo 객체 생성 시 partialScore가 음수인 경우 예외 발생 테스트") + @Test + void createCorrectInfoWithNegativePartialScore() { + // given + Integer partialScore = -1; + + // when, then + assertThatThrownBy(() -> BaekjoonJudgementResult.partialScoreFrom(partialScore)) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.PARTIAL_SCORE_IS_NEGATIVE.getMessage()); + } + + @DisplayName("BaekjoonCorrectInfo 객체 생성 시 ac가 null인 경우 예외 발생 테스트") + @Test + void createCorrectInfoWithNullAcTot() { + // given + Integer ac = null; + Integer tot = 10; + + // when, then + assertThatThrownBy(() -> BaekjoonJudgementResult.acTotOf(ac, tot)) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.AC_OR_TOT_IS_NULL.getMessage()); + } + + @DisplayName("BaekjoonCorrectInfo 객체 생성 시 tot가 null인 경우 예외 발생 테스트") + @Test + void createCorrectInfoWithNullTot() { + // given + Integer ac = 0; + Integer tot = null; + + // when, then + assertThatThrownBy(() -> BaekjoonJudgementResult.acTotOf(ac, tot)) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.AC_OR_TOT_IS_NULL.getMessage()); + } + + @DisplayName("BaekjoonCorrectInfo 객체 생성 시 ac가 음수인 경우 예외 발생 테스트") + @Test + void createCorrectInfoWithNegativeAc() { + // given + Integer ac = -1; + Integer tot = 10; + + // when, then + assertThatThrownBy(() -> BaekjoonJudgementResult.acTotOf(ac, tot)) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.AC_OR_TOT_IS_NEGATIVE.getMessage()); + } + + @DisplayName("BaekjoonCorrectInfo 객체 생성 시 tot가 음수인 경우 예외 발생 테스트") + @Test + void createCorrectInfoWithNegativeTot() { + // given + Integer ac = 0; + Integer tot = -1; + + // when, then + assertThatThrownBy(() -> BaekjoonJudgementResult.acTotOf(ac, tot)) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.AC_OR_TOT_IS_NEGATIVE.getMessage()); + } + + @DisplayName("BaekjoonCorrectInfo 객체 생성 시 ac가 tot보다 큰 경우 예외 발생 테스트") + @Test + void createCorrectInfoWithAcGreaterThanTot() { + // given + Integer ac = 10; + Integer tot = 5; + + // when, then + assertThatThrownBy(() -> BaekjoonJudgementResult.acTotOf(ac, tot)) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.AC_GREATER_THAN_TOT.getMessage()); + } + + @DisplayName("defaultResult로 생성한 BaekjoonCorrectInfo 객체의 subtaskScore, partialScore, ac, tot 값 확인 테스트") + @Test + void defaultResult() { + // given + + + // when + BaekjoonJudgementResult defaultResult = BaekjoonJudgementResult.defaultResult(); + + // then + assertThat(defaultResult) + .extracting("subtaskScore", "partialScore", "ac", "tot") + .contains(0, 0, 0, 0); + + } + + @DisplayName("subtaskScoreFrom으로 생성한 BaekjoonCorrectInfo 객체의 subtaskScore 값 확인 테스트") + @Test + void subtaskScoreFrom() { + // given + Integer subtaskScore = 10; + + // when + BaekjoonJudgementResult subtaskScoreResult = BaekjoonJudgementResult.subtaskScoreFrom(subtaskScore); + + // then + assertThat(subtaskScoreResult) + .extracting("subtaskScore") + .isEqualTo(10); + } + + @DisplayName("partialScoreFrom으로 생성한 BaekjoonCorrectInfo 객체의 partialScore 값 확인 테스트") + @Test + void partialScoreFrom() { + // given + Integer partialScore = 10; + + // when + BaekjoonJudgementResult partialScoreResult = BaekjoonJudgementResult.partialScoreFrom(partialScore); + + // then + assertThat(partialScoreResult) + .extracting("partialScore") + .isEqualTo(10); + } + + @DisplayName("acTotOf으로 생성한 BaekjoonCorrectInfo 객체의 ac, tot 값 확인 테스트") + @Test + void acTotOf() { + // given + Integer ac = 10; + Integer tot = 20; + + // when + BaekjoonJudgementResult acTotResult = BaekjoonJudgementResult.acTotOf(ac, tot); + + // then + assertThat(acTotResult) + .extracting("ac", "tot") + .contains(10, 20); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/result/JudgementResultTest.java b/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/result/JudgementResultTest.java new file mode 100644 index 00000000..b2005c06 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/result/JudgementResultTest.java @@ -0,0 +1,175 @@ +package kr.co.morandi.backend.judgement.domain.model.baekjoon.result; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.domain.error.JudgementResultErrorCode; +import kr.co.morandi.backend.judgement.domain.model.submit.JudgementResult; +import kr.co.morandi.backend.judgement.domain.model.submit.JudgementStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JudgementResultTest { + + @DisplayName("JudgementResult reject 정적 팩토리 메서드 테스트") + @Test + void rejected() { + // given + JudgementStatus judgementStatus = JudgementStatus.WRONG_ANSWER; + + // when + final JudgementResult judgementResult = JudgementResult.rejected(judgementStatus); + + // then + assertThat(judgementResult).isNotNull() + .extracting("judgementStatus", "memory", "time") + .containsExactly(judgementStatus, 0, 0); + } + + @DisplayName("JudgementResult 생성자에서 JudgementStatus가 null인 경우 예외 발생 테스트") + @Test + void constructorWithNullJudgementStatus() { + // given + + // when & then + assertThatThrownBy(() -> JudgementResult.builder() + .judgementStatus(null) + .memory(0) + .time(0) + .build() + ) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.JUDGEMENT_RESULT_NOT_FOUND.getMessage()); + } + + @DisplayName("JudgementStatus가 ACCEPTED가 아닐 때 메모리 값이 0이 아닌 경우 예외 발생 테스트") + @Test + void validateCanExistMemory() { + // given + Integer memory = 1; + Integer time = 0; + + // when & then + assertThatThrownBy(() -> JudgementResult.builder() + .judgementStatus(JudgementStatus.WRONG_ANSWER) + .memory(memory) + .time(time) + .build() + ) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.NOT_ACCEPTED_CANNOT_HAVE_MEMORY_AND_TIME.getMessage()); + } + + @DisplayName("JudgementStatus가 ACCEPTED가 아닐 때 시간 값이 0이 아닌 경우 예외 발생 테스트") + @Test + void validateCanExistTime() { + // given + Integer memory = 0; + Integer time = 1; + + // when & then + assertThatThrownBy(() -> JudgementResult.builder() + .judgementStatus(JudgementStatus.WRONG_ANSWER) + .memory(memory) + .time(time) + .build() + ) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.NOT_ACCEPTED_CANNOT_HAVE_MEMORY_AND_TIME.getMessage()); + } + + @DisplayName("정상적인 JudgementResult 객체 생성 테스트") + @Test + void createCorrectInfo() { + // given + Integer memory = 0; + Integer time = 0; + + // when + final JudgementResult judgementResult = JudgementResult.builder() + .judgementStatus(JudgementStatus.ACCEPTED) + .memory(memory) + .time(time) + .build(); + + // then + assertThat(judgementResult).isNotNull() + .extracting("memory", "time") + .containsExactly(memory, time); + + } + + @DisplayName("JudgementResult 객체 생성 시 memory가 null인 경우 예외 발생 테스트") + @Test + void createCorrectInfoWithNullMemory() { + // given + Integer memory = null; + Integer time = 0; + + // when, then + assertThatThrownBy(() -> JudgementResult.builder() + .judgementStatus(JudgementStatus.ACCEPTED) + .memory(memory) + .time(time) + .build() + ) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.MEMORY_IS_NULL.getMessage()); + } + + @DisplayName("JudgementResult 객체 생성 시 memory가 음수인 경우 예외 발생 테스트") + @Test + void createCorrectInfoWithNegativeMemory() { + // given + Integer memory = -1; + Integer time = 0; + + // when, then + assertThatThrownBy(() -> JudgementResult.builder() + .judgementStatus(JudgementStatus.ACCEPTED) + .memory(memory) + .time(time) + .build() + ) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.MEMORY_IS_NEGATIVE.getMessage()); + } + + @DisplayName("JudgementResult 객체 생성 시 time이 null인 경우 예외 발생 테스트") + @Test + void createCorrectInfoWithNullTime() { + // given + Integer memory = 0; + Integer time = null; + + // when, then + assertThatThrownBy(() -> JudgementResult.builder() + .judgementStatus(JudgementStatus.ACCEPTED) + .memory(memory) + .time(time) + .build() + ) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.TIME_IS_NULL.getMessage()); + } + + @DisplayName("JudgementResult 객체 생성 시 time이 음수인 경우 예외 발생 테스트") + @Test + void createCorrectInfoWithNegativeTime() { + // given + Integer memory = 0; + Integer time = -1; + + // when, then + assertThatThrownBy(() -> JudgementResult.builder() + .judgementStatus(JudgementStatus.ACCEPTED) + .memory(memory) + .time(time) + .build() + ) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.TIME_IS_NEGATIVE.getMessage()); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/submit/BaekjoonSubmitTest.java b/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/submit/BaekjoonSubmitTest.java new file mode 100644 index 00000000..d570f6ea --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/domain/model/baekjoon/submit/BaekjoonSubmitTest.java @@ -0,0 +1,117 @@ +package kr.co.morandi.backend.judgement.domain.model.baekjoon.submit; + +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.factory.TestDefenseFactory; +import kr.co.morandi.backend.factory.TestMemberFactory; +import kr.co.morandi.backend.factory.TestProblemFactory; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.result.BaekjoonJudgementResult; +import kr.co.morandi.backend.judgement.domain.model.submit.JudgementResult; +import kr.co.morandi.backend.judgement.domain.model.submit.SourceCode; +import kr.co.morandi.backend.judgement.domain.model.submit.SubmitVisibility; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.Map; + +import static kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language.JAVA; +import static kr.co.morandi.backend.judgement.domain.model.submit.JudgementStatus.ACCEPTED; +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles("test") +class BaekjoonSubmitTest { + + @DisplayName("백준 제출을 생성할 수 있다.") + @Test + void create() { + // given + Member 사용자 = TestMemberFactory.createMember(); + Map 문제 = TestProblemFactory.createProblems(5); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + LocalDateTime 제출_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + // when + BaekjoonSubmit 백준_제출 = BaekjoonSubmit.builder() + .sourceCode(제출할_코드) + .member(사용자) + .submitDateTime(제출_시간) + .detail(dailyRecord.getDetail(1L)) + .submitVisibility(SubmitVisibility.OPEN) + .build(); + + // then + assertThat(백준_제출) + .isNotNull() + .extracting("sourceCode.sourceCode", "sourceCode.language", "member", "detail", "submitVisibility") + .contains(제출할_코드.getSourceCode(), 제출할_코드.getLanguage(), 사용자, dailyRecord.getDetail(1L), SubmitVisibility.OPEN); + } + + @DisplayName("백준 제출을 default와 함께 정답 처리할 수 있다.") + @Test + void updateStatusToAccepted() { + // given + Member 사용자 = TestMemberFactory.createMember(); + Map 문제 = TestProblemFactory.createProblems(5); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + LocalDateTime 제출_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + BaekjoonSubmit 백준_제출 = BaekjoonSubmit.builder() + .sourceCode(제출할_코드) + .member(사용자) + .submitDateTime(제출_시간) + .detail(dailyRecord.getDetail(1L)) + .submitVisibility(SubmitVisibility.OPEN) + .build(); + + JudgementResult judgementResult = JudgementResult.builder() + .judgementStatus(ACCEPTED) + .memory(300) + .time(30) + .build(); + + + // when + final BaekjoonJudgementResult baekjoonJudgementResult = BaekjoonJudgementResult.defaultResult(); + + 백준_제출.updateJudgementResult(judgementResult, baekjoonJudgementResult); + + // then + assertThat(백준_제출) + .extracting("judgementResult.judgementStatus", "judgementResult.memory", "judgementResult.time", "baekjoonJudgementResult") + .contains(ACCEPTED, 300, 30, baekjoonJudgementResult); + + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/domain/model/submit/SourceCodeTest.java b/src/test/java/kr/co/morandi/backend/judgement/domain/model/submit/SourceCodeTest.java new file mode 100644 index 00000000..5fb16680 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/domain/model/submit/SourceCodeTest.java @@ -0,0 +1,57 @@ +package kr.co.morandi.backend.judgement.domain.model.submit; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.judgement.domain.error.SubmitErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SourceCodeTest { + + @DisplayName("SourceCode 객체를 생성할 수 있다.") + @Test + void sourceCodeOf() { + // given + String source = "sourceCode"; + + // when + SourceCode sourceCode = SourceCode.of(source, Language.JAVA); + + // then + assertThat(sourceCode) + .isNotNull() + .extracting("sourceCode", "language") + .containsExactly(source, Language.JAVA); + + } + + @DisplayName("SourceCode 객체를 생성할 때 source가 null이면 예외가 발생한다.") + @Test + void sourceCodeOfWithNullSource() { + // given + String source = null; + + // when & then + assertThatThrownBy(() -> SourceCode.of(source, Language.JAVA)) + .isInstanceOf(MorandiException.class) + .hasMessage(SubmitErrorCode.SOURCE_CODE_NOT_FOUND.getMessage()); + } + + @DisplayName("SourceCode 객체를 생성할 때 source가 빈 문자열이면 예외가 발생한다.") + @Test + void sourceCodeOfWithEmptySource() { + // given + String source = ""; + + // when & then + assertThatThrownBy(() -> SourceCode.of(source, Language.JAVA)) + .isInstanceOf(MorandiException.class) + .hasMessage(SubmitErrorCode.SOURCE_CODE_NOT_FOUND.getMessage()); + } + + + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/domain/model/submit/SubmitTest.java b/src/test/java/kr/co/morandi/backend/judgement/domain/model/submit/SubmitTest.java new file mode 100644 index 00000000..7703e99c --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/domain/model/submit/SubmitTest.java @@ -0,0 +1,578 @@ +package kr.co.morandi.backend.judgement.domain.model.submit; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.defense_record.domain.model.record.Detail; +import kr.co.morandi.backend.factory.TestDefenseFactory; +import kr.co.morandi.backend.factory.TestMemberFactory; +import kr.co.morandi.backend.factory.TestProblemFactory; +import kr.co.morandi.backend.judgement.domain.error.JudgementResultErrorCode; +import kr.co.morandi.backend.judgement.domain.error.SubmitErrorCode; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.Map; + +import static kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language.JAVA; +import static kr.co.morandi.backend.judgement.domain.model.submit.JudgementStatus.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ActiveProfiles("test") +class SubmitTest { + + /* + * 추상 클래스에 관한 공통 메서드 테스트 작성을 위해 + * Submit 클래스를 상속받는 SubmitImpl 클래스를 생성했습니다. + */ + static class SubmitTestImpl extends Submit { + public SubmitTestImpl(Member member, Detail detail, SourceCode sourceCode, LocalDateTime submitDateTime, SubmitVisibility submitVisibility) { + super(member, detail, sourceCode, submitDateTime, submitVisibility); + } + } + + @DisplayName("Submit을 생성할 수 있다.") + @Test + void createSubmit() { + Member 사용자 = TestMemberFactory.createMember(); + Map 문제 = TestProblemFactory.createProblems(5); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + LocalDateTime 제출_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + // when + Submit 제출 = new SubmitTestImpl(사용자, + dailyRecord.getDetail(1L), + 제출할_코드, + 제출_시간, + SubmitVisibility.OPEN); + + // then + assertThat(제출) + .extracting("member", "detail", "sourceCode", "submitVisibility", "submitDateTime") + .contains(사용자, dailyRecord.getDetail(1L), 제출할_코드, SubmitVisibility.OPEN, 제출_시간); + + } + + @DisplayName("sourceCode를 추가하지 않고 Submit을 생성하려고 하면 예외가 발생한다.") + @Test + void createSubmitWithoutDetail() { + // given + Member 사용자 = TestMemberFactory.createMember(); + Map 문제 = TestProblemFactory.createProblems(5); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + LocalDateTime 제출_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + + // when & then + assertThatThrownBy( + () -> new SubmitTestImpl(사용자, + dailyRecord.getDetail(1L), + null, + 제출_시간, + SubmitVisibility.OPEN) + ).isInstanceOf(MorandiException.class) + .hasMessage(SubmitErrorCode.SOURCE_CODE_IS_NULL.getMessage()); + + + } + + @DisplayName("detail을 추가하지 않고 Submit을 생성하려고 하면 예외가 발생한다.") + @Test + void createSubmitWithoutSourceCode() { + // given + Member 사용자 = TestMemberFactory.createMember(); + LocalDateTime 제출_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + // when & then + assertThatThrownBy( + () -> new SubmitTestImpl(사용자, + null, + 제출할_코드, + 제출_시간, + SubmitVisibility.OPEN) + ).isInstanceOf(MorandiException.class) + .hasMessage(SubmitErrorCode.DETAIL_IS_NULL.getMessage()); + + } + + @DisplayName("submitVisibility를 추가하지 않고 Submit을 생성하려고 하면 예외가 발생한다.") + @Test + void createSubmitWithoutSubmitVisibility() { + // given + Member 사용자 = TestMemberFactory.createMember(); + Map 문제 = TestProblemFactory.createProblems(5); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + LocalDateTime 제출_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + // when & then + assertThatThrownBy( + () -> new SubmitTestImpl(사용자, + dailyRecord.getDetail(1L), + 제출할_코드, + 제출_시간, + null) + ).isInstanceOf(MorandiException.class) + .hasMessage(SubmitErrorCode.VISIBILITY_NOT_NULL.getMessage()); + + + } + + + @DisplayName("submit을 여러 번 진행하면 detail의 submitCount가 1씩 증가한다.") + @Test + void 여러_번_제출시_Detail의_제출횟수가_증가한다() { + // given + Member 사용자 = TestMemberFactory.createMember(); + Map 문제 = TestProblemFactory.createProblems(5); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + LocalDateTime 제출_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + // when & then + Submit 제출 = new SubmitTestImpl(사용자, dailyRecord.getDetail(1L), 제출할_코드, 제출_시간, SubmitVisibility.OPEN); + assertThat(제출.getDetail().getSubmitCount()).isEqualTo(1L); + + new SubmitTestImpl(사용자, dailyRecord.getDetail(1L), 제출할_코드, 제출_시간, SubmitVisibility.OPEN); + assertThat(제출.getDetail().getSubmitCount()).isEqualTo(2L); + + new SubmitTestImpl(사용자, dailyRecord.getDetail(1L), 제출할_코드, 제출_시간, SubmitVisibility.OPEN); + assertThat(제출.getDetail().getSubmitCount()).isEqualTo(3L); + + } + + @DisplayName("submit을 진행하면 detail의 submitCount가 1 증가한다.") + @Test + void 제출시_Detail의_제출횟수가_증가한다() { + // given + Member 사용자 = TestMemberFactory.createMember(); + Map 문제 = TestProblemFactory.createProblems(5); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + LocalDateTime 제출_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + // when + Submit 제출 = new SubmitTestImpl(사용자, + dailyRecord.getDetail(1L), + 제출할_코드, + 제출_시간, + SubmitVisibility.OPEN); + + // then + assertThat(제출) + .extracting("detail.submitCount") + .isEqualTo(1L); + + } + + @DisplayName("제출 시간이 null일 때 Submit을 생성하려고 하면 예외가 발생한다.") + @Test + void submitDateTimeIsNull() { + // given + Member 사용자 = TestMemberFactory.createMember(); + Map 문제 = TestProblemFactory.createProblems(5); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + LocalDateTime 제출_시간 = null; + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + // when & then + assertThatThrownBy( + () -> new SubmitTestImpl(사용자, + dailyRecord.getDetail(1L), + 제출할_코드, + 제출_시간, + SubmitVisibility.OPEN) + ).isInstanceOf(MorandiException.class) + .hasMessage(SubmitErrorCode.SUBMIT_DATE_TIME_IS_NULL.getMessage()); + + } + + @DisplayName("Submit 후 정답 상태로 변경할 수 있다.") + @Test + void updateStatusToAccepted() { + // given + Member 사용자 = TestMemberFactory.createMember(); + Map 문제 = TestProblemFactory.createProblems(5); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + LocalDateTime 제출_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + Submit 제출 = new SubmitTestImpl(사용자, + dailyRecord.getDetail(1L), + 제출할_코드, + 제출_시간, + SubmitVisibility.OPEN); + + JudgementResult judgementResult = JudgementResult.builder() + .judgementStatus(ACCEPTED) + .memory(300) + .time(30) + .build(); + // when + 제출.updateJudgementResult(judgementResult); + + // then + assertThat(제출) + .extracting("judgementResult.judgementStatus", "judgementResult.memory", "judgementResult.time") + .contains(ACCEPTED, 300, 30); + + } + + @DisplayName("이미 정답상태인 Submit은 다시 정답상태로 변경할 수 없다.") + @Test + void updateStatusToAcceptedWhenAlreadyAccepted() { + // given + Member 사용자 = TestMemberFactory.createMember(); + Map 문제 = TestProblemFactory.createProblems(5); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + LocalDateTime 제출_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + Submit 제출 = new SubmitTestImpl(사용자, + dailyRecord.getDetail(1L), + 제출할_코드, + 제출_시간, + SubmitVisibility.OPEN); + + JudgementResult judgementResult = JudgementResult.builder() + .judgementStatus(ACCEPTED) + .memory(300) + .time(30) + .build(); + + 제출.updateJudgementResult(judgementResult); + + // when & then + assertThatThrownBy(() -> 제출.updateJudgementResult(judgementResult)) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.ALREADY_JUDGED.getMessage()); + + } + + @DisplayName("이미 런타임 에러인 Submit은 다시 다른 상태로 변경할 수 없다.") + @Test + void updateStatusToAcceptedWhenAlreadyRuntimeError() { + // given + Member 사용자 = TestMemberFactory.createMember(); + Map 문제 = TestProblemFactory.createProblems(5); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + LocalDateTime 제출_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + Submit 제출 = new SubmitTestImpl(사용자, + dailyRecord.getDetail(1L), + 제출할_코드, + 제출_시간, + SubmitVisibility.OPEN); + + JudgementResult judgementResult = JudgementResult.builder() + .judgementStatus(RUNTIME_ERROR) + .memory(0) + .time(0) + .build(); + + 제출.updateJudgementResult(judgementResult); + + // when & then + assertThatThrownBy(() -> 제출.updateJudgementResult(judgementResult)) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.ALREADY_JUDGED.getMessage()); + + } + + @DisplayName("이미 컴파일 에러인 Submit은 다시 다른 상태로 변경할 수 없다.") + @Test + void updateStatusToAcceptedWhenAlreadyCompileError() { + // given + Member 사용자 = TestMemberFactory.createMember(); + Map 문제 = TestProblemFactory.createProblems(5); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + LocalDateTime 제출_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + Submit 제출 = new SubmitTestImpl(사용자, + dailyRecord.getDetail(1L), + 제출할_코드, + 제출_시간, + SubmitVisibility.OPEN); + + JudgementResult judgementResult = JudgementResult.builder() + .judgementStatus(COMPILE_ERROR) + .memory(0) + .time(0) + .build(); + + 제출.updateJudgementResult(judgementResult); + + // when & then + assertThatThrownBy(() -> 제출.updateJudgementResult(judgementResult)) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.ALREADY_JUDGED.getMessage()); + + } + + @DisplayName("이미 시간 초과인 Submit은 다시 다른 상태로 변경할 수 없다.") + @Test + void updateStatusToAcceptedWhenAlreadyTimeLimitExceeded() { + // given + Member 사용자 = TestMemberFactory.createMember(); + Map 문제 = TestProblemFactory.createProblems(5); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + LocalDateTime 제출_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + Submit 제출 = new SubmitTestImpl(사용자, + dailyRecord.getDetail(1L), + 제출할_코드, + 제출_시간, + SubmitVisibility.OPEN); + + JudgementResult judgementResult = JudgementResult.builder() + .judgementStatus(TIME_LIMIT_EXCEEDED) + .memory(0) + .time(0) + .build(); + + 제출.updateJudgementResult(judgementResult); + + // when & then + assertThatThrownBy(() -> 제출.updateJudgementResult(judgementResult)) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.ALREADY_JUDGED.getMessage()); + + } + + @DisplayName("이미 메모리 초과인 Submit은 다시 다른 상태로 변경할 수 없다.") + @Test + void updateStatusToAcceptedWhenAlreadyMemoryLimitExceeded() { + // given + Member 사용자 = TestMemberFactory.createMember(); + Map 문제 = TestProblemFactory.createProblems(5); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + LocalDateTime 제출_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + Submit 제출 = new SubmitTestImpl(사용자, + dailyRecord.getDetail(1L), + 제출할_코드, + 제출_시간, + SubmitVisibility.OPEN); + + JudgementResult judgementResult = JudgementResult.builder() + .judgementStatus(MEMORY_LIMIT_EXCEEDED) + .memory(0) + .time(0) + .build(); + + 제출.updateJudgementResult(judgementResult); + + // when & then + assertThatThrownBy(() -> 제출.updateJudgementResult(judgementResult)) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.ALREADY_JUDGED.getMessage()); + + } + + @DisplayName("이미 틀린 답인 Submit은 다시 다른 상태로 변경할 수 없다.") + @Test + void updateStatusToAcceptedWhenAlreadyWrongAnswer() { + // given + Member 사용자 = TestMemberFactory.createMember(); + Map 문제 = TestProblemFactory.createProblems(5); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + LocalDateTime 제출_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + Submit 제출 = new SubmitTestImpl(사용자, + dailyRecord.getDetail(1L), + 제출할_코드, + 제출_시간, + SubmitVisibility.OPEN); + + JudgementResult judgementResult = JudgementResult.builder() + .judgementStatus(WRONG_ANSWER) + .memory(0) + .time(0) + .build(); + + 제출.updateJudgementResult(judgementResult); + + // when & then + assertThatThrownBy(() -> 제출.updateJudgementResult(judgementResult)) + .isInstanceOf(MorandiException.class) + .hasMessage(JudgementResultErrorCode.ALREADY_JUDGED.getMessage()); + + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/domain/service/BaekjoonJudgementServiceTest.java b/src/test/java/kr/co/morandi/backend/judgement/domain/service/BaekjoonJudgementServiceTest.java new file mode 100644 index 00000000..db19548e --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/domain/service/BaekjoonJudgementServiceTest.java @@ -0,0 +1,111 @@ +package kr.co.morandi.backend.judgement.domain.service; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseRepository; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.defense_record.infrastructure.persistence.dailydefense_record.DailyRecordRepository; +import kr.co.morandi.backend.factory.TestDefenseFactory; +import kr.co.morandi.backend.factory.TestMemberFactory; +import kr.co.morandi.backend.factory.TestProblemFactory; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.result.BaekjoonJudgementResult; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.submit.BaekjoonSubmit; +import kr.co.morandi.backend.judgement.domain.model.submit.JudgementStatus; +import kr.co.morandi.backend.judgement.domain.model.submit.SourceCode; +import kr.co.morandi.backend.judgement.domain.model.submit.SubmitVisibility; +import kr.co.morandi.backend.judgement.infrastructure.persistence.submit.BaekjoonSubmitRepository; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.util.AopTestUtils; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Optional; + +import static kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language.JAVA; +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +class BaekjoonJudgementServiceTest extends IntegrationTestSupport { + + @Autowired + private BaekjoonSubmitRepository baekjoonSubmitRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private DailyDefenseRepository dailyDefenseRepository; + + @Autowired + private DailyRecordRepository dailyRecordRepository; + + @Autowired + private BaekjoonJudgementService baekjoonJudgementService; + + @DisplayName("채점 결과를 업데이트할 수 있다.") + @Test + void canUpdateJudgementStatusCorrectly() { + + baekjoonJudgementService = AopTestUtils.getTargetObject(baekjoonJudgementService); + + Member 사용자 = TestMemberFactory.createMember(); + memberRepository.save(사용자); + Map 문제 = TestProblemFactory.createProblems(5); + problemRepository.saveAll(문제.values()); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + dailyDefenseRepository.save(오늘의_문제); + + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + dailyRecordRepository.save(dailyRecord); + + LocalDateTime 제출_시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + BaekjoonSubmit 백준_제출 = BaekjoonSubmit.builder() + .sourceCode(제출할_코드) + .member(사용자) + .submitDateTime(제출_시간) + .detail(dailyRecord.getDetail(1L)) + .submitVisibility(SubmitVisibility.OPEN) + .build(); + + final BaekjoonSubmit 저장된_백준_제출 = baekjoonSubmitRepository.save(백준_제출); + + final BaekjoonJudgementResult 백준_채점_디테일_정보 = BaekjoonJudgementResult.defaultResult(); + final JudgementStatus 채점_결과 = JudgementStatus.ACCEPTED; + + // when + baekjoonJudgementService.asyncUpdateJudgementStatus(저장된_백준_제출.getSubmitId(), 채점_결과, 512, 120, 백준_채점_디테일_정보); + + final Optional maybeBaekjoonSubmit = baekjoonSubmitRepository.findById(저장된_백준_제출.getSubmitId()); + + assertThat(maybeBaekjoonSubmit).isPresent() + .get() + .isNotNull() + .extracting("judgementResult.memory", "judgementResult.time", "baekjoonJudgementResult.subtaskScore", "baekjoonJudgementResult.partialScore", "baekjoonJudgementResult.ac", "baekjoonJudgementResult.tot") + .containsExactly(512, 120, 백준_채점_디테일_정보.getSubtaskScore(), 백준_채점_디테일_정보.getPartialScore(), 백준_채점_디테일_정보.getAc(), 백준_채점_디테일_정보.getTot()); + + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/infrastructure/SubmitVisibilityTest.java b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/SubmitVisibilityTest.java new file mode 100644 index 00000000..5b0c572f --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/SubmitVisibilityTest.java @@ -0,0 +1,121 @@ +package kr.co.morandi.backend.judgement.infrastructure; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.domain.error.SubmitErrorCode; +import kr.co.morandi.backend.judgement.domain.model.submit.SubmitVisibility; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SubmitVisibilityTest { + + @DisplayName("getValue 테스트") + @Test + void getValue() { + // given + SubmitVisibility submitVisibility = SubmitVisibility.OPEN; + + // when + String value = submitVisibility.getValue(); + + // then + assertThat(value).isEqualTo("OPEN"); + } + @DisplayName("OPEN 값으로 SubmitVisibility 생성") + @Test + void fromOPEN() { + // given + String value = "OPEN"; + + // when + SubmitVisibility submitVisibility = SubmitVisibility.fromValue(value); + + // then + assertThat(submitVisibility).isEqualTo(SubmitVisibility.OPEN); + + } + @DisplayName("대소문자 구분없이 값으로 SubmitVisibility 생성") + @Test + void fromOpen() { + // given + String value = "Open"; + + // when + SubmitVisibility submitVisibility = SubmitVisibility.fromValue(value); + + // then + assertThat(submitVisibility).isEqualTo(SubmitVisibility.OPEN); + + } + + @DisplayName("CLOSE 값으로 SubmitVisibility 생성") + @Test + void fromCLOSE() { + // given + String value = "CLOSE"; + + // when + SubmitVisibility submitVisibility = SubmitVisibility.fromValue(value); + + // then + assertThat(submitVisibility).isEqualTo(SubmitVisibility.CLOSE); + + } + @DisplayName("대소문자 구분없이 값으로 SubmitVisibility 생성") + @Test + void fromClose() { + // given + String value = "Close"; + + // when + SubmitVisibility submitVisibility = SubmitVisibility.fromValue(value); + + // then + assertThat(submitVisibility).isEqualTo(SubmitVisibility.CLOSE); + + } + + @DisplayName("null 값으로 SubmitVisibility 생성") + @Test + void fromNull() { + // given + String value = null; + + // when & then + assertThatThrownBy(() -> SubmitVisibility.fromValue(value)) + .isInstanceOf(MorandiException.class) + .hasMessage(SubmitErrorCode.SUBMIT_VISIBILITY_NOT_FOUND.getMessage()); + + } + + @DisplayName("빈 값으로 SubmitVisibility 생성") + @Test + void fromEmpty() { + // given + String value = ""; + + // when & then + assertThatThrownBy(() -> SubmitVisibility.fromValue(value)) + .isInstanceOf(MorandiException.class) + .hasMessage(SubmitErrorCode.SUBMIT_VISIBILITY_NOT_FOUND.getMessage()); + + } + + @DisplayName("잘못된 값으로 SubmitVisibility 생성") + @Test + void fromInvalidValue() { + // given + String value = "INVALID"; + + // when & then + assertThatThrownBy(() -> SubmitVisibility.fromValue(value)) + .isInstanceOf(MorandiException.class) + .hasMessage(SubmitErrorCode.INVALID_VISIBILITY_VALUE.getMessage()); + + } + + + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitHtmlParserTest.java b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitHtmlParserTest.java new file mode 100644 index 00000000..279d2cee --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitHtmlParserTest.java @@ -0,0 +1,156 @@ +package kr.co.morandi.backend.judgement.infrastructure.baekjoon.submit; + +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.judgement.domain.error.SubmitErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class BaekjoonSubmitHtmlParserTest { + + @DisplayName("CSRF 키를 정상적으로 파싱한다.") + @Test + void parseCsrfKeyInSubmitPage_validResponse() { + // given + String validHtml = ""; + + // when + String csrfKey = BaekjoonSubmitHtmlParser.parseCsrfKeyInSubmitPage(validHtml); + + // then + assertThat(csrfKey).isEqualTo("validCsrfKey"); + } + + @DisplayName("CSRF 키가 없는 경우 예외를 던진다.") + @Test + void parseCsrfKeyInSubmitPage_missingCsrfKey() { + // given + String invalidHtml = ""; + + // when & then + assertThatThrownBy(() -> BaekjoonSubmitHtmlParser.parseCsrfKeyInSubmitPage(invalidHtml)) + .isInstanceOf(MorandiException.class) + .hasMessage(SubmitErrorCode.CSRF_KEY_NOT_FOUND.getMessage()); + } + + @DisplayName("null 응답인 경우 예외를 던진다.") + @Test + void parseCsrfKeyInSubmitPage_nullResponse() { + // given + String nullResponse = null; + + // when & then + assertThatThrownBy(() -> BaekjoonSubmitHtmlParser.parseCsrfKeyInSubmitPage(nullResponse)) + .isInstanceOf(MorandiException.class) + .hasMessage(SubmitErrorCode.BAEKJOON_SUBMIT_PAGE_ERROR.getMessage()); + } + + @DisplayName("솔루션 아이디를 정상적으로 파싱한다.") + @Test + void parseSolutionIdFromHtml_validHtml() { + // given + String validHtml = """ + + + + + + +
123456
+ """; + + // when + String solutionId = BaekjoonSubmitHtmlParser.parseSolutionIdFromHtml(validHtml); + + // then + assertThat(solutionId).isEqualTo("123456"); + } + + @DisplayName("솔루션 아이디가 없는 경우 예외를 던진다.") + @Test + void parseSolutionIdFromHtml_missingSolutionId() { + // given + String invalidHtml = """ + + + + + + +
+ """; + + // when & then + assertThatThrownBy(() -> BaekjoonSubmitHtmlParser.parseSolutionIdFromHtml(invalidHtml)) + .isInstanceOf(MorandiException.class) + .hasMessage(SubmitErrorCode.CANT_FIND_SOLUTION_ID.getMessage()); + } + + @DisplayName("상태 테이블이 없는 경우 예외를 던진다.") + @Test + void parseSolutionIdFromHtml_missingStatusTable() { + // given + String invalidHtml = ""; + + // when & then + assertThatThrownBy(() -> BaekjoonSubmitHtmlParser.parseSolutionIdFromHtml(invalidHtml)) + .isInstanceOf(MorandiException.class) + .hasMessage(SubmitErrorCode.CANT_FIND_SOLUTION_ID.getMessage()); + } + + @DisplayName("첫 번째 행이 없는 경우 예외를 던진다.") + @Test + void parseSolutionIdFromHtml_missingFirstRow() { + // given + String invalidHtml = """ + + + +
+ """; + + // when & then + assertThatThrownBy(() -> BaekjoonSubmitHtmlParser.parseSolutionIdFromHtml(invalidHtml)) + .isInstanceOf(MorandiException.class) + .hasMessage(SubmitErrorCode.CANT_FIND_SOLUTION_ID.getMessage()); + } + + @DisplayName("solutionIdElement가 null인 경우 예외를 던진다.") + @Test + void parseSolutionIdFromHtml_nullSolutionIdElement() { + // given + String invalidHtml = """ + + + + + +
+ """; + + // when & then + assertThatThrownBy(() -> BaekjoonSubmitHtmlParser.parseSolutionIdFromHtml(invalidHtml)) + .isInstanceOf(MorandiException.class) + .hasMessage(SubmitErrorCode.CANT_FIND_SOLUTION_ID.getMessage()); + } + + @DisplayName("solutionIdElement의 텍스트가 비어있는 경우 예외를 던진다.") + @Test + void parseSolutionIdFromHtml_emptySolutionIdElement() { + // given + String invalidHtml = """ + + + + + + +
+ """; + + // when & then + assertThatThrownBy(() -> BaekjoonSubmitHtmlParser.parseSolutionIdFromHtml(invalidHtml)) + .isInstanceOf(MorandiException.class) + .hasMessage(SubmitErrorCode.CANT_FIND_SOLUTION_ID.getMessage()); + } +} diff --git a/src/test/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitLanguageCodeTest.java b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitLanguageCodeTest.java new file mode 100644 index 00000000..95299331 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitLanguageCodeTest.java @@ -0,0 +1,58 @@ +package kr.co.morandi.backend.judgement.infrastructure.baekjoon.submit; + +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class BaekjoonSubmitLanguageCodeTest { + + @DisplayName("BaekjoonJudgementConstants의 CPP에 해당하는 아이디를 가져올 수 있다.") + @Test + void getLanguageId() { + // given + Language language = Language.CPP; + + // when + String languageId = BaekjoonSubmitLanguageCode.getLanguageCode(language); + + // then + assertThat(languageId) + .isEqualTo("84"); + + } + + @DisplayName("BaekjoonJudgementConstants의 JAVA 해당하는 아이디를 가져올 수 있다.") + @Test + void getLanguageId2() { + // given + Language language = Language.JAVA; + + // when + String languageId = BaekjoonSubmitLanguageCode.getLanguageCode(language); + + // then + assertThat(languageId) + .isEqualTo("93"); + + } + + @DisplayName("BaekjoonJudgementConstants의 PYTHON에 해당하는 아이디를 가져올 수 있다.") + @Test + void getLanguageId3() { + // given + Language language = Language.PYTHON; + + // when + String languageId = BaekjoonSubmitLanguageCode.getLanguageCode(language); + + // then + assertThat(languageId) + .isEqualTo("28"); + + } + + + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitStrategyTest.java b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitStrategyTest.java new file mode 100644 index 00000000..a42f2a88 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/baekjoon/submit/BaekjoonSubmitStrategyTest.java @@ -0,0 +1,222 @@ +package kr.co.morandi.backend.judgement.infrastructure.baekjoon.submit; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.common.exception.MorandiException; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.judgement.domain.error.SubmitErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +class BaekjoonSubmitStrategyTest extends IntegrationTestSupport { + + @Autowired + private BaekjoonSubmitApiAdapter baekjoonSubmitApiAdapter; + + @Autowired + private ExchangeFunction exchangeFunction; + + @DisplayName("제출을 하고 솔루션 아이디를 가져온다.") + @Order(1) + @Test + void submitAndGetSolutionId() { + // given + // reqeust parameter + String 백준_문제_ID = "1000"; + String 사용자_쿠키 = "cookieValue"; + Language 제출_언어 = Language.PYTHON; + String 제출_코드_공개범위 = "open"; + String 제출_코드 = "print('Hello World')"; + + // WebClient response stubbing + String csrfKey = "stubbingCsrfKey"; + String csrfHtml = ""; + String solutionIdHtml = """ + + + + + + +
123456
+ """; + + // WebClient stubbing + when(exchangeFunction.exchange(any(ClientRequest.class))) + .thenReturn(Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "text/html") + .body(csrfHtml) + .build())) + .thenReturn(Mono.just(ClientResponse.create(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, "/status") + .build())) + .thenReturn(Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "text/html") + .body(solutionIdHtml) + .build())); + + // when + String solutionId = baekjoonSubmitApiAdapter.submitAndGetSolutionId(백준_문제_ID, 사용자_쿠키, 제출_언어, 제출_코드, 제출_코드_공개범위); + + // then + assertThat(solutionId) + .isNotNull() + .isEqualTo("123456"); + + } + + @DisplayName("제출할 때 CSRF_KEY를 가져오는데 HTML이 빈 문자열인 경우") + @Test + void CSRF_KEY를_가져오는데_HTML이_빈_문자열인_경우() { + // given + // reqeust parameter + String 백준_문제_ID = "1000"; + String 사용자_쿠키 = "cookieValue"; + Language 제출_언어 = Language.PYTHON; + String 제출_코드_공개범위 = "open"; + String 제출_코드 = "print('Hello World')"; + + // WebClient response stubbing + + // invalid csrf key + String csrfHtml = ""; + + + when(exchangeFunction.exchange(any(ClientRequest.class))) + .thenReturn(Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "text/html") + .body(csrfHtml) + .build())); + + // when & then + assertThatThrownBy(() -> baekjoonSubmitApiAdapter.submitAndGetSolutionId(백준_문제_ID, 사용자_쿠키, 제출_언어, 제출_코드, 제출_코드_공개범위)) + .isInstanceOf(MorandiException.class) + .hasMessageContaining("제출 페이지에서 CSRF 키를 찾을 수 없습니다."); + + } + + @DisplayName("제출할 때 CSRF_KEY를 가져오는데 실패한 경우") + @Test + void CSRF_KEY를_가져오는데_실패한_경우() { + // given + // reqeust parameter + String 백준_문제_ID = "1000"; + String 사용자_쿠키 = "cookieValue"; + Language 제출_언어 = Language.PYTHON; + String 제출_코드_공개범위 = "open"; + String 제출_코드 = "print('Hello World')"; + + // WebClient response stubbing + + // invalid csrf key + String csrfHtml = ""; + + + when(exchangeFunction.exchange(any(ClientRequest.class))) + .thenReturn(Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "text/html") + .body(csrfHtml) + .build())); + + // when & then + assertThatThrownBy(() -> baekjoonSubmitApiAdapter.submitAndGetSolutionId(백준_문제_ID, 사용자_쿠키, 제출_언어, 제출_코드, 제출_코드_공개범위)) + .isInstanceOf(MorandiException.class) + .hasMessageContaining("제출 페이지에서 CSRF 키를 찾을 수 없습니다."); + + } + + @DisplayName("제출을 한 뒤 Solution Id를 정상적으로 찾지 못한 경우") + @Test + void 제출_뒤_Solution_Id를_정상적으로_찾지_못한_경우() { + // given + // reqeust parameter + String 백준_문제_ID = "1000"; + String 사용자_쿠키 = "cookieValue"; + Language 제출_언어 = Language.PYTHON; + String 제출_코드_공개범위 = "open"; + String 제출_코드 = "print('Hello World')"; + + // WebClient response stubbing + String csrfKey = "stubbingCsrfKey"; + String csrfHtml = ""; + + // invalid solution id HTML + String solutionIdHtml = """ + + + + + + +
123456
+ """; + + // WebClient stubbing + when(exchangeFunction.exchange(any(ClientRequest.class))) + .thenReturn(Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "text/html") + .body(csrfHtml) + .build())) + .thenReturn(Mono.just(ClientResponse.create(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, "/status") + .build())) + .thenReturn(Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "text/html") + .body(solutionIdHtml) + .build())); + + // when & then + assertThatThrownBy(() -> baekjoonSubmitApiAdapter.submitAndGetSolutionId(백준_문제_ID, 사용자_쿠키, 제출_언어, 제출_코드, 제출_코드_공개범위)) + .isInstanceOf(MorandiException.class) + .hasMessageContaining(SubmitErrorCode.CANT_FIND_SOLUTION_ID.getMessage()); + + } + + + @DisplayName("Redirection에서 location 헤더가 없는 경우 예외를 던진다.") + @Test + void handleRedirection_noLocationHeader() { + // given + // reqeust parameter + String 백준_문제_ID = "1000"; + String 사용자_쿠키 = "cookieValue"; + Language 제출_언어 = Language.PYTHON; + String 제출_코드_공개범위 = "open"; + String 제출_코드 = "print('Hello World')"; + + // WebClient response stubbing + String csrfKey = "stubbingCsrfKey"; + String csrfHtml = ""; + + when(exchangeFunction.exchange(any(ClientRequest.class))) + .thenReturn(Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "text/html") + .body(csrfHtml) + .build())) + .thenReturn(Mono.just(ClientResponse.create(HttpStatus.FOUND) + .header("Content-Type", "text/html") + .build())); + + + // when & then + assertThatThrownBy(() -> baekjoonSubmitApiAdapter.submitAndGetSolutionId(백준_문제_ID, 사용자_쿠키, 제출_언어, 제출_코드, 제출_코드_공개범위)) + .isInstanceOf(MorandiException.class) + .hasMessageContaining(SubmitErrorCode.REDIRECTION_LOCATION_NOT_FOUND.getMessage()); + + } + + + + } diff --git a/src/test/java/kr/co/morandi/backend/judgement/infrastructure/controller/BaekjoonSubmitControllerTest.java b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/controller/BaekjoonSubmitControllerTest.java new file mode 100644 index 00000000..1962a844 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/controller/BaekjoonSubmitControllerTest.java @@ -0,0 +1,307 @@ +package kr.co.morandi.backend.judgement.infrastructure.controller; + +import kr.co.morandi.backend.ControllerTestSupport; +import kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language; +import kr.co.morandi.backend.judgement.domain.model.submit.SubmitVisibility; +import kr.co.morandi.backend.judgement.infrastructure.controller.request.BaekjoonJudgementRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.ResultActions; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class BaekjoonSubmitControllerTest extends ControllerTestSupport { + + @DisplayName("제출 요청을 받아 처리한다.") + @Test + void submit() throws Exception { + + Long defenseSessionId = 1L; + Long problemNumber = 1L; + Language language = Language.JAVA; + String sourceCode = "sourceCode"; + SubmitVisibility submitVisibility = SubmitVisibility.OPEN; + BaekjoonJudgementRequest request = BaekjoonJudgementRequest.builder() + .defenseSessionId(defenseSessionId) + .problemNumber(problemNumber) + .language(language) + .sourceCode(sourceCode) + .submitVisibility(submitVisibility) + .build(); + + doNothing().when(baekjoonSubmitUsecase).judgement(any()); + + + // when + final ResultActions perform = mockMvc.perform(post("/submit") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + perform + .andExpect(status().isOk()); + } + + @DisplayName("defenseSessionId가 null일 경우 처리한다.") + @Test + void submitWhenDefenseSessionIdIsNull() throws Exception { + + Long defenseSessionId = null; + Long problemNumber = 1L; + Language language = Language.JAVA; + String sourceCode = "sourceCode"; + SubmitVisibility submitVisibility = SubmitVisibility.OPEN; + BaekjoonJudgementRequest request = BaekjoonJudgementRequest.builder() + .defenseSessionId(defenseSessionId) + .problemNumber(problemNumber) + .language(language) + .sourceCode(sourceCode) + .submitVisibility(submitVisibility) + .build(); + + doNothing().when(baekjoonSubmitUsecase).judgement(any()); + + + // when + final ResultActions perform = mockMvc.perform(post("/submit") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + perform + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.response.message").value("defenseSessionId: defenseSessionId가 존재해야 합니다.")); + + } + + @DisplayName("defenseSessionId가 음수일 경우 처리한다.") + @Test + void submitWhenDefenseSessionIdIsNegative() throws Exception { + + Long defenseSessionId = -1L; + Long problemNumber = 1L; + Language language = Language.JAVA; + String sourceCode = "sourceCode"; + SubmitVisibility submitVisibility = SubmitVisibility.OPEN; + BaekjoonJudgementRequest request = BaekjoonJudgementRequest.builder() + .defenseSessionId(defenseSessionId) + .problemNumber(problemNumber) + .language(language) + .sourceCode(sourceCode) + .submitVisibility(submitVisibility) + .build(); + + doNothing().when(baekjoonSubmitUsecase).judgement(any()); + + + // when + final ResultActions perform = mockMvc.perform(post("/submit") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + perform + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.response.message").value("defenseSessionId: defenseSessionId는 양수여야 합니다.")); + + } + + @DisplayName("problemNumber가 null일 경우 처리한다.") + @Test + void submitWhenProblemNumberIsNull() throws Exception { + // when + Long defenseSessionId = 1L; + Long problemNumber = null; + Language language = Language.JAVA; + String sourceCode = "sourceCode"; + SubmitVisibility submitVisibility = SubmitVisibility.OPEN; + BaekjoonJudgementRequest request = BaekjoonJudgementRequest.builder() + .defenseSessionId(defenseSessionId) + .problemNumber(problemNumber) + .language(language) + .sourceCode(sourceCode) + .submitVisibility(submitVisibility) + .build(); + + doNothing().when(baekjoonSubmitUsecase).judgement(any()); + + // when + final ResultActions perform = mockMvc.perform(post("/submit") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + perform + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.response.message").value("problemNumber: problemNumber가 존재해야 합니다.")); + + } + + @DisplayName("problemNumber가 음수일 경우 처리한다.") + @Test + void submitWhenProblemNumberIsNegative() throws Exception { + // when + Long defenseSessionId = 1L; + Long problemNumber = -1L; + Language language = Language.JAVA; + String sourceCode = "sourceCode"; + SubmitVisibility submitVisibility = SubmitVisibility.OPEN; + BaekjoonJudgementRequest request = BaekjoonJudgementRequest.builder() + .defenseSessionId(defenseSessionId) + .problemNumber(problemNumber) + .language(language) + .sourceCode(sourceCode) + .submitVisibility(submitVisibility) + .build(); + + doNothing().when(baekjoonSubmitUsecase).judgement(any()); + + // when + final ResultActions perform = mockMvc.perform(post("/submit") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + perform + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.response.message").value("problemNumber: problemNumber가 양수여야 합니다.")); + + } + + + @DisplayName("language가 null일 경우 처리한다.") + @Test + void submitWhenLanguageIsNull() throws Exception { + // when + Long defenseSessionId = 1L; + Long problemNumber = 1L; + Language language = null; + String sourceCode = "sourceCode"; + SubmitVisibility submitVisibility = SubmitVisibility.OPEN; + BaekjoonJudgementRequest request = BaekjoonJudgementRequest.builder() + .defenseSessionId(defenseSessionId) + .problemNumber(problemNumber) + .language(language) + .sourceCode(sourceCode) + .submitVisibility(submitVisibility) + .build(); + + doNothing().when(baekjoonSubmitUsecase).judgement(any()); + + // when + final ResultActions perform = mockMvc.perform(post("/submit") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + perform + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.response.message").value("language: language가 존재해야 합니다.")); + + } + + @DisplayName("sourceCode가 null일 경우 처리한다.") + @Test + void submitWhenSourceCodeIsNull() throws Exception { + // when + Long defenseSessionId = 1L; + Long problemNumber = 1L; + Language language = Language.JAVA; + String sourceCode = null; + SubmitVisibility submitVisibility = SubmitVisibility.OPEN; + BaekjoonJudgementRequest request = BaekjoonJudgementRequest.builder() + .defenseSessionId(defenseSessionId) + .problemNumber(problemNumber) + .language(language) + .sourceCode(sourceCode) + .submitVisibility(submitVisibility) + .build(); + + doNothing().when(baekjoonSubmitUsecase).judgement(any()); + + // when + final ResultActions perform = mockMvc.perform(post("/submit") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + perform + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.response.message").value("sourceCode: sourceCode가 존재해야 합니다.")); + + } + + @DisplayName("sourceCode가 비어있을 경우 처리한다.") + @Test + void submitWhenSourceCodeIsEmpty() throws Exception { + // when + Long defenseSessionId = 1L; + Long problemNumber = 1L; + Language language = Language.JAVA; + String sourceCode = ""; + SubmitVisibility submitVisibility = SubmitVisibility.OPEN; + BaekjoonJudgementRequest request = BaekjoonJudgementRequest.builder() + .defenseSessionId(defenseSessionId) + .problemNumber(problemNumber) + .language(language) + .sourceCode(sourceCode) + .submitVisibility(submitVisibility) + .build(); + + doNothing().when(baekjoonSubmitUsecase).judgement(any()); + + // when + final ResultActions perform = mockMvc.perform(post("/submit") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + perform + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.response.message").value("sourceCode: sourceCode가 존재해야 합니다.")); + + } + + @DisplayName("submitVisibility가 null일 경우 처리한다.") + @Test + void submitWhenSubmitVisibilityIsNull() throws Exception { + // when + Long defenseSessionId = 1L; + Long problemNumber = 1L; + Language language = Language.JAVA; + String sourceCode = "sourceCode"; + SubmitVisibility submitVisibility = null; + BaekjoonJudgementRequest request = BaekjoonJudgementRequest.builder() + .defenseSessionId(defenseSessionId) + .problemNumber(problemNumber) + .language(language) + .sourceCode(sourceCode) + .submitVisibility(submitVisibility) + .build(); + + doNothing().when(baekjoonSubmitUsecase).judgement(any()); + + // when + final ResultActions perform = mockMvc.perform(post("/submit") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + perform + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.response.message").value("submitVisibility: submitVisibility가 존재해야 합니다.")); + + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/infrastructure/controller/cookie/CookieControllerTest.java b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/controller/cookie/CookieControllerTest.java new file mode 100644 index 00000000..9c87eca1 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/controller/cookie/CookieControllerTest.java @@ -0,0 +1,58 @@ +package kr.co.morandi.backend.judgement.infrastructure.controller.cookie; + +import kr.co.morandi.backend.ControllerTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.ResultActions; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class CookieControllerTest extends ControllerTestSupport { + + @DisplayName("백준 쿠키 정보를 저장할 수 있다.") + @Test + void saveMemberBaekjoonCookie() throws Exception { + // given + String 쿠키 = "dummycookie"; + BaekjoonMemberCookieRequest request = new BaekjoonMemberCookieRequest(쿠키); + doNothing().when(baekjoonMemberCookieService).saveMemberBaekjoonCookie(any()); + + + // when + final ResultActions perform = mockMvc.perform(post("/cookie/baekjoon") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + perform + .andExpect(status().isOk()); + + } + + @DisplayName("cokkie 값이 빈 값일 경우 예외를 반환한다.") + @Test + void saveMemberBaekjoonCookieWithEmptyCookie() throws Exception { + // given + String 쿠키 = null; + BaekjoonMemberCookieRequest request = new BaekjoonMemberCookieRequest(쿠키); + doNothing().when(baekjoonMemberCookieService).saveMemberBaekjoonCookie(any()); + + + // when + final ResultActions perform = mockMvc.perform(post("/cookie/baekjoon") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + perform + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.response.message").value("cookie: Cookie 값은 비어 있을 수 없습니다.")); + + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/infrastructure/persistence/baekjoon/BaekjoonGlobalCookieRepositoryTest.java b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/persistence/baekjoon/BaekjoonGlobalCookieRepositoryTest.java new file mode 100644 index 00000000..d6d61f59 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/persistence/baekjoon/BaekjoonGlobalCookieRepositoryTest.java @@ -0,0 +1,52 @@ +package kr.co.morandi.backend.judgement.infrastructure.persistence.baekjoon; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie.BaekjoonCookie; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie.BaekjoonGlobalCookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; + +@Transactional +class BaekjoonGlobalCookieRepositoryTest extends IntegrationTestSupport { + + @Autowired + private BaekjoonGlobalCookieRepository globalCookieRepository; + + @DisplayName("유효한 글로벌 쿠키를 찾을 수 있다.") + @Test + void findValidGlobalCookies() { + // given + LocalDateTime 현재_시간 = LocalDateTime.of(2021, 1, 1, 0, 0, 0); + + BaekjoonCookie 백준_쿠키 = BaekjoonCookie.builder() + .cookie("cookie") + .nowDateTime(현재_시간) + .build(); + + BaekjoonGlobalCookie 글로벌_관리_쿠키 = BaekjoonGlobalCookie.builder() + .baekjoonCookie(백준_쿠키) + .globalUserId("globalUserId") + .baekjoonRefreshToken("refreshToken") + .build(); + globalCookieRepository.save(글로벌_관리_쿠키); + + // when + final List validGlobalCookies = globalCookieRepository.findValidGlobalCookies(현재_시간); + + + // then + assertThat(validGlobalCookies).isNotEmpty() + .extracting("baekjoonCookie", "globalUserId", "baekjoonRefreshToken") + .containsExactly(tuple(백준_쿠키, "globalUserId", "refreshToken")); + + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/infrastructure/persistence/baekjoon/BaekjoonMemberCookieRepositoryTest.java b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/persistence/baekjoon/BaekjoonMemberCookieRepositoryTest.java new file mode 100644 index 00000000..6b35c4ef --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/persistence/baekjoon/BaekjoonMemberCookieRepositoryTest.java @@ -0,0 +1,55 @@ +package kr.co.morandi.backend.judgement.infrastructure.persistence.baekjoon; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.factory.TestMemberFactory; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie.BaekjoonCookie; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.cookie.BaekjoonMemberCookie; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +class BaekjoonMemberCookieRepositoryTest extends IntegrationTestSupport { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private BaekjoonMemberCookieRepository baekjoonMemberCookieRepository; + + @DisplayName("사용자의 Id로 백준 쿠키를 찾을 수 있다.") + @Test + void findBaekjoonMemberCookieByMemberId() { + // given + Member 사용자 = TestMemberFactory.createMember(); + String 쿠키 = "dummyCookie"; + + BaekjoonMemberCookie 백준_사용자_쿠키 = BaekjoonMemberCookie.builder() + .member(사용자) + .cookie(쿠키) + .nowDateTime(LocalDateTime.of(2021, 1, 1, 0, 0)) + .build(); + + memberRepository.save(사용자); + baekjoonMemberCookieRepository.save(백준_사용자_쿠키); + + // when + Optional maybeBaekjoonMemberCookie = baekjoonMemberCookieRepository.findBaekjoonMemberCookieByMember_MemberId(사용자.getMemberId()); + + // then + assertThat(maybeBaekjoonMemberCookie).isPresent() + .get() + .extracting("baekjoonCookie.value").isEqualTo("dummyCookie"); + + } + + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/judgement/infrastructure/persistence/submit/BaekjoonSubmitRepositoryTest.java b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/persistence/submit/BaekjoonSubmitRepositoryTest.java new file mode 100644 index 00000000..7f9b53c0 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/judgement/infrastructure/persistence/submit/BaekjoonSubmitRepositoryTest.java @@ -0,0 +1,149 @@ +package kr.co.morandi.backend.judgement.infrastructure.persistence.submit; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense; +import kr.co.morandi.backend.defense_information.infrastructure.persistence.dailydefense.DailyDefenseRepository; +import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord; +import kr.co.morandi.backend.defense_record.infrastructure.persistence.dailydefense_record.DailyRecordRepository; +import kr.co.morandi.backend.factory.TestDefenseFactory; +import kr.co.morandi.backend.factory.TestMemberFactory; +import kr.co.morandi.backend.factory.TestProblemFactory; +import kr.co.morandi.backend.judgement.domain.model.baekjoon.submit.BaekjoonSubmit; +import kr.co.morandi.backend.judgement.domain.model.submit.SourceCode; +import kr.co.morandi.backend.judgement.domain.model.submit.SubmitVisibility; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.infrastructure.persistence.member.MemberRepository; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.problem.ProblemRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Optional; + +import static kr.co.morandi.backend.defense_management.domain.model.tempcode.model.Language.JAVA; +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +class BaekjoonSubmitRepositoryTest extends IntegrationTestSupport { + + @Autowired + private BaekjoonSubmitRepository baekjoonSubmitRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private DailyDefenseRepository dailyDefenseRepository; + + @Autowired + private DailyRecordRepository dailyRecordRepository; + + @DisplayName("Detail과 Record를 fetch 조인해서 제출을 찾아올 수 있다.") + @Test + void findSubmitJoinFetchDetailAndRecord() { + Member 사용자 = TestMemberFactory.createMember(); + memberRepository.save(사용자); + Map 문제 = TestProblemFactory.createProblems(5); + problemRepository.saveAll(문제.values()); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + dailyDefenseRepository.save(오늘의_문제); + + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + dailyRecordRepository.save(dailyRecord); + + LocalDateTime 제출시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + BaekjoonSubmit 백준_제출 = BaekjoonSubmit.builder() + .sourceCode(제출할_코드) + .member(사용자) + .submitDateTime(제출시간) + .detail(dailyRecord.getDetail(1L)) + .submitVisibility(SubmitVisibility.OPEN) + .build(); + + final BaekjoonSubmit 저장된_백준_제출 = baekjoonSubmitRepository.save(백준_제출); + + // when + final Optional 찾아온_백준_제출 = baekjoonSubmitRepository.findSubmitJoinFetchDetailAndRecord(저장된_백준_제출.getSubmitId()); + + + // then + assertThat(찾아온_백준_제출).isPresent() + .get() + .extracting("sourceCode.sourceCode", "sourceCode.language", + "member", "detail", "detail.submitCount", "detail.isSolved", + "detail.solvedTime", "submitVisibility", "detail.record.defense") + .containsExactly("code", JAVA, + 사용자, dailyRecord.getDetail(1L), 1L, false, + 0L, SubmitVisibility.OPEN, 오늘의_문제); + + + } + + @DisplayName("제출을 저장할 수 있다.") + @Test + void save() { + // given + Member 사용자 = TestMemberFactory.createMember(); + memberRepository.save(사용자); + Map 문제 = TestProblemFactory.createProblems(5); + problemRepository.saveAll(문제.values()); + DailyDefense 오늘의_문제 = TestDefenseFactory.createDailyDefense(문제); + dailyDefenseRepository.save(오늘의_문제); + + Map 시도할_문제 = Map.of(1L, 문제.get(1L)); + + DailyRecord dailyRecord = DailyRecord.builder() + .date(LocalDateTime.of(2021, 1, 1, 0, 0)) + .problems(시도할_문제) + .defense(오늘의_문제) + .member(사용자) + .build(); + dailyRecordRepository.save(dailyRecord); + + LocalDateTime 제출시간 = LocalDateTime.of(2021, 1, 1, 0, 0); + + SourceCode 제출할_코드 = SourceCode.builder() + .sourceCode("code") + .language(JAVA) + .build(); + + BaekjoonSubmit 백준_제출 = BaekjoonSubmit.builder() + .sourceCode(제출할_코드) + .member(사용자) + .submitDateTime(제출시간) + .detail(dailyRecord.getDetail(1L)) + .submitVisibility(SubmitVisibility.OPEN) + .build(); + + + // when + final BaekjoonSubmit 저장된_백준_제출 = baekjoonSubmitRepository.save(백준_제출); + + // then + assertThat(저장된_백준_제출) + .isNotNull() + .extracting("sourceCode.sourceCode", "sourceCode.language", "member", "detail", "submitVisibility", "detail.submitCount") + .containsExactly("code", JAVA, 사용자, dailyRecord.getDetail(1L), SubmitVisibility.OPEN, 1L); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/member_management/domain/model/member/MemberTest.java b/src/test/java/kr/co/morandi/backend/member_management/domain/model/member/MemberTest.java new file mode 100644 index 00000000..3862e479 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/member_management/domain/model/member/MemberTest.java @@ -0,0 +1,32 @@ +package kr.co.morandi.backend.member_management.domain.model.member; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static kr.co.morandi.backend.member_management.domain.model.member.SocialType.GOOGLE; +import static org.assertj.core.api.Assertions.assertThat; + +class MemberTest { + + @DisplayName("회원을 생성한다") + @Test + void createMember() { + // given + String nickname = "test"; + String email = "test@test.com"; + SocialType socialType = GOOGLE; + String profileImageURL = "testImageUrl"; + String description = "testDescription"; + + // when + Member member = Member.create(nickname, email, socialType, profileImageURL, description); + + // then + assertThat(member) + .extracting("nickname", "email", "socialType", "profileImageURL", "description") + .containsExactly(nickname, email, socialType, profileImageURL, description); + } + + + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/member_management/infrastructure/persistence/member/MemberRepositoryTest.java b/src/test/java/kr/co/morandi/backend/member_management/infrastructure/persistence/member/MemberRepositoryTest.java new file mode 100644 index 00000000..927bd9ed --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/member_management/infrastructure/persistence/member/MemberRepositoryTest.java @@ -0,0 +1,87 @@ +package kr.co.morandi.backend.member_management.infrastructure.persistence.member; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.factory.TestMemberFactory; +import kr.co.morandi.backend.member_management.domain.model.member.Member; +import kr.co.morandi.backend.member_management.domain.model.member.SocialType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Transactional +class MemberRepositoryTest extends IntegrationTestSupport { + + @Autowired + private MemberRepository memberRepository; + + @DisplayName("사용자 ID로 사용자와 쿠키를 조인해서 조회할 수 있다.") + @Test + void findMemberJoinFetchCookie() { + // given + Member 사용자 = TestMemberFactory.createMember(); + 사용자.saveBaekjoonCookie("dummyCookie", LocalDateTime.of(2021, 1, 1, 0, 0, 0)); + final Member 저장된_사용자 = memberRepository.save(사용자); + + + // when + Optional 찾은_사용자 = memberRepository.findMemberJoinFetchCookie(저장된_사용자.getMemberId()); + + // then + assertThat(찾은_사용자).isPresent() + .get() + .extracting("baekjoonMemberCookie.baekjoonCookie.value", "baekjoonMemberCookie.baekjoonCookie.expiredAt") + .contains("dummyCookie", LocalDateTime.of(2021, 1, 1, 0, 0, 0).plusHours(6)); + } + + + @DisplayName("닉네임이 이미 존재하면 true를 반환한다") + @Test + void existsByNickname() { + // given + String nickname = "test"; + Member member = Member.create(nickname, "test@test.com", SocialType.GOOGLE, "testImageUrl", "testDescription"); + memberRepository.save(member); + + // when + Boolean result = memberRepository.existsByNickname(nickname); + + // then + assertThat(result).isTrue(); + } + @DisplayName("닉네임이 이미 존재하지 않으면 false를 반환한다") + @Test + void whenNicknameNotExists() { + // given + String nickname = "test"; + + // when + Boolean result = memberRepository.existsByNickname(nickname); + + // then + assertThat(result).isFalse(); + } + + @DisplayName("닉네임이 이미 있는데 다른 회원이 사용하려고 하면 예외를 발생시킨다") + @Test + void test() { + // given + String nickname = "test"; + Member originMember = Member.create(nickname, "test@test.com", SocialType.GOOGLE, "testImageUrl", "testDescription"); + memberRepository.save(originMember); + + Member newMember = Member.create(nickname, "test2@test.com", SocialType.GOOGLE, "testImageUrl", "testDescription"); + // when & then + assertThatThrownBy(() -> memberRepository.save(newMember)) + .isInstanceOf(DataIntegrityViolationException.class); + + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/problem_information/domain/model/problem/ProblemTest.java b/src/test/java/kr/co/morandi/backend/problem_information/domain/model/problem/ProblemTest.java new file mode 100644 index 00000000..218e3377 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/problem_information/domain/model/problem/ProblemTest.java @@ -0,0 +1,46 @@ +package kr.co.morandi.backend.problem_information.domain.model.problem; + +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import kr.co.morandi.backend.problem_information.domain.model.problem.ProblemStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static kr.co.morandi.backend.problem_information.domain.model.problem.ProblemStatus.INIT; +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles("test") +class ProblemTest { + @DisplayName("문제가 처음 생성될 때, 문제의 상태는 INIT이어야 한다.") + @Test + void createProblem() { + // when + Problem problem1 = Problem.create(1L, B5, 0L); + + // then + assertThat(problem1) + .extracting("baekjoonProblemId", "problemTier", "problemStatus", "solvedCount") + .containsExactly( + 1L, B5, INIT, 0L + ); + } + + @DisplayName("문제가 활성화되면 문제의 상태는 ACTIVE여야 한다.") + @Test + void activate() { + // given + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + + // when + problem1.activate(); + + // then + assertThat(problem1.getProblemStatus()).isEqualTo(ProblemStatus.ACTIVE); + assertThat(problem2.getProblemStatus()).isEqualTo(INIT); + + } + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/problem_information/infrastructure/adapter/problem/ProblemAdapterTest.java b/src/test/java/kr/co/morandi/backend/problem_information/infrastructure/adapter/problem/ProblemAdapterTest.java new file mode 100644 index 00000000..dfa221a5 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/problem_information/infrastructure/adapter/problem/ProblemAdapterTest.java @@ -0,0 +1,9 @@ +package kr.co.morandi.backend.problem_information.infrastructure.adapter.problem; + +import kr.co.morandi.backend.IntegrationTestSupport; + + +class ProblemAdapterTest extends IntegrationTestSupport { + + +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/problem_information/infrastructure/adapter/problemcontent/ProblemContentAdapterTest.java b/src/test/java/kr/co/morandi/backend/problem_information/infrastructure/adapter/problemcontent/ProblemContentAdapterTest.java new file mode 100644 index 00000000..fd31f7b4 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/problem_information/infrastructure/adapter/problemcontent/ProblemContentAdapterTest.java @@ -0,0 +1,133 @@ +package kr.co.morandi.backend.problem_information.infrastructure.adapter.problemcontent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.morandi.backend.problem_information.application.response.problemcontent.ProblemContent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; + +@ActiveProfiles("test") +class ProblemContentAdapterTest { + + private ProblemContentAdapter problemContentAdapter; + + private ExchangeFunction exchangeFunction; + + @BeforeEach + void setUp() { + ObjectMapper objectMapper = new ObjectMapper(); + + exchangeFunction = Mockito.mock(ExchangeFunction.class); + + WebClient webClient = WebClient.builder() + .exchangeFunction(exchangeFunction) + .build(); + + problemContentAdapter = new ProblemContentAdapter(webClient, objectMapper); + } + + + + @DisplayName("문제 번호 리스트를 받아서 해당 문제 번호의 문제 정보를 반환한다.") + @Test + void getProblemContents() { + // given + List list = List.of(1000L, 1001L); + + Mockito.when(exchangeFunction.exchange(Mockito.any())) + .thenReturn(Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body(""" + [ + { + "baekjoonProblemId": 1000, + "title": "A+B" + }, + { + "baekjoonProblemId": 1001, + "title": "A-B" + } + ]""") + .build())); + + + // when + final Map result = problemContentAdapter.getProblemContents(list); + + // then + assertThat(result.values()).hasSize(2) + .extracting("baekjoonProblemId", "title") + .containsExactlyInAnyOrder( + tuple(1000L, "A+B"), + tuple(1001L, "A-B") + ); + + + } + + @DisplayName("존재하지 않는 문제 번호를 포함하여 요청하면 해당 문제 번호를 제외하고 반환한다.") + @Test + void getProblemContentsContainsInvalidBaekjoonProblemId() { + // given + List list = List.of(1000L, 1001L, 999L); + + Mockito.when(exchangeFunction.exchange(Mockito.any())) + .thenReturn(Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body(""" + [ + { + "baekjoonProblemId": 1000, + "title": "A+B" + }, + { + "baekjoonProblemId": 1001, + "title": "A-B" + }, + { + "error" : "problem/999.json not exist" + } + ]""") + .build())); + + + // when + final Map result = problemContentAdapter.getProblemContents(list); + + // then + assertThat(result.values()).hasSize(2) + .extracting("baekjoonProblemId", "title") + .containsExactlyInAnyOrder( + tuple(1000L, "A+B"), + tuple(1001L, "A-B") + ); + + } + + @DisplayName("10개 이상의 문제 번호를 요청하면 예외가 발생한다.") + @Test + void getProblemContentsContainsMoreThan10BaekjoonProblemId() { + // given + List list = List.of(1000L, 1001L, 1002L, 1003L, 1004L, 1005L, 1006L, 1007L, 1008L, 1009L, 1010L); + + // when & then + assertThatThrownBy(() -> problemContentAdapter.getProblemContents(list)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("문제 번호는 10개 이하로 요청해주세요."); + + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/problem_information/infrastructure/config/AlgorithmInitializerTest.java b/src/test/java/kr/co/morandi/backend/problem_information/infrastructure/config/AlgorithmInitializerTest.java new file mode 100644 index 00000000..85c0c152 --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/problem_information/infrastructure/config/AlgorithmInitializerTest.java @@ -0,0 +1,59 @@ +package kr.co.morandi.backend.problem_information.infrastructure.config; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.problem_information.domain.model.algorithm.Algorithm; +import kr.co.morandi.backend.problem_information.infrastructure.initializer.AlgorithmInitializer; +import kr.co.morandi.backend.problem_information.infrastructure.persistence.algorithm.AlgorithmRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.io.IOException; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; + +class AlgorithmInitializerTest extends IntegrationTestSupport { + @Autowired + private AlgorithmInitializer algorithmInitializer; + + @Autowired + private AlgorithmRepository algorithmRepository; + @AfterEach + void tearDown() { + algorithmRepository.deleteAllInBatch(); + } + @DisplayName("알고리즘 시드 데이터가 초기화가 정상적으로 이루어진다.") + @Test + void whenDatabaseIsEmpty_thenInitializesDataSuccessfully() throws IOException { + // when + algorithmInitializer.init(); + + // then + List allAlgorithms = algorithmRepository.findAll(); + + assertThat(allAlgorithms).hasSizeGreaterThan(0) + .extracting("bojTagId", "algorithmKey", "algorithmName") + .containsAnyOf( + tuple(1, "2_sat", "2-sat"), + tuple(218, "floor_sum", "유리 등차수열의 내림 합") + ); + + + } + @DisplayName("데이터베이스에 데이터가 이미 초기화되어 있을 때 중복 초기화가 방지된다.") + @Test + void whenDataAlreadyExists_thenPreventsDuplicateInitialization() throws IOException { + // given + algorithmInitializer.init(); + long initialCount = algorithmRepository.count(); + + // when + algorithmInitializer.init(); + + // then + assertThat(algorithmRepository.count()).isEqualTo(initialCount); + } +} \ No newline at end of file diff --git a/src/test/java/kr/co/morandi/backend/problem_information/infrastructure/persistence/problem/ProblemRepositoryTest.java b/src/test/java/kr/co/morandi/backend/problem_information/infrastructure/persistence/problem/ProblemRepositoryTest.java new file mode 100644 index 00000000..45f3030d --- /dev/null +++ b/src/test/java/kr/co/morandi/backend/problem_information/infrastructure/persistence/problem/ProblemRepositoryTest.java @@ -0,0 +1,76 @@ +package kr.co.morandi.backend.problem_information.infrastructure.persistence.problem; + +import kr.co.morandi.backend.IntegrationTestSupport; +import kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier; +import kr.co.morandi.backend.problem_information.domain.model.problem.Problem; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier.*; +import static kr.co.morandi.backend.problem_information.domain.model.problem.ProblemStatus.ACTIVE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +@Transactional +class ProblemRepositoryTest extends IntegrationTestSupport { + + @Autowired + private ProblemRepository problemRepository; + + @DisplayName("startTier와 endTier사이고, ACTIVE, dailyDefenseProblem에 속하지 않은 문제들을 가져올 수 있다.") + @Test + void findDailyDefenseProblems() { + // given + Problem problem1 = Problem.create(1L, B5, 1000L); + Problem problem2 = Problem.create(2L, S5, 2000L); + problem2.activate(); + Problem problem3 = Problem.create(3L, G5, 3000L); + + problemRepository.saveAll(List.of(problem1, problem2, problem3)); + + List tierRange = ProblemTier.tierRangeOf(S5, S1); + Long startSolvedCount = 1500L; + Long endSolvedCount = 2500L; + + PageRequest pageRequest = PageRequest.of(0, 1); + + List problems = problemRepository.getDailyDefenseProblems(tierRange, startSolvedCount, endSolvedCount, pageRequest); + + + // then + assertThat(problems).hasSize(1) + .allMatch(problem -> problem.getProblemTier().compareTo(S5) >= 0 + && problem.getProblemTier().compareTo(S1) <= 0); + + } + + @DisplayName("활성화된 문제들의 리스트를 조회할 수 있다.") + @Test + void findAllByProblemStatus() { + // given + Problem problem1 = Problem.create(1L, B5, 0L); + Problem problem2 = Problem.create(2L, S5, 0L); + Problem problem3 = Problem.create(3L, G5, 0L); + problem1.activate(); + problem2.activate(); + problemRepository.saveAll(List.of(problem1, problem2, problem3)); + + // when + List problems = problemRepository.findAllByProblemStatus(ACTIVE); + + // then + assertThat(problems) + .hasSize(2) + .extracting("baekjoonProblemId", "problemTier", "problemStatus", "solvedCount") + .containsExactlyInAnyOrder( + tuple(1L, B5, ACTIVE, 0L), + tuple(2L, S5, ACTIVE, 0L) + ); + } + +} \ No newline at end of file diff --git a/src/test/resources/org.springframework.restdocs.templates/request-fields.snippet b/src/test/resources/org.springframework.restdocs.templates/request-fields.snippet new file mode 100644 index 00000000..95aa88f3 --- /dev/null +++ b/src/test/resources/org.springframework.restdocs.templates/request-fields.snippet @@ -0,0 +1,14 @@ +==== Request Fields +|=== +|Path|Type|Optional|Description + +{{#fields}} + +|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} +|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{#optional}}O{{/optional}}{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/fields}} + +|=== \ No newline at end of file diff --git a/src/test/resources/org.springframework.restdocs.templates/response-fields.snippet b/src/test/resources/org.springframework.restdocs.templates/response-fields.snippet new file mode 100644 index 00000000..795882b6 --- /dev/null +++ b/src/test/resources/org.springframework.restdocs.templates/response-fields.snippet @@ -0,0 +1,14 @@ +==== Response Fields +|=== +|Path|Type|Optional|Description + +{{#fields}} + +|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} +|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{#optional}}O{{/optional}}{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/fields}} + +|=== \ No newline at end of file