diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 80a77c62..ba246e78 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,7 +9,7 @@ - [ ] sre: 시스템 관리 및 IT 인프라 자동화 작업 ### 🪾 반영 브랜치 - + ### ✨ 변경 사항 diff --git a/.github/workflows/deployment-to-dev.yml b/.github/workflows/deploy-to-dev.yml similarity index 94% rename from .github/workflows/deployment-to-dev.yml rename to .github/workflows/deploy-to-dev.yml index 02f5b49a..fabf2485 100644 --- a/.github/workflows/deployment-to-dev.yml +++ b/.github/workflows/deploy-to-dev.yml @@ -1,8 +1,8 @@ -name: Deployment to Development Server +name: Deploy to Development Server on: push: - branches: [develop] + branches: [dev] env: AWS_IAM_ROLE_TO_ASSUME: ${{ secrets.DEV_AWS_IAM_ROLE_TO_ASSUME }} @@ -168,7 +168,7 @@ jobs: { "author": { "name": "${{ github.actor }}" }, "title": "Deployment Succeeded", - "description": "Branch: `${{ github.ref_name }}`\nWorkflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", + "description": "Branch: `${{ github.ref_name }}`\nWorkflow: [View on GitHub](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "color": 10478271, "fields": [ { "name": "Service", "value": "`${{ steps.extract-name.outputs.service_name }}`", "inline": true }, @@ -189,7 +189,7 @@ jobs: { "author": { "name": "${{ github.actor }}" }, "title": "Deployment Failed", - "description": "Branch: `${{ github.ref_name }}`\nWorkflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n**Action**: App Runner will automatically attempt to roll back to the last known good configuration.", + "description": "Branch: `${{ github.ref_name }}`\nWorkflow: [View on GitHub](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})\n\n**Action**: App Runner will automatically attempt to roll back to the last known good configuration.", "color": 13458524, "fields": [ { "name": "Service", "value": "`${{ steps.extract-name.outputs.service_name }}`", "inline": true }, diff --git a/.github/workflows/deployment-to-prod.yml b/.github/workflows/deploy-to-prod.yml similarity index 95% rename from .github/workflows/deployment-to-prod.yml rename to .github/workflows/deploy-to-prod.yml index 0a9e72a0..5ff5d716 100644 --- a/.github/workflows/deployment-to-prod.yml +++ b/.github/workflows/deploy-to-prod.yml @@ -1,4 +1,4 @@ -name: Deployment to Production Server +name: Deploy to Production Server on: push: @@ -145,7 +145,7 @@ jobs: { "author": { "name": "${{ github.actor }}" }, "title": "Deployment Succeeded", - "description": "Branch: `${{ github.ref_name }}`\nWorkflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", + "description": "Branch: `${{ github.ref_name }}`\nWorkflow: [View on GitHub](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "color": 10478271, "fields": [ { "name": "Cluster", "value": "`${{ env.ECS_CLUSTER }}`", "inline": true }, @@ -188,7 +188,7 @@ jobs: { "author": { "name": "${{ github.actor }}" }, "title": "Deployment Failed", - "description": "Branch: `${{ github.ref_name }}`\nWorkflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n**Action**: Attempted to roll back to the previous stable task definition.", + "description": "Branch: `${{ github.ref_name }}`\nWorkflow: [View on GitHub](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})\n\n**Action**: Attempted to roll back to the previous stable task definition.", "color": 13458524, "fields": [ { "name": "Service", "value": "`${{ env.ECS_SERVICE }}`", "inline": true }, diff --git a/.github/workflows/deployemnt-to-stg.yml b/.github/workflows/deploy-to-stg.yml similarity index 95% rename from .github/workflows/deployemnt-to-stg.yml rename to .github/workflows/deploy-to-stg.yml index 64514061..347d7040 100644 --- a/.github/workflows/deployemnt-to-stg.yml +++ b/.github/workflows/deploy-to-stg.yml @@ -1,4 +1,4 @@ -name: Deployment to Staging Server +name: Deploy to Staging Server on: push: @@ -145,7 +145,7 @@ jobs: { "author": { "name": "${{ github.actor }}" }, "title": "Deployment Succeeded", - "description": "Branch: `${{ github.ref_name }}`\nWorkflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", + "description": "Branch: `${{ github.ref_name }}`\nWorkflow: [View on GitHub](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "color": 10478271, "fields": [ { "name": "Cluster", "value": "`${{ env.ECS_CLUSTER }}`", "inline": true }, @@ -188,7 +188,7 @@ jobs: { "author": { "name": "${{ github.actor }}" }, "title": "Deployment Failed", - "description": "Branch: `${{ github.ref_name }}`\nWorkflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n**Action**: Attempted to roll back to the previous stable task definition.", + "description": "Branch: `${{ github.ref_name }}`\nWorkflow: [View on GitHub](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})\n\n**Action**: Attempted to roll back to the previous stable task definition.", "color": 13458524, "fields": [ { "name": "Service", "value": "`${{ env.ECS_SERVICE }}`", "inline": true }, diff --git a/.github/workflows/notify-assigned-issue.yml b/.github/workflows/notify-assigned-issue.yml index 7d304cf2..37f83d79 100644 --- a/.github/workflows/notify-assigned-issue.yml +++ b/.github/workflows/notify-assigned-issue.yml @@ -25,7 +25,7 @@ jobs: { "title": "${{ github.event.issue.title }}", "color": 10478271, - "description": "${{ github.event.issue.html_url }}", + "description": "[View on GitHub](${{ github.event.issue.html_url }})", "fields": [ { "name": "Issue Number", diff --git a/.github/workflows/notify-on-comment.yml b/.github/workflows/notify-on-comment.yml new file mode 100644 index 00000000..484cbc1e --- /dev/null +++ b/.github/workflows/notify-on-comment.yml @@ -0,0 +1,67 @@ +name: Notify on Comment + +on: + issue_comment: + types: [created] + +env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + +permissions: + contents: read + +jobs: + notify-on-comment: + runs-on: ubuntu-latest + steps: + - name: Send PR Comment Notification + if: github.event.issue.pull_request + uses: Ilshidur/action-discord@0.3.2 + with: + args: "A new comment has been added to the pull request 💬" + env: + DISCORD_WEBHOOK: ${{ env.DISCORD_WEBHOOK }} + DISCORD_EMBEDS: | + [ + { + "author": { + "name": "${{ github.event.comment.user.login }}" + }, + "title": "Comment on PR #${{ github.event.issue.number }}: ${{ github.event.issue.title }}", + "color": 10478271, + "description": "${{ github.event.comment.body }}\n\n[View Comment](${{ github.event.comment.html_url }})", + "fields": [ + { + "name": "Pull Request", + "value": "[Link](${{ github.event.issue.html_url }})", + "inline": true + } + ] + } + ] + + - name: Send Issue Comment Notification + if: ${{ !github.event.issue.pull_request }} + uses: Ilshidur/action-discord@0.3.2 + with: + args: "A new comment has been added to the issue 💬" + env: + DISCORD_WEBHOOK: ${{ env.DISCORD_WEBHOOK }} + DISCORD_EMBEDS: | + [ + { + "author": { + "name": "${{ github.event.comment.user.login }}" + }, + "title": "Comment on Issue #${{ github.event.issue.number }}: ${{ github.event.issue.title }}", + "color": 10478271, + "description": "${{ github.event.comment.body }}\n\n[View Comment](${{ github.event.comment.html_url }})", + "fields": [ + { + "name": "Issue", + "value": "[Link](${{ github.event.issue.html_url }})", + "inline": true + } + ] + } + ] diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index e5791ffa..2916aaa9 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -27,7 +27,8 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis @@ -71,7 +72,7 @@ jobs: }, "title": "#${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}", "color": 10478271, - "description": "${{ github.event.pull_request.html_url }}", + "description": "[View on GitHub](${{ github.event.pull_request.html_url }})", "fields": [ { "name": "Base Branch", @@ -102,7 +103,7 @@ jobs: }, "title": "#${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}", "color": 13458524, - "description": "${{ github.event.pull_request.html_url }}", + "description": "[View on GitHub](${{ github.event.pull_request.html_url }})", "fields": [ { "name": "Base Branch", diff --git a/build.gradle b/build.gradle index bf9f7fd1..66a36107 100644 --- a/build.gradle +++ b/build.gradle @@ -54,6 +54,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-log4j2' testImplementation 'org.springframework.boot:spring-boot-starter-test' @@ -137,7 +138,8 @@ jacocoTestCoverageVerification { classDirectories.setFrom(files(classDirectories.files.collect { fileTree(dir: it, excludes: [ '**/*Application.class', - '**/config/*Config.class' + '**/config/*Config.class', + '**/GlobalExceptionHandler.class' ]) })) } diff --git a/checkstyle/naver-checkstyle-rules.xml b/checkstyle/naver-checkstyle-rules.xml index e298381c..80632859 100644 --- a/checkstyle/naver-checkstyle-rules.xml +++ b/checkstyle/naver-checkstyle-rules.xml @@ -426,7 +426,7 @@ The following rules in the Naver coding convention cannot be checked by this con - - + + diff --git a/src/main/java/com/und/server/ServerApplication.java b/src/main/java/com/und/server/ServerApplication.java index 8508ee81..e0d37e45 100644 --- a/src/main/java/com/und/server/ServerApplication.java +++ b/src/main/java/com/und/server/ServerApplication.java @@ -4,9 +4,11 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableFeignClients +@EnableScheduling @ConfigurationPropertiesScan public class ServerApplication { diff --git a/src/main/java/com/und/server/config/ObservabilityUserConfig.java b/src/main/java/com/und/server/auth/config/ObservabilityUserConfig.java similarity index 55% rename from src/main/java/com/und/server/config/ObservabilityUserConfig.java rename to src/main/java/com/und/server/auth/config/ObservabilityUserConfig.java index 2a9ffa44..96cb45cd 100644 --- a/src/main/java/com/und/server/config/ObservabilityUserConfig.java +++ b/src/main/java/com/und/server/auth/config/ObservabilityUserConfig.java @@ -1,4 +1,4 @@ -package com.und.server.config; +package com.und.server.auth.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -10,14 +10,19 @@ @Configuration public class ObservabilityUserConfig { - @Value("${observability.prometheus.username}") - private String prometheusUsername; + private final String prometheusUsername; + private final String prometheusPassword; - @Value("${observability.prometheus.password}") - private String prometheusPassword; + public ObservabilityUserConfig( + @Value("${observability.prometheus.username}") final String prometheusUsername, + @Value("${observability.prometheus.password}") final String prometheusPassword + ) { + this.prometheusUsername = prometheusUsername; + this.prometheusPassword = prometheusPassword; + } @Bean - public InMemoryUserDetailsManager prometheusUserDetails(PasswordEncoder passwordEncoder) { + public InMemoryUserDetailsManager prometheusUserDetails(final PasswordEncoder passwordEncoder) { return new InMemoryUserDetailsManager(User.builder() .username(prometheusUsername) .password(passwordEncoder.encode(prometheusPassword)) diff --git a/src/main/java/com/und/server/config/SecurityConfig.java b/src/main/java/com/und/server/auth/config/SecurityConfig.java similarity index 69% rename from src/main/java/com/und/server/config/SecurityConfig.java rename to src/main/java/com/und/server/auth/config/SecurityConfig.java index b5fac196..7340d0bd 100644 --- a/src/main/java/com/und/server/config/SecurityConfig.java +++ b/src/main/java/com/und/server/auth/config/SecurityConfig.java @@ -1,4 +1,4 @@ -package com.und.server.config; +package com.und.server.auth.config; import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; import org.springframework.context.annotation.Bean; @@ -15,8 +15,9 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import com.und.server.security.CustomAuthenticationEntryPoint; -import com.und.server.security.JwtAuthenticationFilter; +import com.und.server.auth.filter.CustomAuthenticationEntryPoint; +import com.und.server.auth.filter.JwtAuthenticationFilter; +import com.und.server.common.util.ProfileManager; import lombok.RequiredArgsConstructor; @@ -27,6 +28,7 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final ProfileManager profileManager; @Bean public PasswordEncoder passwordEncoder() { @@ -35,7 +37,7 @@ public PasswordEncoder passwordEncoder() { @Bean @Order(1) - public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain actuatorSecurityFilterChain(final HttpSecurity http) throws Exception { return http .securityMatcher(EndpointRequest.toAnyEndpoint()) .authorizeHttpRequests(authorize -> authorize @@ -51,21 +53,27 @@ public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws @Bean @Order(2) - public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain apiSecurityFilterChain(final HttpSecurity http) throws Exception { return http + .authorizeHttpRequests(authorize -> { + authorize + .requestMatchers(HttpMethod.POST, "/v*/auth/**").permitAll() + .requestMatchers("/error").permitAll(); + + if (!profileManager.isProdOrStgProfile()) { + authorize + .requestMatchers(HttpMethod.POST, "/v*/test/access").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll(); + } + + authorize.anyRequest().authenticated(); + }) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(handler -> handler.authenticationEntryPoint(customAuthenticationEntryPoint)) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) - .sessionManagement(sessionManagement -> sessionManagement - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(authorize -> authorize - // FIXME: Remove "/v*/test/access" when deleting TestController - .requestMatchers(HttpMethod.POST, "/v*/auth/**", "/v*/test/access").permitAll() - .requestMatchers("/error").permitAll() - .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() - .anyRequest().authenticated()) - .exceptionHandling(handler -> handler.authenticationEntryPoint(customAuthenticationEntryPoint)) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); } diff --git a/src/main/java/com/und/server/auth/controller/AuthController.java b/src/main/java/com/und/server/auth/controller/AuthController.java new file mode 100644 index 00000000..4b1f4cff --- /dev/null +++ b/src/main/java/com/und/server/auth/controller/AuthController.java @@ -0,0 +1,63 @@ +package com.und.server.auth.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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; + +import com.und.server.auth.dto.request.AuthRequest; +import com.und.server.auth.dto.request.NonceRequest; +import com.und.server.auth.dto.request.RefreshTokenRequest; +import com.und.server.auth.dto.response.AuthResponse; +import com.und.server.auth.dto.response.NonceResponse; +import com.und.server.auth.filter.AuthMember; +import com.und.server.auth.service.AuthService; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/auth") +public class AuthController { + + private final AuthService authService; + + @PostMapping("/nonce") + @ApiResponse(responseCode = "201", description = "Nonce created") + public ResponseEntity generateNonce(@RequestBody @Valid final NonceRequest nonceRequest) { + final NonceResponse nonceResponse = authService.generateNonce(nonceRequest); + + return ResponseEntity.status(HttpStatus.CREATED).body(nonceResponse); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody @Valid final AuthRequest authRequest) { + final AuthResponse authResponse = authService.login(authRequest); + + return ResponseEntity.status(HttpStatus.OK).body(authResponse); + } + + @PostMapping("/tokens") + @ApiResponse(responseCode = "201", description = "Tokens reissued") + public ResponseEntity reissueTokens( + @RequestBody @Valid final RefreshTokenRequest refreshTokenRequest + ) { + final AuthResponse authResponse = authService.reissueTokens(refreshTokenRequest); + + return ResponseEntity.status(HttpStatus.CREATED).body(authResponse); + } + + @DeleteMapping("/logout") + @ApiResponse(responseCode = "204", description = "Logout successful") + public ResponseEntity logout(@Parameter(hidden = true) @AuthMember final Long memberId) { + authService.logout(memberId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + +} diff --git a/src/main/java/com/und/server/dto/OidcPublicKey.java b/src/main/java/com/und/server/auth/dto/OidcPublicKey.java similarity index 63% rename from src/main/java/com/und/server/dto/OidcPublicKey.java rename to src/main/java/com/und/server/auth/dto/OidcPublicKey.java index c542f9cd..f5178eef 100644 --- a/src/main/java/com/und/server/dto/OidcPublicKey.java +++ b/src/main/java/com/und/server/auth/dto/OidcPublicKey.java @@ -1,26 +1,24 @@ -package com.und.server.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; +package com.und.server.auth.dto; import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "OIDC Public Key Details") public record OidcPublicKey( @Schema(description = "Key ID", example = "a1b2c3d4e5") - @JsonProperty("kid") String kid, + String kid, @Schema(description = "Key Type", example = "RSA") - @JsonProperty("kty") String kty, + String kty, @Schema(description = "Algorithm", example = "RS256") - @JsonProperty("alg") String alg, + String alg, @Schema(description = "Usage", example = "sig") - @JsonProperty("use") String use, + String use, @Schema(description = "Modulus", example = "q8zZ0b_MNaLd6Ny8wd4...") - @JsonProperty("n") String n, + String n, @Schema(description = "Exponent", example = "AQAB") - @JsonProperty("e") String e + String e ) { } diff --git a/src/main/java/com/und/server/dto/OidcPublicKeys.java b/src/main/java/com/und/server/auth/dto/OidcPublicKeys.java similarity index 56% rename from src/main/java/com/und/server/dto/OidcPublicKeys.java rename to src/main/java/com/und/server/auth/dto/OidcPublicKeys.java index 069b3173..3d48a124 100644 --- a/src/main/java/com/und/server/dto/OidcPublicKeys.java +++ b/src/main/java/com/und/server/auth/dto/OidcPublicKeys.java @@ -1,22 +1,21 @@ -package com.und.server.dto; +package com.und.server.auth.dto; import java.util.List; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.common.exception.ServerException; import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "A list of OIDC Public Keys") public record OidcPublicKeys( @Schema(description = "List of public keys", example = "[...]") - @JsonProperty("keys") List keys + List keys ) { public OidcPublicKey matchingKey(final String kid, final String alg) { return keys.stream() .filter(key -> key.kid().equals(kid) && key.alg().equals(alg)) .findAny() - .orElseThrow(() -> new ServerException(ServerErrorResult.PUBLIC_KEY_NOT_FOUND)); + .orElseThrow(() -> new ServerException(AuthErrorResult.PUBLIC_KEY_NOT_FOUND)); } } diff --git a/src/main/java/com/und/server/dto/AuthRequest.java b/src/main/java/com/und/server/auth/dto/request/AuthRequest.java similarity index 50% rename from src/main/java/com/und/server/dto/AuthRequest.java rename to src/main/java/com/und/server/auth/dto/request/AuthRequest.java index 8ae29d50..b43b11a5 100644 --- a/src/main/java/com/und/server/dto/AuthRequest.java +++ b/src/main/java/com/und/server/auth/dto/request/AuthRequest.java @@ -1,15 +1,13 @@ -package com.und.server.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; +package com.und.server.auth.dto.request; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; @Schema(description = "Request for Authentication with ID Token") public record AuthRequest( @Schema(description = "OAuth provider name", example = "kakao") - @NotNull(message = "Provider must not be null") @JsonProperty("provider") String provider, + @NotBlank(message = "Provider name must not be blank") String provider, @Schema(description = "ID Token from the OAuth provider", example = "eyJhbGciOiJIUzI1Ni...") - @NotNull(message = "ID Token must not be null") @JsonProperty("id_token") String idToken + @NotBlank(message = "ID Token must not be blank") String idToken ) { } diff --git a/src/main/java/com/und/server/auth/dto/request/NonceRequest.java b/src/main/java/com/und/server/auth/dto/request/NonceRequest.java new file mode 100644 index 00000000..423d01ea --- /dev/null +++ b/src/main/java/com/und/server/auth/dto/request/NonceRequest.java @@ -0,0 +1,10 @@ +package com.und.server.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "Request for issuing a Nonce") +public record NonceRequest( + @Schema(description = "OAuth provider name", example = "kakao") + @NotBlank(message = "Provider name must not be blank") String provider +) { } diff --git a/src/main/java/com/und/server/auth/dto/request/RefreshTokenRequest.java b/src/main/java/com/und/server/auth/dto/request/RefreshTokenRequest.java new file mode 100644 index 00000000..8db3d3e2 --- /dev/null +++ b/src/main/java/com/und/server/auth/dto/request/RefreshTokenRequest.java @@ -0,0 +1,13 @@ +package com.und.server.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "Request to Reissue Tokens") +public record RefreshTokenRequest( + @Schema(description = "Expired Access Token", example = "eyJhbGciOiJIUzI1Ni...") + @NotBlank(message = "Access Token must not be blank") String accessToken, + + @Schema(description = "Valid Refresh Token", example = "a1b2c3d4-e5f6-78...") + @NotBlank(message = "Refresh Token must not be blank") String refreshToken +) { } diff --git a/src/main/java/com/und/server/dto/AuthResponse.java b/src/main/java/com/und/server/auth/dto/response/AuthResponse.java similarity index 60% rename from src/main/java/com/und/server/dto/AuthResponse.java rename to src/main/java/com/und/server/auth/dto/response/AuthResponse.java index 6a22433c..3e6cf4a3 100644 --- a/src/main/java/com/und/server/dto/AuthResponse.java +++ b/src/main/java/com/und/server/auth/dto/response/AuthResponse.java @@ -1,23 +1,21 @@ -package com.und.server.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; +package com.und.server.auth.dto.response; import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "Authentication Token Response") public record AuthResponse( @Schema(description = "Token type", example = "Bearer") - @JsonProperty("token_type") String tokenType, + String tokenType, @Schema(description = "Access Token for API authentication", example = "eyJhbGciOiJIUzI1Ni...") - @JsonProperty("access_token") String accessToken, + String accessToken, @Schema(description = "Access Token expiration time in seconds", example = "3600") - @JsonProperty("access_token_expires_in") Integer accessTokenExpiresIn, + Integer accessTokenExpiresIn, @Schema(description = "Refresh Token for renewing the Access Token", example = "a1b2c3d4-e5f6-78...") - @JsonProperty("refresh_token") String refreshToken, + String refreshToken, @Schema(description = "Refresh Token expiration time in seconds", example = "604800") - @JsonProperty("refresh_token_expires_in") Integer refreshTokenExpiresIn + Integer refreshTokenExpiresIn ) { } diff --git a/src/main/java/com/und/server/auth/dto/response/NonceResponse.java b/src/main/java/com/und/server/auth/dto/response/NonceResponse.java new file mode 100644 index 00000000..06bcaca9 --- /dev/null +++ b/src/main/java/com/und/server/auth/dto/response/NonceResponse.java @@ -0,0 +1,9 @@ +package com.und.server.auth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Response with a Nonce") +public record NonceResponse( + @Schema(description = "A unique and single-use string for security", example = "a1b2c3d4-e5f6-78...") + String nonce +) { } diff --git a/src/main/java/com/und/server/entity/Nonce.java b/src/main/java/com/und/server/auth/entity/Nonce.java similarity index 85% rename from src/main/java/com/und/server/entity/Nonce.java rename to src/main/java/com/und/server/auth/entity/Nonce.java index d71395a9..7d9c40bc 100644 --- a/src/main/java/com/und/server/entity/Nonce.java +++ b/src/main/java/com/und/server/auth/entity/Nonce.java @@ -1,9 +1,9 @@ -package com.und.server.entity; +package com.und.server.auth.entity; import org.springframework.data.annotation.Id; import org.springframework.data.redis.core.RedisHash; -import com.und.server.oauth.Provider; +import com.und.server.auth.oauth.Provider; import lombok.AccessLevel; import lombok.AllArgsConstructor; diff --git a/src/main/java/com/und/server/entity/RefreshToken.java b/src/main/java/com/und/server/auth/entity/RefreshToken.java similarity index 74% rename from src/main/java/com/und/server/entity/RefreshToken.java rename to src/main/java/com/und/server/auth/entity/RefreshToken.java index 3ac3197b..6ea48f9a 100644 --- a/src/main/java/com/und/server/entity/RefreshToken.java +++ b/src/main/java/com/und/server/auth/entity/RefreshToken.java @@ -1,4 +1,4 @@ -package com.und.server.entity; +package com.und.server.auth.entity; import org.springframework.data.annotation.Id; import org.springframework.data.redis.core.RedisHash; @@ -13,12 +13,12 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder -@RedisHash(value = "refreshToken", timeToLive = 604800) // Valid for 7 days +@RedisHash(value = "refreshToken", timeToLive = 1209600) // Valid for 14 days public class RefreshToken { @Id private Long memberId; - private String refreshToken; + private String value; } diff --git a/src/main/java/com/und/server/exception/ServerErrorResult.java b/src/main/java/com/und/server/auth/exception/AuthErrorResult.java similarity index 65% rename from src/main/java/com/und/server/exception/ServerErrorResult.java rename to src/main/java/com/und/server/auth/exception/AuthErrorResult.java index 943a9cc7..26f154e5 100644 --- a/src/main/java/com/und/server/exception/ServerErrorResult.java +++ b/src/main/java/com/und/server/auth/exception/AuthErrorResult.java @@ -1,27 +1,29 @@ -package com.und.server.exception; +package com.und.server.auth.exception; import org.springframework.http.HttpStatus; +import com.und.server.common.exception.ErrorResult; + import lombok.Getter; import lombok.RequiredArgsConstructor; @Getter @RequiredArgsConstructor -public enum ServerErrorResult { +public enum AuthErrorResult implements ErrorResult { - INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "Invalid Parameter"), INVALID_NONCE(HttpStatus.BAD_REQUEST, "Invalid nonce"), INVALID_PROVIDER(HttpStatus.BAD_REQUEST, "Invalid Provider"), + INVALID_PROVIDER_ID(HttpStatus.BAD_REQUEST, "Invalid Provider ID"), PUBLIC_KEY_NOT_FOUND(HttpStatus.BAD_REQUEST, "Public Key Not Found"), INVALID_PUBLIC_KEY(HttpStatus.BAD_REQUEST, "Invalid Public Key"), EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "Expired Token"), + NOT_EXPIRED_TOKEN(HttpStatus.BAD_REQUEST, "Not Expired Token"), MALFORMED_TOKEN(HttpStatus.BAD_REQUEST, "Malformed Token"), INVALID_TOKEN_SIGNATURE(HttpStatus.UNAUTHORIZED, "Invalid Token Signature"), + UNSUPPORTED_TOKEN(HttpStatus.BAD_REQUEST, "Unsupported Token"), + WEAK_TOKEN_KEY(HttpStatus.INTERNAL_SERVER_ERROR, "Token Key is Weak"), INVALID_TOKEN(HttpStatus.BAD_REQUEST, "Invalid Token"), - UNAUTHORIZED_ACCESS(HttpStatus.UNAUTHORIZED, "Unauthorized Access"), - // FIXME: Remove MEMBER_NOT_FOUND when deleting TestController - MEMBER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "Member Not Found"), - UNKNOWN_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "Unknown Exception"); + UNAUTHORIZED_ACCESS(HttpStatus.UNAUTHORIZED, "Unauthorized Access"); private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/com/und/server/auth/filter/AuthMember.java b/src/main/java/com/und/server/auth/filter/AuthMember.java new file mode 100644 index 00000000..0552c2da --- /dev/null +++ b/src/main/java/com/und/server/auth/filter/AuthMember.java @@ -0,0 +1,11 @@ +package com.und.server.auth.filter; + +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 AuthMember { +} diff --git a/src/main/java/com/und/server/auth/filter/AuthMemberArgumentResolver.java b/src/main/java/com/und/server/auth/filter/AuthMemberArgumentResolver.java new file mode 100644 index 00000000..23035c38 --- /dev/null +++ b/src/main/java/com/und/server/auth/filter/AuthMemberArgumentResolver.java @@ -0,0 +1,34 @@ +package com.und.server.auth.filter; + +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +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; + +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.common.exception.ServerException; + +@Component +public class AuthMemberArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(final MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthMember.class) + && parameter.getParameterType().equals(Long.class); + } + + @Override + public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer, + final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) { + final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof final Long memberId)) { + throw new ServerException(AuthErrorResult.UNAUTHORIZED_ACCESS); + } + return memberId; + } + +} diff --git a/src/main/java/com/und/server/security/CustomAuthenticationEntryPoint.java b/src/main/java/com/und/server/auth/filter/CustomAuthenticationEntryPoint.java similarity index 66% rename from src/main/java/com/und/server/security/CustomAuthenticationEntryPoint.java rename to src/main/java/com/und/server/auth/filter/CustomAuthenticationEntryPoint.java index 41ed9ae5..e82bdf86 100644 --- a/src/main/java/com/und/server/security/CustomAuthenticationEntryPoint.java +++ b/src/main/java/com/und/server/auth/filter/CustomAuthenticationEntryPoint.java @@ -1,4 +1,4 @@ -package com.und.server.security; +package com.und.server.auth.filter; import java.io.IOException; @@ -6,7 +6,7 @@ import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; -import com.und.server.exception.ServerErrorResult; +import com.und.server.auth.exception.AuthErrorResult; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -20,10 +20,10 @@ public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint @Override public void commence( - HttpServletRequest request, - HttpServletResponse response, - AuthenticationException authException + final HttpServletRequest request, + final HttpServletResponse response, + final AuthenticationException authException ) throws IOException { - errorResponseWriter.sendErrorResponse(response, ServerErrorResult.UNAUTHORIZED_ACCESS); + errorResponseWriter.sendErrorResponse(response, AuthErrorResult.UNAUTHORIZED_ACCESS); } } diff --git a/src/main/java/com/und/server/security/JwtAuthenticationFilter.java b/src/main/java/com/und/server/auth/filter/JwtAuthenticationFilter.java similarity index 74% rename from src/main/java/com/und/server/security/JwtAuthenticationFilter.java rename to src/main/java/com/und/server/auth/filter/JwtAuthenticationFilter.java index 8102e7b9..89eac499 100644 --- a/src/main/java/com/und/server/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/und/server/auth/filter/JwtAuthenticationFilter.java @@ -1,4 +1,4 @@ -package com.und.server.security; +package com.und.server.auth.filter; import java.io.IOException; import java.util.List; @@ -9,9 +9,9 @@ import org.springframework.util.AntPathMatcher; import org.springframework.web.filter.OncePerRequestFilter; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; -import com.und.server.jwt.JwtProvider; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.auth.jwt.JwtProvider; +import com.und.server.common.exception.ServerException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -29,20 +29,22 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final List permissivePaths = List.of("/v*/auth/tokens"); @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - + protected void doFilterInternal( + final HttpServletRequest request, + final HttpServletResponse response, + final FilterChain filterChain + ) throws ServletException, IOException { final String token = resolveToken(request); - if (token != null) { try { final Authentication authentication = jwtProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(authentication); - } catch (ServerException e) { + } catch (final ServerException e) { final boolean isPermissivePath = permissivePaths.stream().anyMatch( - pattern -> pathMatcher.match(pattern, request.getServletPath())); + pattern -> pathMatcher.match(pattern, request.getServletPath()) + ); - if (e.getErrorResult() != ServerErrorResult.EXPIRED_TOKEN || !isPermissivePath) { + if (e.getErrorResult() != AuthErrorResult.EXPIRED_TOKEN || !isPermissivePath) { errorResponseWriter.sendErrorResponse(response, e.getErrorResult()); return; } @@ -52,11 +54,12 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); } - private String resolveToken(HttpServletRequest request) { + private String resolveToken(final HttpServletRequest request) { final String bearerToken = request.getHeader("Authorization"); if (bearerToken != null && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } + return null; } diff --git a/src/main/java/com/und/server/security/SecurityErrorResponseWriter.java b/src/main/java/com/und/server/auth/filter/SecurityErrorResponseWriter.java similarity index 71% rename from src/main/java/com/und/server/security/SecurityErrorResponseWriter.java rename to src/main/java/com/und/server/auth/filter/SecurityErrorResponseWriter.java index c0af1d3d..47a5829c 100644 --- a/src/main/java/com/und/server/security/SecurityErrorResponseWriter.java +++ b/src/main/java/com/und/server/auth/filter/SecurityErrorResponseWriter.java @@ -1,4 +1,4 @@ -package com.und.server.security; +package com.und.server.auth.filter; import java.io.IOException; @@ -6,8 +6,8 @@ import org.springframework.stereotype.Component; import com.fasterxml.jackson.databind.ObjectMapper; -import com.und.server.dto.ErrorResponse; -import com.und.server.exception.ServerErrorResult; +import com.und.server.common.dto.response.ErrorResponse; +import com.und.server.common.exception.ErrorResult; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -18,7 +18,10 @@ public class SecurityErrorResponseWriter { private final ObjectMapper objectMapper; - public void sendErrorResponse(HttpServletResponse response, ServerErrorResult errorResult) throws IOException { + public void sendErrorResponse( + final HttpServletResponse response, + final ErrorResult errorResult + ) throws IOException { response.setStatus(errorResult.getHttpStatus().value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding("UTF-8"); diff --git a/src/main/java/com/und/server/jwt/JwtProperties.java b/src/main/java/com/und/server/auth/jwt/JwtProperties.java similarity index 92% rename from src/main/java/com/und/server/jwt/JwtProperties.java rename to src/main/java/com/und/server/auth/jwt/JwtProperties.java index 38bf8eac..cba68bcd 100644 --- a/src/main/java/com/und/server/jwt/JwtProperties.java +++ b/src/main/java/com/und/server/auth/jwt/JwtProperties.java @@ -1,4 +1,4 @@ -package com.und.server.jwt; +package com.und.server.auth.jwt; import javax.crypto.SecretKey; diff --git a/src/main/java/com/und/server/auth/jwt/JwtProvider.java b/src/main/java/com/und/server/auth/jwt/JwtProvider.java new file mode 100644 index 00000000..20ee0f42 --- /dev/null +++ b/src/main/java/com/und/server/auth/jwt/JwtProvider.java @@ -0,0 +1,181 @@ +package com.und.server.auth.jwt; + +import java.nio.charset.StandardCharsets; +import java.security.PublicKey; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.common.exception.ServerException; +import com.und.server.common.util.ProfileManager; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.JwtParserBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.SignatureException; +import io.jsonwebtoken.security.WeakKeyException; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class JwtProvider { + + private final JwtProperties jwtProperties; + private final ProfileManager profileManager; + + public Map getDecodedHeader(final String token) { + try { + final String decodedHeader = decodeBase64UrlPart(token.split("\\.")[0]); + return new ObjectMapper().readValue(decodedHeader, new TypeReference<>() { }); + } catch (final Exception e) { + throw new ServerException(AuthErrorResult.INVALID_TOKEN, e); + } + } + + public String extractNonce(final String idToken) { + try { + final String payloadJson = decodeBase64UrlPart(idToken.split("\\.")[1]); + final Map claims = new ObjectMapper().readValue(payloadJson, new TypeReference<>() { }); + return (String) claims.get("nonce"); + } catch (final Exception e) { + throw new ServerException(AuthErrorResult.INVALID_TOKEN, e); + } + } + + public String parseOidcIdToken( + final String token, + final String iss, + final String aud, + final PublicKey publicKey + ) { + final JwtParserBuilder builder = Jwts.parser() + .verifyWith(publicKey) + .requireIssuer(iss) + .requireAudience(aud); + final Claims claims = parseClaims(token, builder); + + return getValidSubject(claims); + } + + public String generateAccessToken(final Long memberId) { + final LocalDateTime now = LocalDateTime.now(); + final Date issuedAt = toDate(now); + final Date expiration = toDate(now.plusSeconds(jwtProperties.accessTokenExpireTime())); + + return Jwts.builder() + .subject(memberId.toString()) + .issuer(jwtProperties.issuer()) + .issuedAt(issuedAt) + .expiration(expiration) + .signWith(jwtProperties.secretKey()) + .compact(); + } + + public Authentication getAuthentication(final String token) { + final Long memberId = getMemberIdFromToken(token); + final List authorities = Collections.emptyList(); + + return new UsernamePasswordAuthenticationToken(memberId, token, authorities); + } + + public Long getMemberIdFromToken(final String token) { + final Claims claims = parseClaims(token, getAccessTokenParserBuilder()); + return getMemberIdFromClaims(claims); + } + + private Claims parseClaims(final String token, final JwtParserBuilder builder) { + try { + return parseToken(token, builder); + } catch (final ExpiredJwtException e) { + throw new ServerException(AuthErrorResult.EXPIRED_TOKEN, e); + } + } + + public ParsedTokenInfo parseTokenForReissue(final String token) { + try { + final Claims claims = parseToken(token, getAccessTokenParserBuilder()); + return new ParsedTokenInfo(getMemberIdFromClaims(claims), false); + } catch (final ExpiredJwtException e) { + // If the token is expired, we can still extract the member ID. + return new ParsedTokenInfo(getMemberIdFromClaims(e.getClaims()), true); + } + } + + private Claims parseToken(final String token, final JwtParserBuilder builder) { + try { + return builder.build() + .parseSignedClaims(token) + .getPayload(); + } catch (final ExpiredJwtException e) { + // This must be re-thrown for parseTokenForReissue to work correctly. + throw e; + } catch (final JwtException e) { + // For prod or stg environments, return a generic error to avoid leaking details. + if (profileManager.isProdOrStgProfile()) { + throw new ServerException(AuthErrorResult.UNAUTHORIZED_ACCESS, e); + } + + // For non-production environments, provide detailed error messages. + if (e instanceof MalformedJwtException) { + throw new ServerException(AuthErrorResult.MALFORMED_TOKEN, e); + } else if (e instanceof UnsupportedJwtException) { + throw new ServerException(AuthErrorResult.UNSUPPORTED_TOKEN, e); + } else if (e instanceof WeakKeyException) { + throw new ServerException(AuthErrorResult.WEAK_TOKEN_KEY, e); + } else if (e instanceof SignatureException) { + throw new ServerException(AuthErrorResult.INVALID_TOKEN_SIGNATURE, e); + } else { + // Fallback for any other JWT-related exceptions. + throw new ServerException(AuthErrorResult.INVALID_TOKEN, e); + } + } + } + + private JwtParserBuilder getAccessTokenParserBuilder() { + return Jwts.parser() + .verifyWith(jwtProperties.secretKey()); + } + + private String decodeBase64UrlPart(final String encodedPart) { + return new String(Decoders.BASE64URL.decode(encodedPart), StandardCharsets.UTF_8); + } + + private Date toDate(final LocalDateTime dateTime) { + return Date.from(dateTime.atZone(ZoneId.systemDefault()).toInstant()); + } + + private Long getMemberIdFromClaims(final Claims claims) { + final String subject = getValidSubject(claims); + try { + return Long.valueOf(subject); + } catch (final NumberFormatException e) { + // The subject was not a valid Long, which is unexpected for our tokens. + throw new ServerException(AuthErrorResult.INVALID_TOKEN, e); + } + } + + private String getValidSubject(final Claims claims) { + final String subject = claims.getSubject(); + if (subject == null) { + throw new ServerException(AuthErrorResult.INVALID_TOKEN); + } + return subject; + } + +} diff --git a/src/main/java/com/und/server/auth/jwt/ParsedTokenInfo.java b/src/main/java/com/und/server/auth/jwt/ParsedTokenInfo.java new file mode 100644 index 00000000..b141d1bd --- /dev/null +++ b/src/main/java/com/und/server/auth/jwt/ParsedTokenInfo.java @@ -0,0 +1,6 @@ +package com.und.server.auth.jwt; + +public record ParsedTokenInfo( + Long memberId, + boolean isExpired +) { } diff --git a/src/main/java/com/und/server/auth/oauth/AppleClient.java b/src/main/java/com/und/server/auth/oauth/AppleClient.java new file mode 100644 index 00000000..e7e6d77a --- /dev/null +++ b/src/main/java/com/und/server/auth/oauth/AppleClient.java @@ -0,0 +1,17 @@ +package com.und.server.auth.oauth; + +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + +import com.und.server.auth.dto.OidcPublicKeys; + +@FeignClient(name = "AppleClient", url = "${oauth.apple.base-url}") +public interface AppleClient extends OidcClient { + + @Override + @Cacheable(cacheNames = "OidcApple", cacheManager = "oidcCacheManager") + @GetMapping("${oauth.apple.public-key-url}") + OidcPublicKeys getOidcPublicKeys(); + +} diff --git a/src/main/java/com/und/server/auth/oauth/AppleProvider.java b/src/main/java/com/und/server/auth/oauth/AppleProvider.java new file mode 100644 index 00000000..94a38349 --- /dev/null +++ b/src/main/java/com/und/server/auth/oauth/AppleProvider.java @@ -0,0 +1,45 @@ +package com.und.server.auth.oauth; + +import java.security.PublicKey; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.und.server.auth.dto.OidcPublicKeys; +import com.und.server.auth.jwt.JwtProvider; + +@Component +public class AppleProvider implements OidcProvider { + + private final JwtProvider jwtProvider; + private final PublicKeyProvider publicKeyProvider; + private final String appleBaseUrl; + private final String appleAppId; + + public AppleProvider( + final JwtProvider jwtProvider, + final PublicKeyProvider publicKeyProvider, + @Value("${oauth.apple.base-url}") final String appleBaseUrl, + @Value("${oauth.apple.app-id}") final String appleAppId + ) { + this.jwtProvider = jwtProvider; + this.publicKeyProvider = publicKeyProvider; + this.appleBaseUrl = appleBaseUrl; + this.appleAppId = appleAppId; + } + + @Override + public String getProviderId(final String token, final OidcPublicKeys oidcPublicKeys) { + final Map decodedHeader = jwtProvider.getDecodedHeader(token); + final PublicKey publicKey = publicKeyProvider.generatePublicKey(decodedHeader, oidcPublicKeys); + + return jwtProvider.parseOidcIdToken( + token, + appleBaseUrl, + appleAppId, + publicKey + ); + } + +} diff --git a/src/main/java/com/und/server/oauth/KakaoClient.java b/src/main/java/com/und/server/auth/oauth/KakaoClient.java similarity index 84% rename from src/main/java/com/und/server/oauth/KakaoClient.java rename to src/main/java/com/und/server/auth/oauth/KakaoClient.java index 47892227..04fa05be 100644 --- a/src/main/java/com/und/server/oauth/KakaoClient.java +++ b/src/main/java/com/und/server/auth/oauth/KakaoClient.java @@ -1,10 +1,10 @@ -package com.und.server.oauth; +package com.und.server.auth.oauth; import org.springframework.cache.annotation.Cacheable; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; -import com.und.server.dto.OidcPublicKeys; +import com.und.server.auth.dto.OidcPublicKeys; @FeignClient(name = "KakaoClient", url = "${oauth.kakao.base-url}") public interface KakaoClient extends OidcClient { diff --git a/src/main/java/com/und/server/oauth/KakaoProvider.java b/src/main/java/com/und/server/auth/oauth/KakaoProvider.java similarity index 69% rename from src/main/java/com/und/server/oauth/KakaoProvider.java rename to src/main/java/com/und/server/auth/oauth/KakaoProvider.java index 03aa673d..7fb84265 100644 --- a/src/main/java/com/und/server/oauth/KakaoProvider.java +++ b/src/main/java/com/und/server/auth/oauth/KakaoProvider.java @@ -1,4 +1,4 @@ -package com.und.server.oauth; +package com.und.server.auth.oauth; import java.security.PublicKey; import java.util.Map; @@ -6,8 +6,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import com.und.server.dto.OidcPublicKeys; -import com.und.server.jwt.JwtProvider; +import com.und.server.auth.dto.OidcPublicKeys; +import com.und.server.auth.jwt.JwtProvider; @Component public class KakaoProvider implements OidcProvider { @@ -18,10 +18,10 @@ public class KakaoProvider implements OidcProvider { private final String kakaoAppKey; public KakaoProvider( - JwtProvider jwtProvider, - PublicKeyProvider publicKeyProvider, - @Value("${oauth.kakao.base-url}") String kakaoBaseUrl, - @Value("${oauth.kakao.app-key}") String kakaoAppKey + final JwtProvider jwtProvider, + final PublicKeyProvider publicKeyProvider, + @Value("${oauth.kakao.base-url}") final String kakaoBaseUrl, + @Value("${oauth.kakao.app-key}") final String kakaoAppKey ) { this.jwtProvider = jwtProvider; this.publicKeyProvider = publicKeyProvider; @@ -30,7 +30,7 @@ public KakaoProvider( } @Override - public IdTokenPayload getIdTokenPayload(String token, OidcPublicKeys oidcPublicKeys) { + public String getProviderId(final String token, final OidcPublicKeys oidcPublicKeys) { final Map decodedHeader = jwtProvider.getDecodedHeader(token); final PublicKey publicKey = publicKeyProvider.generatePublicKey(decodedHeader, oidcPublicKeys); diff --git a/src/main/java/com/und/server/auth/oauth/OidcClient.java b/src/main/java/com/und/server/auth/oauth/OidcClient.java new file mode 100644 index 00000000..508b8129 --- /dev/null +++ b/src/main/java/com/und/server/auth/oauth/OidcClient.java @@ -0,0 +1,9 @@ +package com.und.server.auth.oauth; + +import com.und.server.auth.dto.OidcPublicKeys; + +public interface OidcClient { + + OidcPublicKeys getOidcPublicKeys(); + +} diff --git a/src/main/java/com/und/server/auth/oauth/OidcClientFactory.java b/src/main/java/com/und/server/auth/oauth/OidcClientFactory.java new file mode 100644 index 00000000..73a67905 --- /dev/null +++ b/src/main/java/com/und/server/auth/oauth/OidcClientFactory.java @@ -0,0 +1,31 @@ +package com.und.server.auth.oauth; + +import java.util.EnumMap; +import java.util.Map; +import java.util.Optional; + +import org.springframework.stereotype.Component; + +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.common.exception.ServerException; + +@Component +public class OidcClientFactory { + + private final Map oidcClients; + + public OidcClientFactory( + final KakaoClient kakaoClient, + final AppleClient appleClient + ) { + oidcClients = new EnumMap<>(Provider.class); + oidcClients.put(Provider.KAKAO, kakaoClient); + oidcClients.put(Provider.APPLE, appleClient); + } + + public OidcClient getOidcClient(final Provider provider) { + return Optional.ofNullable(oidcClients.get(provider)) + .orElseThrow(() -> new ServerException(AuthErrorResult.INVALID_PROVIDER)); + } + +} diff --git a/src/main/java/com/und/server/auth/oauth/OidcProvider.java b/src/main/java/com/und/server/auth/oauth/OidcProvider.java new file mode 100644 index 00000000..86a376fd --- /dev/null +++ b/src/main/java/com/und/server/auth/oauth/OidcProvider.java @@ -0,0 +1,9 @@ +package com.und.server.auth.oauth; + +import com.und.server.auth.dto.OidcPublicKeys; + +public interface OidcProvider { + + String getProviderId(final String token, final OidcPublicKeys oidcPublicKeys); + +} diff --git a/src/main/java/com/und/server/auth/oauth/OidcProviderFactory.java b/src/main/java/com/und/server/auth/oauth/OidcProviderFactory.java new file mode 100644 index 00000000..a2a8f7cf --- /dev/null +++ b/src/main/java/com/und/server/auth/oauth/OidcProviderFactory.java @@ -0,0 +1,40 @@ +package com.und.server.auth.oauth; + +import java.util.EnumMap; +import java.util.Map; +import java.util.Optional; + +import org.springframework.stereotype.Component; + +import com.und.server.auth.dto.OidcPublicKeys; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.common.exception.ServerException; + +@Component +public class OidcProviderFactory { + + private final Map oidcProviders; + + public OidcProviderFactory( + final KakaoProvider kakaoProvider, + final AppleProvider appleProvider + ) { + this.oidcProviders = new EnumMap<>(Provider.class); + oidcProviders.put(Provider.KAKAO, kakaoProvider); + oidcProviders.put(Provider.APPLE, appleProvider); + } + + public String getProviderId( + final Provider provider, + final String token, + final OidcPublicKeys oidcPublicKeys + ) { + return getOidcProvider(provider).getProviderId(token, oidcPublicKeys); + } + + private OidcProvider getOidcProvider(final Provider provider) { + return Optional.ofNullable(oidcProviders.get(provider)) + .orElseThrow(() -> new ServerException(AuthErrorResult.INVALID_PROVIDER)); + } + +} diff --git a/src/main/java/com/und/server/oauth/Provider.java b/src/main/java/com/und/server/auth/oauth/Provider.java similarity index 68% rename from src/main/java/com/und/server/oauth/Provider.java rename to src/main/java/com/und/server/auth/oauth/Provider.java index 9da19f45..7e14b5cf 100644 --- a/src/main/java/com/und/server/oauth/Provider.java +++ b/src/main/java/com/und/server/auth/oauth/Provider.java @@ -1,4 +1,4 @@ -package com.und.server.oauth; +package com.und.server.auth.oauth; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -7,7 +7,8 @@ @RequiredArgsConstructor public enum Provider { - KAKAO("kakao"); + KAKAO("kakao"), + APPLE("apple"); private final String name; diff --git a/src/main/java/com/und/server/oauth/PublicKeyProvider.java b/src/main/java/com/und/server/auth/oauth/PublicKeyProvider.java similarity index 57% rename from src/main/java/com/und/server/oauth/PublicKeyProvider.java rename to src/main/java/com/und/server/auth/oauth/PublicKeyProvider.java index 256e9c46..b5bf5e06 100644 --- a/src/main/java/com/und/server/oauth/PublicKeyProvider.java +++ b/src/main/java/com/und/server/auth/oauth/PublicKeyProvider.java @@ -1,4 +1,4 @@ -package com.und.server.oauth; +package com.und.server.auth.oauth; import java.math.BigInteger; import java.security.KeyFactory; @@ -11,15 +11,15 @@ import org.springframework.stereotype.Component; -import com.und.server.dto.OidcPublicKey; -import com.und.server.dto.OidcPublicKeys; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; +import com.und.server.auth.dto.OidcPublicKey; +import com.und.server.auth.dto.OidcPublicKeys; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.common.exception.ServerException; @Component public class PublicKeyProvider { - public PublicKey generatePublicKey(Map decodedHeader, OidcPublicKeys oidcPublicKeys) { + public PublicKey generatePublicKey(final Map decodedHeader, final OidcPublicKeys oidcPublicKeys) { final OidcPublicKey matchingKey = oidcPublicKeys .matchingKey(decodedHeader.get("kid"), decodedHeader.get("alg")); @@ -28,16 +28,16 @@ public PublicKey generatePublicKey(Map decodedHeader, OidcPublic private PublicKey getPublicKey(final OidcPublicKey matchingKey) { try { - byte[] modulusBytes = Base64.getUrlDecoder().decode(matchingKey.n()); - byte[] exponentBytes = Base64.getUrlDecoder().decode(matchingKey.e()); + final byte[] modulusBytes = Base64.getUrlDecoder().decode(matchingKey.n()); + final byte[] exponentBytes = Base64.getUrlDecoder().decode(matchingKey.e()); final BigInteger modulus = new BigInteger(1, modulusBytes); final BigInteger exponent = new BigInteger(1, exponentBytes); final RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(modulus, exponent); return KeyFactory.getInstance(matchingKey.kty()).generatePublic(publicKeySpec); - } catch (IllegalArgumentException | NoSuchAlgorithmException | InvalidKeySpecException e) { - throw new ServerException(ServerErrorResult.INVALID_PUBLIC_KEY, e); + } catch (final IllegalArgumentException | NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new ServerException(AuthErrorResult.INVALID_PUBLIC_KEY, e); } } diff --git a/src/main/java/com/und/server/repository/NonceRepository.java b/src/main/java/com/und/server/auth/repository/NonceRepository.java similarity index 71% rename from src/main/java/com/und/server/repository/NonceRepository.java rename to src/main/java/com/und/server/auth/repository/NonceRepository.java index dc13cde0..59ccf35c 100644 --- a/src/main/java/com/und/server/repository/NonceRepository.java +++ b/src/main/java/com/und/server/auth/repository/NonceRepository.java @@ -1,9 +1,9 @@ -package com.und.server.repository; +package com.und.server.auth.repository; import org.springframework.data.repository.CrudRepository; import org.springframework.stereotype.Repository; -import com.und.server.entity.Nonce; +import com.und.server.auth.entity.Nonce; @Repository public interface NonceRepository extends CrudRepository { } diff --git a/src/main/java/com/und/server/repository/RefreshTokenRepository.java b/src/main/java/com/und/server/auth/repository/RefreshTokenRepository.java similarity index 70% rename from src/main/java/com/und/server/repository/RefreshTokenRepository.java rename to src/main/java/com/und/server/auth/repository/RefreshTokenRepository.java index 209237f7..e2d19341 100644 --- a/src/main/java/com/und/server/repository/RefreshTokenRepository.java +++ b/src/main/java/com/und/server/auth/repository/RefreshTokenRepository.java @@ -1,9 +1,9 @@ -package com.und.server.repository; +package com.und.server.auth.repository; import org.springframework.data.repository.CrudRepository; import org.springframework.stereotype.Repository; -import com.und.server.entity.RefreshToken; +import com.und.server.auth.entity.RefreshToken; @Repository public interface RefreshTokenRepository extends CrudRepository { } diff --git a/src/main/java/com/und/server/auth/service/AuthService.java b/src/main/java/com/und/server/auth/service/AuthService.java new file mode 100644 index 00000000..d268448e --- /dev/null +++ b/src/main/java/com/und/server/auth/service/AuthService.java @@ -0,0 +1,153 @@ +package com.und.server.auth.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.und.server.auth.dto.OidcPublicKeys; +import com.und.server.auth.dto.request.AuthRequest; +import com.und.server.auth.dto.request.NonceRequest; +import com.und.server.auth.dto.request.RefreshTokenRequest; +import com.und.server.auth.dto.response.AuthResponse; +import com.und.server.auth.dto.response.NonceResponse; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.auth.jwt.JwtProperties; +import com.und.server.auth.jwt.JwtProvider; +import com.und.server.auth.jwt.ParsedTokenInfo; +import com.und.server.auth.oauth.OidcClient; +import com.und.server.auth.oauth.OidcClientFactory; +import com.und.server.auth.oauth.OidcProviderFactory; +import com.und.server.auth.oauth.Provider; +import com.und.server.common.dto.request.TestAuthRequest; +import com.und.server.common.exception.ServerException; +import com.und.server.common.util.ProfileManager; +import com.und.server.member.entity.Member; +import com.und.server.member.exception.MemberErrorResult; +import com.und.server.member.service.MemberService; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private final MemberService memberService; + private final OidcClientFactory oidcClientFactory; + private final OidcProviderFactory oidcProviderFactory; + private final JwtProvider jwtProvider; + private final JwtProperties jwtProperties; + private final NonceService nonceService; + private final RefreshTokenService refreshTokenService; + private final ProfileManager profileManager; + + @Transactional + public AuthResponse issueTokensForTest(final TestAuthRequest request) { + final Provider provider = convertToProvider(request.provider()); + final String providerId = request.providerId(); + final Member member = memberService.findOrCreateMember(provider, providerId); + + return issueTokens(member.getId()); + } + + @Transactional + public NonceResponse generateNonce(final NonceRequest nonceRequest) { + final String nonce = nonceService.generateNonceValue(); + final Provider provider = convertToProvider(nonceRequest.provider()); + + nonceService.saveNonce(nonce, provider); + + return new NonceResponse(nonce); + } + + @Transactional + public AuthResponse login(final AuthRequest authRequest) { + final Provider provider = convertToProvider(authRequest.provider()); + final String idToken = authRequest.idToken(); + + verifyIdTokenNonce(provider, idToken); + final String providerId = getProviderIdFromIdToken(provider, idToken); + final Member member = memberService.findOrCreateMember(provider, providerId); + + return issueTokens(member.getId()); + } + + @Transactional + public AuthResponse reissueTokens(final RefreshTokenRequest refreshTokenRequest) { + final String accessToken = refreshTokenRequest.accessToken(); + final String providedRefreshToken = refreshTokenRequest.refreshToken(); + + final Long memberId = getMemberIdForReissue(accessToken); + + try { + memberService.checkMemberExists(memberId); + } catch (final ServerException e) { + if (e.getErrorResult() == MemberErrorResult.MEMBER_NOT_FOUND) { + // The member ID is not null, but the member doesn't exist. + // This is a security concern, so delete the orphaned refresh token. + refreshTokenService.deleteRefreshToken(memberId); + } + // For both MEMBER_NOT_FOUND and INVALID_MEMBER_ID, treat it as an invalid token situation. + throw new ServerException(AuthErrorResult.INVALID_TOKEN, e); + } + + refreshTokenService.verifyRefreshToken(memberId, providedRefreshToken); + + return issueTokens(memberId); + } + + @Transactional + public void logout(final Long memberId) { + refreshTokenService.deleteRefreshToken(memberId); + } + + private Provider convertToProvider(final String providerName) { + try { + return Provider.valueOf(providerName.toUpperCase()); + } catch (final IllegalArgumentException e) { + throw new ServerException(AuthErrorResult.INVALID_PROVIDER); + } + } + + private void verifyIdTokenNonce(final Provider provider, final String idToken) { + final String nonce = jwtProvider.extractNonce(idToken); + nonceService.verifyNonce(nonce, provider); + } + + private String getProviderIdFromIdToken(final Provider provider, final String idToken) { + final OidcClient oidcClient = oidcClientFactory.getOidcClient(provider); + final OidcPublicKeys oidcPublicKeys = oidcClient.getOidcPublicKeys(); + + return oidcProviderFactory.getProviderId(provider, idToken, oidcPublicKeys); + } + + private AuthResponse issueTokens(final Long memberId) { + final String accessToken = jwtProvider.generateAccessToken(memberId); + final String refreshToken = refreshTokenService.generateRefreshToken(); + refreshTokenService.saveRefreshToken(memberId, refreshToken); + + return new AuthResponse( + jwtProperties.type(), + accessToken, + jwtProperties.accessTokenExpireTime(), + refreshToken, + jwtProperties.refreshTokenExpireTime()); + } + + private Long getMemberIdForReissue(final String accessToken) { + final ParsedTokenInfo tokenInfo = jwtProvider.parseTokenForReissue(accessToken); + final Long memberId = tokenInfo.memberId(); + + if (!tokenInfo.isExpired()) { + // An attempt to reissue with a non-expired token may be a security risk. + // For security, delete the refresh token. + refreshTokenService.deleteRefreshToken(memberId); + if (profileManager.isProdOrStgProfile()) { + throw new ServerException(AuthErrorResult.INVALID_TOKEN); + } + throw new ServerException(AuthErrorResult.NOT_EXPIRED_TOKEN); + } + + return memberId; + } + +} diff --git a/src/main/java/com/und/server/auth/service/NonceService.java b/src/main/java/com/und/server/auth/service/NonceService.java new file mode 100644 index 00000000..7cfd44d6 --- /dev/null +++ b/src/main/java/com/und/server/auth/service/NonceService.java @@ -0,0 +1,64 @@ +package com.und.server.auth.service; + +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.und.server.auth.entity.Nonce; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.auth.oauth.Provider; +import com.und.server.auth.repository.NonceRepository; +import com.und.server.common.exception.ServerException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NonceService { + + private final NonceRepository nonceRepository; + + public String generateNonceValue() { + return UUID.randomUUID().toString(); + } + + @Transactional + public void verifyNonce(final String value, final Provider provider) { + validateNonceValue(value); + validateProvider(provider); + + nonceRepository.findById(value) + .filter(n -> n.getProvider() == provider) + .orElseThrow(() -> new ServerException(AuthErrorResult.INVALID_NONCE)); + + nonceRepository.deleteById(value); + } + + @Transactional + public void saveNonce(final String value, final Provider provider) { + validateNonceValue(value); + validateProvider(provider); + + final Nonce nonce = Nonce.builder() + .value(value) + .provider(provider) + .build(); + + nonceRepository.save(nonce); + } + + private void validateNonceValue(final String value) { + if (value == null) { + throw new ServerException(AuthErrorResult.INVALID_NONCE); + } + } + + private void validateProvider(final Provider provider) { + if (provider == null) { + throw new ServerException(AuthErrorResult.INVALID_PROVIDER); + } + } + +} diff --git a/src/main/java/com/und/server/auth/service/RefreshTokenService.java b/src/main/java/com/und/server/auth/service/RefreshTokenService.java new file mode 100644 index 00000000..24604e32 --- /dev/null +++ b/src/main/java/com/und/server/auth/service/RefreshTokenService.java @@ -0,0 +1,73 @@ +package com.und.server.auth.service; + +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.und.server.auth.entity.RefreshToken; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.auth.repository.RefreshTokenRepository; +import com.und.server.common.exception.ServerException; +import com.und.server.member.exception.MemberErrorResult; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + + public String generateRefreshToken() { + return UUID.randomUUID().toString(); + } + + @Transactional + public void verifyRefreshToken(final Long memberId, final String providedToken) { + validateMemberIdIsNotNull(memberId); + validateTokenValueIsNotNull(providedToken); + + refreshTokenRepository.findById(memberId) + .map(RefreshToken::getValue) + .filter(savedToken -> providedToken.equals(savedToken)) + .orElseThrow(() -> { + deleteRefreshToken(memberId); + return new ServerException(AuthErrorResult.INVALID_TOKEN); + }); + } + + @Transactional + public void saveRefreshToken(final Long memberId, final String value) { + validateMemberIdIsNotNull(memberId); + validateTokenValueIsNotNull(value); + + final RefreshToken token = RefreshToken.builder() + .memberId(memberId) + .value(value) + .build(); + + refreshTokenRepository.save(token); + } + + @Transactional + public void deleteRefreshToken(final Long memberId) { + validateMemberIdIsNotNull(memberId); + + refreshTokenRepository.deleteById(memberId); + } + + private void validateMemberIdIsNotNull(final Long memberId) { + if (memberId == null) { + throw new ServerException(MemberErrorResult.INVALID_MEMBER_ID); + } + } + + private void validateTokenValueIsNotNull(final String token) { + if (token == null) { + throw new ServerException(AuthErrorResult.INVALID_TOKEN); + } + } + +} diff --git a/src/main/java/com/und/server/common/config/AsyncConfig.java b/src/main/java/com/und/server/common/config/AsyncConfig.java new file mode 100644 index 00000000..490e15a0 --- /dev/null +++ b/src/main/java/com/und/server/common/config/AsyncConfig.java @@ -0,0 +1,32 @@ +package com.und.server.common.config; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean + @Primary + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(10); + executor.setThreadNamePrefix("async-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setKeepAliveSeconds(60); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.initialize(); + return executor; + } + +} diff --git a/src/main/java/com/und/server/config/RedisConfig.java b/src/main/java/com/und/server/common/config/RedisConfig.java similarity index 65% rename from src/main/java/com/und/server/config/RedisConfig.java rename to src/main/java/com/und/server/common/config/RedisConfig.java index 58f15971..0ab297f2 100644 --- a/src/main/java/com/und/server/config/RedisConfig.java +++ b/src/main/java/com/und/server/common/config/RedisConfig.java @@ -1,4 +1,4 @@ -package com.und.server.config; +package com.und.server.common.config; import java.time.Duration; @@ -10,6 +10,7 @@ import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisKeyValueAdapter; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; @@ -24,7 +25,7 @@ public class RedisConfig { @Bean - public CacheManager oidcCacheManager(RedisConnectionFactory redisConnectionFactory) { + public CacheManager oidcCacheManager(final RedisConnectionFactory redisConnectionFactory) { final RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith( RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()) @@ -37,4 +38,19 @@ public CacheManager oidcCacheManager(RedisConnectionFactory redisConnectionFacto return RedisCacheManager.builder(redisConnectionFactory).cacheDefaults(config).build(); } + @Bean + public RedisTemplate redisTemplate(final RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + + redisTemplate.afterPropertiesSet(); + return redisTemplate; + } + } diff --git a/src/main/java/com/und/server/config/SwaggerConfig.java b/src/main/java/com/und/server/common/config/SwaggerConfig.java similarity index 97% rename from src/main/java/com/und/server/config/SwaggerConfig.java rename to src/main/java/com/und/server/common/config/SwaggerConfig.java index 5736849d..ee232a35 100644 --- a/src/main/java/com/und/server/config/SwaggerConfig.java +++ b/src/main/java/com/und/server/common/config/SwaggerConfig.java @@ -1,4 +1,4 @@ -package com.und.server.config; +package com.und.server.common.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/com/und/server/common/config/TimeConfig.java b/src/main/java/com/und/server/common/config/TimeConfig.java new file mode 100644 index 00000000..c3772226 --- /dev/null +++ b/src/main/java/com/und/server/common/config/TimeConfig.java @@ -0,0 +1,16 @@ +package com.und.server.common.config; + +import java.time.Clock; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class TimeConfig { + + @Bean + public Clock clock() { + return Clock.systemUTC(); + } + +} diff --git a/src/main/java/com/und/server/common/config/WebMvcConfig.java b/src/main/java/com/und/server/common/config/WebMvcConfig.java new file mode 100644 index 00000000..bc9205fa --- /dev/null +++ b/src/main/java/com/und/server/common/config/WebMvcConfig.java @@ -0,0 +1,22 @@ +package com.und.server.common.config; + +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import com.und.server.auth.filter.AuthMemberArgumentResolver; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + private final AuthMemberArgumentResolver authMemberArgumentResolver; + + @Override + public void addArgumentResolvers(final List resolvers) { + resolvers.add(authMemberArgumentResolver); + } +} diff --git a/src/main/java/com/und/server/common/controller/TestController.java b/src/main/java/com/und/server/common/controller/TestController.java new file mode 100644 index 00000000..de8928dd --- /dev/null +++ b/src/main/java/com/und/server/common/controller/TestController.java @@ -0,0 +1,72 @@ +package com.und.server.common.controller; + + +import java.util.List; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.und.server.auth.dto.response.AuthResponse; +import com.und.server.auth.filter.AuthMember; +import com.und.server.auth.service.AuthService; +import com.und.server.common.dto.request.TestAuthRequest; +import com.und.server.common.dto.response.TestHelloResponse; +import com.und.server.member.dto.response.MemberResponse; +import com.und.server.member.entity.Member; +import com.und.server.member.service.MemberService; +import com.und.server.terms.dto.response.TermsAgreementResponse; +import com.und.server.terms.service.TermsService; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +// This controller is for testing/development and is disabled in prod/stg via @Profile. +@Profile("!prod & !stg") +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/test") +public class TestController { + + private final AuthService authService; + private final MemberService memberService; + private final TermsService termsService; + + @PostMapping("/access") + @ApiResponse(responseCode = "201", description = "Access token created") + public ResponseEntity loginWithoutProviderId(@RequestBody @Valid final TestAuthRequest request) { + final AuthResponse response = authService.issueTokensForTest(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/hello") + public ResponseEntity greet(@Parameter(hidden = true) @AuthMember final Long memberId) { + final Member member = memberService.findMemberById(memberId); + final String nickname = member.getNickname(); + final TestHelloResponse response = new TestHelloResponse("Hello, " + nickname + "!"); + + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + @GetMapping("/members") + public ResponseEntity> getMemberList() { + final List members = memberService.getMemberList(); + + return ResponseEntity.status(HttpStatus.OK).body(members); + } + + @GetMapping("/terms") + public ResponseEntity> getTermsList() { + final List terms = termsService.getTermsList(); + + return ResponseEntity.status(HttpStatus.OK).body(terms); + } + +} diff --git a/src/main/java/com/und/server/common/dto/request/TestAuthRequest.java b/src/main/java/com/und/server/common/dto/request/TestAuthRequest.java new file mode 100644 index 00000000..525a3f3b --- /dev/null +++ b/src/main/java/com/und/server/common/dto/request/TestAuthRequest.java @@ -0,0 +1,15 @@ +package com.und.server.common.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "Request for issuing test tokens") +public record TestAuthRequest( + @Schema(description = "OAuth provider name", example = "kakao") + @NotBlank(message = "Provider name must not be blank") + String provider, + + @Schema(description = "Unique ID from the provider", example = "123456789") + @NotBlank(message = "Provider ID must not be blank") + String providerId +) { } diff --git a/src/main/java/com/und/server/common/dto/response/ErrorResponse.java b/src/main/java/com/und/server/common/dto/response/ErrorResponse.java new file mode 100644 index 00000000..6b71a23a --- /dev/null +++ b/src/main/java/com/und/server/common/dto/response/ErrorResponse.java @@ -0,0 +1,12 @@ +package com.und.server.common.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "API Error Response") +public record ErrorResponse( + @Schema(description = "Error Code", example = "UNAUTHORIZED_ACCESS") + String code, + + @Schema(description = "Error Message", example = "Unauthorized Access") + Object message +) { } diff --git a/src/main/java/com/und/server/dto/TestHelloResponse.java b/src/main/java/com/und/server/common/dto/response/TestHelloResponse.java similarity index 68% rename from src/main/java/com/und/server/dto/TestHelloResponse.java rename to src/main/java/com/und/server/common/dto/response/TestHelloResponse.java index 2076026b..0bff920c 100644 --- a/src/main/java/com/und/server/dto/TestHelloResponse.java +++ b/src/main/java/com/und/server/common/dto/response/TestHelloResponse.java @@ -1,12 +1,9 @@ -package com.und.server.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; +package com.und.server.common.dto.response; import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "Response for the test hello endpoint") public record TestHelloResponse( @Schema(description = "Greeting message", example = "Hello, Chori!") - @JsonProperty("message") String message ) { } diff --git a/src/main/java/com/und/server/common/entity/BaseTimeEntity.java b/src/main/java/com/und/server/common/entity/BaseTimeEntity.java new file mode 100644 index 00000000..664bc5cf --- /dev/null +++ b/src/main/java/com/und/server/common/entity/BaseTimeEntity.java @@ -0,0 +1,24 @@ +package com.und.server.common.entity; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@Getter +@MappedSuperclass +public class BaseTimeEntity { + + @CreationTimestamp + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(nullable = false) + private LocalDateTime updatedAt; + +} diff --git a/src/main/java/com/und/server/common/exception/CommonErrorResult.java b/src/main/java/com/und/server/common/exception/CommonErrorResult.java new file mode 100644 index 00000000..1fa032fa --- /dev/null +++ b/src/main/java/com/und/server/common/exception/CommonErrorResult.java @@ -0,0 +1,19 @@ +package com.und.server.common.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CommonErrorResult implements ErrorResult { + + INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "Invalid Parameter"), + DATA_INTEGRITY_VIOLATION(HttpStatus.CONFLICT, "Data Integrity Violation"), + UNKNOWN_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "Unknown Exception"); + + private final HttpStatus httpStatus; + private final String message; + +} diff --git a/src/main/java/com/und/server/common/exception/ErrorResult.java b/src/main/java/com/und/server/common/exception/ErrorResult.java new file mode 100644 index 00000000..d3d03a3b --- /dev/null +++ b/src/main/java/com/und/server/common/exception/ErrorResult.java @@ -0,0 +1,15 @@ +package com.und.server.common.exception; + +import java.io.Serializable; + +import org.springframework.http.HttpStatus; + +public interface ErrorResult extends Serializable { + + String name(); + + HttpStatus getHttpStatus(); + + String getMessage(); + +} diff --git a/src/main/java/com/und/server/exception/GlobalExceptionHandler.java b/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java similarity index 54% rename from src/main/java/com/und/server/exception/GlobalExceptionHandler.java rename to src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java index 15886e15..0434f5aa 100644 --- a/src/main/java/com/und/server/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java @@ -1,9 +1,10 @@ -package com.und.server.exception; +package com.und.server.common.exception; import java.util.List; -import java.util.stream.Collectors; +import org.hibernate.exception.ConstraintViolationException; import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; @@ -13,7 +14,8 @@ import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; -import com.und.server.dto.ErrorResponse; +import com.und.server.common.dto.response.ErrorResponse; +import com.und.server.member.exception.MemberErrorResult; import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; @@ -23,7 +25,7 @@ @RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { - private ResponseEntity buildErrorResponse(final ServerErrorResult errorResult, final Object message) { + private ResponseEntity buildErrorResponse(final ErrorResult errorResult, final Object message) { return ResponseEntity.status(errorResult.getHttpStatus()) .body(new ErrorResponse(errorResult.name(), message)); } @@ -39,29 +41,51 @@ protected ResponseEntity handleMethodArgumentNotValid( .getAllErrors() .stream() .map(DefaultMessageSourceResolvable::getDefaultMessage) - .collect(Collectors.toList()); + .toList(); log.warn("Invalid DTO Parameter Errors: {}", errorList); - return this.buildErrorResponse(ServerErrorResult.INVALID_PARAMETER, errorList); + return this.buildErrorResponse(CommonErrorResult.INVALID_PARAMETER, errorList); } @ExceptionHandler({ServerException.class}) public ResponseEntity handleRestApiException(final ServerException exception) { + final ErrorResult errorResult = exception.getErrorResult(); log.warn("ServerException occur: ", exception); return this.buildErrorResponse( - exception.getErrorResult(), - exception.getErrorResult().getMessage() + errorResult, + errorResult.getMessage() ); } + @ExceptionHandler({DataIntegrityViolationException.class}) + public ResponseEntity handleDuplicateKey(final DataIntegrityViolationException exception) { + log.warn("Data Integrity Violation Exception occur: ", exception); + + ErrorResult errorResult = CommonErrorResult.DATA_INTEGRITY_VIOLATION; + if (exception.getCause() instanceof final ConstraintViolationException violationException) { + final String constraintName = violationException.getConstraintName(); + errorResult = switch (constraintName) { + case "uk_kakao_id" -> MemberErrorResult.DUPLICATE_KAKAO_ID; + case "uk_apple_id" -> MemberErrorResult.DUPLICATE_APPLE_ID; + default -> { + log.warn("Unhandled constraint violation: {}", constraintName); + yield CommonErrorResult.DATA_INTEGRITY_VIOLATION; + } + }; + } + + return this.buildErrorResponse(errorResult, errorResult.getMessage()); + } + @ExceptionHandler({Exception.class}) public ResponseEntity handleException(final Exception exception) { log.warn("Exception occur: ", exception); return this.buildErrorResponse( - ServerErrorResult.UNKNOWN_EXCEPTION, - ServerErrorResult.UNKNOWN_EXCEPTION.getMessage() + CommonErrorResult.UNKNOWN_EXCEPTION, + CommonErrorResult.UNKNOWN_EXCEPTION.getMessage() ); } + } diff --git a/src/main/java/com/und/server/exception/ServerException.java b/src/main/java/com/und/server/common/exception/ServerException.java similarity index 52% rename from src/main/java/com/und/server/exception/ServerException.java rename to src/main/java/com/und/server/common/exception/ServerException.java index d976540b..0200a157 100644 --- a/src/main/java/com/und/server/exception/ServerException.java +++ b/src/main/java/com/und/server/common/exception/ServerException.java @@ -1,18 +1,18 @@ -package com.und.server.exception; +package com.und.server.common.exception; import lombok.Getter; @Getter public class ServerException extends RuntimeException { - private final ServerErrorResult errorResult; + private final ErrorResult errorResult; - public ServerException(ServerErrorResult errorResult) { + public ServerException(final ErrorResult errorResult) { super(errorResult.getMessage()); this.errorResult = errorResult; } - public ServerException(ServerErrorResult errorResult, Throwable cause) { + public ServerException(final ErrorResult errorResult, final Throwable cause) { super(errorResult.getMessage(), cause); this.errorResult = errorResult; } diff --git a/src/main/java/com/und/server/common/util/ProfileManager.java b/src/main/java/com/und/server/common/util/ProfileManager.java new file mode 100644 index 00000000..91821018 --- /dev/null +++ b/src/main/java/com/und/server/common/util/ProfileManager.java @@ -0,0 +1,21 @@ +package com.und.server.common.util; + +import java.util.Arrays; +import java.util.Set; + +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ProfileManager { + + private final Environment environment; + + public boolean isProdOrStgProfile() { + return Arrays.stream(environment.getActiveProfiles()) + .anyMatch(Set.of("prod", "stg")::contains); + } +} diff --git a/src/main/java/com/und/server/controller/AuthController.java b/src/main/java/com/und/server/controller/AuthController.java deleted file mode 100644 index bbc27de4..00000000 --- a/src/main/java/com/und/server/controller/AuthController.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.und.server.controller; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.und.server.dto.AuthRequest; -import com.und.server.dto.AuthResponse; -import com.und.server.dto.HandshakeRequest; -import com.und.server.dto.HandshakeResponse; -import com.und.server.dto.RefreshTokenRequest; -import com.und.server.service.AuthService; - -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/v1/auth") -public class AuthController { - - private final AuthService authService; - - @PostMapping("/nonce") - public ResponseEntity handshake(@RequestBody @Valid final HandshakeRequest handshakeRequest) { - final HandshakeResponse handshakeResponse = authService.handshake(handshakeRequest); - - return ResponseEntity.status(HttpStatus.OK).body(handshakeResponse); - } - - @PostMapping("/login") - public ResponseEntity login(@RequestBody @Valid final AuthRequest authRequest) { - final AuthResponse authResponse = authService.login(authRequest); - - return ResponseEntity.status(HttpStatus.OK).body(authResponse); - } - - @PostMapping("/tokens") - public ResponseEntity reissueTokens( - @RequestBody @Valid final RefreshTokenRequest refreshTokenRequest - ) { - final AuthResponse authResponse = authService.reissueTokens(refreshTokenRequest); - - return ResponseEntity.status(HttpStatus.OK).body(authResponse); - } - -} diff --git a/src/main/java/com/und/server/controller/TestController.java b/src/main/java/com/und/server/controller/TestController.java deleted file mode 100644 index 8e6374d9..00000000 --- a/src/main/java/com/und/server/controller/TestController.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.und.server.controller; - - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.und.server.dto.AuthResponse; -import com.und.server.dto.TestAuthRequest; -import com.und.server.dto.TestHelloResponse; -import com.und.server.entity.Member; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; -import com.und.server.repository.MemberRepository; -import com.und.server.service.AuthService; - -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/v1/test") -public class TestController { - - private final AuthService authService; - private final MemberRepository memberRepository; - - @PostMapping("/access") - public ResponseEntity requireAccessToken(@RequestBody @Valid TestAuthRequest request) { - final AuthResponse response = authService.issueTokensForTest(request); - return ResponseEntity.status(HttpStatus.OK).body(response); - } - - @GetMapping("/hello") - public ResponseEntity greet(Authentication authentication) { - final Long memberId = (Long) authentication.getPrincipal(); - final Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new ServerException(ServerErrorResult.MEMBER_NOT_FOUND)); - final String nickname = member.getNickname() != null ? member.getNickname() : "Member"; - final TestHelloResponse response = new TestHelloResponse("Hello, " + nickname + "!"); - - return ResponseEntity.status(HttpStatus.OK).body(response); - } - -} diff --git a/src/main/java/com/und/server/dto/ErrorResponse.java b/src/main/java/com/und/server/dto/ErrorResponse.java deleted file mode 100644 index 401a67b5..00000000 --- a/src/main/java/com/und/server/dto/ErrorResponse.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.und.server.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "API Error Response") -public record ErrorResponse( - @Schema(description = "Error Code", example = "INVALID_TOKEN") - @JsonProperty("code") - String code, - - @Schema(description = "Error Message", example = "Invalid Token") - @JsonProperty("message") - Object message -) { } diff --git a/src/main/java/com/und/server/dto/HandshakeRequest.java b/src/main/java/com/und/server/dto/HandshakeRequest.java deleted file mode 100644 index 32c814bd..00000000 --- a/src/main/java/com/und/server/dto/HandshakeRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.und.server.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; - -@Schema(description = "Request for Handshake") -public record HandshakeRequest( - @Schema(description = "OAuth provider name", example = "kakao") - @NotNull(message = "Provider must not be null") @JsonProperty("provider") String provider -) { } diff --git a/src/main/java/com/und/server/dto/HandshakeResponse.java b/src/main/java/com/und/server/dto/HandshakeResponse.java deleted file mode 100644 index aeb753a3..00000000 --- a/src/main/java/com/und/server/dto/HandshakeResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.und.server.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "Handshake Response with Nonce") -public record HandshakeResponse( - @Schema(description = "A unique and single-use string for security", example = "a1b2c3d4-e5f6-78...") - @JsonProperty("nonce") String nonce -) { } diff --git a/src/main/java/com/und/server/dto/RefreshTokenRequest.java b/src/main/java/com/und/server/dto/RefreshTokenRequest.java deleted file mode 100644 index 8e098fd9..00000000 --- a/src/main/java/com/und/server/dto/RefreshTokenRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.und.server.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; - -@Schema(description = "Request to Reissue Tokens") -public record RefreshTokenRequest( - @Schema(description = "Expired Access Token", example = "eyJhbGciOiJIUzI1Ni...") - @NotNull(message = "Access Token must not be null") @JsonProperty("access_token") String accessToken, - - @Schema(description = "Valid Refresh Token", example = "a1b2c3d4-e5f6-78...") - @NotNull(message = "Refresh Token must not be null") @JsonProperty("refresh_token") String refreshToken -) { } diff --git a/src/main/java/com/und/server/dto/TestAuthRequest.java b/src/main/java/com/und/server/dto/TestAuthRequest.java deleted file mode 100644 index aabc2cd2..00000000 --- a/src/main/java/com/und/server/dto/TestAuthRequest.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.und.server.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; - -@Schema(description = "Request for issuing test tokens") -public record TestAuthRequest( - @Schema(description = "OAuth provider name", example = "kakao") - @NotNull(message = "Provider must not be null") @JsonProperty("provider") - String provider, - - @Schema(description = "Unique ID from the provider", example = "123456789") - @NotNull(message = "Provider ID must not be null") @JsonProperty("provider_id") - String providerId, - - @Schema(description = "User's nickname", example = "Chori") - @NotNull(message = "Nickname must not be null") @JsonProperty("nickname") - String nickname -) { } diff --git a/src/main/java/com/und/server/entity/Member.java b/src/main/java/com/und/server/entity/Member.java deleted file mode 100644 index 37d63c3c..00000000 --- a/src/main/java/com/und/server/entity/Member.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.und.server.entity; - -import java.time.LocalDateTime; - -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Getter -@Table -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -public class Member { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column - private String nickname; - - @Column - private String kakaoId; - - @CreationTimestamp - @Column(nullable = false, updatable = false) - private LocalDateTime createdAt; - - @UpdateTimestamp - @Column - private LocalDateTime updatedAt; - -} diff --git a/src/main/java/com/und/server/jwt/JwtProvider.java b/src/main/java/com/und/server/jwt/JwtProvider.java deleted file mode 100644 index 3b920379..00000000 --- a/src/main/java/com/und/server/jwt/JwtProvider.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.und.server.jwt; - -import java.nio.charset.StandardCharsets; -import java.security.PublicKey; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.Map; - -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.stereotype.Component; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; -import com.und.server.oauth.IdTokenPayload; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.JwtException; -import io.jsonwebtoken.JwtParserBuilder; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.MalformedJwtException; -import io.jsonwebtoken.io.Decoders; -import io.jsonwebtoken.security.SecurityException; -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class JwtProvider { - - private final JwtProperties jwtProperties; - - public Map getDecodedHeader(final String token) { - try { - String decodedHeader = decodeBase64UrlPart(token.split("\\.")[0]); - return new ObjectMapper().readValue(decodedHeader, new TypeReference<>() { }); - } catch (Exception e) { - throw new ServerException(ServerErrorResult.INVALID_TOKEN, e); - } - } - - public String extractNonce(String idToken) { - try { - final String payloadJson = decodeBase64UrlPart(idToken.split("\\.")[1]); - final Map claims = new ObjectMapper().readValue(payloadJson, new TypeReference<>() { }); - return (String) claims.get("nonce"); - } catch (Exception e) { - throw new ServerException(ServerErrorResult.INVALID_TOKEN, e); - } - } - - public IdTokenPayload parseOidcIdToken( - final String token, - final String iss, - final String aud, - final PublicKey publicKey - ) { - JwtParserBuilder builder = Jwts.parser() - .verifyWith(publicKey) - .requireIssuer(iss) - .requireAudience(aud); - - final Claims claims = parseClaims(token, builder); - - return new IdTokenPayload(claims.getSubject(), claims.get("nickname", String.class)); - } - - public String generateAccessToken(final Long memberId) { - final LocalDateTime now = LocalDateTime.now(); - final Date issuedAt = toDate(now); - final Date expiration = toDate(now.plusSeconds(jwtProperties.accessTokenExpireTime())); - - return Jwts.builder() - .subject(memberId.toString()) - .issuer(jwtProperties.issuer()) - .issuedAt(issuedAt) - .expiration(expiration) - .signWith(jwtProperties.secretKey()) - .compact(); - } - - public Authentication getAuthentication(final String token) { - final Long memberId = getMemberIdFromToken(token); - final List authorities = Collections.emptyList(); - - return new UsernamePasswordAuthenticationToken(memberId, token, authorities); - } - - public Long getMemberIdFromToken(final String token) { - return Long.valueOf(parseAccessTokenClaims(token).getSubject()); - } - - private Claims parseAccessTokenClaims(final String token) { - JwtParserBuilder builder = Jwts.parser() - .verifyWith(jwtProperties.secretKey()); - return parseClaims(token, builder); - } - - private Claims parseClaims(final String token, final JwtParserBuilder builder) { - try { - return parseToken(token, builder); - } catch (ExpiredJwtException e) { - throw new ServerException(ServerErrorResult.EXPIRED_TOKEN, e); - } - } - - public Long getMemberIdFromExpiredAccessToken(final String token) { - final JwtParserBuilder builder = Jwts.parser().verifyWith(jwtProperties.secretKey()); - try { - parseToken(token, builder); - throw new ServerException(ServerErrorResult.INVALID_TOKEN); - } catch (ExpiredJwtException e) { - return Long.valueOf(e.getClaims().getSubject()); - } - } - - private Claims parseToken(final String token, final JwtParserBuilder builder) { - try { - return builder.build() - .parseSignedClaims(token) - .getPayload(); - } catch (MalformedJwtException e) { - throw new ServerException(ServerErrorResult.MALFORMED_TOKEN, e); - } catch (SecurityException e) { - throw new ServerException(ServerErrorResult.INVALID_TOKEN_SIGNATURE, e); - } catch (ExpiredJwtException e) { - throw e; - } catch (JwtException e) { - throw new ServerException(ServerErrorResult.INVALID_TOKEN, e); - } - } - - private String decodeBase64UrlPart(String encodedPart) { - return new String(Decoders.BASE64URL.decode(encodedPart), StandardCharsets.UTF_8); - } - - private Date toDate(final LocalDateTime dateTime) { - return Date.from(dateTime.atZone(ZoneId.systemDefault()).toInstant()); - } - -} diff --git a/src/main/java/com/und/server/member/controller/MemberController.java b/src/main/java/com/und/server/member/controller/MemberController.java new file mode 100644 index 00000000..6f83cc81 --- /dev/null +++ b/src/main/java/com/und/server/member/controller/MemberController.java @@ -0,0 +1,46 @@ +package com.und.server.member.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.und.server.auth.filter.AuthMember; +import com.und.server.member.dto.request.NicknameRequest; +import com.und.server.member.dto.response.MemberResponse; +import com.und.server.member.service.MemberService; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1") +public class MemberController { + + private final MemberService memberService; + + @PatchMapping("/member/nickname") + public ResponseEntity updateNickname( + @Parameter(hidden = true) @AuthMember final Long memberId, + @RequestBody @Valid final NicknameRequest nicknameRequest + ) { + final MemberResponse memberResponse = memberService.updateNickname(memberId, nicknameRequest); + + return ResponseEntity.status(HttpStatus.OK).body(memberResponse); + } + + @DeleteMapping("/member") + @ApiResponse(responseCode = "204", description = "Delete member successful") + public ResponseEntity deleteMember(@Parameter(hidden = true) @AuthMember final Long memberId) { + memberService.deleteMemberById(memberId); + + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + +} diff --git a/src/main/java/com/und/server/member/dto/request/NicknameRequest.java b/src/main/java/com/und/server/member/dto/request/NicknameRequest.java new file mode 100644 index 00000000..a5196aac --- /dev/null +++ b/src/main/java/com/und/server/member/dto/request/NicknameRequest.java @@ -0,0 +1,10 @@ +package com.und.server.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "Nickname Request DTO") +public record NicknameRequest( + @Schema(description = "Member's nickname", example = "Chori") + @NotBlank(message = "Nickname must not be blank") String nickname +) { } diff --git a/src/main/java/com/und/server/member/dto/response/MemberResponse.java b/src/main/java/com/und/server/member/dto/response/MemberResponse.java new file mode 100644 index 00000000..9b85f117 --- /dev/null +++ b/src/main/java/com/und/server/member/dto/response/MemberResponse.java @@ -0,0 +1,39 @@ +package com.und.server.member.dto.response; + +import java.time.LocalDateTime; + +import com.und.server.member.entity.Member; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Member Response DTO") +public record MemberResponse( + @Schema(description = "Member ID", example = "1") + Long id, + + @Schema(description = "Member's nickname", example = "Chori") + String nickname, + + @Schema(description = "Kakao ID", example = "1234567890") + String kakaoId, + + @Schema(description = "Apple ID", example = "1234567890") + String appleId, + + @Schema(description = "Creation timestamp of the member", example = "2025-07-31T22:27:36Z") + LocalDateTime createdAt, + + @Schema(description = "Last update timestamp of the member", example = "2025-07-31T22:27:36Z") + LocalDateTime updatedAt +) { + public static MemberResponse from(final Member member) { + return new MemberResponse( + member.getId(), + member.getNickname(), + member.getKakaoId(), + member.getAppleId(), + member.getCreatedAt(), + member.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/und/server/member/entity/Member.java b/src/main/java/com/und/server/member/entity/Member.java new file mode 100644 index 00000000..d7fd2dd2 --- /dev/null +++ b/src/main/java/com/und/server/member/entity/Member.java @@ -0,0 +1,50 @@ +package com.und.server.member.entity; + +import com.und.server.common.entity.BaseTimeEntity; +import com.und.server.terms.entity.Terms; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "member") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Member extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + @Builder.Default + private String nickname = "워리"; + + @Column(nullable = true, unique = true) + private String kakaoId; + + @Column(nullable = true, unique = true) + private String appleId; + + @OneToOne(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private Terms terms; + + public void updateNickname(final String nickname) { + this.nickname = nickname; + } + +} diff --git a/src/main/java/com/und/server/member/exception/MemberErrorResult.java b/src/main/java/com/und/server/member/exception/MemberErrorResult.java new file mode 100644 index 00000000..b482b9fc --- /dev/null +++ b/src/main/java/com/und/server/member/exception/MemberErrorResult.java @@ -0,0 +1,22 @@ +package com.und.server.member.exception; + +import org.springframework.http.HttpStatus; + +import com.und.server.common.exception.ErrorResult; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MemberErrorResult implements ErrorResult { + + INVALID_MEMBER_ID(HttpStatus.BAD_REQUEST, "Invalid Member ID"), + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "Member Not Found"), + DUPLICATE_KAKAO_ID(HttpStatus.CONFLICT, "Duplicate Kakao ID"), + DUPLICATE_APPLE_ID(HttpStatus.CONFLICT, "Duplicate Apple ID"); + + private final HttpStatus httpStatus; + private final String message; + +} diff --git a/src/main/java/com/und/server/repository/MemberRepository.java b/src/main/java/com/und/server/member/repository/MemberRepository.java similarity index 66% rename from src/main/java/com/und/server/repository/MemberRepository.java rename to src/main/java/com/und/server/member/repository/MemberRepository.java index 334d750e..4a1d2287 100644 --- a/src/main/java/com/und/server/repository/MemberRepository.java +++ b/src/main/java/com/und/server/member/repository/MemberRepository.java @@ -1,15 +1,17 @@ -package com.und.server.repository; +package com.und.server.member.repository; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import com.und.server.entity.Member; +import com.und.server.member.entity.Member; @Repository public interface MemberRepository extends JpaRepository { Optional findByKakaoId(final String kakaoId); + Optional findByAppleId(final String appleId); + } diff --git a/src/main/java/com/und/server/member/service/MemberService.java b/src/main/java/com/und/server/member/service/MemberService.java new file mode 100644 index 00000000..a61aaaf4 --- /dev/null +++ b/src/main/java/com/und/server/member/service/MemberService.java @@ -0,0 +1,110 @@ +package com.und.server.member.service; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.auth.oauth.Provider; +import com.und.server.auth.service.RefreshTokenService; +import com.und.server.common.exception.ServerException; +import com.und.server.member.dto.request.NicknameRequest; +import com.und.server.member.dto.response.MemberResponse; +import com.und.server.member.entity.Member; +import com.und.server.member.exception.MemberErrorResult; +import com.und.server.member.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberService { + + private final MemberRepository memberRepository; + private final RefreshTokenService refreshTokenService; + + public List getMemberList() { + return memberRepository.findAll() + .stream().map(MemberResponse::from).toList(); + } + + @Transactional + public Member findOrCreateMember(final Provider provider, final String providerId) { + validateProviderIsNotNull(provider); + validateProviderIdIsNotNull(providerId); + + return findMemberByProviderId(provider, providerId) + .orElseGet(() -> createMember(provider, providerId)); + } + + public Member findMemberById(final Long memberId) { + validateMemberIdIsNotNull(memberId); + + return memberRepository.findById(memberId) + .orElseThrow(() -> new ServerException(MemberErrorResult.MEMBER_NOT_FOUND)); + } + + public void checkMemberExists(final Long memberId) { + validateMemberIdIsNotNull(memberId); + + if (!memberRepository.existsById(memberId)) { + throw new ServerException(MemberErrorResult.MEMBER_NOT_FOUND); + } + } + + @Transactional + public MemberResponse updateNickname(final Long memberId, final NicknameRequest nicknameRequest) { + final Member member = findMemberById(memberId); + member.updateNickname(nicknameRequest.nickname()); + + return MemberResponse.from(member); + } + + @Transactional + public void deleteMemberById(final Long memberId) { + validateMemberIdIsNotNull(memberId); + checkMemberExists(memberId); + + refreshTokenService.deleteRefreshToken(memberId); + memberRepository.deleteById(memberId); + } + + private Optional findMemberByProviderId(final Provider provider, final String providerId) { + return switch (provider) { + case KAKAO -> memberRepository.findByKakaoId(providerId); + case APPLE -> memberRepository.findByAppleId(providerId); + }; + } + + private Member createMember(final Provider provider, final String providerId) { + final Member.MemberBuilder memberBuilder = Member.builder(); + switch (provider) { + case KAKAO -> memberBuilder.kakaoId(providerId); + case APPLE -> memberBuilder.appleId(providerId); + } + + return memberRepository.save(memberBuilder.build()); + } + + private void validateMemberIdIsNotNull(final Long memberId) { + if (memberId == null) { + throw new ServerException(MemberErrorResult.INVALID_MEMBER_ID); + } + } + + private void validateProviderIsNotNull(final Provider provider) { + if (provider == null) { + throw new ServerException(AuthErrorResult.INVALID_PROVIDER); + } + } + + private void validateProviderIdIsNotNull(final String providerId) { + if (providerId == null) { + throw new ServerException(AuthErrorResult.INVALID_PROVIDER_ID); + } + } + +} diff --git a/src/main/java/com/und/server/notification/constants/LocationTrackingRadiusType.java b/src/main/java/com/und/server/notification/constants/LocationTrackingRadiusType.java new file mode 100644 index 00000000..ea72ddc8 --- /dev/null +++ b/src/main/java/com/und/server/notification/constants/LocationTrackingRadiusType.java @@ -0,0 +1,19 @@ +package com.und.server.notification.constants; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum LocationTrackingRadiusType { + + M_100(100), + M_500(500), + KM_1(1_000), + KM_2(2_000), + KM_3(3_000), + KM_4(4_000); + + private final int meters; + +} diff --git a/src/main/java/com/und/server/notification/constants/NotificationMethodType.java b/src/main/java/com/und/server/notification/constants/NotificationMethodType.java new file mode 100644 index 00000000..b47ccdf9 --- /dev/null +++ b/src/main/java/com/und/server/notification/constants/NotificationMethodType.java @@ -0,0 +1,17 @@ +package com.und.server.notification.constants; + +import com.fasterxml.jackson.annotation.JsonCreator; + +public enum NotificationMethodType { + + PUSH, ALARM; + + @JsonCreator + public static NotificationMethodType fromValue(final String value) { + if (value == null) { + return null; + } + return NotificationMethodType.valueOf(value.toUpperCase()); + } + +} diff --git a/src/main/java/com/und/server/notification/constants/NotificationType.java b/src/main/java/com/und/server/notification/constants/NotificationType.java new file mode 100644 index 00000000..3fb4f9c1 --- /dev/null +++ b/src/main/java/com/und/server/notification/constants/NotificationType.java @@ -0,0 +1,17 @@ +package com.und.server.notification.constants; + +import com.fasterxml.jackson.annotation.JsonCreator; + +public enum NotificationType { + + TIME, LOCATION; + + @JsonCreator + public static NotificationType fromValue(final String value) { + if (value == null) { + return null; + } + return NotificationType.valueOf(value.toUpperCase()); + } + +} diff --git a/src/main/java/com/und/server/notification/controller/NotificationApiDocs.java b/src/main/java/com/und/server/notification/controller/NotificationApiDocs.java new file mode 100644 index 00000000..ff22174c --- /dev/null +++ b/src/main/java/com/und/server/notification/controller/NotificationApiDocs.java @@ -0,0 +1,217 @@ +package com.und.server.notification.controller; + +import org.springframework.http.ResponseEntity; + +import com.und.server.common.dto.response.ErrorResponse; +import com.und.server.notification.dto.response.ScenarioNotificationListResponse; +import com.und.server.notification.dto.response.ScenarioNotificationResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.constraints.NotNull; + +public interface NotificationApiDocs { + + @Operation(summary = "Update Notification Active Status API") + @ApiResponses({ + @ApiResponse( + responseCode = "204", + description = "Successfully updated notification active status", + content = @Content + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized access", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Unauthorized access", + value = """ + { + "code": "UNAUTHORIZED_ACCESS", + "message": "Unauthorized access" + } + """ + ) + ) + ) + }) + ResponseEntity updateNotificationActive( + @Parameter(hidden = true) final Long memberId, + @Parameter(description = "Notification active status") @NotNull final Boolean isActive + ); + + + @Operation(summary = "Get Scenario Notification List API") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved scenario notification list", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ScenarioNotificationListResponse.class), + examples = @ExampleObject( + name = "Scenario notification list", + value = """ + { + "etag": "1756272632565", + "scenarios": [ + { + "scenarioId": 1, + "scenarioName": "Home out", + "memo": "Item to carry", + "notificationId": 2, + "notificationType": "TIME", + "notificationMethodType": "PUSH", + "daysOfWeekOrdinal": [0, 1, 2, 3, 4, 5, 6], + "notificationCondition": { + "notificationType": "TIME", + "startHour": 12, + "startMinute": 58 + } + } + ] + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "304", + description = "Not modified - data has not changed since last request", + content = @Content + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized access", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Unauthorized access", + value = """ + { + "code": "UNAUTHORIZED_ACCESS", + "message": "Unauthorized access" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "Server error - failed to retrieve notification cache", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Cache fetch failed", + value = """ + { + "code": "CACHE_FETCH_ALL_FAILED", + "message": "Failed to fetch all scenarios notification cache" + } + """ + ) + ) + ) + }) + ResponseEntity getScenarioNotifications( + @Parameter(hidden = true) final Long memberId, + @Parameter(description = "ETag for client caching") final String ifNoneMatch + ); + + + @Operation(summary = "Get Single Scenario Notification API") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved scenario notification data", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ScenarioNotificationResponse.class), + examples = @ExampleObject( + name = "Time notification scenario", + value = """ + { + "scenarioId": 1, + "scenarioName": "Home out", + "memo": "Item to carry", + "notificationId": 2, + "notificationType": "TIME", + "notificationMethodType": "PUSH", + "daysOfWeekOrdinal": [0, 1, 2, 3, 4, 5, 6], + "notificationCondition": { + "notificationType": "TIME", + "startHour": 12, + "startMinute": 58 + } + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized access", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Unauthorized access", + value = """ + { + "code": "UNAUTHORIZED_ACCESS", + "message": "Unauthorized access" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "Scenario notification cache not found", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Scenario notification cache not found", + value = """ + { + "code": "CACHE_NOT_FOUND_SCENARIO_NOTIFICATION", + "message": "Not found scenario notification cache" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "Server error - failed to retrieve notification cache", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Cache fetch failed", + value = """ + { + "code": "CACHE_FETCH_SINGLE_FAILED", + "message": "Failed to fetch single scenario notification cache" + } + """ + ) + ) + ) + }) + ResponseEntity getSingleScenarioNotification( + @Parameter(hidden = true) final Long memberId, + @Parameter(description = "Scenario ID") final Long scenarioId + ); + +} diff --git a/src/main/java/com/und/server/notification/controller/NotificationController.java b/src/main/java/com/und/server/notification/controller/NotificationController.java new file mode 100644 index 00000000..fadbed38 --- /dev/null +++ b/src/main/java/com/und/server/notification/controller/NotificationController.java @@ -0,0 +1,76 @@ +package com.und.server.notification.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.und.server.auth.filter.AuthMember; +import com.und.server.notification.dto.response.ScenarioNotificationListResponse; +import com.und.server.notification.dto.response.ScenarioNotificationResponse; +import com.und.server.notification.service.NotificationCacheService; +import com.und.server.notification.service.NotificationService; + +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1") +public class NotificationController implements NotificationApiDocs { + + private final NotificationService notificationService; + private final NotificationCacheService notificationCacheService; + + + @Override + @PatchMapping("notifications/active") + public ResponseEntity updateNotificationActive( + @AuthMember final Long memberId, + @RequestBody @NotNull final Boolean isActive + ) { + notificationService.updateNotificationActiveStatus(memberId, isActive); + + return ResponseEntity.noContent().build(); + } + + + @Override + @GetMapping("/scenarios/notifications") + public ResponseEntity getScenarioNotifications( + @AuthMember final Long memberId, + @Parameter(description = "ETag for client caching") + @RequestHeader(value = "If-None-Match", required = false) final String ifNoneMatch + ) { + final ScenarioNotificationListResponse scenarioNotificationListResponse = + notificationCacheService.getScenariosNotificationCache(memberId); + + if (ifNoneMatch != null && ifNoneMatch.equals(scenarioNotificationListResponse.etag())) { + return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build(); + } + + return ResponseEntity.ok() + .header("ETag", scenarioNotificationListResponse.etag()) + .body(scenarioNotificationListResponse); + } + + + @Override + @GetMapping("/scenarios/{scenarioId}/notifications") + public ResponseEntity getSingleScenarioNotification( + @AuthMember final Long memberId, + @PathVariable final Long scenarioId + ) { + final ScenarioNotificationResponse scenarioNotificationResponse = + notificationCacheService.getSingleScenarioNotificationCache(memberId, scenarioId); + + return ResponseEntity.ok().body(scenarioNotificationResponse); + } + +} diff --git a/src/main/java/com/und/server/notification/dto/NotificationInfoDto.java b/src/main/java/com/und/server/notification/dto/NotificationInfoDto.java new file mode 100644 index 00000000..fbd2d70e --- /dev/null +++ b/src/main/java/com/und/server/notification/dto/NotificationInfoDto.java @@ -0,0 +1,13 @@ +package com.und.server.notification.dto; + +import java.util.List; + +import com.und.server.notification.dto.response.NotificationConditionResponse; + +public record NotificationInfoDto( + + Boolean isEveryDay, + List daysOfWeekOrdinal, + NotificationConditionResponse notificationConditionResponse + +) { } diff --git a/src/main/java/com/und/server/notification/dto/cache/NotificationCacheData.java b/src/main/java/com/und/server/notification/dto/cache/NotificationCacheData.java new file mode 100644 index 00000000..34e95457 --- /dev/null +++ b/src/main/java/com/und/server/notification/dto/cache/NotificationCacheData.java @@ -0,0 +1,61 @@ +package com.und.server.notification.dto.cache; + +import java.util.List; + +import com.und.server.notification.constants.NotificationMethodType; +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.response.ScenarioNotificationResponse; +import com.und.server.notification.entity.Notification; +import com.und.server.scenario.entity.Scenario; + +import lombok.Builder; + +@Builder +public record NotificationCacheData( + + Long scenarioId, + String scenarioName, + String scenarioMemo, + Long notificationId, + NotificationType notificationType, + NotificationMethodType notificationMethodType, + List daysOfWeekOrdinal, + String conditionJson + +) { + + public static NotificationCacheData from( + final ScenarioNotificationResponse scenarioNotificationResponse, + final String serializedCondition + ) { + return NotificationCacheData.builder() + .scenarioId(scenarioNotificationResponse.scenarioId()) + .scenarioName(scenarioNotificationResponse.scenarioName()) + .scenarioMemo(scenarioNotificationResponse.memo()) + .notificationId(scenarioNotificationResponse.notificationId()) + .notificationType(scenarioNotificationResponse.notificationType()) + .notificationMethodType(scenarioNotificationResponse.notificationMethodType()) + .daysOfWeekOrdinal(scenarioNotificationResponse.daysOfWeekOrdinal()) + .conditionJson(serializedCondition) + .build(); + } + + public static NotificationCacheData from( + final Scenario scenario, + final String serializedCondition + ) { + Notification notification = scenario.getNotification(); + + return NotificationCacheData.builder() + .scenarioId(scenario.getId()) + .scenarioName(scenario.getScenarioName()) + .scenarioMemo(scenario.getMemo()) + .notificationId(notification.getId()) + .notificationType(notification.getNotificationType()) + .notificationMethodType(notification.getNotificationMethodType()) + .daysOfWeekOrdinal(notification.getDaysOfWeekOrdinalList()) + .conditionJson(serializedCondition) + .build(); + } + +} diff --git a/src/main/java/com/und/server/notification/dto/request/NotificationConditionRequest.java b/src/main/java/com/und/server/notification/dto/request/NotificationConditionRequest.java new file mode 100644 index 00000000..5b23f6ec --- /dev/null +++ b/src/main/java/com/und/server/notification/dto/request/NotificationConditionRequest.java @@ -0,0 +1,21 @@ +package com.und.server.notification.dto.request; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "notificationType" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = TimeNotificationRequest.class, name = "time") +}) +@Schema( + description = + "Notification condition request. The request body structure changes depending on the 'notificationType'.", + discriminatorProperty = "notificationType" +) +public interface NotificationConditionRequest { } diff --git a/src/main/java/com/und/server/notification/dto/request/NotificationRequest.java b/src/main/java/com/und/server/notification/dto/request/NotificationRequest.java new file mode 100644 index 00000000..14b98fd2 --- /dev/null +++ b/src/main/java/com/und/server/notification/dto/request/NotificationRequest.java @@ -0,0 +1,81 @@ +package com.und.server.notification.dto.request; + +import java.util.List; + +import org.hibernate.validator.constraints.UniqueElements; + +import com.und.server.notification.constants.NotificationMethodType; +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.entity.Notification; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +@Schema(description = "Notification request") +public record NotificationRequest( + + @Schema(description = "Whether notification is active", example = "true") + @NotNull(message = "isActive must not be null") + Boolean isActive, + + @Schema(description = "Notification type", example = "time") + @NotNull(message = "notificationType must not be null") + NotificationType notificationType, + + @Schema(description = "Notification method type - required when isActive is true", example = "push") + NotificationMethodType notificationMethodType, + + @ArraySchema( + uniqueItems = true, + arraySchema = @Schema(description = """ + List of days in week when notification is active (0=Monday ... 6=Sunday) + - required when isActive is true"""), + schema = @Schema(type = "integer", minimum = "0", maximum = "6") + ) + @Schema(example = "[0,1,2,3,4,5,6]") + @Size(max = 7, message = "DayOfWeek list must contain at most 7 items") + @UniqueElements(message = "DayOfWeek must not contain duplicates") + List< + @NotNull(message = "DayOfWeek must not be null") + @Min(value = 0, message = "DayOfWeek must be between 0 and 6") + @Max(value = 6, message = "DayOfWeek must be between 0 and 6") Integer> daysOfWeekOrdinal + +) { + + @AssertTrue(message = "Notification method and days required when isActive is true") + private boolean isValidActiveNotification() { + if (!isActive) { + return true; + } + return notificationMethodType != null && daysOfWeekOrdinal != null && !daysOfWeekOrdinal.isEmpty(); + } + + @AssertTrue(message = "Notification method and days not allowed when isActive is false") + private boolean isValidInactiveNotification() { + if (isActive) { + return true; + } + return notificationMethodType == null && (daysOfWeekOrdinal == null || daysOfWeekOrdinal.isEmpty()); + } + + public Notification toEntity() { + Notification notification = Notification.builder() + .isActive(isActive) + .notificationType(notificationType) + .notificationMethodType(notificationMethodType) + .build(); + if (isActive) { + notification.updateDaysOfWeekOrdinal(daysOfWeekOrdinal); + } + + return notification; + } + +} diff --git a/src/main/java/com/und/server/notification/dto/request/TimeNotificationRequest.java b/src/main/java/com/und/server/notification/dto/request/TimeNotificationRequest.java new file mode 100644 index 00000000..bb6034b9 --- /dev/null +++ b/src/main/java/com/und/server/notification/dto/request/TimeNotificationRequest.java @@ -0,0 +1,55 @@ +package com.und.server.notification.dto.request; + +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.entity.Notification; +import com.und.server.notification.entity.TimeNotification; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +@Schema(description = "Time notification detail condition request") +public record TimeNotificationRequest( + + @Schema( + description = "Time notification type", + example = "time", + defaultValue = "time", + allowableValues = {"time"}, + requiredMode = Schema.RequiredMode.REQUIRED + ) + @NotNull + NotificationType notificationType, + + @Schema(description = "hour, 24-hour format", example = "12") + @NotNull(message = "Hour must not be null") + @Min(value = 0, message = "Hour must be between 0 and 23") + @Max(value = 23, message = "Hour must be between 0 and 23") + Integer startHour, + + @Schema(description = "minute", example = "58") + @NotNull(message = "Minute must not be null") + @Min(value = 0, message = "Minute must be between 0 and 59") + @Max(value = 59, message = "Minute must be between 0 and 59") + Integer startMinute + +) implements NotificationConditionRequest { + + public TimeNotificationRequest { + if (notificationType == null) { + notificationType = NotificationType.TIME; + } + } + + public TimeNotification toEntity(final Notification notification) { + return TimeNotification.builder() + .notification(notification) + .startHour(startHour) + .startMinute(startMinute) + .build(); + } + +} diff --git a/src/main/java/com/und/server/notification/dto/response/NotificationConditionResponse.java b/src/main/java/com/und/server/notification/dto/response/NotificationConditionResponse.java new file mode 100644 index 00000000..531e752e --- /dev/null +++ b/src/main/java/com/und/server/notification/dto/response/NotificationConditionResponse.java @@ -0,0 +1,21 @@ +package com.und.server.notification.dto.response; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "notificationType" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = TimeNotificationResponse.class, name = "TIME") +}) +@Schema( + description = "Notification condition polymorphic base", + discriminatorProperty = "notificationType", + oneOf = {TimeNotificationResponse.class} +) +public interface NotificationConditionResponse { } diff --git a/src/main/java/com/und/server/notification/dto/response/NotificationResponse.java b/src/main/java/com/und/server/notification/dto/response/NotificationResponse.java new file mode 100644 index 00000000..ec021d6d --- /dev/null +++ b/src/main/java/com/und/server/notification/dto/response/NotificationResponse.java @@ -0,0 +1,64 @@ +package com.und.server.notification.dto.response; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.und.server.notification.constants.NotificationMethodType; +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.entity.Notification; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "Notification response") +public record NotificationResponse( + + @Schema(description = "Notification id", example = "1") + Long notificationId, + + @Schema(description = "Notification active status", example = "true") + Boolean isActive, + + @Schema(description = "Notification type", example = "TIME") + NotificationType notificationType, + + @Schema(description = "Notification method type", example = "PUSH") + NotificationMethodType notificationMethodType, + + @Schema(description = "Whether the notification applies to every day of the week", example = "true") + Boolean isEveryDay, + + @ArraySchema( + uniqueItems = true, + arraySchema = @Schema( + description = "List of days in week when notification is active (0=Monday ... 6=Sunday)"), + schema = @Schema(type = "integer", minimum = "0", maximum = "6") + ) + @Schema(example = "[0,1,2,3,4,5,6]") + List daysOfWeekOrdinal + +) { + + public static NotificationResponse from(final Notification notification) { + if (notification.isActive()) { + return NotificationResponse.builder() + .notificationId(notification.getId()) + .isActive(true) + .notificationType(notification.getNotificationType()) + .notificationMethodType(notification.getNotificationMethodType()) + .isEveryDay(notification.isEveryDay()) + .daysOfWeekOrdinal(notification.getDaysOfWeekOrdinalList()) + .build(); + } else { + return NotificationResponse.builder() + .notificationId(notification.getId()) + .isActive(false) + .notificationType(notification.getNotificationType()) + .build(); + } + } + +} diff --git a/src/main/java/com/und/server/notification/dto/response/ScenarioNotificationListResponse.java b/src/main/java/com/und/server/notification/dto/response/ScenarioNotificationListResponse.java new file mode 100644 index 00000000..bb40a243 --- /dev/null +++ b/src/main/java/com/und/server/notification/dto/response/ScenarioNotificationListResponse.java @@ -0,0 +1,24 @@ +package com.und.server.notification.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "ETag and Scenario notification list response with notification active") +public record ScenarioNotificationListResponse( + + @Schema(description = "ETag (for client caching)", example = "1756272632565") + String etag, + + @Schema(description = "Notification list by scenario") + List scenarios + +) { + + public static ScenarioNotificationListResponse from( + final String etag, final List scenarios + ) { + return new ScenarioNotificationListResponse(etag, scenarios); + } + +} diff --git a/src/main/java/com/und/server/notification/dto/response/ScenarioNotificationResponse.java b/src/main/java/com/und/server/notification/dto/response/ScenarioNotificationResponse.java new file mode 100644 index 00000000..bdca9121 --- /dev/null +++ b/src/main/java/com/und/server/notification/dto/response/ScenarioNotificationResponse.java @@ -0,0 +1,84 @@ +package com.und.server.notification.dto.response; + +import java.util.List; + +import com.und.server.notification.constants.NotificationMethodType; +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.cache.NotificationCacheData; +import com.und.server.notification.entity.Notification; +import com.und.server.scenario.entity.Scenario; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "Scenario notification response with notification active") +public record ScenarioNotificationResponse( + + @Schema(description = "Scenario id", example = "1") + Long scenarioId, + + @Schema(description = "Scenario name", example = "Home out") + String scenarioName, + + @Schema(description = "Scenario memo", example = "Item to carry") + String memo, + + @Schema(description = "Notification id", example = "2") + Long notificationId, + + @Schema(description = "Notification type", example = "TIME") + NotificationType notificationType, + + @Schema(description = "Notification method type", example = "PUSH") + NotificationMethodType notificationMethodType, + + @ArraySchema( + uniqueItems = true, + arraySchema = @Schema( + description = "List of days in week when notification is active (0=Monday ... 6=Sunday)"), + schema = @Schema(type = "integer", minimum = "0", maximum = "6") + ) + @Schema(example = "[0,1,2,3,4,5,6]") + List daysOfWeekOrdinal, + + @Schema(description = "Notification condition, present only when active") + NotificationConditionResponse notificationCondition + +) { + + public static ScenarioNotificationResponse from( + final NotificationCacheData notificationCacheData, + final NotificationConditionResponse notificationConditionResponse + ) { + return ScenarioNotificationResponse.builder() + .scenarioId(notificationCacheData.scenarioId()) + .scenarioName(notificationCacheData.scenarioName()) + .memo(notificationCacheData.scenarioMemo()) + .notificationId(notificationCacheData.notificationId()) + .notificationType(notificationCacheData.notificationType()) + .notificationMethodType(notificationCacheData.notificationMethodType()) + .daysOfWeekOrdinal(notificationCacheData.daysOfWeekOrdinal()) + .notificationCondition(notificationConditionResponse) + .build(); + } + + public static ScenarioNotificationResponse from( + final Scenario scenario, final NotificationConditionResponse notificationConditionResponse + ) { + Notification notification = scenario.getNotification(); + + return ScenarioNotificationResponse.builder() + .scenarioId(scenario.getId()) + .scenarioName(scenario.getScenarioName()) + .memo(scenario.getMemo()) + .notificationId(notification.getId()) + .notificationType(notification.getNotificationType()) + .notificationMethodType(notification.getNotificationMethodType()) + .daysOfWeekOrdinal(notification.getDaysOfWeekOrdinalList()) + .notificationCondition(notificationConditionResponse) + .build(); + } + +} diff --git a/src/main/java/com/und/server/notification/dto/response/TimeNotificationResponse.java b/src/main/java/com/und/server/notification/dto/response/TimeNotificationResponse.java new file mode 100644 index 00000000..5f5d6cdc --- /dev/null +++ b/src/main/java/com/und/server/notification/dto/response/TimeNotificationResponse.java @@ -0,0 +1,40 @@ +package com.und.server.notification.dto.response; + +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.entity.TimeNotification; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +@Schema(description = "Time notification detail condition response") +public record TimeNotificationResponse( + + @Schema( + description = "Time notification type", + example = "TIME", + defaultValue = "TIME", + allowableValues = {"TIME"}, + requiredMode = Schema.RequiredMode.REQUIRED + ) + @NotNull + NotificationType notificationType, + + @Schema(description = "hour", example = "12") + Integer startHour, + + @Schema(description = "minute", example = "58") + Integer startMinute + +) implements NotificationConditionResponse { + + public static NotificationConditionResponse from(final TimeNotification timeNotification) { + return TimeNotificationResponse.builder() + .notificationType(NotificationType.TIME) + .startHour(timeNotification.getStartHour()) + .startMinute(timeNotification.getStartMinute()) + .build(); + } + +} diff --git a/src/main/java/com/und/server/notification/entity/LocationNotification.java b/src/main/java/com/und/server/notification/entity/LocationNotification.java new file mode 100644 index 00000000..8a40740a --- /dev/null +++ b/src/main/java/com/und/server/notification/entity/LocationNotification.java @@ -0,0 +1,82 @@ +package com.und.server.notification.entity; + +import java.math.BigDecimal; + +import com.und.server.common.entity.BaseTimeEntity; +import com.und.server.notification.constants.LocationTrackingRadiusType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder +@Table(name = "location_notification") +public class LocationNotification extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "notification_id", nullable = false) + private Notification notification; + + @Column(nullable = false, precision = 9, scale = 6) + @DecimalMin("-90.0") + @DecimalMax("90.0") + @Digits(integer = 3, fraction = 6) + private BigDecimal latitude; + + @Column(nullable = false, precision = 9, scale = 6) + @DecimalMin("-180.0") + @DecimalMax("180.0") + @Digits(integer = 3, fraction = 6) + private BigDecimal longitude; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private LocationTrackingRadiusType trackingRadiusType; + + @Column(nullable = false) + @Min(0) + @Max(23) + private Integer startHour; + + @Column(nullable = false) + @Min(0) + @Max(59) + private Integer startMinute; + + @Column(nullable = false) + @Min(0) + @Max(23) + private Integer endHour; + + @Column(nullable = false) + @Min(0) + @Max(59) + private Integer endMinute; + +} diff --git a/src/main/java/com/und/server/notification/entity/Notification.java b/src/main/java/com/und/server/notification/entity/Notification.java new file mode 100644 index 00000000..a497f96e --- /dev/null +++ b/src/main/java/com/und/server/notification/entity/Notification.java @@ -0,0 +1,104 @@ +package com.und.server.notification.entity; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import com.und.server.common.entity.BaseTimeEntity; +import com.und.server.notification.constants.NotificationMethodType; +import com.und.server.notification.constants.NotificationType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder +@Table(name = "notification") +public class Notification extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Boolean isActive; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private NotificationType notificationType; + + @Enumerated(EnumType.STRING) + private NotificationMethodType notificationMethodType; + + @Column + private String daysOfWeek; + + public boolean isActive() { + return isActive; + } + + public boolean isEveryDay() { + List days = getDaysOfWeekOrdinalList(); + return days.size() == 7; + } + + public boolean hasNotificationCondition() { + return notificationMethodType != null && daysOfWeek != null; + } + + public void updateActive(Boolean isActive) { + this.isActive = isActive; + } + + public void activate( + final NotificationType notificationType, + final NotificationMethodType notificationMethodType, + final List daysOfWeek + ) { + this.isActive = true; + this.notificationType = notificationType; + this.notificationMethodType = notificationMethodType; + updateDaysOfWeekOrdinal(daysOfWeek); + } + + public void deactivate() { + this.isActive = false; + this.notificationMethodType = null; + this.daysOfWeek = null; + } + + public List getDaysOfWeekOrdinalList() { + if (daysOfWeek == null || daysOfWeek.isEmpty()) { + return List.of(); + } + return Arrays.stream(daysOfWeek.split(",")) + .map(String::trim) + .map(Integer::parseInt) + .collect(Collectors.toList()); + } + + public void updateDaysOfWeekOrdinal(final List daysOfWeekOrdinal) { + if (!isActive || daysOfWeekOrdinal == null || daysOfWeekOrdinal.isEmpty()) { + this.daysOfWeek = null; + return; + } + this.daysOfWeek = daysOfWeekOrdinal.stream() + .map(String::valueOf) + .collect(Collectors.joining(",")); + } + +} diff --git a/src/main/java/com/und/server/notification/entity/TimeNotification.java b/src/main/java/com/und/server/notification/entity/TimeNotification.java new file mode 100644 index 00000000..f678d7a8 --- /dev/null +++ b/src/main/java/com/und/server/notification/entity/TimeNotification.java @@ -0,0 +1,53 @@ +package com.und.server.notification.entity; + +import com.und.server.common.entity.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder +@Table(name = "time_notification") +public class TimeNotification extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "notification_id", nullable = false) + private Notification notification; + + @Column(nullable = false) + @Min(0) + @Max(23) + private Integer startHour; + + @Column(nullable = false) + @Min(0) + @Max(59) + private Integer startMinute; + + public void updateTimeCondition(final Integer hour, final Integer minute) { + this.startHour = hour; + this.startMinute = minute; + } + +} diff --git a/src/main/java/com/und/server/notification/event/ActiveUpdateEvent.java b/src/main/java/com/und/server/notification/event/ActiveUpdateEvent.java new file mode 100644 index 00000000..712a630c --- /dev/null +++ b/src/main/java/com/und/server/notification/event/ActiveUpdateEvent.java @@ -0,0 +1,8 @@ +package com.und.server.notification.event; + +public record ActiveUpdateEvent( + + Long memberId, + boolean isActive + +) { } diff --git a/src/main/java/com/und/server/notification/event/ActiveUpdateEventListener.java b/src/main/java/com/und/server/notification/event/ActiveUpdateEventListener.java new file mode 100644 index 00000000..99c36f5f --- /dev/null +++ b/src/main/java/com/und/server/notification/event/ActiveUpdateEventListener.java @@ -0,0 +1,45 @@ +package com.und.server.notification.event; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.und.server.notification.service.NotificationCacheService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +@RequiredArgsConstructor +public class ActiveUpdateEventListener { + + private final NotificationCacheService notificationCacheService; + + @Async + @TransactionalEventListener + public void handleActiveUpdate(final ActiveUpdateEvent event) { + final Long memberId = event.memberId(); + final boolean isActive = event.isActive(); + + try { + if (isActive) { + processWithNotification(memberId); + } else { + processWithoutNotification(memberId); + } + } catch (Exception e) { + log.error("Failed to process notification active update event due to an unexpected error: {}", event, e); + notificationCacheService.deleteMemberAllCache(memberId); + } + } + + private void processWithNotification(Long memberId) { + notificationCacheService.refreshCacheFromDatabase(memberId); + } + + private void processWithoutNotification(Long memberId) { + notificationCacheService.deleteMemberAllCache(memberId); + } + +} diff --git a/src/main/java/com/und/server/notification/event/NotificationEventPublisher.java b/src/main/java/com/und/server/notification/event/NotificationEventPublisher.java new file mode 100644 index 00000000..68a01122 --- /dev/null +++ b/src/main/java/com/und/server/notification/event/NotificationEventPublisher.java @@ -0,0 +1,47 @@ +package com.und.server.notification.event; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +import com.und.server.scenario.entity.Scenario; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationEventPublisher { + + private final ApplicationEventPublisher eventPublisher; + + public void publishCreateEvent(final Long memberId, final Scenario scenario) { + ScenarioCreateEvent event = new ScenarioCreateEvent(memberId, scenario); + + eventPublisher.publishEvent(event); + } + + public void publishUpdateEvent( + final Long memberId, final Scenario scenario, final Boolean isOldScenarioNotificationActive + ) { + ScenarioUpdateEvent event = + new ScenarioUpdateEvent(memberId, scenario, isOldScenarioNotificationActive); + + eventPublisher.publishEvent(event); + } + + public void publishDeleteEvent( + final Long memberId, final Long scenarioId, final Boolean isNotificationActive + ) { + ScenarioDeleteEvent event = new ScenarioDeleteEvent(memberId, scenarioId, isNotificationActive); + + eventPublisher.publishEvent(event); + } + + public void publishActiveUpdateEvent(final Long memberId, final boolean isActive) { + ActiveUpdateEvent event = new ActiveUpdateEvent(memberId, isActive); + + eventPublisher.publishEvent(event); + } + +} diff --git a/src/main/java/com/und/server/notification/event/ScenarioCreateEvent.java b/src/main/java/com/und/server/notification/event/ScenarioCreateEvent.java new file mode 100644 index 00000000..57a632fd --- /dev/null +++ b/src/main/java/com/und/server/notification/event/ScenarioCreateEvent.java @@ -0,0 +1,10 @@ +package com.und.server.notification.event; + +import com.und.server.scenario.entity.Scenario; + +public record ScenarioCreateEvent( + + Long memberId, + Scenario scenario + +) { } diff --git a/src/main/java/com/und/server/notification/event/ScenarioCreateEventListener.java b/src/main/java/com/und/server/notification/event/ScenarioCreateEventListener.java new file mode 100644 index 00000000..ad2d6ee0 --- /dev/null +++ b/src/main/java/com/und/server/notification/event/ScenarioCreateEventListener.java @@ -0,0 +1,48 @@ +package com.und.server.notification.event; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.und.server.notification.entity.Notification; +import com.und.server.notification.exception.NotificationCacheException; +import com.und.server.notification.service.NotificationCacheService; +import com.und.server.scenario.entity.Scenario; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +@RequiredArgsConstructor +public class ScenarioCreateEventListener { + + private final NotificationCacheService notificationCacheService; + + @Async + @TransactionalEventListener + public void handleCreate(final ScenarioCreateEvent event) { + final Long memberId = event.memberId(); + final Scenario scenario = event.scenario(); + final Notification notification = scenario.getNotification(); + + try { + if (notification == null || !notification.isActive()) { + return; + } + processWithNotification(memberId, scenario); + + } catch (NotificationCacheException e) { + log.error("Failed to process scenario create event due to cache error: {}", event, e); + notificationCacheService.deleteMemberAllCache(memberId); + } catch (Exception e) { + log.error("Failed to process scenario create event due to an unexpected error: {}", event, e); + notificationCacheService.deleteMemberAllCache(memberId); + } + } + + private void processWithNotification(final Long memberId, final Scenario scenario) { + notificationCacheService.updateCache(memberId, scenario); + } + +} diff --git a/src/main/java/com/und/server/notification/event/ScenarioDeleteEvent.java b/src/main/java/com/und/server/notification/event/ScenarioDeleteEvent.java new file mode 100644 index 00000000..9a8e45aa --- /dev/null +++ b/src/main/java/com/und/server/notification/event/ScenarioDeleteEvent.java @@ -0,0 +1,9 @@ +package com.und.server.notification.event; + +public record ScenarioDeleteEvent( + + Long memberId, + Long scenarioId, + Boolean isNotificationActive + +) { } diff --git a/src/main/java/com/und/server/notification/event/ScenarioDeleteEventListener.java b/src/main/java/com/und/server/notification/event/ScenarioDeleteEventListener.java new file mode 100644 index 00000000..a39b3a44 --- /dev/null +++ b/src/main/java/com/und/server/notification/event/ScenarioDeleteEventListener.java @@ -0,0 +1,47 @@ +package com.und.server.notification.event; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.und.server.notification.exception.NotificationCacheException; +import com.und.server.notification.service.NotificationCacheService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +@RequiredArgsConstructor +public class ScenarioDeleteEventListener { + + private final NotificationCacheService notificationCacheService; + + @Async + @TransactionalEventListener + public void handleDelete(final ScenarioDeleteEvent event) { + final Long memberId = event.memberId(); + final Long scenarioId = event.scenarioId(); + final Boolean isNotificationActive = event.isNotificationActive(); + + try { + if (!isNotificationActive) { + return; + } + processWithNotification(memberId, scenarioId); + + } catch (NotificationCacheException e) { + log.error("Failed to process scenario delete event due to cache error: {}", event, e); + notificationCacheService.deleteMemberAllCache(memberId); + } catch (Exception e) { + log.error("Failed to process scenario delete event due to an unexpected error: {}", event, e); + notificationCacheService.deleteMemberAllCache(memberId); + } + } + + private void processWithNotification(final Long memberId, final Long scenarioId) { + notificationCacheService.deleteCache(memberId, scenarioId); + } + +} + diff --git a/src/main/java/com/und/server/notification/event/ScenarioUpdateEvent.java b/src/main/java/com/und/server/notification/event/ScenarioUpdateEvent.java new file mode 100644 index 00000000..b7cdf7ab --- /dev/null +++ b/src/main/java/com/und/server/notification/event/ScenarioUpdateEvent.java @@ -0,0 +1,11 @@ +package com.und.server.notification.event; + +import com.und.server.scenario.entity.Scenario; + +public record ScenarioUpdateEvent( + + Long memberId, + Scenario updatedScenario, + Boolean isOldScenarioNotificationActive + +) { } diff --git a/src/main/java/com/und/server/notification/event/ScenarioUpdateEventListener.java b/src/main/java/com/und/server/notification/event/ScenarioUpdateEventListener.java new file mode 100644 index 00000000..40c8f2ae --- /dev/null +++ b/src/main/java/com/und/server/notification/event/ScenarioUpdateEventListener.java @@ -0,0 +1,57 @@ +package com.und.server.notification.event; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.und.server.notification.entity.Notification; +import com.und.server.notification.exception.NotificationCacheException; +import com.und.server.notification.service.NotificationCacheService; +import com.und.server.scenario.entity.Scenario; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +@RequiredArgsConstructor +public class ScenarioUpdateEventListener { + + private final NotificationCacheService notificationCacheService; + + @Async + @TransactionalEventListener + public void handleUpdate(final ScenarioUpdateEvent event) { + final Long memberId = event.memberId(); + final Boolean isOldScenarioNotificationActive = event.isOldScenarioNotificationActive(); + final Scenario updatedScenario = event.updatedScenario(); + final Notification notification = updatedScenario.getNotification(); + + try { + if (notification == null || !notification.isActive()) { + if (!isOldScenarioNotificationActive) { + return; + } + processWithoutNotification(memberId, updatedScenario); + return; + } + processWithNotification(memberId, updatedScenario); + + } catch (NotificationCacheException e) { + log.error("Failed to process scenario update event due to cache error: {}", event, e); + notificationCacheService.deleteMemberAllCache(memberId); + } catch (Exception e) { + log.error("Failed to process scenario update event due to an unexpected error: {}", event, e); + notificationCacheService.deleteMemberAllCache(memberId); + } + } + + private void processWithNotification(final Long memberId, final Scenario scenario) { + notificationCacheService.updateCache(memberId, scenario); + } + + private void processWithoutNotification(final Long memberId, final Scenario scenario) { + notificationCacheService.deleteCache(memberId, scenario.getId()); + } + +} diff --git a/src/main/java/com/und/server/notification/exception/NotificationCacheErrorResult.java b/src/main/java/com/und/server/notification/exception/NotificationCacheErrorResult.java new file mode 100644 index 00000000..8258db31 --- /dev/null +++ b/src/main/java/com/und/server/notification/exception/NotificationCacheErrorResult.java @@ -0,0 +1,36 @@ +package com.und.server.notification.exception; + +import org.springframework.http.HttpStatus; + +import com.und.server.common.exception.ErrorResult; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum NotificationCacheErrorResult implements ErrorResult { + + CACHE_FETCH_ALL_FAILED( + HttpStatus.INTERNAL_SERVER_ERROR, "Failed to fetch all scenarios notification cache"), + CACHE_FETCH_SINGLE_FAILED( + HttpStatus.INTERNAL_SERVER_ERROR, "Failed to fetch single scenario notification cache"), + CACHE_NOT_FOUND_SCENARIO_NOTIFICATION( + HttpStatus.NOT_FOUND, "Not found scenario notification cache"), + CACHE_UPDATE_FAILED( + HttpStatus.INTERNAL_SERVER_ERROR, "Failed to update notification cache"), + CACHE_DELETE_FAILED( + HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete notification cache"), + SERIALIZE_FAILED( + HttpStatus.INTERNAL_SERVER_ERROR, "Failed to serialize NotificationCacheData"), + DESERIALIZE_FAILED( + HttpStatus.INTERNAL_SERVER_ERROR, "Failed to deserialize NotificationCacheData"), + CONDITION_PARSE_FAILED( + HttpStatus.INTERNAL_SERVER_ERROR, "Failed to parse NotificationConditionResponse from cache"), + CONDITION_SERIALIZE_FAILED( + HttpStatus.INTERNAL_SERVER_ERROR, "Failed to serialize NotificationConditionResponse"); + + private final HttpStatus httpStatus; + private final String message; + +} diff --git a/src/main/java/com/und/server/notification/exception/NotificationCacheException.java b/src/main/java/com/und/server/notification/exception/NotificationCacheException.java new file mode 100644 index 00000000..42b6f8ba --- /dev/null +++ b/src/main/java/com/und/server/notification/exception/NotificationCacheException.java @@ -0,0 +1,12 @@ +package com.und.server.notification.exception; + +import com.und.server.common.exception.ErrorResult; +import com.und.server.common.exception.ServerException; + +public class NotificationCacheException extends ServerException { + + public NotificationCacheException(ErrorResult errorResult) { + super(errorResult); + } + +} diff --git a/src/main/java/com/und/server/notification/exception/NotificationErrorResult.java b/src/main/java/com/und/server/notification/exception/NotificationErrorResult.java new file mode 100644 index 00000000..090e8230 --- /dev/null +++ b/src/main/java/com/und/server/notification/exception/NotificationErrorResult.java @@ -0,0 +1,22 @@ +package com.und.server.notification.exception; + +import org.springframework.http.HttpStatus; + +import com.und.server.common.exception.ErrorResult; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum NotificationErrorResult implements ErrorResult { + + UNSUPPORTED_NOTIFICATION( + HttpStatus.BAD_REQUEST, "Unsupported notification type"), + NOT_FOUND_NOTIFICATION( + HttpStatus.NOT_FOUND, "Notification not found"); + + private final HttpStatus httpStatus; + private final String message; + +} diff --git a/src/main/java/com/und/server/notification/repository/NotificationRepository.java b/src/main/java/com/und/server/notification/repository/NotificationRepository.java new file mode 100644 index 00000000..1c84b66c --- /dev/null +++ b/src/main/java/com/und/server/notification/repository/NotificationRepository.java @@ -0,0 +1,7 @@ +package com.und.server.notification.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.und.server.notification.entity.Notification; + +public interface NotificationRepository extends JpaRepository { } diff --git a/src/main/java/com/und/server/notification/repository/TimeNotificationRepository.java b/src/main/java/com/und/server/notification/repository/TimeNotificationRepository.java new file mode 100644 index 00000000..2055d2d9 --- /dev/null +++ b/src/main/java/com/und/server/notification/repository/TimeNotificationRepository.java @@ -0,0 +1,18 @@ +package com.und.server.notification.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; + +import com.und.server.notification.entity.TimeNotification; + +import jakarta.validation.constraints.NotNull; + +public interface TimeNotificationRepository extends JpaRepository { + + @NotNull + TimeNotification findByNotificationId(@NotNull Long notificationId); + + @Modifying + void deleteByNotificationId(@NotNull Long notificationId); + +} diff --git a/src/main/java/com/und/server/notification/service/NotificationCacheService.java b/src/main/java/com/und/server/notification/service/NotificationCacheService.java new file mode 100644 index 00000000..7c030f8b --- /dev/null +++ b/src/main/java/com/und/server/notification/service/NotificationCacheService.java @@ -0,0 +1,212 @@ +package com.und.server.notification.service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.RedisSystemException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.und.server.notification.dto.cache.NotificationCacheData; +import com.und.server.notification.dto.response.NotificationConditionResponse; +import com.und.server.notification.dto.response.ScenarioNotificationListResponse; +import com.und.server.notification.dto.response.ScenarioNotificationResponse; +import com.und.server.notification.exception.NotificationCacheErrorResult; +import com.und.server.notification.exception.NotificationCacheException; +import com.und.server.notification.util.NotificationCacheKeyGenerator; +import com.und.server.notification.util.NotificationCacheSerializer; +import com.und.server.scenario.entity.Scenario; +import com.und.server.scenario.service.ScenarioNotificationService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationCacheService { + + private static final int CACHE_TTL_DAYS = 30; + private final RedisTemplate redisTemplate; + private final NotificationCacheKeyGenerator keyGenerator; + private final NotificationCacheSerializer serializer; + private final NotificationConditionSelector notificationConditionSelector; + private final ScenarioNotificationService scenarioNotificationService; + + + public ScenarioNotificationListResponse getScenariosNotificationCache(final Long memberId) { + try { + String cacheKey = keyGenerator.generateNotificationCacheKey(memberId); + String etagKey = keyGenerator.generateEtagKey(memberId); + + String etag = (String) redisTemplate.opsForValue().get(etagKey); + if (etag == null) { + List scenarioNotifications = + scenarioNotificationService.getScenarioNotifications(memberId); + + saveToCache(memberId, scenarioNotifications); + String newEtag = updateEtag(memberId); + + return ScenarioNotificationListResponse.from(newEtag, scenarioNotifications); + } + + Map cacheData = redisTemplate.opsForHash().entries(cacheKey); + if (cacheData.isEmpty()) { + return new ScenarioNotificationListResponse(etag, new ArrayList<>()); + } + + List scenarios = new ArrayList<>(); + for (Object value : cacheData.values()) { + NotificationCacheData cacheDto = serializer.deserialize((String) value); + scenarios.add(convertToResponse(cacheDto)); + } + + return new ScenarioNotificationListResponse(etag, scenarios); + + } catch (RedisSystemException | RedisConnectionFailureException e) { + throw new NotificationCacheException(NotificationCacheErrorResult.CACHE_FETCH_ALL_FAILED); + } + } + + + public ScenarioNotificationResponse getSingleScenarioNotificationCache( + final Long memberId, final Long scenarioId + ) { + try { + handleRefreshCacheFromDatabase(memberId); + + String cacheKey = keyGenerator.generateNotificationCacheKey(memberId); + String fieldKey = scenarioId.toString(); + Object cachedValue = redisTemplate.opsForHash().get(cacheKey, fieldKey); + if (cachedValue == null) { + throw new NotificationCacheException( + NotificationCacheErrorResult.CACHE_NOT_FOUND_SCENARIO_NOTIFICATION); + } + NotificationCacheData cacheData = serializer.deserialize((String) cachedValue); + + return convertToResponse(cacheData); + + } catch (RedisSystemException | RedisConnectionFailureException e) { + throw new NotificationCacheException(NotificationCacheErrorResult.CACHE_FETCH_SINGLE_FAILED); + } + } + + + public void updateCache(final Long memberId, final Scenario scenario) { + if (handleRefreshCacheFromDatabase(memberId)) { + return; + } + + NotificationCacheData cacheData = createCacheData(scenario); + + String cacheKey = keyGenerator.generateNotificationCacheKey(memberId); + String fieldKey = scenario.getId().toString(); + String jsonValue = serializer.serialize(cacheData); + + redisTemplate.opsForHash().put(cacheKey, fieldKey, jsonValue); + redisTemplate.expire(cacheKey, CACHE_TTL_DAYS, TimeUnit.DAYS); + + updateEtag(memberId); + } + + + public void deleteCache(final Long memberId, final Long scenarioId) { + if (handleRefreshCacheFromDatabase(memberId)) { + return; + } + + String cacheKey = keyGenerator.generateNotificationCacheKey(memberId); + String fieldKey = scenarioId.toString(); + + redisTemplate.opsForHash().delete(cacheKey, fieldKey); + + updateEtag(memberId); + } + + + public void deleteMemberAllCache(final Long memberId) { + try { + String cacheKey = keyGenerator.generateNotificationCacheKey(memberId); + String etagKey = keyGenerator.generateEtagKey(memberId); + + redisTemplate.delete(cacheKey); + redisTemplate.delete(etagKey); + + } catch (RedisSystemException | RedisConnectionFailureException e) { + log.error("Failed to process scenario fetch delete event memberId={}", memberId, e); + } + } + + + private boolean handleRefreshCacheFromDatabase(Long memberId) { + String etagKey = keyGenerator.generateEtagKey(memberId); + String etag = (String) redisTemplate.opsForValue().get(etagKey); + if (etag == null) { + refreshCacheFromDatabase(memberId); + return true; + } + return false; + } + + public void refreshCacheFromDatabase(Long memberId) { + List scenarioNotifications = + scenarioNotificationService.getScenarioNotifications(memberId); + + saveToCache(memberId, scenarioNotifications); + updateEtag(memberId); + } + + private void saveToCache( + final Long memberId, final List scenarioNotificationResponses + ) { + if (scenarioNotificationResponses == null || scenarioNotificationResponses.isEmpty()) { + return; + } + String cacheKey = keyGenerator.generateNotificationCacheKey(memberId); + Map values = new HashMap<>(); + + for (ScenarioNotificationResponse scenario : scenarioNotificationResponses) { + NotificationCacheData cacheData = NotificationCacheData.from( + scenario, + serializer.serializeCondition(scenario.notificationCondition()) + ); + + String fieldKey = scenario.scenarioId().toString(); + String jsonValue = serializer.serialize(cacheData); + values.put(fieldKey, jsonValue); + } + + redisTemplate.opsForHash().putAll(cacheKey, values); + redisTemplate.expire(cacheKey, CACHE_TTL_DAYS, TimeUnit.DAYS); + } + + private String updateEtag(Long memberId) { + String etagKey = keyGenerator.generateEtagKey(memberId); + String etag = String.valueOf(System.currentTimeMillis()); + + redisTemplate.opsForValue().set(etagKey, etag); + redisTemplate.expire(etagKey, CACHE_TTL_DAYS, TimeUnit.DAYS); + + return etag; + } + + private NotificationCacheData createCacheData(final Scenario scenario) { + NotificationConditionResponse condition = + notificationConditionSelector.findNotificationCondition(scenario.getNotification()); + + return NotificationCacheData.from(scenario, serializer.serializeCondition(condition)); + } + + private ScenarioNotificationResponse convertToResponse(final NotificationCacheData notificationCacheData) { + NotificationConditionResponse condition = serializer.parseCondition(notificationCacheData); + + return ScenarioNotificationResponse.from(notificationCacheData, condition); + } + +} diff --git a/src/main/java/com/und/server/notification/service/NotificationConditionSelector.java b/src/main/java/com/und/server/notification/service/NotificationConditionSelector.java new file mode 100644 index 00000000..e6e6a5e0 --- /dev/null +++ b/src/main/java/com/und/server/notification/service/NotificationConditionSelector.java @@ -0,0 +1,61 @@ +package com.und.server.notification.service; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.und.server.common.exception.ServerException; +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.request.NotificationConditionRequest; +import com.und.server.notification.dto.response.NotificationConditionResponse; +import com.und.server.notification.entity.Notification; +import com.und.server.notification.exception.NotificationErrorResult; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class NotificationConditionSelector { + + private final List services; + + + public NotificationConditionResponse findNotificationCondition(final Notification notification) { + NotificationConditionService service = findServiceByNotificationType(notification.getNotificationType()); + + return service.findNotificationInfoByType(notification); + } + + + public void addNotificationCondition( + final Notification notification, + final NotificationConditionRequest notificationConditionRequest + ) { + NotificationConditionService service = findServiceByNotificationType(notification.getNotificationType()); + service.addNotificationCondition(notification, notificationConditionRequest); + } + + + public void updateNotificationCondition( + final Notification notification, + final NotificationConditionRequest notificationConditionRequest + ) { + NotificationConditionService service = findServiceByNotificationType(notification.getNotificationType()); + service.updateNotificationCondition(notification, notificationConditionRequest); + } + + + public void deleteNotificationCondition(final NotificationType notificationType, final Long notificationId) { + NotificationConditionService service = findServiceByNotificationType(notificationType); + service.deleteNotificationCondition(notificationId); + } + + + private NotificationConditionService findServiceByNotificationType(final NotificationType notificationType) { + return services.stream() + .filter(service -> service.supports(notificationType)) + .findAny() + .orElseThrow(() -> new ServerException(NotificationErrorResult.UNSUPPORTED_NOTIFICATION)); + } + +} diff --git a/src/main/java/com/und/server/notification/service/NotificationConditionService.java b/src/main/java/com/und/server/notification/service/NotificationConditionService.java new file mode 100644 index 00000000..42972350 --- /dev/null +++ b/src/main/java/com/und/server/notification/service/NotificationConditionService.java @@ -0,0 +1,24 @@ +package com.und.server.notification.service; + +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.request.NotificationConditionRequest; +import com.und.server.notification.dto.response.NotificationConditionResponse; +import com.und.server.notification.entity.Notification; + +public interface NotificationConditionService { + + boolean supports(final NotificationType notificationType); + + NotificationConditionResponse findNotificationInfoByType(final Notification notification); + + void addNotificationCondition( + final Notification notification, + final NotificationConditionRequest notificationConditionRequest); + + void updateNotificationCondition( + final Notification notification, + final NotificationConditionRequest notificationConditionRequest); + + void deleteNotificationCondition(final Long notificationId); + +} diff --git a/src/main/java/com/und/server/notification/service/NotificationService.java b/src/main/java/com/und/server/notification/service/NotificationService.java new file mode 100644 index 00000000..795ef55d --- /dev/null +++ b/src/main/java/com/und/server/notification/service/NotificationService.java @@ -0,0 +1,151 @@ +package com.und.server.notification.service; + +import java.util.List; +import java.util.Objects; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.request.NotificationConditionRequest; +import com.und.server.notification.dto.request.NotificationRequest; +import com.und.server.notification.dto.response.NotificationConditionResponse; +import com.und.server.notification.entity.Notification; +import com.und.server.notification.event.NotificationEventPublisher; +import com.und.server.notification.repository.NotificationRepository; +import com.und.server.scenario.entity.Scenario; +import com.und.server.scenario.repository.ScenarioRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final ScenarioRepository scenarioRepository; + private final NotificationConditionSelector notificationConditionSelector; + private final NotificationEventPublisher notificationEventPublisher; + + + @Transactional(readOnly = true) + public NotificationConditionResponse findNotificationDetails(final Notification notification) { + return notificationConditionSelector.findNotificationCondition(notification); + } + + + @Transactional + public Notification addNotification( + final NotificationRequest notificationRequest, + final NotificationConditionRequest notificationConditionRequest + ) { + boolean isNotificationActive = notificationRequest.isActive(); + if (isNotificationActive) { + return addWithNotification(notificationRequest, notificationConditionRequest); + } else { + return addWithoutNotification(notificationRequest); + } + } + + + @Transactional + public void updateNotification( + final Notification notification, + final NotificationRequest notificationRequest, + final NotificationConditionRequest notificationConditionRequest + ) { + boolean isNotificationActive = notificationRequest.isActive(); + if (isNotificationActive) { + updateWithNotification(notification, notificationRequest, notificationConditionRequest); + } else { + updateWithoutNotification(notification); + } + } + + + @Transactional + public void updateNotificationActiveStatus(final Long memberId, final Boolean isActive) { + List scenarios = scenarioRepository.findByMemberId(memberId); + + if (scenarios.isEmpty()) { + return; + } + scenarios.stream() + .map(Scenario::getNotification) + .filter(Objects::nonNull) + .filter(notification -> { + if (!isActive) { + return notification.isActive() && notification.hasNotificationCondition(); + } else { + return notification.hasNotificationCondition(); + } + }) + .forEach(notification -> notification.updateActive(isActive)); + + notificationEventPublisher.publishActiveUpdateEvent(memberId, isActive); + } + + + @Transactional + public void deleteNotification(final Notification notification) { + notificationConditionSelector.deleteNotificationCondition( + notification.getNotificationType(), + notification.getId() + ); + } + + + private Notification addWithNotification( + final NotificationRequest notificationRequest, + final NotificationConditionRequest notificationConditionRequest + ) { + Notification notification = notificationRequest.toEntity(); + + notificationRepository.save(notification); + notificationConditionSelector.addNotificationCondition( + notification, notificationConditionRequest); + + return notification; + } + + private Notification addWithoutNotification(final NotificationRequest notificationRequest) { + Notification notification = notificationRequest.toEntity(); + notificationRepository.save(notification); + + return notification; + } + + private void updateWithNotification( + final Notification notification, + final NotificationRequest notificationRequest, + final NotificationConditionRequest notificationConditionRequest + ) { + NotificationType oldNotificationType = notification.getNotificationType(); + NotificationType newNotificationtype = notificationRequest.notificationType(); + boolean isChangeNotificationType = oldNotificationType != newNotificationtype; + + notification.activate( + newNotificationtype, + notificationRequest.notificationMethodType(), + notificationRequest.daysOfWeekOrdinal() + ); + + if (isChangeNotificationType) { + notificationConditionSelector.deleteNotificationCondition(oldNotificationType, notification.getId()); + notificationConditionSelector.addNotificationCondition( + notification, notificationConditionRequest); + return; + } + + notificationConditionSelector.updateNotificationCondition( + notification, notificationConditionRequest); + } + + private void updateWithoutNotification(final Notification oldNotification) { + notificationConditionSelector.deleteNotificationCondition( + oldNotification.getNotificationType(), oldNotification.getId()); + + oldNotification.deactivate(); + } + +} diff --git a/src/main/java/com/und/server/notification/service/TimeNotificationService.java b/src/main/java/com/und/server/notification/service/TimeNotificationService.java new file mode 100644 index 00000000..23142a7f --- /dev/null +++ b/src/main/java/com/und/server/notification/service/TimeNotificationService.java @@ -0,0 +1,84 @@ +package com.und.server.notification.service; + +import org.springframework.stereotype.Service; + +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.request.NotificationConditionRequest; +import com.und.server.notification.dto.request.TimeNotificationRequest; +import com.und.server.notification.dto.response.NotificationConditionResponse; +import com.und.server.notification.dto.response.TimeNotificationResponse; +import com.und.server.notification.entity.Notification; +import com.und.server.notification.entity.TimeNotification; +import com.und.server.notification.repository.TimeNotificationRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class TimeNotificationService implements NotificationConditionService { + + private final TimeNotificationRepository timeNotificationRepository; + + + @Override + public boolean supports(final NotificationType notificationType) { + return notificationType == NotificationType.TIME; + } + + + @Override + public NotificationConditionResponse findNotificationInfoByType(final Notification notification) { + if (!notification.isActive()) { + return null; + } + + TimeNotification timeNotifications = + timeNotificationRepository.findByNotificationId(notification.getId()); + + return TimeNotificationResponse.from(timeNotifications); + } + + + @Override + public void addNotificationCondition( + final Notification notification, + final NotificationConditionRequest notificationConditionRequest + ) { + if (!notification.isActive()) { + return; + } + + TimeNotificationRequest timeNotificationRequest = (TimeNotificationRequest) notificationConditionRequest; + + TimeNotification timeNotification = timeNotificationRequest.toEntity(notification); + timeNotificationRepository.save(timeNotification); + } + + + @Override + public void updateNotificationCondition( + final Notification notification, + final NotificationConditionRequest notificationConditionRequest + ) { + TimeNotificationRequest timeNotificationRequest = (TimeNotificationRequest) notificationConditionRequest; + TimeNotification oldTimeNotifications = + timeNotificationRepository.findByNotificationId(notification.getId()); + + if (oldTimeNotifications == null) { + addNotificationCondition(notification, notificationConditionRequest); + return; + } + + oldTimeNotifications.updateTimeCondition( + timeNotificationRequest.startHour(), + timeNotificationRequest.startMinute() + ); + } + + + @Override + public void deleteNotificationCondition(final Long notificationId) { + timeNotificationRepository.deleteByNotificationId(notificationId); + } + +} diff --git a/src/main/java/com/und/server/notification/util/NotificationCacheKeyGenerator.java b/src/main/java/com/und/server/notification/util/NotificationCacheKeyGenerator.java new file mode 100644 index 00000000..4a247a57 --- /dev/null +++ b/src/main/java/com/und/server/notification/util/NotificationCacheKeyGenerator.java @@ -0,0 +1,19 @@ +package com.und.server.notification.util; + +import org.springframework.stereotype.Component; + +@Component +public class NotificationCacheKeyGenerator { + + private static final String NOTIFICATION_CACHE_PREFIX = "notif"; + private static final String ETAG_PREFIX = "etag"; + + public String generateNotificationCacheKey(final Long memberId) { + return String.format("%s:%d", NOTIFICATION_CACHE_PREFIX, memberId); + } + + public String generateEtagKey(final Long memberId) { + return String.format("%s:%s:%d", NOTIFICATION_CACHE_PREFIX, ETAG_PREFIX, memberId); + } + +} diff --git a/src/main/java/com/und/server/notification/util/NotificationCacheSerializer.java b/src/main/java/com/und/server/notification/util/NotificationCacheSerializer.java new file mode 100644 index 00000000..c7da0a39 --- /dev/null +++ b/src/main/java/com/und/server/notification/util/NotificationCacheSerializer.java @@ -0,0 +1,55 @@ +package com.und.server.notification.util; + +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.und.server.notification.dto.cache.NotificationCacheData; +import com.und.server.notification.dto.response.NotificationConditionResponse; +import com.und.server.notification.exception.NotificationCacheErrorResult; +import com.und.server.notification.exception.NotificationCacheException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationCacheSerializer { + + private final ObjectMapper objectMapper; + + public String serialize(final NotificationCacheData notificationCacheData) { + try { + return objectMapper.writeValueAsString(notificationCacheData); + } catch (JsonProcessingException e) { + throw new NotificationCacheException(NotificationCacheErrorResult.SERIALIZE_FAILED); + } + } + + public NotificationCacheData deserialize(final String json) { + try { + return objectMapper.readValue(json, NotificationCacheData.class); + } catch (JsonProcessingException e) { + throw new NotificationCacheException(NotificationCacheErrorResult.DESERIALIZE_FAILED); + } + } + + public NotificationConditionResponse parseCondition(final NotificationCacheData notificationCacheData) { + try { + return objectMapper.readValue( + notificationCacheData.conditionJson(), NotificationConditionResponse.class); + } catch (JsonProcessingException e) { + throw new NotificationCacheException(NotificationCacheErrorResult.CONDITION_PARSE_FAILED); + } + } + + public String serializeCondition(final NotificationConditionResponse notificationConditionResponse) { + try { + return objectMapper.writeValueAsString(notificationConditionResponse); + } catch (JsonProcessingException e) { + throw new NotificationCacheException(NotificationCacheErrorResult.CONDITION_SERIALIZE_FAILED); + } + } + +} diff --git a/src/main/java/com/und/server/oauth/IdTokenPayload.java b/src/main/java/com/und/server/oauth/IdTokenPayload.java deleted file mode 100644 index 34236edf..00000000 --- a/src/main/java/com/und/server/oauth/IdTokenPayload.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.und.server.oauth; - -public record IdTokenPayload( - String providerId, - String nickname -) { } diff --git a/src/main/java/com/und/server/oauth/OidcClient.java b/src/main/java/com/und/server/oauth/OidcClient.java deleted file mode 100644 index ac928900..00000000 --- a/src/main/java/com/und/server/oauth/OidcClient.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.und.server.oauth; - -import com.und.server.dto.OidcPublicKeys; - -public interface OidcClient { - - OidcPublicKeys getOidcPublicKeys(); - -} diff --git a/src/main/java/com/und/server/oauth/OidcClientFactory.java b/src/main/java/com/und/server/oauth/OidcClientFactory.java deleted file mode 100644 index 5d70ece7..00000000 --- a/src/main/java/com/und/server/oauth/OidcClientFactory.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.und.server.oauth; - -import java.util.EnumMap; -import java.util.Map; -import java.util.Optional; - -import org.springframework.stereotype.Component; - -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; - -@Component -public class OidcClientFactory { - - private final Map oidcClients; - - public OidcClientFactory(KakaoClient kakaoClient) { - oidcClients = new EnumMap<>(Provider.class); - oidcClients.put(Provider.KAKAO, kakaoClient); - } - - public OidcClient getOidcClient(Provider provider) { - return Optional.ofNullable(oidcClients.get(provider)) - .orElseThrow(() -> new ServerException(ServerErrorResult.INVALID_PROVIDER)); - } - -} diff --git a/src/main/java/com/und/server/oauth/OidcProvider.java b/src/main/java/com/und/server/oauth/OidcProvider.java deleted file mode 100644 index d574bcd0..00000000 --- a/src/main/java/com/und/server/oauth/OidcProvider.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.und.server.oauth; - -import com.und.server.dto.OidcPublicKeys; - -public interface OidcProvider { - - IdTokenPayload getIdTokenPayload(String token, OidcPublicKeys oidcPublicKeys); - -} diff --git a/src/main/java/com/und/server/oauth/OidcProviderFactory.java b/src/main/java/com/und/server/oauth/OidcProviderFactory.java deleted file mode 100644 index cbb01deb..00000000 --- a/src/main/java/com/und/server/oauth/OidcProviderFactory.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.und.server.oauth; - -import java.util.EnumMap; -import java.util.Map; -import java.util.Optional; - -import org.springframework.stereotype.Component; - -import com.und.server.dto.OidcPublicKeys; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; - -@Component -public class OidcProviderFactory { - - private final Map oidcProviders; - - public OidcProviderFactory(KakaoProvider kakaoProvider) { - this.oidcProviders = new EnumMap<>(Provider.class); - oidcProviders.put(Provider.KAKAO, kakaoProvider); - } - - public IdTokenPayload getIdTokenPayload( - Provider provider, - String token, - OidcPublicKeys oidcPublicKeys - ) { - return getOidcProvider(provider).getIdTokenPayload(token, oidcPublicKeys); - } - - private OidcProvider getOidcProvider(final Provider provider) { - return Optional.ofNullable(oidcProviders.get(provider)) - .orElseThrow(() -> new ServerException(ServerErrorResult.INVALID_PROVIDER)); - } - -} diff --git a/src/main/java/com/und/server/scenario/constants/MissionSearchType.java b/src/main/java/com/und/server/scenario/constants/MissionSearchType.java new file mode 100644 index 00000000..d8af3541 --- /dev/null +++ b/src/main/java/com/und/server/scenario/constants/MissionSearchType.java @@ -0,0 +1,43 @@ +package com.und.server.scenario.constants; + +import java.time.LocalDate; + +import com.und.server.common.exception.ServerException; +import com.und.server.scenario.exception.ScenarioErrorResult; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum MissionSearchType { + + TODAY(0), + PAST(14), + FUTURE(14); + + private final int rangeDays; + + public static MissionSearchType getMissionSearchType(final LocalDate today, final LocalDate requestDate) { + if (requestDate == null || today.isEqual(requestDate)) { + return TODAY; + } + + if (requestDate.isBefore(today)) { + if (requestDate.isBefore(today.minusDays(PAST.getRangeDays()))) { + throw new ServerException(ScenarioErrorResult.INVALID_MISSION_FOUND_DATE); + } + return PAST; + } + + if (requestDate.isAfter(today)) { + if (requestDate.isAfter(today.plusDays(FUTURE.getRangeDays()))) { + throw new ServerException(ScenarioErrorResult.INVALID_MISSION_FOUND_DATE); + } + return FUTURE; + } + + throw new ServerException(ScenarioErrorResult.INVALID_MISSION_FOUND_DATE); + } + +} diff --git a/src/main/java/com/und/server/scenario/constants/MissionType.java b/src/main/java/com/und/server/scenario/constants/MissionType.java new file mode 100644 index 00000000..702e275b --- /dev/null +++ b/src/main/java/com/und/server/scenario/constants/MissionType.java @@ -0,0 +1,17 @@ +package com.und.server.scenario.constants; + +import com.fasterxml.jackson.annotation.JsonCreator; + +public enum MissionType { + + BASIC, TODAY; + + @JsonCreator + public static MissionType fromValue(final String value) { + if (value == null) { + return null; + } + return MissionType.valueOf(value.toUpperCase()); + } + +} diff --git a/src/main/java/com/und/server/scenario/controller/MissionApiDocs.java b/src/main/java/com/und/server/scenario/controller/MissionApiDocs.java new file mode 100644 index 00000000..1051b7eb --- /dev/null +++ b/src/main/java/com/und/server/scenario/controller/MissionApiDocs.java @@ -0,0 +1,308 @@ +package com.und.server.scenario.controller; + +import java.time.LocalDate; + +import org.springframework.http.ResponseEntity; + +import com.und.server.common.dto.response.ErrorResponse; +import com.und.server.scenario.dto.request.TodayMissionRequest; +import com.und.server.scenario.dto.response.MissionGroupResponse; +import com.und.server.scenario.dto.response.MissionResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +public interface MissionApiDocs { + + @Operation(summary = "Get Missions by Scenario ID API") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "Get missions successful, Return empty array if no mission exists", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = MissionGroupResponse.class) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Bad request", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "Invalid mission found date", + value = """ + { + "code": "INVALID_MISSION_FOUND_DATE", + "message": "Mission can only be founded for mission dates" + } + """ + ) + } + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized access", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Unauthorized access", + value = """ + { + "code": "UNAUTHORIZED_ACCESS", + "message": "Unauthorized access" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "Scenario not found", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Scenario not found", + value = """ + { + "code": "NOT_FOUND_SCENARIO", + "message": "Scenario not found" + } + """ + ) + ) + ) + }) + ResponseEntity getMissionsByScenarioId( + @Parameter(hidden = true) final Long memberId, + @Parameter(description = "Scenario ID") final Long scenarioId, + @Parameter(description = "Target date for missions (yyyy-MM-dd)") final LocalDate date + ); + + + @Operation(summary = "Add Today Mission to Scenario API") + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "Create Today Mission successful", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = MissionResponse.class) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Bad Request", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "Content must not be blank", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "Content must not be blank" + } + """ + ), + @ExampleObject( + name = "Content must be at most 10 characters", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "Content must be at most 10 characters" + } + """ + ), + @ExampleObject( + name = "Invalid today mission date", + value = """ + { + "code": "INVALID_TODAY_MISSION_DATE", + "message": "Today mission can only be added for today or future dates" + } + """ + ), + @ExampleObject( + name = "Max mission count exceeded", + value = """ + { + "code": "MAX_MISSION_COUNT_EXCEEDED", + "message": "Maximum mission count exceeded" + } + """ + ) + } + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized access", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Unauthorized access", + value = """ + { + "code": "UNAUTHORIZED_ACCESS", + "message": "Unauthorized access" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "Scenario not found", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Scenario not found", + value = """ + { + "code": "NOT_FOUND_SCENARIO", + "message": "Scenario not found" + } + """ + ) + ) + ) + }) + ResponseEntity addTodayMissionToScenario( + @Parameter(hidden = true) final Long memberId, + @Parameter(description = "Scenario ID") final Long scenarioId, + @Parameter(description = "Today mission request") @Valid final TodayMissionRequest missionAddRequest, + @Parameter(description = "Target date for mission (yyyy-MM-dd)") final LocalDate date + ); + + + @Operation(summary = "Update Mission Check Status API") + @ApiResponses({ + @ApiResponse( + responseCode = "204", + description = "Update check status successful" + ), + @ApiResponse( + responseCode = "400", + description = "Bad Request", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "Invalid today mission date", + value = """ + { + "code": "INVALID_TODAY_MISSION_DATE", + "message": "Today mission can only be added for today or future dates" + } + """ + ) + } + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized access", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Unauthorized access", + value = """ + { + "code": "UNAUTHORIZED_ACCESS", + "message": "Unauthorized access" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "Mission not found", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Mission not found", + value = """ + { + "code": "NOT_FOUND_MISSION", + "message": "Mission not found" + } + """ + ) + ) + ) + }) + ResponseEntity updateMissionCheck( + @Parameter(hidden = true) final Long memberId, + @Parameter(description = "Mission ID") final Long missionId, + @Parameter(description = "Check status to update") @NotNull final Boolean isChecked, + @Parameter(description = "Target date for mission (yyyy-MM-dd)") final LocalDate date + ); + + + @Operation(summary = "Delete Today Mission API") + @ApiResponses({ + @ApiResponse( + responseCode = "204", + description = "Delete Today Mission successful" + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized access", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Unauthorized access", + value = """ + { + "code": "UNAUTHORIZED_ACCESS", + "message": "Unauthorized access" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "Mission not found", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Mission not found", + value = """ + { + "code": "NOT_FOUND_MISSION", + "message": "Mission not found" + } + """ + ) + ) + ) + }) + ResponseEntity deleteTodayMissionById( + @Parameter(hidden = true) final Long memberId, + @Parameter(description = "Mission ID") final Long missionId + ); + +} diff --git a/src/main/java/com/und/server/scenario/controller/MissionController.java b/src/main/java/com/und/server/scenario/controller/MissionController.java new file mode 100644 index 00000000..03eaf92b --- /dev/null +++ b/src/main/java/com/und/server/scenario/controller/MissionController.java @@ -0,0 +1,94 @@ +package com.und.server.scenario.controller; + +import java.time.LocalDate; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.und.server.auth.filter.AuthMember; +import com.und.server.scenario.dto.request.TodayMissionRequest; +import com.und.server.scenario.dto.response.MissionGroupResponse; +import com.und.server.scenario.dto.response.MissionResponse; +import com.und.server.scenario.service.MissionService; +import com.und.server.scenario.service.ScenarioService; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@PreAuthorize("isAuthenticated()") +@RequestMapping("/v1") +public class MissionController implements MissionApiDocs { + + private final ScenarioService scenarioService; + private final MissionService missionService; + + + @Override + @GetMapping("/scenarios/{scenarioId}/missions") + public ResponseEntity getMissionsByScenarioId( + @AuthMember final Long memberId, + @PathVariable final Long scenarioId, + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") final LocalDate date + ) { + final MissionGroupResponse missions = + missionService.findMissionsByScenarioId(memberId, scenarioId, date); + + return ResponseEntity.ok().body(missions); + } + + + @Override + @PostMapping("/scenarios/{scenarioId}/missions/today") + public ResponseEntity addTodayMissionToScenario( + @AuthMember final Long memberId, + @PathVariable final Long scenarioId, + @RequestBody @Valid final TodayMissionRequest missionAddRequest, + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") final LocalDate date + ) { + final MissionResponse missionResponse = + scenarioService.addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, date); + + return ResponseEntity.status(HttpStatus.CREATED).body(missionResponse); + } + + + @Override + @PatchMapping("/missions/{missionId}/check") + public ResponseEntity updateMissionCheck( + @AuthMember final Long memberId, + @PathVariable final Long missionId, + @RequestBody @NotNull final Boolean isChecked, + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") final LocalDate date + ) { + missionService.updateMissionCheck(memberId, missionId, isChecked, date); + + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + + @Override + @DeleteMapping("/missions/{missionId}") + public ResponseEntity deleteTodayMissionById( + @AuthMember final Long memberId, + @PathVariable final Long missionId + ) { + missionService.deleteTodayMission(memberId, missionId); + + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + +} diff --git a/src/main/java/com/und/server/scenario/controller/ScenarioApiDocs.java b/src/main/java/com/und/server/scenario/controller/ScenarioApiDocs.java new file mode 100644 index 00000000..56a2572d --- /dev/null +++ b/src/main/java/com/und/server/scenario/controller/ScenarioApiDocs.java @@ -0,0 +1,688 @@ +package com.und.server.scenario.controller; + +import org.springframework.http.ResponseEntity; + +import com.und.server.common.dto.response.ErrorResponse; +import com.und.server.notification.constants.NotificationType; +import com.und.server.scenario.dto.request.ScenarioDetailRequest; +import com.und.server.scenario.dto.request.ScenarioOrderUpdateRequest; +import com.und.server.scenario.dto.response.MissionGroupResponse; +import com.und.server.scenario.dto.response.OrderUpdateResponse; +import com.und.server.scenario.dto.response.ScenarioDetailResponse; +import com.und.server.scenario.dto.response.ScenarioResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +import java.util.List; + +public interface ScenarioApiDocs { + + @Operation(summary = "Get Scenarios API") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "Get scenarios successful", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ScenarioResponse.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized access", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Unauthorized access", + value = """ + { + "code": "UNAUTHORIZED_ACCESS", + "message": "Unauthorized access" + } + """ + ) + ) + ) + }) + ResponseEntity> getScenarios( + @Parameter(hidden = true) final Long memberId, + @Parameter(description = "Notification type filter (TIME, LOCATION)") final NotificationType notificationType + ); + + + @Operation(summary = "Get Scenario Detail API") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "Get scenario detail successful", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ScenarioDetailResponse.class), + examples = { + @ExampleObject( + name = "(TIME) With notification", + value = """ + { + "scenarioId": 1, + "scenarioName": "Home out", + "memo": "Item to carry", + "basicMissions": [ + { + "missionId": 1, + "content": "Lock door", + "isChecked": false, + "missionType": "BASIC" + }, + { + "missionId": 2, + "content": "Turn off lights", + "isChecked": true, + "missionType": "BASIC" + } + ], + "notification": { + "notificationId": 2, + "notificationType": "TIME", + "notificationMethodType": "PUSH", + "isActive": true, + "daysOfWeekOrdinal": [0, 1, 2, 3, 4, 5, 6] + }, + "notificationCondition": { + "notificationType": "TIME", + "startHour": 12, + "startMinute": 58 + } + } + """ + ), + @ExampleObject( + name = "(TIME) Without notification", + value = """ + { + "scenarioId": 2, + "scenarioName": "Home out", + "memo": "Item to carry", + "basicMissions": [ + { + "missionId": 3, + "content": "Lock door", + "isChecked": false, + "missionType": "BASIC" + }, + { + "missionId": 4, + "content": "Open door", + "isChecked": false, + "missionType": "BASIC" + } + ], + "notification": { + "notificationId": 2, + "isActive": false, + "notificationType": "TIME" + } + } + """ + ) + } + ) + ), + @ApiResponse( + responseCode = "400", + description = "Bad Request", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "Unsupported mission type", + value = """ + { + "code": "UNSUPPORTED_MISSION_TYPE", + "message": "Unsupported mission type" + } + """ + ) + } + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized access", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Unauthorized access", + value = """ + { + "code": "UNAUTHORIZED_ACCESS", + "message": "Unauthorized access" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "Scenario not found", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Scenario not found", + value = """ + { + "code": "NOT_FOUND_SCENARIO", + "message": "Scenario not found" + } + """ + ) + ) + ) + }) + ResponseEntity getScenarioDetail( + @Parameter(hidden = true) final Long memberId, + @Parameter(description = "Scenario ID") final Long scenarioId + ); + + + @Operation(summary = "Add Scenario API") + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "Create Scenario successful" + ), + @ApiResponse( + responseCode = "400", + description = "Bad request", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "Scenario name must not be blank", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "Scenario name must not be blank" + } + """ + ), + @ExampleObject( + name = "Scenario name must be at most 10 characters", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "Scenario name must be at most 10 characters" + } + """ + ), + @ExampleObject( + name = "Memo must be at most 15 characters", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "Memo must be at most 15 characters" + } + """ + ), + @ExampleObject( + name = "Basic mission content must not be blank", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "Basic mission content must not be blank" + } + """ + ), + @ExampleObject( + name = "Basic mission content must be at most 10 characters", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "Basic mission content must be at most 50 characters" + } + """ + ), + @ExampleObject( + name = "Unsupported mission type", + value = """ + { + "code": "UNSUPPORTED_MISSION_TYPE", + "message": "Unsupported mission type" + } + """ + ), + @ExampleObject( + name = "Max scenario count exceeded", + value = """ + { + "code": "MAX_SCENARIO_COUNT_EXCEEDED", + "message": "Maximum scenario count exceeded" + } + """ + ) + } + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized access", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Unauthorized access", + value = """ + { + "code": "UNAUTHORIZED_ACCESS", + "message": "Unauthorized access" + } + """ + ) + ) + ) + }) + ResponseEntity> addScenario( + @Parameter(hidden = true) Long memberId, + @RequestBody( + description = "Scenario detail request", + required = true, + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ScenarioDetailRequest.class), + examples = { + @ExampleObject( + name = "(TIME) With notification", + value = """ + { + "scenarioName": "Home out", + "memo": "Item to carry", + "basicMissions": [ + { "content": "Lock door" }, + { "content": "Open door" } + ], + "notification": { + "isActive": true, + "notificationType": "time", + "notificationMethodType": "alarm", + "daysOfWeekOrdinal": [0,1,2] + }, + "notificationCondition": { + "notificationType": "time", + "startHour": 1, + "startMinute": 5 + } + } + """ + ), + @ExampleObject( + name = "(TIME) Without notification", + value = """ + { + "scenarioName": "Home out", + "memo": "Item to carry", + "basicMissions": [ + { "content": "Lock door" }, + { "content": "Open door" } + ], + "notification": { + "isActive": false, + "notificationType": "time" + } + } + """ + ) + } + ) + ) + ScenarioDetailRequest scenarioRequest + ); + + + @Operation(summary = "Update Scenario API") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "Update Scenario successful" + ), + @ApiResponse( + responseCode = "400", + description = "Bad request", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "Scenario name must not be blank", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "Scenario name must not be blank" + } + """ + ), + @ExampleObject( + name = "Scenario name must be at most 10 characters", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "Scenario name must be at most 10 characters" + } + """ + ), + @ExampleObject( + name = "Memo must be at most 15 characters", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "Memo must be at most 15 characters" + } + """ + ), + @ExampleObject( + name = "Basic mission content must not be blank", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "Basic mission content must not be blank" + } + """ + ), + @ExampleObject( + name = "Basic mission content must be at most 10 characters", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "Basic mission content must be at most 50 characters" + } + """ + ), + @ExampleObject( + name = "Unsupported mission type", + value = """ + { + "code": "UNSUPPORTED_MISSION_TYPE", + "message": "Unsupported mission type" + } + """ + ), + @ExampleObject( + name = "Max scenario count exceeded", + value = """ + { + "code": "MAX_SCENARIO_COUNT_EXCEEDED", + "message": "Maximum scenario count exceeded" + } + """ + ) + } + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized access", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Unauthorized access", + value = """ + { + "code": "UNAUTHORIZED_ACCESS", + "message": "Unauthorized access" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "Scenario not found", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Scenario not found", + value = """ + { + "code": "NOT_FOUND_SCENARIO", + "message": "Scenario not found" + } + """ + ) + ) + ) + }) + ResponseEntity> updateScenario( + @Parameter(hidden = true) Long memberId, + @Parameter(description = "Scenario ID") Long scenarioId, + @RequestBody( + description = "Scenario detail request", + required = true, + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ScenarioDetailRequest.class), + examples = { + @ExampleObject( + name = "(TIME) Without notification", + value = """ + { + "scenarioName": "Home out", + "memo": "Item to carry", + "basicMissions": [ + { "content": "new" }, + { "missionId": 1, "content": "old" } + ], + "notification": { + "isActive": false, + "notificationType": "time" + } + } + """ + ) + } + ) + ) + ScenarioDetailRequest scenarioRequest + ); + + + @Operation(summary = "Update Scenario Order API") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "Update Scenario order successful", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = OrderUpdateResponse.class), + examples = { + @ExampleObject( + name = "No reorder required", + value = """ + { + "isReorder": false, + "orderUpdates": [ + { + "id": 1, + "newOrder": 100500 + } + ] + } + """ + ), + @ExampleObject( + name = "Reorder required", + value = """ + { + "isReorder": true, + "orderUpdates": [ + { + "id": 1, + "newOrder": 100000 + }, + { + "id": 2, + "newOrder": 101000 + } + ] + } + """ + ) + } + ) + ), + @ApiResponse( + responseCode = "400", + description = "Bad request - invalid parameters", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "PrevOrder must be greater than or equal to 1", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "PrevOrder must be greater than or equal to 1" + } + """ + ), + @ExampleObject( + name = "NextOrder must be greater than or equal to 1", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "NextOrder must be greater than or equal to 1" + } + """ + ) + } + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized access", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Unauthorized access", + value = """ + { + "code": "UNAUTHORIZED_ACCESS", + "message": "Unauthorized access" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "Scenario not found", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Scenario not found", + value = """ + { + "code": "NOT_FOUND_SCENARIO", + "message": "Scenario not found" + } + """ + ) + ) + ) + }) + ResponseEntity updateScenarioOrder( + @Parameter(hidden = true) Long memberId, + @Parameter(description = "Scenario ID") Long scenarioId, + @RequestBody( + description = "Scenario order update request", + required = true, + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ScenarioOrderUpdateRequest.class), + examples = { + @ExampleObject( + name = "Move to front", + value = """ + { + "prevOrder": null, + "nextOrder": 100000 + } + """ + ), + @ExampleObject( + name = "Move to back", + value = """ + { + "prevOrder": 101000, + "nextOrder": null + } + """ + ) + } + ) + ) + ScenarioOrderUpdateRequest scenarioOrderUpdateRequest + ); + + + @Operation(summary = "Delete Scenario API") + @ApiResponses({ + @ApiResponse( + responseCode = "204", + description = "Delete Scenario successful" + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized access", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Unauthorized access", + value = """ + { + "code": "UNAUTHORIZED_ACCESS", + "message": "Unauthorized access" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "Scenario not found", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Scenario not found", + value = """ + { + "code": "NOT_FOUND_SCENARIO", + "message": "Scenario not found" + } + """ + ) + ) + ) + }) + ResponseEntity deleteScenario( + @Parameter(hidden = true) Long memberId, + @Parameter(description = "Scenario ID") Long scenarioId + ); + +} diff --git a/src/main/java/com/und/server/scenario/controller/ScenarioController.java b/src/main/java/com/und/server/scenario/controller/ScenarioController.java new file mode 100644 index 00000000..a0c610a5 --- /dev/null +++ b/src/main/java/com/und/server/scenario/controller/ScenarioController.java @@ -0,0 +1,118 @@ +package com.und.server.scenario.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.und.server.auth.filter.AuthMember; +import com.und.server.notification.constants.NotificationType; +import com.und.server.scenario.dto.request.ScenarioDetailRequest; +import com.und.server.scenario.dto.request.ScenarioOrderUpdateRequest; +import com.und.server.scenario.dto.response.OrderUpdateResponse; +import com.und.server.scenario.dto.response.ScenarioDetailResponse; +import com.und.server.scenario.dto.response.ScenarioResponse; +import com.und.server.scenario.service.ScenarioService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@PreAuthorize("isAuthenticated()") +@RequestMapping("/v1") +public class ScenarioController implements ScenarioApiDocs { + + private final ScenarioService scenarioService; + + + @Override + @GetMapping("/scenarios") + public ResponseEntity> getScenarios( + @AuthMember final Long memberId, + @RequestParam(defaultValue = "TIME") final NotificationType notificationType + ) { + final List scenarios = + scenarioService.findScenariosByMemberId(memberId, notificationType); + + return ResponseEntity.ok().body(scenarios); + } + + + @Override + @GetMapping("/scenarios/{scenarioId}") + public ResponseEntity getScenarioDetail( + @AuthMember final Long memberId, + @PathVariable final Long scenarioId + ) { + final ScenarioDetailResponse scenarioDetail = + scenarioService.findScenarioDetailByScenarioId(memberId, scenarioId); + + return ResponseEntity.ok().body(scenarioDetail); + } + + + @Override + @PostMapping("/scenarios") + public ResponseEntity> addScenario( + @AuthMember final Long memberId, + @RequestBody @Valid final ScenarioDetailRequest scenarioRequest + ) { + final List scenarios = + scenarioService.addScenario(memberId, scenarioRequest); + + return ResponseEntity.status(HttpStatus.CREATED).body(scenarios); + } + + + @Override + @PutMapping("/scenarios/{scenarioId}") + public ResponseEntity> updateScenario( + @AuthMember final Long memberId, + @PathVariable final Long scenarioId, + @RequestBody @Valid final ScenarioDetailRequest scenarioRequest + ) { + final List scenarios = + scenarioService.updateScenario(memberId, scenarioId, scenarioRequest); + + return ResponseEntity.ok().body(scenarios); + } + + + @Override + @PatchMapping("/scenarios/{scenarioId}/order") + public ResponseEntity updateScenarioOrder( + @AuthMember final Long memberId, + @PathVariable final Long scenarioId, + @RequestBody @Valid final ScenarioOrderUpdateRequest scenarioOrderUpdateRequest + ) { + final OrderUpdateResponse orderUpdateResponse = + scenarioService.updateScenarioOrder(memberId, scenarioId, scenarioOrderUpdateRequest); + + return ResponseEntity.ok().body(orderUpdateResponse); + } + + + @Override + @DeleteMapping("/scenarios/{scenarioId}") + public ResponseEntity deleteScenario( + @AuthMember final Long memberId, + @PathVariable final Long scenarioId + ) { + scenarioService.deleteScenarioWithAllMissions(memberId, scenarioId); + + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + +} diff --git a/src/main/java/com/und/server/scenario/dto/request/BasicMissionRequest.java b/src/main/java/com/und/server/scenario/dto/request/BasicMissionRequest.java new file mode 100644 index 00000000..6b771c46 --- /dev/null +++ b/src/main/java/com/und/server/scenario/dto/request/BasicMissionRequest.java @@ -0,0 +1,37 @@ +package com.und.server.scenario.dto.request; + +import com.und.server.scenario.constants.MissionType; +import com.und.server.scenario.entity.Mission; +import com.und.server.scenario.entity.Scenario; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +@Schema(description = "Basic type Mission request") +public record BasicMissionRequest( + + @Schema(description = "Mission id for exist mission. Do not send when creating a new mission", + example = "1") + Long missionId, + + @Schema(description = "Mission content", example = "Lock door") + @NotBlank(message = "Content must not be blank") + @Size(max = 10, message = "Content must be at most 10 characters") + String content + +) { + + public Mission toEntity(final Scenario scenario, final Integer order) { + return Mission.builder() + .scenario(scenario) + .content(content) + .isChecked(false) + .missionOrder(order) + .missionType(MissionType.BASIC) + .build(); + } + +} diff --git a/src/main/java/com/und/server/scenario/dto/request/ScenarioDetailRequest.java b/src/main/java/com/und/server/scenario/dto/request/ScenarioDetailRequest.java new file mode 100644 index 00000000..f7e7c117 --- /dev/null +++ b/src/main/java/com/und/server/scenario/dto/request/ScenarioDetailRequest.java @@ -0,0 +1,76 @@ +package com.und.server.scenario.dto.request; + +import java.util.List; + +import com.und.server.notification.dto.request.NotificationConditionRequest; +import com.und.server.notification.dto.request.NotificationRequest; +import com.und.server.notification.dto.request.TimeNotificationRequest; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +@Schema(description = "Scenario request for create and update") +public record ScenarioDetailRequest( + + @Schema(description = "Scenario name", example = "Home out") + @NotBlank(message = "Scenario name must not be blank") + @Size(max = 10, message = "Scenario name must be at most 10 characters") + String scenarioName, + + @Schema(description = "Scenario memo", example = "Item to carry") + @Size(max = 15, message = "Memo must be at most 15 characters") + String memo, + + @ArraySchema( + arraySchema = @Schema(description = "Basic type mission list"), + schema = @Schema(implementation = BasicMissionRequest.class), maxItems = 20 + ) + @Size(max = 20, message = "Maximum mission count exceeded") + @Valid + List basicMissions, + + @Schema( + description = "Notification default settings", + implementation = NotificationRequest.class + ) + @Valid + @NotNull(message = "notification must not be null") + NotificationRequest notification, + + @Schema( + description = "Notification details condition - required when notification is active", + discriminatorProperty = "notificationType", + discriminatorMapping = { + @DiscriminatorMapping(value = "time", schema = TimeNotificationRequest.class) + } + ) + @Valid + NotificationConditionRequest notificationCondition + +) { + + @AssertTrue(message = "Notification condition required when notification is active") + private boolean isValidActiveNotificationCondition() { + if (!notification.isActive()) { + return true; + } + return notificationCondition != null; + } + + @AssertTrue(message = "Notification condition not allowed when notification is inactive") + private boolean isValidInactiveNotificationCondition() { + if (notification.isActive()) { + return true; + } + return notificationCondition == null; + } + +} diff --git a/src/main/java/com/und/server/scenario/dto/request/ScenarioOrderUpdateRequest.java b/src/main/java/com/und/server/scenario/dto/request/ScenarioOrderUpdateRequest.java new file mode 100644 index 00000000..ea49009d --- /dev/null +++ b/src/main/java/com/und/server/scenario/dto/request/ScenarioOrderUpdateRequest.java @@ -0,0 +1,19 @@ +package com.und.server.scenario.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import lombok.Builder; + +@Builder +@Schema(description = "Scenario order update request") +public record ScenarioOrderUpdateRequest( + + @Schema(description = "Previous Scenario order", example = "101000") + @Min(value = 0, message = "prevOrder must be greater than or equal to 1") + Integer prevOrder, + + @Schema(description = "Next Scenario order", example = "102000") + @Min(value = 0, message = "nextOrder must be greater than or equal to 1") + Integer nextOrder + +) { } diff --git a/src/main/java/com/und/server/scenario/dto/request/TodayMissionRequest.java b/src/main/java/com/und/server/scenario/dto/request/TodayMissionRequest.java new file mode 100644 index 00000000..d9b1d4a1 --- /dev/null +++ b/src/main/java/com/und/server/scenario/dto/request/TodayMissionRequest.java @@ -0,0 +1,33 @@ +package com.und.server.scenario.dto.request; + +import java.time.LocalDate; + +import com.und.server.scenario.constants.MissionType; +import com.und.server.scenario.entity.Mission; +import com.und.server.scenario.entity.Scenario; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(description = "Today type Mission request") +public record TodayMissionRequest( + + @Schema(description = "Mission content", example = "Lock door") + @NotBlank(message = "Content must not be blank") + @Size(max = 10, message = "Content must be at most 10 characters") + String content + +) { + + public Mission toEntity(final Scenario scenario, final LocalDate date) { + return Mission.builder() + .scenario(scenario) + .content(content) + .isChecked(false) + .useDate(date) + .missionType(MissionType.TODAY) + .build(); + } + +} diff --git a/src/main/java/com/und/server/scenario/dto/response/MissionGroupResponse.java b/src/main/java/com/und/server/scenario/dto/response/MissionGroupResponse.java new file mode 100644 index 00000000..bbd75836 --- /dev/null +++ b/src/main/java/com/und/server/scenario/dto/response/MissionGroupResponse.java @@ -0,0 +1,59 @@ +package com.und.server.scenario.dto.response; + +import java.util.List; + +import com.und.server.scenario.entity.Mission; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "Home display Mission group by Mission type response") +public record MissionGroupResponse( + + @Schema(description = "Scenario id", example = "1") + Long scenarioId, + + @ArraySchema( + arraySchema = @Schema(description = "Basic type mission list, Sort in order"), + schema = @Schema(implementation = MissionResponse.class), maxItems = 20 + ) + List basicMissions, + + @ArraySchema( + arraySchema = @Schema(description = "Today type mission list, Sort in order of created date"), + schema = @Schema(implementation = MissionResponse.class), maxItems = 20 + ) + List todayMissions + +) { + + public static MissionGroupResponse from(final List basic, final List today) { + return MissionGroupResponse.builder() + .basicMissions(MissionResponse.listFrom(basic)) + .todayMissions(MissionResponse.listFrom(today)) + .build(); + } + + public static MissionGroupResponse from( + final Long scenarioId, final List basic, final List today + ) { + return MissionGroupResponse.builder() + .scenarioId(scenarioId) + .basicMissions(MissionResponse.listFrom(basic)) + .todayMissions(MissionResponse.listFrom(today)) + .build(); + } + + public static MissionGroupResponse futureFrom( + final Long scenarioId, final List futureBasic, final List today + ) { + return MissionGroupResponse.builder() + .scenarioId(scenarioId) + .basicMissions(futureBasic) + .todayMissions(MissionResponse.listFrom(today)) + .build(); + } + +} diff --git a/src/main/java/com/und/server/scenario/dto/response/MissionResponse.java b/src/main/java/com/und/server/scenario/dto/response/MissionResponse.java new file mode 100644 index 00000000..2ae6d2ee --- /dev/null +++ b/src/main/java/com/und/server/scenario/dto/response/MissionResponse.java @@ -0,0 +1,57 @@ +package com.und.server.scenario.dto.response; + +import java.util.ArrayList; +import java.util.List; + +import com.und.server.scenario.constants.MissionType; +import com.und.server.scenario.entity.Mission; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "All Type Mission response") +public record MissionResponse( + + @Schema(description = "Mission id", example = "1") + Long missionId, + + @Schema(description = "Mission content", example = "Lock door") + String content, + + @Schema(description = "Check box check display status", example = "true") + Boolean isChecked, + + @Schema(description = "Mission type", example = "BASIC") + MissionType missionType + +) { + + public static MissionResponse from(final Mission mission) { + return MissionResponse.builder() + .missionId(mission.getId()) + .content(mission.getContent()) + .isChecked(mission.getIsChecked()) + .missionType(mission.getMissionType()) + .build(); + } + + public static MissionResponse fromWithOverride(final Mission mission, final Boolean overrideChecked) { + return MissionResponse.builder() + .missionId(mission.getId()) + .content(mission.getContent()) + .isChecked(overrideChecked) + .missionType(mission.getMissionType()) + .build(); + } + + public static List listFrom(final List missionList) { + if (missionList == null || missionList.isEmpty()) { + return new ArrayList<>(); + } + return missionList.stream() + .map(MissionResponse::from) + .toList(); + } + +} diff --git a/src/main/java/com/und/server/scenario/dto/response/OrderResponse.java b/src/main/java/com/und/server/scenario/dto/response/OrderResponse.java new file mode 100644 index 00000000..b3635750 --- /dev/null +++ b/src/main/java/com/und/server/scenario/dto/response/OrderResponse.java @@ -0,0 +1,27 @@ +package com.und.server.scenario.dto.response; + +import com.und.server.scenario.entity.Scenario; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "Scenario order update response") +public record OrderResponse( + + @Schema(description = "Scenario id", example = "1") + Long id, + + @Schema(description = "Updated Scenario order", example = "2500") + Integer newOrder + +) { + + public static OrderResponse from(final Scenario scenario) { + return OrderResponse.builder() + .id(scenario.getId()) + .newOrder(scenario.getScenarioOrder()) + .build(); + } + +} diff --git a/src/main/java/com/und/server/scenario/dto/response/OrderUpdateResponse.java b/src/main/java/com/und/server/scenario/dto/response/OrderUpdateResponse.java new file mode 100644 index 00000000..6362fb25 --- /dev/null +++ b/src/main/java/com/und/server/scenario/dto/response/OrderUpdateResponse.java @@ -0,0 +1,42 @@ +package com.und.server.scenario.dto.response; + +import java.util.List; + +import com.und.server.scenario.entity.Scenario; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "Scenarios order update response") +public record OrderUpdateResponse( + + @Schema(description = "Reordering all Scenarios", example = "false") + Boolean isReorder, + + @ArraySchema( + arraySchema = @Schema( + description = """ + List of (id, order) pairs reflecting the final order. + When isReorder=false, it usually contains only one item. + When true, it includes all affected scenarios. + """), + schema = @Schema(implementation = OrderResponse.class), minItems = 1, maxItems = 20 + ) + List orderUpdates + +) { + + public static OrderUpdateResponse from(final List scenarios, final Boolean isReorder) { + List orderResponses = scenarios.stream() + .map(OrderResponse::from) + .toList(); + + return OrderUpdateResponse.builder() + .isReorder(isReorder) + .orderUpdates(orderResponses) + .build(); + } + +} diff --git a/src/main/java/com/und/server/scenario/dto/response/ScenarioDetailResponse.java b/src/main/java/com/und/server/scenario/dto/response/ScenarioDetailResponse.java new file mode 100644 index 00000000..69e77f64 --- /dev/null +++ b/src/main/java/com/und/server/scenario/dto/response/ScenarioDetailResponse.java @@ -0,0 +1,62 @@ +package com.und.server.scenario.dto.response; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.und.server.notification.dto.response.NotificationConditionResponse; +import com.und.server.notification.dto.response.NotificationResponse; +import com.und.server.scenario.entity.Mission; +import com.und.server.scenario.entity.Scenario; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "Scenario detail response") +public record ScenarioDetailResponse( + + @Schema(description = "Scenario id", example = "1") + Long scenarioId, + + @Schema(description = "Scenario name", example = "Home out") + String scenarioName, + + @Schema(description = "Scenario memo", example = "Item to carry") + String memo, + + @ArraySchema( + arraySchema = @Schema(description = "Basic type mission list, Sort in order"), + schema = @Schema(implementation = MissionResponse.class), maxItems = 20 + ) + List basicMissions, + + @Schema( + description = "Notification default settings", + implementation = NotificationResponse.class + ) + NotificationResponse notification, + + @Schema(description = "Notification condition, present only when active") + NotificationConditionResponse notificationCondition + +) { + + public static ScenarioDetailResponse from( + final Scenario scenario, + final List basicMissionList, + final NotificationResponse notificationResponse, + final NotificationConditionResponse notificationConditionResponse + ) { + return ScenarioDetailResponse.builder() + .scenarioId(scenario.getId()) + .scenarioName(scenario.getScenarioName()) + .memo(scenario.getMemo()) + .basicMissions(MissionResponse.listFrom(basicMissionList)) + .notification(notificationResponse) + .notificationCondition(notificationConditionResponse) + .build(); + } + +} diff --git a/src/main/java/com/und/server/scenario/dto/response/ScenarioResponse.java b/src/main/java/com/und/server/scenario/dto/response/ScenarioResponse.java new file mode 100644 index 00000000..5e0ea72f --- /dev/null +++ b/src/main/java/com/und/server/scenario/dto/response/ScenarioResponse.java @@ -0,0 +1,43 @@ +package com.und.server.scenario.dto.response; + +import java.util.List; + +import com.und.server.scenario.entity.Scenario; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "Scenario response") +public record ScenarioResponse( + + @Schema(description = "Scenario id", example = "1") + Long scenarioId, + + @Schema(description = "Scenario name", example = "Home out") + String scenarioName, + + @Schema(description = "Scenario memo", example = "Item to carry") + String memo, + + @Schema(description = "Scenario order", example = "3000") + Integer scenarioOrder + +) { + + public static ScenarioResponse from(final Scenario scenario) { + return ScenarioResponse.builder() + .scenarioId(scenario.getId()) + .scenarioName(scenario.getScenarioName()) + .memo(scenario.getMemo()) + .scenarioOrder(scenario.getScenarioOrder()) + .build(); + } + + public static List listFrom(final List scenarioList) { + return scenarioList.stream() + .map(ScenarioResponse::from) + .toList(); + } + +} diff --git a/src/main/java/com/und/server/scenario/entity/Mission.java b/src/main/java/com/und/server/scenario/entity/Mission.java new file mode 100644 index 00000000..80202cc2 --- /dev/null +++ b/src/main/java/com/und/server/scenario/entity/Mission.java @@ -0,0 +1,83 @@ +package com.und.server.scenario.entity; + +import java.time.LocalDate; + +import com.und.server.common.entity.BaseTimeEntity; +import com.und.server.scenario.constants.MissionType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder +@Table(name = "mission") +public class Mission extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "scenario_id", nullable = false) + private Scenario scenario; + + @Column(nullable = false, length = 10) + private String content; + + @Column(nullable = false) + private Boolean isChecked; + + @Column + @Min(0) + @Max(10_000_000) + private Integer missionOrder; + + @Column + private Long parentMissionId; + + @Column + private LocalDate useDate; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private MissionType missionType; + + public void updateCheckStatus(final Boolean checked) { + this.isChecked = checked; + } + + public void updateMissionOrder(final Integer missionOrder) { + this.missionOrder = missionOrder; + } + + public Mission createFutureChildMission(final boolean isChecked, final LocalDate future) { + return Mission.builder() + .scenario(this.scenario) + .content(this.content) + .isChecked(isChecked) + .parentMissionId(this.id) + .useDate(future) + .missionType(this.missionType) + .build(); + } + +} diff --git a/src/main/java/com/und/server/scenario/entity/Scenario.java b/src/main/java/com/und/server/scenario/entity/Scenario.java new file mode 100644 index 00000000..fc963962 --- /dev/null +++ b/src/main/java/com/und/server/scenario/entity/Scenario.java @@ -0,0 +1,75 @@ +package com.und.server.scenario.entity; + +import java.util.List; + +import com.und.server.common.entity.BaseTimeEntity; +import com.und.server.member.entity.Member; +import com.und.server.notification.entity.Notification; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder +@Table(name = "scenario") +public class Scenario extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Column(nullable = false, length = 10) + private String scenarioName; + + @Column(length = 15) + private String memo; + + @Column(nullable = false) + @Min(0) + @Max(10_000_000) + private Integer scenarioOrder; + + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "notification_id", nullable = false, unique = true) + private Notification notification; + + @OneToMany(mappedBy = "scenario", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private List missions; + + public void updateScenarioName(final String scenarioName) { + this.scenarioName = scenarioName; + } + + public void updateMemo(final String memo) { + this.memo = memo; + } + + public void updateScenarioOrder(final Integer scenarioOrder) { + this.scenarioOrder = scenarioOrder; + } + +} diff --git a/src/main/java/com/und/server/scenario/exception/ReorderRequiredException.java b/src/main/java/com/und/server/scenario/exception/ReorderRequiredException.java new file mode 100644 index 00000000..fa65a57a --- /dev/null +++ b/src/main/java/com/und/server/scenario/exception/ReorderRequiredException.java @@ -0,0 +1,17 @@ +package com.und.server.scenario.exception; + +import com.und.server.common.exception.ServerException; + +import lombok.Getter; + +@Getter +public class ReorderRequiredException extends ServerException { + + private final int errorOrder; + + public ReorderRequiredException(int errorOrder) { + super(ScenarioErrorResult.REORDER_REQUIRED); + this.errorOrder = errorOrder; + } + +} diff --git a/src/main/java/com/und/server/scenario/exception/ScenarioErrorResult.java b/src/main/java/com/und/server/scenario/exception/ScenarioErrorResult.java new file mode 100644 index 00000000..d0e4af2c --- /dev/null +++ b/src/main/java/com/und/server/scenario/exception/ScenarioErrorResult.java @@ -0,0 +1,34 @@ +package com.und.server.scenario.exception; + +import org.springframework.http.HttpStatus; + +import com.und.server.common.exception.ErrorResult; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ScenarioErrorResult implements ErrorResult { + + NOT_FOUND_SCENARIO( + HttpStatus.NOT_FOUND, "Scenario not found"), + NOT_FOUND_MISSION( + HttpStatus.NOT_FOUND, "Mission not found"), + UNSUPPORTED_MISSION_TYPE( + HttpStatus.BAD_REQUEST, "Unsupported mission type"), + REORDER_REQUIRED( + HttpStatus.BAD_REQUEST, "Reorder required"), + INVALID_TODAY_MISSION_DATE( + HttpStatus.BAD_REQUEST, "Today mission can only be added for today or future dates"), + INVALID_MISSION_FOUND_DATE( + HttpStatus.BAD_REQUEST, "Mission can only be founded for mission dates"), + MAX_SCENARIO_COUNT_EXCEEDED( + HttpStatus.BAD_REQUEST, "Maximum scenario count exceeded"), + MAX_MISSION_COUNT_EXCEEDED( + HttpStatus.BAD_REQUEST, "Maximum mission count exceeded"); + + private final HttpStatus httpStatus; + private final String message; + +} diff --git a/src/main/java/com/und/server/scenario/repository/MissionRepository.java b/src/main/java/com/und/server/scenario/repository/MissionRepository.java new file mode 100644 index 00000000..4b5efc96 --- /dev/null +++ b/src/main/java/com/und/server/scenario/repository/MissionRepository.java @@ -0,0 +1,100 @@ +package com.und.server.scenario.repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import com.und.server.scenario.entity.Mission; + +import jakarta.validation.constraints.NotNull; + +public interface MissionRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"scenario"}) + Optional findByIdAndScenarioMemberId(Long missionId, Long memberId); + + Optional findByParentMissionIdAndUseDate(Long parentMissionId, LocalDate useDate); + + @Query(""" + SELECT m FROM Mission m + LEFT JOIN m.scenario s + WHERE s.id = :scenarioId + AND s.member.id = :memberId + AND (m.useDate IS NULL OR m.useDate = :date) + """) + @NotNull + List findTodayAndFutureMissions( + @NotNull Long memberId, @NotNull Long scenarioId, @NotNull LocalDate date); + + @Query(""" + SELECT m FROM Mission m + LEFT JOIN m.scenario s + WHERE s.id = :scenarioId + AND s.member.id = :memberId + AND m.useDate = :date + """) + @NotNull + List findPastMissionsByDate( + @NotNull Long memberId, @NotNull Long scenarioId, @NotNull LocalDate date); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM Mission m WHERE m.scenario.id = :scenarioId") + int deleteByScenarioId(Long scenarioId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM Mission m WHERE m.parentMissionId IN :parentMissionIds") + void deleteByParentMissionIdIn(@NotNull List parentMissionIds); + + @Modifying + @Query(""" + DELETE FROM Mission m + WHERE m.useDate = :today + AND m.parentMissionId IS NOT NULL + AND m.missionType = 'BASIC' + """) + int deleteTodayChildBasics(LocalDate today); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE Mission p + SET p.isChecked = COALESCE( + (SELECT c.isChecked + FROM Mission c + WHERE c.parentMissionId = p.id + AND c.useDate = :today + AND c.missionType = 'BASIC' + ), false + ) + WHERE p.useDate IS NULL + AND p.missionType = 'BASIC' + """) + int bulkResetBasicIsChecked(LocalDate today); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + INSERT INTO mission ( + scenario_id, content, is_checked, mission_order, use_date, mission_type, created_at, updated_at + ) + SELECT m.scenario_id, m.content, m.is_checked, m.mission_order, :yesterday, m.mission_type, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM mission m + WHERE m.use_date IS NULL + AND m.mission_type = 'BASIC' + """, nativeQuery = true) + int bulkCloneBasicToYesterday(LocalDate yesterday); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + DELETE FROM mission + WHERE use_date IS NOT NULL + AND use_date < :expireBefore + LIMIT :limit + """, nativeQuery = true) + int bulkDeleteExpired(LocalDate expireBefore, int limit); + +} diff --git a/src/main/java/com/und/server/scenario/repository/ScenarioRepository.java b/src/main/java/com/und/server/scenario/repository/ScenarioRepository.java new file mode 100644 index 00000000..26f6affd --- /dev/null +++ b/src/main/java/com/und/server/scenario/repository/ScenarioRepository.java @@ -0,0 +1,74 @@ +package com.und.server.scenario.repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import com.und.server.notification.constants.NotificationType; +import com.und.server.scenario.entity.Scenario; + +import jakarta.validation.constraints.NotNull; + +public interface ScenarioRepository extends JpaRepository, ScenarioRepositoryCustom { + + Optional findByIdAndMemberId(@NotNull Long id, @NotNull Long memberId); + + @EntityGraph(attributePaths = {"notification"}) + List findByMemberId(Long memberId); + + @Query(""" + SELECT s FROM Scenario s + LEFT JOIN FETCH s.notification + LEFT JOIN FETCH s.missions m + WHERE s.id = :id + AND s.member.id = :memberId + AND m.missionType = 'BASIC' + AND m.useDate IS NULL + """) + Optional findScenarioDetailFetchByIdAndMemberId(@NotNull Long memberId, @NotNull Long id); + + @Query(""" + SELECT s FROM Scenario s + LEFT JOIN FETCH s.notification + LEFT JOIN FETCH s.missions m + WHERE s.id = :id + AND s.member.id = :memberId + AND (m.useDate IS NULL OR m.useDate = :date) + """) + Optional findTodayScenarioFetchByIdAndMemberId( + @NotNull Long memberId, @NotNull Long id, @NotNull LocalDate date); + + @Query(""" + SELECT s FROM Scenario s + LEFT JOIN FETCH s.notification + WHERE s.id = :id + AND s.member.id = :memberId + """) + Optional findNotificationFetchByIdAndMemberId(@NotNull Long memberId, @NotNull Long id); + + @Query(""" + SELECT s FROM Scenario s + WHERE s.member.id = :memberId + AND s.notification.notificationType = :notificationType + ORDER BY s.scenarioOrder + """) + @NotNull + List findByMemberIdAndNotificationType( + @NotNull Long memberId, @NotNull NotificationType notificationType); + + @Query(""" + SELECT s.scenarioOrder + FROM Scenario s + WHERE s.member.id = :memberId + AND s.notification.notificationType = :notificationType + ORDER BY s.scenarioOrder + """) + @NotNull + List findOrdersByMemberIdAndNotificationType( + @NotNull Long memberId, @NotNull NotificationType notificationType); + +} diff --git a/src/main/java/com/und/server/scenario/repository/ScenarioRepositoryCustom.java b/src/main/java/com/und/server/scenario/repository/ScenarioRepositoryCustom.java new file mode 100644 index 00000000..4f61e0fa --- /dev/null +++ b/src/main/java/com/und/server/scenario/repository/ScenarioRepositoryCustom.java @@ -0,0 +1,11 @@ +package com.und.server.scenario.repository; + +import java.util.List; + +import com.und.server.notification.dto.response.ScenarioNotificationResponse; + +public interface ScenarioRepositoryCustom { + + List findTimeScenarioNotifications(Long memberId); + +} diff --git a/src/main/java/com/und/server/scenario/repository/ScenarioRepositoryCustomImpl.java b/src/main/java/com/und/server/scenario/repository/ScenarioRepositoryCustomImpl.java new file mode 100644 index 00000000..4f2d772f --- /dev/null +++ b/src/main/java/com/und/server/scenario/repository/ScenarioRepositoryCustomImpl.java @@ -0,0 +1,97 @@ +package com.und.server.scenario.repository; + +import java.util.Arrays; +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.und.server.notification.constants.NotificationMethodType; +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.response.ScenarioNotificationResponse; +import com.und.server.notification.dto.response.TimeNotificationResponse; + +import jakarta.persistence.EntityManager; +import lombok.Builder; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ScenarioRepositoryCustomImpl implements ScenarioRepositoryCustom { + + private final EntityManager em; + + @Override + public List findTimeScenarioNotifications(Long memberId) { + String jpql = """ + SELECT new com.und.server.scenario.repository.ScenarioRepositoryCustomImpl$TimeNotificationQueryDto( + s.id, + s.scenarioName, + s.memo, + n.id, + n.notificationType, + n.notificationMethodType, + n.daysOfWeek, + t.startHour, + t.startMinute + ) + FROM Scenario s + JOIN s.notification n + JOIN TimeNotification t ON n.id = t.notification.id + WHERE s.member.id = :memberId + AND n.notificationType = :timeType + AND n.isActive = true + """; + + List queryResults = em.createQuery(jpql, TimeNotificationQueryDto.class) + .setParameter("memberId", memberId) + .setParameter("timeType", NotificationType.TIME) + .getResultList(); + + return queryResults.stream() + .map(TimeNotificationQueryDto::toResponse) + .toList(); + } + + + @Builder + public record TimeNotificationQueryDto( + Long scenarioId, + String scenarioName, + String memo, + Long notificationId, + NotificationType notificationType, + NotificationMethodType notificationMethodType, + String daysOfWeek, + Integer startHour, + Integer startMinute + ) { + + public ScenarioNotificationResponse toResponse() { + List days = (daysOfWeek == null || daysOfWeek.isBlank()) + ? List.of() + : Arrays.stream(daysOfWeek.split(",")) + .map(String::trim) + .map(Integer::parseInt) + .toList(); + + TimeNotificationResponse timeNotificationResponse = + TimeNotificationResponse.builder() + .notificationType(NotificationType.TIME) + .startHour(startHour) + .startMinute(startMinute) + .build(); + + return ScenarioNotificationResponse.builder() + .scenarioId(scenarioId) + .scenarioName(scenarioName) + .memo(memo) + .notificationId(notificationId) + .notificationType(notificationType) + .notificationMethodType(notificationMethodType) + .daysOfWeekOrdinal(days) + .notificationCondition(timeNotificationResponse) + .build(); + } + } + +} diff --git a/src/main/java/com/und/server/scenario/scheduler/ScenarioMissionDailyJob.java b/src/main/java/com/und/server/scenario/scheduler/ScenarioMissionDailyJob.java new file mode 100644 index 00000000..006acc8d --- /dev/null +++ b/src/main/java/com/und/server/scenario/scheduler/ScenarioMissionDailyJob.java @@ -0,0 +1,75 @@ +package com.und.server.scenario.scheduler; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.und.server.scenario.repository.MissionRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +@RequiredArgsConstructor +public class ScenarioMissionDailyJob { + + private static final int DEFAULT_DELETE_LIMIT = 10_000; + private static final int DAYS_TO_SUBTRACT = 1; + private static final int MONTHS_TO_SUBTRACT = 1; + private final MissionRepository missionRepository; + private final Clock clock; + + /** + * Daily job at midnight (00:00) - BASIC 미션 백업, DEFAULT BASIC 미션 체크상태 리셋 + */ + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") + @Transactional + public void runDailyBackupJob() { + LocalDate today = LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul"))); + LocalDate yesterday = today.minusDays(DAYS_TO_SUBTRACT); + + try { + int cloned = missionRepository.bulkCloneBasicToYesterday(yesterday); + int reset = missionRepository.bulkResetBasicIsChecked(today); + int deleteChildBasic = missionRepository.deleteTodayChildBasics(today); + + log.info("[MISSION DAILY] Daily Mission Job: cloned={}, reset={} deleteChildBasic={}", + cloned, reset, deleteChildBasic); + } catch (Exception e) { + log.error("[MISSION DAILY] Backup and reset failed, rolling back", e); + throw e; + } + } + + /** + * Daily cleanup job at 1 AM (01:00) - 기간 만료 미션 삭제 + */ + @Scheduled(cron = "0 0 1 * * *", zone = "Asia/Seoul") + @Transactional + public void runExpiredMissionCleanupJob() { + LocalDate today = LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul"))); + LocalDate expireBefore = today.minusMonths(MONTHS_TO_SUBTRACT); + + int totalDeleted = 0; + + try { + int batchDeleted; + + do { + batchDeleted = missionRepository.bulkDeleteExpired(expireBefore, DEFAULT_DELETE_LIMIT); + totalDeleted += batchDeleted; + } while (batchDeleted == DEFAULT_DELETE_LIMIT); + + log.info("[MISSION DAILY] Expired mission cleanup completed: deleted={}", totalDeleted); + } catch (Exception e) { + log.error("[MISSION DAILY] Expired mission cleanup failed. expireBefore={}, deletedUntilError={}", + expireBefore, totalDeleted, e); + } + } + +} diff --git a/src/main/java/com/und/server/scenario/service/MissionService.java b/src/main/java/com/und/server/scenario/service/MissionService.java new file mode 100644 index 00000000..56eb4c8d --- /dev/null +++ b/src/main/java/com/und/server/scenario/service/MissionService.java @@ -0,0 +1,252 @@ +package com.und.server.scenario.service; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.und.server.common.exception.ServerException; +import com.und.server.scenario.constants.MissionSearchType; +import com.und.server.scenario.constants.MissionType; +import com.und.server.scenario.dto.request.BasicMissionRequest; +import com.und.server.scenario.dto.request.TodayMissionRequest; +import com.und.server.scenario.dto.response.MissionGroupResponse; +import com.und.server.scenario.dto.response.MissionResponse; +import com.und.server.scenario.entity.Mission; +import com.und.server.scenario.entity.Scenario; +import com.und.server.scenario.exception.ScenarioErrorResult; +import com.und.server.scenario.repository.MissionRepository; +import com.und.server.scenario.util.MissionTypeGroupSorter; +import com.und.server.scenario.util.MissionValidator; +import com.und.server.scenario.util.OrderCalculator; +import com.und.server.scenario.util.ScenarioValidator; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MissionService { + + private final MissionRepository missionRepository; + private final MissionTypeGroupSorter missionTypeGroupSorter; + private final ScenarioValidator scenarioValidator; + private final MissionValidator missionValidator; + private final Clock clock; + + + @Transactional(readOnly = true) + public MissionGroupResponse findMissionsByScenarioId( + final Long memberId, final Long scenarioId, final LocalDate date + ) { + scenarioValidator.validateScenarioExists(scenarioId); + + LocalDate today = LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul"))); + MissionSearchType missionSearchType = MissionSearchType.getMissionSearchType(today, date); + + List missions = getMissionsByDate(missionSearchType, memberId, scenarioId, date); + + if (missions == null || missions.isEmpty()) { + return MissionGroupResponse.from(scenarioId, List.of(), List.of()); + } + + List groupedBasicMissions = + missionTypeGroupSorter.groupAndSortByType(missions, MissionType.BASIC); + List groupedTodayMissions = + missionTypeGroupSorter.groupAndSortByType(missions, MissionType.TODAY); + + if (missionSearchType == MissionSearchType.FUTURE) { + return MissionGroupResponse.futureFrom( + scenarioId, getFutureCheckStatusMissions(groupedBasicMissions), groupedTodayMissions); + } + return MissionGroupResponse.from(scenarioId, groupedBasicMissions, groupedTodayMissions); + } + + + @Transactional + public List addBasicMission(final Scenario scenario, final List missionRequests) { + if (missionRequests.isEmpty()) { + return List.of(); + } + + List missions = new ArrayList<>(); + + int order = OrderCalculator.START_ORDER; + for (BasicMissionRequest missionInfo : missionRequests) { + missions.add(missionInfo.toEntity(scenario, order)); + order += OrderCalculator.DEFAULT_ORDER; + } + missionValidator.validateMaxBasicMissionCount(missions); + + return missionRepository.saveAll(missions); + } + + + @Transactional + public MissionResponse addTodayMission( + final Scenario scenario, + final TodayMissionRequest todayMissionRequest, + final LocalDate date + ) { + LocalDate today = LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul"))); + missionValidator.validateTodayMissionDateRange(today, date); + + List todayMissions = missionTypeGroupSorter.groupAndSortByType( + scenario.getMissions(), MissionType.TODAY); + missionValidator.validateMaxTodayMissionCount(todayMissions); + + Mission newMission = todayMissionRequest.toEntity(scenario, date); + missionRepository.save(newMission); + + return MissionResponse.from(newMission); + } + + + @Transactional + public void updateBasicMission(final Scenario oldSCenario, final List missionRequests) { + List oldMissions = + missionTypeGroupSorter.groupAndSortByType(oldSCenario.getMissions(), MissionType.BASIC); + + if (missionRequests.isEmpty()) { + oldSCenario.getMissions().removeIf(mission -> + mission.getMissionType() == MissionType.BASIC + ); + return; + } + + Map existingMissions = oldMissions.stream() + .collect(Collectors.toMap(Mission::getId, mission -> mission)); + Set existingMissionIds = existingMissions.keySet(); + List requestedMissionIds = new ArrayList<>(); + + List toAdd = new ArrayList<>(); + + int order = OrderCalculator.START_ORDER; + for (BasicMissionRequest missionInfo : missionRequests) { + Long missionId = missionInfo.missionId(); + + if (missionId == null) { + toAdd.add(missionInfo.toEntity(oldSCenario, order)); + } else { + Mission existingMission = existingMissions.get(missionId); + if (existingMission != null) { + existingMission.updateMissionOrder(order); + toAdd.add(existingMission); + requestedMissionIds.add(missionId); + } + } + order += OrderCalculator.DEFAULT_ORDER; + } + missionValidator.validateMaxBasicMissionCount(toAdd); + + List toDeleteId = existingMissionIds.stream() + .filter(id -> !requestedMissionIds.contains(id)) + .toList(); + + oldSCenario.getMissions().removeIf(mission -> + mission.getMissionType() == MissionType.BASIC + && toDeleteId.contains(mission.getId()) + ); + + missionRepository.deleteByParentMissionIdIn(toDeleteId); + missionRepository.saveAll(toAdd); + } + + + @Transactional + public void updateMissionCheck( + final Long memberId, + final Long missionId, + final Boolean isChecked, + final LocalDate date + ) { + Mission mission = missionRepository.findByIdAndScenarioMemberId(missionId, memberId) + .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_MISSION)); + + MissionSearchType missionSearchType = MissionSearchType.getMissionSearchType( + LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul"))), date); + + if (mission.getMissionType() == MissionType.BASIC && missionSearchType == MissionSearchType.FUTURE) { + updateFutureBasicMission(mission, isChecked, date); + return; + } + mission.updateCheckStatus(isChecked); + } + + + @Transactional + public void deleteMissions(final Long scenarioId) { + missionRepository.deleteByScenarioId(scenarioId); + } + + + @Transactional + public void deleteTodayMission(final Long memberId, final Long missionId) { + Mission mission = missionRepository.findByIdAndScenarioMemberId(missionId, memberId) + .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_MISSION)); + + missionRepository.delete(mission); + } + + + private List getMissionsByDate( + final MissionSearchType missionSearchType, + final Long memberId, + final Long scenarioId, + final LocalDate date + ) { + switch (missionSearchType) { + case TODAY, FUTURE -> { + return missionRepository.findTodayAndFutureMissions(memberId, scenarioId, date); + } + case PAST -> { + return missionRepository.findPastMissionsByDate(memberId, scenarioId, date); + } + } + throw new ServerException(ScenarioErrorResult.INVALID_MISSION_FOUND_DATE); + } + + private List getFutureCheckStatusMissions(List groupedBasicMissions) { + Map overlayMap = groupedBasicMissions.stream() + .filter(m -> m.getParentMissionId() != null) + .collect(Collectors.toMap(Mission::getParentMissionId, m -> m)); + + return groupedBasicMissions.stream() + .filter(m -> m.getParentMissionId() == null && m.getUseDate() == null) + .map(tpl -> { + Mission overlay = overlayMap.get(tpl.getId()); + boolean checked = overlay != null && Boolean.TRUE.equals(overlay.getIsChecked()); + return MissionResponse.fromWithOverride(tpl, checked); + }) + .toList(); + } + + private void updateFutureBasicMission( + final Mission mission, + final Boolean isChecked, + final LocalDate date + ) { + missionRepository.findByParentMissionIdAndUseDate(mission.getId(), date) + .ifPresentOrElse( + future -> { + if (isChecked) { + future.updateCheckStatus(true); + } else { + missionRepository.delete(future); + } + }, + () -> { + if (isChecked) { + missionRepository.save(mission.createFutureChildMission(true, date)); + } + } + ); + } + +} diff --git a/src/main/java/com/und/server/scenario/service/ScenarioNotificationService.java b/src/main/java/com/und/server/scenario/service/ScenarioNotificationService.java new file mode 100644 index 00000000..230bcd5d --- /dev/null +++ b/src/main/java/com/und/server/scenario/service/ScenarioNotificationService.java @@ -0,0 +1,34 @@ +package com.und.server.scenario.service; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.response.ScenarioNotificationResponse; +import com.und.server.scenario.repository.ScenarioRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ScenarioNotificationService { + + private final ScenarioRepository scenarioRepository; + + public List getScenarioNotifications(final Long memberId) { + List scenarioNotificationResponses = new ArrayList<>(); + + for (NotificationType type : NotificationType.values()) { + switch (type) { + case TIME -> scenarioNotificationResponses.addAll( + scenarioRepository.findTimeScenarioNotifications(memberId)); + } + } + return scenarioNotificationResponses; + } + +} diff --git a/src/main/java/com/und/server/scenario/service/ScenarioService.java b/src/main/java/com/und/server/scenario/service/ScenarioService.java new file mode 100644 index 00000000..6c26d55d --- /dev/null +++ b/src/main/java/com/und/server/scenario/service/ScenarioService.java @@ -0,0 +1,222 @@ +package com.und.server.scenario.service; + +import java.time.Clock; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.und.server.common.exception.ServerException; +import com.und.server.member.entity.Member; +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.request.NotificationRequest; +import com.und.server.notification.dto.response.NotificationConditionResponse; +import com.und.server.notification.dto.response.NotificationResponse; +import com.und.server.notification.entity.Notification; +import com.und.server.notification.event.NotificationEventPublisher; +import com.und.server.notification.service.NotificationService; +import com.und.server.scenario.constants.MissionType; +import com.und.server.scenario.dto.request.ScenarioDetailRequest; +import com.und.server.scenario.dto.request.ScenarioOrderUpdateRequest; +import com.und.server.scenario.dto.request.TodayMissionRequest; +import com.und.server.scenario.dto.response.MissionResponse; +import com.und.server.scenario.dto.response.OrderUpdateResponse; +import com.und.server.scenario.dto.response.ScenarioDetailResponse; +import com.und.server.scenario.dto.response.ScenarioResponse; +import com.und.server.scenario.entity.Mission; +import com.und.server.scenario.entity.Scenario; +import com.und.server.scenario.exception.ReorderRequiredException; +import com.und.server.scenario.exception.ScenarioErrorResult; +import com.und.server.scenario.repository.ScenarioRepository; +import com.und.server.scenario.util.MissionTypeGroupSorter; +import com.und.server.scenario.util.OrderCalculator; +import com.und.server.scenario.util.ScenarioValidator; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ScenarioService { + + private final NotificationService notificationService; + private final MissionService missionService; + private final ScenarioRepository scenarioRepository; + private final MissionTypeGroupSorter missionTypeGroupSorter; + private final OrderCalculator orderCalculator; + private final ScenarioValidator scenarioValidator; + private final EntityManager em; + private final NotificationEventPublisher notificationEventPublisher; + private final Clock clock; + + + @Transactional(readOnly = true) + public List findScenariosByMemberId( + final Long memberId, final NotificationType notificationType + ) { + List scenarios = + scenarioRepository.findByMemberIdAndNotificationType(memberId, notificationType); + + return ScenarioResponse.listFrom(scenarios); + } + + + @Transactional(readOnly = true) + public ScenarioDetailResponse findScenarioDetailByScenarioId(final Long memberId, final Long scenarioId) { + Scenario scenario = scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId) + .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_SCENARIO)); + + List basicMissions = + missionTypeGroupSorter.groupAndSortByType(scenario.getMissions(), MissionType.BASIC); + + Notification notification = scenario.getNotification(); + + NotificationResponse notificationResponse = NotificationResponse.from(notification); + NotificationConditionResponse notificationConditionResponse = + notificationService.findNotificationDetails(notification); + + return ScenarioDetailResponse.from( + scenario, basicMissions, notificationResponse, notificationConditionResponse); + } + + + @Transactional + public MissionResponse addTodayMissionToScenario( + final Long memberId, + final Long scenarioId, + final TodayMissionRequest todayMissionRequest, + final LocalDate date + ) { + Scenario scenario = scenarioRepository.findTodayScenarioFetchByIdAndMemberId(memberId, scenarioId, date) + .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_SCENARIO)); + + return missionService.addTodayMission(scenario, todayMissionRequest, date); + } + + + @Transactional + public List addScenario(final Long memberId, final ScenarioDetailRequest scenarioDetailRequest) { + Member member = em.getReference(Member.class, memberId); + + NotificationRequest notificationRequest = scenarioDetailRequest.notification(); + NotificationType notificationType = notificationRequest.notificationType(); + + List orders = + scenarioRepository.findOrdersByMemberIdAndNotificationType(memberId, notificationType); + scenarioValidator.validateMaxScenarioCount(orders); + + int order = orders.isEmpty() + ? OrderCalculator.START_ORDER + : getValidScenarioOrder(Collections.min(orders), memberId, notificationType); + + Notification notification = notificationService.addNotification( + notificationRequest, scenarioDetailRequest.notificationCondition()); + + Scenario scenario = Scenario.builder() + .member(member) + .scenarioName(scenarioDetailRequest.scenarioName()) + .memo(scenarioDetailRequest.memo()) + .scenarioOrder(order) + .notification(notification) + .build(); + + scenarioRepository.save(scenario); + missionService.addBasicMission(scenario, scenarioDetailRequest.basicMissions()); + + notificationEventPublisher.publishCreateEvent(memberId, scenario); + return findScenariosByMemberId(memberId, notificationType); + } + + + @Transactional + public List updateScenario( + final Long memberId, + final Long scenarioId, + final ScenarioDetailRequest scenarioDetailRequest + ) { + Scenario oldScenario = scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId) + .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_SCENARIO)); + Notification oldNotification = oldScenario.getNotification(); + + Boolean isOldScenarioNotificationActive = oldNotification.isActive(); + + notificationService.updateNotification( + oldNotification, + scenarioDetailRequest.notification(), + scenarioDetailRequest.notificationCondition() + ); + + missionService.updateBasicMission(oldScenario, scenarioDetailRequest.basicMissions()); + + oldScenario.updateScenarioName(scenarioDetailRequest.scenarioName()); + oldScenario.updateMemo(scenarioDetailRequest.memo()); + + notificationEventPublisher.publishUpdateEvent(memberId, oldScenario, isOldScenarioNotificationActive); + return findScenariosByMemberId(memberId, scenarioDetailRequest.notification().notificationType()); + } + + + @Transactional + public OrderUpdateResponse updateScenarioOrder( + final Long memberId, + final Long scenarioId, + final ScenarioOrderUpdateRequest scenarioOrderUpdateRequest + ) { + Scenario scenario = scenarioRepository.findByIdAndMemberId(scenarioId, memberId) + .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_SCENARIO)); + + try { + int toUpdateOrder = orderCalculator.getOrder( + scenarioOrderUpdateRequest.prevOrder(), + scenarioOrderUpdateRequest.nextOrder() + ); + scenario.updateScenarioOrder(toUpdateOrder); + return OrderUpdateResponse.from(List.of(scenario), false); + + } catch (ReorderRequiredException e) { + int errorOrder = e.getErrorOrder(); + Notification notification = scenario.getNotification(); + List scenarios = + scenarioRepository.findByMemberIdAndNotificationType(memberId, notification.getNotificationType()); + scenarios = orderCalculator.reorder(scenarios, scenarioId, errorOrder); + + return OrderUpdateResponse.from(scenarios, true); + } + } + + + @Transactional + public void deleteScenarioWithAllMissions(final Long memberId, final Long scenarioId) { + Scenario scenario = scenarioRepository.findNotificationFetchByIdAndMemberId(memberId, scenarioId) + .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_SCENARIO)); + + Notification notification = scenario.getNotification(); + boolean isNotificationActive = notification.isActive(); + + missionService.deleteMissions(scenarioId); + + notificationService.deleteNotification(notification); + scenarioRepository.delete(scenario); + + notificationEventPublisher.publishDeleteEvent(memberId, scenarioId, isNotificationActive); + } + + + private int getValidScenarioOrder( + final int minScenarioOrder, + final Long memberId, + final NotificationType notificationType + ) { + try { + return orderCalculator.getOrder(null, minScenarioOrder); + } catch (ReorderRequiredException e) { + List scenarios = + scenarioRepository.findByMemberIdAndNotificationType(memberId, notificationType); + + return orderCalculator.getMinOrderAfterReorder(scenarios); + } + } + +} diff --git a/src/main/java/com/und/server/scenario/util/MissionTypeGroupSorter.java b/src/main/java/com/und/server/scenario/util/MissionTypeGroupSorter.java new file mode 100644 index 00000000..328d1a48 --- /dev/null +++ b/src/main/java/com/und/server/scenario/util/MissionTypeGroupSorter.java @@ -0,0 +1,41 @@ +package com.und.server.scenario.util; + +import java.util.Comparator; +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.und.server.common.exception.ServerException; +import com.und.server.scenario.constants.MissionType; +import com.und.server.scenario.entity.Mission; +import com.und.server.scenario.exception.ScenarioErrorResult; + +@Component +public class MissionTypeGroupSorter { + + public List groupAndSortByType(final List missions, final MissionType missionType) { + if (missionType == null) { + throw new ServerException(ScenarioErrorResult.UNSUPPORTED_MISSION_TYPE); + } + if (missions == null || missions.isEmpty()) { + return missions; + } + + return missions.stream() + .filter(m -> m.getMissionType() == missionType) + .sorted(getComparatorByType(missionType)) + .toList(); + } + + private Comparator getComparatorByType(final MissionType type) { + return switch (type) { + + case BASIC -> Comparator.comparing(Mission::getMissionOrder, + Comparator.nullsLast(Comparator.naturalOrder())); + case TODAY -> Comparator.comparing(Mission::getCreatedAt).reversed(); + + default -> throw new ServerException(ScenarioErrorResult.UNSUPPORTED_MISSION_TYPE); + }; + } + +} diff --git a/src/main/java/com/und/server/scenario/util/MissionValidator.java b/src/main/java/com/und/server/scenario/util/MissionValidator.java new file mode 100644 index 00000000..49a741ba --- /dev/null +++ b/src/main/java/com/und/server/scenario/util/MissionValidator.java @@ -0,0 +1,41 @@ +package com.und.server.scenario.util; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.und.server.common.exception.ServerException; +import com.und.server.scenario.constants.MissionSearchType; +import com.und.server.scenario.entity.Mission; +import com.und.server.scenario.exception.ScenarioErrorResult; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class MissionValidator { + + private static final int BASIC_MISSION_MAX_COUNT = 20; + private static final int TODAY_MISSION_MAX_COUNT = 20; + + public void validateMaxBasicMissionCount(final List missions) { + if (missions.size() >= BASIC_MISSION_MAX_COUNT) { + throw new ServerException(ScenarioErrorResult.MAX_MISSION_COUNT_EXCEEDED); + } + } + + public void validateMaxTodayMissionCount(final List missions) { + if (missions.size() >= TODAY_MISSION_MAX_COUNT) { + throw new ServerException(ScenarioErrorResult.MAX_MISSION_COUNT_EXCEEDED); + } + } + + public void validateTodayMissionDateRange(final LocalDate today, final LocalDate requestDate) { + MissionSearchType missionSearchType = MissionSearchType.getMissionSearchType(today, requestDate); + if (missionSearchType == MissionSearchType.PAST) { + throw new ServerException(ScenarioErrorResult.INVALID_TODAY_MISSION_DATE); + } + } + +} diff --git a/src/main/java/com/und/server/scenario/util/OrderCalculator.java b/src/main/java/com/und/server/scenario/util/OrderCalculator.java new file mode 100644 index 00000000..607b21cf --- /dev/null +++ b/src/main/java/com/und/server/scenario/util/OrderCalculator.java @@ -0,0 +1,106 @@ +package com.und.server.scenario.util; + +import java.util.Comparator; +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.und.server.scenario.entity.Scenario; +import com.und.server.scenario.exception.ReorderRequiredException; + +@Component +public class OrderCalculator { + + public static final int START_ORDER = 100000; + public static final int DEFAULT_ORDER = 1000; + private static final int MIN_ORDER = 0; + private static final int MAX_ORDER = 10_000_000; + private static final int MIN_GAP = 100; + + + public int getOrder(final Integer prevOrder, final Integer nextOrder) { + int resultOrder = 0; + + if (prevOrder == null && nextOrder == null) { + return START_ORDER; + } + if (prevOrder == null) { + resultOrder = calculateStartOrder(nextOrder); + } else if (nextOrder == null) { + resultOrder = calculateLastOrder(prevOrder); + } else { + resultOrder = calculateMiddleOrder(prevOrder, nextOrder); + validateOrderGap(prevOrder, nextOrder, resultOrder); + } + validateOrderRange(resultOrder); + + return resultOrder; + } + + + public List reorder( + final List scenarios, + final Long targetScenarioId, + final int errorOrder + ) { + scenarios.sort( + Comparator + .comparingInt((Scenario s) -> + s.getId().equals(targetScenarioId) ? errorOrder : s.getScenarioOrder()) + .thenComparingLong(Scenario::getId) + ); + + assignSequentialOrders(scenarios); + scenarios.sort(Comparator.comparing(Scenario::getScenarioOrder)); + + return scenarios; + } + + + public Integer getMinOrderAfterReorder(final List scenarios) { + if (scenarios.isEmpty()) { + return START_ORDER; + } + scenarios.sort(Comparator.comparing(Scenario::getScenarioOrder)); + + assignSequentialOrders(scenarios); + Scenario firstScenario = scenarios.get(0); + + return calculateStartOrder(firstScenario.getScenarioOrder()); + } + + + private void assignSequentialOrders(final List scenarios) { + int order = OrderCalculator.START_ORDER; + for (Scenario scenario : scenarios) { + scenario.updateScenarioOrder(order); + order += OrderCalculator.DEFAULT_ORDER; + } + } + + private Integer calculateMiddleOrder(final Integer prevOrder, final Integer nextOrder) { + return (prevOrder + nextOrder) / 2; + } + + private Integer calculateStartOrder(final int minOrder) { + return minOrder - DEFAULT_ORDER; + } + + private Integer calculateLastOrder(final int maxOrder) { + return maxOrder + DEFAULT_ORDER; + } + + private void validateOrderGap(final Integer prevOrder, final Integer nextOrder, final int resultOrder) { + int gap = nextOrder - prevOrder; + if (gap <= MIN_GAP) { + throw new ReorderRequiredException(resultOrder); + } + } + + private void validateOrderRange(final int order) { + if (order < MIN_ORDER || order > MAX_ORDER) { + throw new ReorderRequiredException(order); + } + } + +} diff --git a/src/main/java/com/und/server/scenario/util/ScenarioValidator.java b/src/main/java/com/und/server/scenario/util/ScenarioValidator.java new file mode 100644 index 00000000..9338fbfe --- /dev/null +++ b/src/main/java/com/und/server/scenario/util/ScenarioValidator.java @@ -0,0 +1,32 @@ +package com.und.server.scenario.util; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.und.server.common.exception.ServerException; +import com.und.server.scenario.exception.ScenarioErrorResult; +import com.und.server.scenario.repository.ScenarioRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ScenarioValidator { + + private static final int SCENARIO_MAX_COUNT = 20; + private final ScenarioRepository scenarioRepository; + + public void validateScenarioExists(final Long scenarioId) { + if (!scenarioRepository.existsById(scenarioId)) { + throw new ServerException(ScenarioErrorResult.NOT_FOUND_SCENARIO); + } + } + + public void validateMaxScenarioCount(final List orderList) { + if (orderList.size() >= SCENARIO_MAX_COUNT) { + throw new ServerException(ScenarioErrorResult.MAX_SCENARIO_COUNT_EXCEEDED); + } + } + +} diff --git a/src/main/java/com/und/server/service/AuthService.java b/src/main/java/com/und/server/service/AuthService.java deleted file mode 100644 index b16e6ce3..00000000 --- a/src/main/java/com/und/server/service/AuthService.java +++ /dev/null @@ -1,140 +0,0 @@ -package com.und.server.service; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.und.server.dto.AuthRequest; -import com.und.server.dto.AuthResponse; -import com.und.server.dto.HandshakeRequest; -import com.und.server.dto.HandshakeResponse; -import com.und.server.dto.OidcPublicKeys; -import com.und.server.dto.RefreshTokenRequest; -import com.und.server.dto.TestAuthRequest; -import com.und.server.entity.Member; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; -import com.und.server.jwt.JwtProperties; -import com.und.server.jwt.JwtProvider; -import com.und.server.oauth.IdTokenPayload; -import com.und.server.oauth.OidcClient; -import com.und.server.oauth.OidcClientFactory; -import com.und.server.oauth.OidcProviderFactory; -import com.und.server.oauth.Provider; -import com.und.server.repository.MemberRepository; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class AuthService { - - private final MemberRepository memberRepository; - private final OidcClientFactory oidcClientFactory; - private final OidcProviderFactory oidcProviderFactory; - private final JwtProvider jwtProvider; - private final JwtProperties jwtProperties; - private final NonceService nonceService; - private final RefreshTokenService refreshTokenService; - - // FIXME: Remove this method when deleting TestController - @Transactional - public AuthResponse issueTokensForTest(TestAuthRequest request) { - final Provider provider = convertToProvider(request.provider()); - final IdTokenPayload payload = new IdTokenPayload(request.providerId(), request.nickname()); - final Member member = findOrCreateMember(provider, payload); - - return issueTokens(member.getId()); - } - - @Transactional - public HandshakeResponse handshake(final HandshakeRequest handshakeRequest) { - final String nonce = nonceService.generateNonceValue(); - final Provider provider = convertToProvider(handshakeRequest.provider()); - - nonceService.saveNonce(nonce, provider); - - return new HandshakeResponse(nonce); - } - - @Transactional - public AuthResponse login(final AuthRequest authRequest) { - final Provider provider = convertToProvider(authRequest.provider()); - final IdTokenPayload idTokenPayload = validateIdTokenAndGetPayload(provider, authRequest.idToken()); - final Member member = findOrCreateMember(provider, idTokenPayload); - - return issueTokens(member.getId()); - } - - @Transactional - public AuthResponse reissueTokens(final RefreshTokenRequest refreshTokenRequest) { - final String accessToken = refreshTokenRequest.accessToken(); - final String providedRefreshToken = refreshTokenRequest.refreshToken(); - - final Long memberId = jwtProvider.getMemberIdFromExpiredAccessToken(accessToken); - final String savedRefreshToken = refreshTokenService.getRefreshToken(memberId); - if (!providedRefreshToken.equals(savedRefreshToken)) { - refreshTokenService.deleteRefreshToken(memberId); - throw new ServerException(ServerErrorResult.INVALID_TOKEN); - } - - return issueTokens(memberId); - } - - private Provider convertToProvider(final String providerName) { - try { - return Provider.valueOf(providerName.toUpperCase()); - } catch (IllegalArgumentException e) { - throw new ServerException(ServerErrorResult.INVALID_PROVIDER); - } - } - - private IdTokenPayload validateIdTokenAndGetPayload(final Provider provider, final String idToken) { - final String nonce = jwtProvider.extractNonce(idToken); - nonceService.validateNonce(nonce, provider); - - final OidcClient oidcClient = oidcClientFactory.getOidcClient(provider); - final OidcPublicKeys oidcPublicKeys = oidcClient.getOidcPublicKeys(); - - return oidcProviderFactory.getIdTokenPayload(provider, idToken, oidcPublicKeys); - } - - private Member findOrCreateMember(final Provider provider, final IdTokenPayload payload) { - final String providerId = payload.providerId(); - final Member member = findMemberByProviderId(provider, providerId); - - return member != null ? member : createMember(provider, providerId, payload.nickname()); - } - - private Member findMemberByProviderId(final Provider provider, final String providerId) { - return switch (provider) { - case KAKAO -> memberRepository.findByKakaoId(providerId).orElse(null); - // Add extra providers - default -> throw new ServerException(ServerErrorResult.INVALID_PROVIDER); - }; - } - - private Member createMember(final Provider provider, final String providerId, final String nickname) { - final Member newMember = Member.builder() - .kakaoId(provider == Provider.KAKAO ? providerId : null) - // Add extra providers - .nickname(nickname) - .build(); - - return memberRepository.save(newMember); - } - - private AuthResponse issueTokens(final Long memberId) { - final String accessToken = jwtProvider.generateAccessToken(memberId); - final String refreshToken = refreshTokenService.generateRefreshToken(); - refreshTokenService.saveRefreshToken(memberId, refreshToken); - - return new AuthResponse( - jwtProperties.type(), - accessToken, - jwtProperties.accessTokenExpireTime(), - refreshToken, - jwtProperties.refreshTokenExpireTime()); - } - -} diff --git a/src/main/java/com/und/server/service/NonceService.java b/src/main/java/com/und/server/service/NonceService.java deleted file mode 100644 index f994ec27..00000000 --- a/src/main/java/com/und/server/service/NonceService.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.und.server.service; - -import java.util.UUID; - -import org.springframework.stereotype.Service; - -import com.und.server.entity.Nonce; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; -import com.und.server.oauth.Provider; -import com.und.server.repository.NonceRepository; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class NonceService { - - private final NonceRepository nonceRepository; - - public String generateNonceValue() { - return UUID.randomUUID().toString(); - } - - public void validateNonce(final String nonceValue, final Provider provider) { - nonceRepository.findById(nonceValue) - .filter(n -> n.getProvider() == provider) - .orElseThrow(() -> new ServerException(ServerErrorResult.INVALID_NONCE)); - - nonceRepository.deleteById(nonceValue); - } - - - public void saveNonce(final String value, final Provider provider) { - Nonce token = Nonce.builder() - .value(value) - .provider(provider) - .build(); - - nonceRepository.save(token); - } - - public void deleteNonce(final String value) { - nonceRepository.deleteById(value); - } - -} diff --git a/src/main/java/com/und/server/service/RefreshTokenService.java b/src/main/java/com/und/server/service/RefreshTokenService.java deleted file mode 100644 index b1abbd49..00000000 --- a/src/main/java/com/und/server/service/RefreshTokenService.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.und.server.service; - -import java.util.UUID; - -import org.springframework.stereotype.Service; - -import com.und.server.entity.RefreshToken; -import com.und.server.repository.RefreshTokenRepository; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class RefreshTokenService { - - private final RefreshTokenRepository refreshTokenRepository; - - public String generateRefreshToken() { - return UUID.randomUUID().toString(); - } - - public String getRefreshToken(final Long memberId) { - return refreshTokenRepository.findById(memberId) - .map(RefreshToken::getRefreshToken) - .orElse(null); - } - - public void saveRefreshToken(final Long memberId, final String refreshToken) { - RefreshToken token = RefreshToken.builder() - .memberId(memberId) - .refreshToken(refreshToken) - .build(); - - refreshTokenRepository.save(token); - } - - public void deleteRefreshToken(final Long memberId) { - refreshTokenRepository.deleteById(memberId); - } -} diff --git a/src/main/java/com/und/server/terms/controller/TermsController.java b/src/main/java/com/und/server/terms/controller/TermsController.java new file mode 100644 index 00000000..80d5aa0f --- /dev/null +++ b/src/main/java/com/und/server/terms/controller/TermsController.java @@ -0,0 +1,62 @@ +package com.und.server.terms.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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; + +import com.und.server.auth.filter.AuthMember; +import com.und.server.terms.dto.request.EventPushAgreementRequest; +import com.und.server.terms.dto.request.TermsAgreementRequest; +import com.und.server.terms.dto.response.TermsAgreementResponse; +import com.und.server.terms.service.TermsService; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/terms") +public class TermsController { + + private final TermsService termsService; + + @GetMapping("") + public ResponseEntity getTermsAgreement( + @Parameter(hidden = true) @AuthMember final Long memberId + ) { + final TermsAgreementResponse termsAgreementResponse = termsService.getTermsAgreement(memberId); + + return ResponseEntity.status(HttpStatus.OK).body(termsAgreementResponse); + } + + @PostMapping("") + @ApiResponse(responseCode = "201", description = "Terms agreement created") + public ResponseEntity addTermsAgreement( + @Parameter(hidden = true) @AuthMember final Long memberId, + @RequestBody @Valid final TermsAgreementRequest termsAgreementRequest + ) { + final TermsAgreementResponse termsAgreementResponse + = termsService.addTermsAgreement(memberId, termsAgreementRequest); + + return ResponseEntity.status(HttpStatus.CREATED).body(termsAgreementResponse); + } + + @PatchMapping("") + public ResponseEntity updateEventPushAgreement( + @Parameter(hidden = true) @AuthMember final Long memberId, + @RequestBody @Valid final EventPushAgreementRequest eventPushAgreementRequest + ) { + final TermsAgreementResponse termsAgreementResponse + = termsService.updateEventPushAgreement(memberId, eventPushAgreementRequest); + + return ResponseEntity.status(HttpStatus.OK).body(termsAgreementResponse); + } + +} diff --git a/src/main/java/com/und/server/terms/dto/request/EventPushAgreementRequest.java b/src/main/java/com/und/server/terms/dto/request/EventPushAgreementRequest.java new file mode 100644 index 00000000..919003b2 --- /dev/null +++ b/src/main/java/com/und/server/terms/dto/request/EventPushAgreementRequest.java @@ -0,0 +1,10 @@ +package com.und.server.terms.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record EventPushAgreementRequest( + @Schema(description = "Event Push Agreement", example = "false") + @NotNull(message = "Event Push Agreement must not be null") + Boolean eventPushAgreed +) { } diff --git a/src/main/java/com/und/server/terms/dto/request/TermsAgreementRequest.java b/src/main/java/com/und/server/terms/dto/request/TermsAgreementRequest.java new file mode 100644 index 00000000..a2f6b968 --- /dev/null +++ b/src/main/java/com/und/server/terms/dto/request/TermsAgreementRequest.java @@ -0,0 +1,27 @@ +package com.und.server.terms.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Terms Agreement Request DTO") +public record TermsAgreementRequest( + @Schema(description = "Terms of Service Agreement", example = "true") + @NotNull(message = "Terms of Service Agreement must not be null") + @AssertTrue(message = "Terms of Service must be agreed to") + Boolean termsOfServiceAgreed, + + @Schema(description = "Privacy Policy Agreement", example = "true") + @NotNull(message = "Privacy Policy Agreement must not be null") + @AssertTrue(message = "Privacy Policy must be agreed to") + Boolean privacyPolicyAgreed, + + @Schema(description = "Over 14 Years Old Confirmation", example = "true") + @NotNull(message = "Over 14 Years Old Confirmation must not be null") + @AssertTrue(message = "User must be over 14 years old") + Boolean isOver14, + + @Schema(description = "Event Push Agreement", example = "true") + @NotNull(message = "Event Push Agreement must not be null") + Boolean eventPushAgreed +) { } diff --git a/src/main/java/com/und/server/terms/dto/response/TermsAgreementResponse.java b/src/main/java/com/und/server/terms/dto/response/TermsAgreementResponse.java new file mode 100644 index 00000000..651804af --- /dev/null +++ b/src/main/java/com/und/server/terms/dto/response/TermsAgreementResponse.java @@ -0,0 +1,37 @@ +package com.und.server.terms.dto.response; + +import com.und.server.terms.entity.Terms; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Terms Agreement Response DTO") +public record TermsAgreementResponse( + @Schema(description = "Terms Agreement ID", example = "1") + Long id, + + @Schema(description = "Member ID", example = "1") + Long memberId, + + @Schema(description = "Terms of Service Agreement", example = "true") + Boolean termsOfServiceAgreed, + + @Schema(description = "Privacy Policy Agreement", example = "true") + Boolean privacyPolicyAgreed, + + @Schema(description = "Over 14 Years Old Confirmation", example = "true") + Boolean isOver14, + + @Schema(description = "Event Push Agreement", example = "true") + Boolean eventPushAgreed +) { + public static TermsAgreementResponse from(final Terms terms) { + return new TermsAgreementResponse( + terms.getId(), + terms.getMember().getId(), + terms.getTermsOfServiceAgreed(), + terms.getPrivacyPolicyAgreed(), + terms.getIsOver14(), + terms.getEventPushAgreed() + ); + } +} diff --git a/src/main/java/com/und/server/terms/entity/Terms.java b/src/main/java/com/und/server/terms/entity/Terms.java new file mode 100644 index 00000000..d23c6056 --- /dev/null +++ b/src/main/java/com/und/server/terms/entity/Terms.java @@ -0,0 +1,57 @@ +package com.und.server.terms.entity; + +import com.und.server.common.entity.BaseTimeEntity; +import com.und.server.member.entity.Member; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "terms") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Terms extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false, unique = true) + private Member member; + + @Column(nullable = false) + @Builder.Default + private Boolean termsOfServiceAgreed = false; + + @Column(nullable = false) + @Builder.Default + private Boolean privacyPolicyAgreed = false; + + @Column(name = "is_over_14", nullable = false) + @Builder.Default + private Boolean isOver14 = false; + + @Column(nullable = false) + @Builder.Default + private Boolean eventPushAgreed = false; + + public void updateEventPushAgreed(final Boolean eventPushAgreed) { + this.eventPushAgreed = eventPushAgreed; + } + +} diff --git a/src/main/java/com/und/server/terms/exception/TermsErrorResult.java b/src/main/java/com/und/server/terms/exception/TermsErrorResult.java new file mode 100644 index 00000000..b5fdfc30 --- /dev/null +++ b/src/main/java/com/und/server/terms/exception/TermsErrorResult.java @@ -0,0 +1,20 @@ +package com.und.server.terms.exception; + +import org.springframework.http.HttpStatus; + +import com.und.server.common.exception.ErrorResult; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TermsErrorResult implements ErrorResult { + + TERMS_NOT_FOUND(HttpStatus.NOT_FOUND, "Terms Not Found"), + TERMS_ALREADY_EXISTS(HttpStatus.CONFLICT, "Terms Already Exists"); + + private final HttpStatus httpStatus; + private final String message; + +} diff --git a/src/main/java/com/und/server/terms/repository/TermsRepository.java b/src/main/java/com/und/server/terms/repository/TermsRepository.java new file mode 100644 index 00000000..00affa95 --- /dev/null +++ b/src/main/java/com/und/server/terms/repository/TermsRepository.java @@ -0,0 +1,21 @@ +package com.und.server.terms.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.und.server.terms.entity.Terms; + +public interface TermsRepository extends JpaRepository { + + @Override + @EntityGraph(attributePaths = {"member"}) + List findAll(); + + Optional findByMemberId(Long memberId); + + boolean existsByMemberId(Long memberId); + +} diff --git a/src/main/java/com/und/server/terms/service/TermsService.java b/src/main/java/com/und/server/terms/service/TermsService.java new file mode 100644 index 00000000..ebaa560b --- /dev/null +++ b/src/main/java/com/und/server/terms/service/TermsService.java @@ -0,0 +1,83 @@ +package com.und.server.terms.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.und.server.common.exception.ServerException; +import com.und.server.member.entity.Member; +import com.und.server.member.service.MemberService; +import com.und.server.terms.dto.request.EventPushAgreementRequest; +import com.und.server.terms.dto.request.TermsAgreementRequest; +import com.und.server.terms.dto.response.TermsAgreementResponse; +import com.und.server.terms.entity.Terms; +import com.und.server.terms.exception.TermsErrorResult; +import com.und.server.terms.repository.TermsRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TermsService { + + private final TermsRepository termsRepository; + private final MemberService memberService; + + public List getTermsList() { + return termsRepository.findAll() + .stream().map(TermsAgreementResponse::from).toList(); + } + + public TermsAgreementResponse getTermsAgreement(final Long memberId) { + memberService.checkMemberExists(memberId); + final Terms terms = findTermsByMemberId(memberId); + + return TermsAgreementResponse.from(terms); + } + + @Transactional + public TermsAgreementResponse addTermsAgreement( + final Long memberId, + final TermsAgreementRequest termsAgreementRequest + ) { + if (hasAgreedTerms(memberId)) { + throw new ServerException(TermsErrorResult.TERMS_ALREADY_EXISTS); + } + + final Member member = memberService.findMemberById(memberId); + final Terms terms = Terms.builder() + .member(member) + .termsOfServiceAgreed(termsAgreementRequest.termsOfServiceAgreed()) + .privacyPolicyAgreed(termsAgreementRequest.privacyPolicyAgreed()) + .isOver14(termsAgreementRequest.isOver14()) + .eventPushAgreed(termsAgreementRequest.eventPushAgreed()) + .build(); + + return TermsAgreementResponse.from(termsRepository.save(terms)); + } + + @Transactional + public TermsAgreementResponse updateEventPushAgreement( + final Long memberId, + final EventPushAgreementRequest eventPushAgreementRequest + ) { + memberService.checkMemberExists(memberId); + + final Terms terms = findTermsByMemberId(memberId); + terms.updateEventPushAgreed(eventPushAgreementRequest.eventPushAgreed()); + + return TermsAgreementResponse.from(terms); + } + + private boolean hasAgreedTerms(final Long memberId) { + return termsRepository.existsByMemberId(memberId); + } + + private Terms findTermsByMemberId(final Long memberId) { + return termsRepository.findByMemberId(memberId) + .orElseThrow(() -> new ServerException(TermsErrorResult.TERMS_NOT_FOUND)); + } + +} diff --git a/src/main/java/com/und/server/weather/config/WeatherConfig.java b/src/main/java/com/und/server/weather/config/WeatherConfig.java new file mode 100644 index 00000000..fef2a680 --- /dev/null +++ b/src/main/java/com/und/server/weather/config/WeatherConfig.java @@ -0,0 +1,28 @@ +package com.und.server.weather.config; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class WeatherConfig { + + @Bean("weatherExecutor") + public Executor weatherExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(10); + executor.setThreadNamePrefix("weather-api-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setKeepAliveSeconds(60); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.initialize(); + return executor; + } + +} diff --git a/src/main/java/com/und/server/weather/config/WeatherProperties.java b/src/main/java/com/und/server/weather/config/WeatherProperties.java new file mode 100644 index 00000000..fe1ac7c4 --- /dev/null +++ b/src/main/java/com/und/server/weather/config/WeatherProperties.java @@ -0,0 +1,27 @@ +package com.und.server.weather.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "weather") +public record WeatherProperties( + + Kma kma, + OpenMeteo openMeteo, + OpenMeteoKma openMeteoKma + +) { + + public record Kma( + String baseUrl, + String serviceKey + ) { } + + public record OpenMeteo( + String baseUrl + ) { } + + public record OpenMeteoKma( + String baseUrl + ) { } + +} diff --git a/src/main/java/com/und/server/weather/constants/FineDustType.java b/src/main/java/com/und/server/weather/constants/FineDustType.java new file mode 100644 index 00000000..6e29ccab --- /dev/null +++ b/src/main/java/com/und/server/weather/constants/FineDustType.java @@ -0,0 +1,60 @@ +package com.und.server.weather.constants; + +import java.util.List; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum FineDustType { + + UNKNOWN("없음", -1, -1, -1, -1, 0), + GOOD("좋음", 0, 30, 0, 15, 1), + NORMAL("보통", 31, 80, 16, 35, 2), + BAD("나쁨", 81, 150, 36, 75, 3), + VERY_BAD("매우나쁨", 151, Integer.MAX_VALUE, 76, Integer.MAX_VALUE, 4); + + private final String description; + private final int minPm10; + private final int maxPm10; + private final int minPm25; + private final int maxPm25; + private final int severity; + + public static final FineDustType DEFAULT = FineDustType.UNKNOWN; + public static final String OPEN_METEO_VARIABLES = "pm2_5,pm10"; + + public static FineDustType fromPm10Concentration(final double pm10Value) { + int pm10 = (int) Math.round(pm10Value); + + for (FineDustType level : values()) { + if (pm10 >= level.minPm10 && pm10 <= level.maxPm10) { + return level; + } + } + return DEFAULT; + } + + public static FineDustType fromPm25Concentration(final double pm25Value) { + int pm25 = (int) Math.round(pm25Value); + + for (FineDustType level : values()) { + if (pm25 >= level.minPm25 && pm25 <= level.maxPm25) { + return level; + } + } + return DEFAULT; + } + + public static FineDustType getWorst(final List levels) { + FineDustType worst = DEFAULT; + for (FineDustType level : levels) { + if (level.severity > worst.severity) { + worst = level; + } + } + return worst; + } + +} diff --git a/src/main/java/com/und/server/weather/constants/TimeSlot.java b/src/main/java/com/und/server/weather/constants/TimeSlot.java new file mode 100644 index 00000000..7888d083 --- /dev/null +++ b/src/main/java/com/und/server/weather/constants/TimeSlot.java @@ -0,0 +1,58 @@ +package com.und.server.weather.constants; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TimeSlot { + + SLOT_00_03(0, 3), + SLOT_03_06(3, 6), + SLOT_06_09(6, 9), + SLOT_09_12(9, 12), + SLOT_12_15(12, 15), + SLOT_15_18(15, 18), + SLOT_18_21(18, 21), + SLOT_21_24(21, 24); + + private final int startHour; + private final int endHour; + + public static TimeSlot getCurrentSlot(final LocalDateTime dateTime) { + return from(dateTime.toLocalTime()); + } + + public static TimeSlot from(final LocalTime time) { + int hour = time.getHour(); + + for (TimeSlot slot : values()) { + if (hour >= slot.startHour && hour < slot.endHour) { + return slot; + } + } + return SLOT_00_03; + } + + public List getForecastHours() { + List hours = new ArrayList<>(); + for (int i = startHour; i < endHour; i++) { + hours.add(i); + } + return hours; + } + + public static List getAllDayHours() { + List hours = new ArrayList<>(); + for (int hour = 0; hour < 24; hour++) { + hours.add(hour); + } + return hours; + } + +} diff --git a/src/main/java/com/und/server/weather/constants/UvType.java b/src/main/java/com/und/server/weather/constants/UvType.java new file mode 100644 index 00000000..569b0d8b --- /dev/null +++ b/src/main/java/com/und/server/weather/constants/UvType.java @@ -0,0 +1,48 @@ +package com.und.server.weather.constants; + +import java.util.List; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum UvType { + + UNKNOWN("없음", -1, -1, 0), + VERY_LOW("매우낮음", 0, 2, 1), + LOW("낮음", 3, 4, 2), + NORMAL("보통", 5, 6, 3), + HIGH("높음", 7, 9, 4), + VERY_HIGH("매우높음", 10, Integer.MAX_VALUE, 5); + + private final String description; + private final int minUvIndex; + private final int maxUvIndex; + private final int severity; + + public static final UvType DEFAULT = UvType.UNKNOWN; + public static final String OPEN_METEO_VARIABLES = "uv_index"; + + public static UvType fromUvIndex(final double uvIndexValue) { + int uvIndex = (int) Math.round(uvIndexValue); + + for (UvType level : values()) { + if (uvIndex >= level.minUvIndex && uvIndex <= level.maxUvIndex) { + return level; + } + } + return DEFAULT; + } + + public static UvType getWorst(final List levels) { + UvType worst = DEFAULT; + for (UvType level : levels) { + if (level.severity > worst.severity) { + worst = level; + } + } + return worst; + } + +} diff --git a/src/main/java/com/und/server/weather/constants/WeatherType.java b/src/main/java/com/und/server/weather/constants/WeatherType.java new file mode 100644 index 00000000..96edbd7a --- /dev/null +++ b/src/main/java/com/und/server/weather/constants/WeatherType.java @@ -0,0 +1,97 @@ +package com.und.server.weather.constants; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Objects; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum WeatherType { + + UNKNOWN("없음", null, null, 0), + SUNNY("맑음", null, 1, 1), + CLOUDY("구름많음", null, 3, 2), + OVERCAST("흐림", null, 4, 2), + RAIN("비", 1, null, 5), + SLEET("진눈깨비", 2, null, 3), + SNOW("눈", 3, null, 4), + SHOWER("소나기", 4, null, 6); + + private final String description; + private final Integer ptyValue; + private final Integer skyValue; + private final int severity; + + public static final WeatherType DEFAULT = WeatherType.UNKNOWN; + public static final String OPEN_METEO_VARIABLES = "weathercode"; + public static final DateTimeFormatter KMA_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + public static WeatherType fromPtyValue(final int ptyValue) { + for (WeatherType type : values()) { + if (Objects.equals(type.ptyValue, ptyValue)) { + return type; + } + } + return DEFAULT; + } + + public static WeatherType fromSkyValue(final int skyValue) { + for (WeatherType type : values()) { + if (Objects.equals(type.skyValue, skyValue)) { + return type; + } + } + return DEFAULT; + } + + public static String getBaseTime(final TimeSlot timeSlot) { + return switch (timeSlot) { + case SLOT_00_03 -> "2300"; + case SLOT_03_06 -> "0200"; + case SLOT_06_09 -> "0500"; + case SLOT_09_12 -> "0800"; + case SLOT_12_15 -> "1100"; + case SLOT_15_18 -> "1400"; + case SLOT_18_21 -> "1700"; + case SLOT_21_24 -> "2000"; + }; + } + + public static LocalDate getBaseDate(final TimeSlot timeSlot, final LocalDate date) { + if (timeSlot == TimeSlot.SLOT_00_03) { + return date.minusDays(1); + } + return date; + } + + public static WeatherType getWorst(final List types) { + WeatherType worst = DEFAULT; + for (WeatherType type : types) { + if (type != null) { + if (type.severity > worst.severity) { + worst = type; + } + } + } + return worst; + } + + public static WeatherType fromOpenMeteoCode(final int weatherCode) { + return switch (weatherCode) { + case 0 -> WeatherType.SUNNY; + case 1, 2, 3 -> WeatherType.CLOUDY; + case 45, 48 -> WeatherType.OVERCAST; + case 51, 53, 55, 56, 57 -> WeatherType.RAIN; + case 61, 63, 65, 66, 67 -> WeatherType.RAIN; + case 71, 73, 75, 77 -> WeatherType.SNOW; + case 80, 81, 82 -> WeatherType.SHOWER; + case 85, 86 -> WeatherType.SLEET; + default -> WeatherType.DEFAULT; + }; + } + +} diff --git a/src/main/java/com/und/server/weather/controller/WeatherApiDocs.java b/src/main/java/com/und/server/weather/controller/WeatherApiDocs.java new file mode 100644 index 00000000..8b505799 --- /dev/null +++ b/src/main/java/com/und/server/weather/controller/WeatherApiDocs.java @@ -0,0 +1,123 @@ +package com.und.server.weather.controller; + +import java.time.LocalDate; +import java.time.ZoneId; + +import org.springframework.http.ResponseEntity; + +import com.und.server.common.dto.response.ErrorResponse; +import com.und.server.weather.dto.request.WeatherRequest; +import com.und.server.weather.dto.response.WeatherResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; + +public interface WeatherApiDocs { + + @Operation(summary = "Get Weather Information API") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved weather information", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = WeatherResponse.class) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Bad request", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "Latitude must not be null", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "Latitude must not be null" + } + """ + ), + @ExampleObject( + name = "Longitude must not be null", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "Longitude must not be null" + } + """ + ), + @ExampleObject( + name = "Latitude out of range", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "Latitude must be at least -90 degrees" + } + """ + ), + @ExampleObject( + name = "Longitude out of range", + value = """ + { + "code": "INVALID_PARAMETER", + "message": "Longitude must be at most 180 degrees" + } + """ + ), + @ExampleObject( + name = "Invalid coordinates", + value = """ + { + "code": "INVALID_COORDINATES", + "message": "Invalid location coordinates" + } + """ + ), + @ExampleObject( + name = "Date out of range", + value = """ + { + "code": "DATE_OUT_OF_RANGE", + "message": "Date is out of range (maximum +3 days)" + } + """ + ) + } + ) + ), + @ApiResponse( + responseCode = "503", + description = "Service unavailable", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "Weather service error", + value = """ + { + "code": "SERVICE_UNAVAILABLE", + "message": "An error occurred while processing weather service" + } + """ + ) + } + ) + ) + }) + ResponseEntity getWeather( + @Parameter(description = "Weather request information") @Valid final WeatherRequest request, + @Parameter(description = "Target date for weather information (yyyy-MM-dd)") final LocalDate date, + @Parameter(description = "Target TimeZone") final ZoneId timeZone + ); + +} diff --git a/src/main/java/com/und/server/weather/controller/WeatherController.java b/src/main/java/com/und/server/weather/controller/WeatherController.java new file mode 100644 index 00000000..296bc6e1 --- /dev/null +++ b/src/main/java/com/und/server/weather/controller/WeatherController.java @@ -0,0 +1,42 @@ +package com.und.server.weather.controller; + +import java.time.LocalDate; +import java.time.ZoneId; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.und.server.weather.dto.request.WeatherRequest; +import com.und.server.weather.dto.response.WeatherResponse; +import com.und.server.weather.service.WeatherService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@PreAuthorize("isAuthenticated()") +@RequestMapping("/v1/weather") +public class WeatherController implements WeatherApiDocs { + + private final WeatherService weatherService; + + @Override + @PostMapping + public ResponseEntity getWeather( + @RequestBody @Valid final WeatherRequest request, + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") final LocalDate date, + @RequestParam(defaultValue = "Asia/Seoul") final ZoneId timezone + ) { + final WeatherResponse response = weatherService.getWeatherInfo(request, date, timezone); + + return ResponseEntity.ok(response); + } + +} diff --git a/src/main/java/com/und/server/weather/dto/GridPoint.java b/src/main/java/com/und/server/weather/dto/GridPoint.java new file mode 100644 index 00000000..9633eff0 --- /dev/null +++ b/src/main/java/com/und/server/weather/dto/GridPoint.java @@ -0,0 +1,17 @@ +package com.und.server.weather.dto; + +import lombok.Builder; + +@Builder +public record GridPoint( + + int gridX, + int gridY + +) { + + public static GridPoint from(final int gridX, final int gridY) { + return new GridPoint(gridX, gridY); + } + +} diff --git a/src/main/java/com/und/server/weather/dto/OpenMeteoWeatherApiResultDto.java b/src/main/java/com/und/server/weather/dto/OpenMeteoWeatherApiResultDto.java new file mode 100644 index 00000000..015e05d5 --- /dev/null +++ b/src/main/java/com/und/server/weather/dto/OpenMeteoWeatherApiResultDto.java @@ -0,0 +1,26 @@ +package com.und.server.weather.dto; + +import com.und.server.weather.infrastructure.dto.OpenMeteoResponse; +import com.und.server.weather.infrastructure.dto.OpenMeteoWeatherResponse; + +import lombok.Builder; + +@Builder +public record OpenMeteoWeatherApiResultDto( + + OpenMeteoWeatherResponse openMeteoWeatherResponse, + OpenMeteoResponse openMeteoResponse + +) { + + public static OpenMeteoWeatherApiResultDto from( + final OpenMeteoWeatherResponse openMeteoWeatherResponse, + final OpenMeteoResponse openMeteoResponse + ) { + return OpenMeteoWeatherApiResultDto.builder() + .openMeteoWeatherResponse(openMeteoWeatherResponse) + .openMeteoResponse(openMeteoResponse) + .build(); + } + +} diff --git a/src/main/java/com/und/server/weather/dto/WeatherApiResultDto.java b/src/main/java/com/und/server/weather/dto/WeatherApiResultDto.java new file mode 100644 index 00000000..f1a9c7c5 --- /dev/null +++ b/src/main/java/com/und/server/weather/dto/WeatherApiResultDto.java @@ -0,0 +1,26 @@ +package com.und.server.weather.dto; + +import com.und.server.weather.infrastructure.dto.KmaWeatherResponse; +import com.und.server.weather.infrastructure.dto.OpenMeteoResponse; + +import lombok.Builder; + +@Builder +public record WeatherApiResultDto( + + KmaWeatherResponse kmaWeatherResponse, + OpenMeteoResponse openMeteoResponse + +) { + + public static WeatherApiResultDto from( + final KmaWeatherResponse kmaWeatherResponse, + final OpenMeteoResponse openMeteoResponse + ) { + return WeatherApiResultDto.builder() + .kmaWeatherResponse(kmaWeatherResponse) + .openMeteoResponse(openMeteoResponse) + .build(); + } + +} diff --git a/src/main/java/com/und/server/weather/dto/cache/WeatherCacheData.java b/src/main/java/com/und/server/weather/dto/cache/WeatherCacheData.java new file mode 100644 index 00000000..ec3a9d85 --- /dev/null +++ b/src/main/java/com/und/server/weather/dto/cache/WeatherCacheData.java @@ -0,0 +1,59 @@ +package com.und.server.weather.dto.cache; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.und.server.weather.constants.FineDustType; +import com.und.server.weather.constants.UvType; +import com.und.server.weather.constants.WeatherType; + +import lombok.Builder; + +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record WeatherCacheData( + + WeatherType weather, + FineDustType fineDust, + UvType uv + +) { + + @JsonIgnore + public boolean isValid() { + return weather != null && fineDust != null && uv != null; + } + + @JsonIgnore + public WeatherCacheData getValidDefault() { + return WeatherCacheData.builder() + .weather(Objects.requireNonNullElse(this.weather(), WeatherType.DEFAULT)) + .fineDust(Objects.requireNonNullElse(this.fineDust(), FineDustType.DEFAULT)) + .uv(Objects.requireNonNullElse(this.uv(), UvType.DEFAULT)) + .build(); + } + + public static WeatherCacheData from( + final WeatherType weather, + final FineDustType findDust, + final UvType uv + ) { + return WeatherCacheData.builder() + .weather(weather) + .fineDust(findDust) + .uv(uv) + .build(); + } + + public static WeatherCacheData getDefault() { + return WeatherCacheData.builder() + .weather(WeatherType.DEFAULT) + .fineDust(FineDustType.DEFAULT) + .uv(UvType.DEFAULT) + .build(); + } + +} diff --git a/src/main/java/com/und/server/weather/dto/cache/WeatherCacheKey.java b/src/main/java/com/und/server/weather/dto/cache/WeatherCacheKey.java new file mode 100644 index 00000000..12e3d7e1 --- /dev/null +++ b/src/main/java/com/und/server/weather/dto/cache/WeatherCacheKey.java @@ -0,0 +1,72 @@ +package com.und.server.weather.dto.cache; + +import java.time.LocalDate; + +import com.und.server.weather.constants.TimeSlot; +import com.und.server.weather.dto.GridPoint; + +import lombok.Builder; + +@Builder +public record WeatherCacheKey( + + boolean isToday, + int gridX, + int gridY, + LocalDate date, + TimeSlot slot + +) { + + private static final String PREFIX = "wx"; + private static final String TODAY_PREFIX = "today"; + private static final String FUTURE_PREFIX = "future"; + private static final String DELIMITER = ":"; + + public static WeatherCacheKey forToday( + final GridPoint gridPoint, final LocalDate today, final TimeSlot timeSlot + ) { + return WeatherCacheKey.builder() + .isToday(true) + .gridX(gridPoint.gridX()) + .gridY(gridPoint.gridY()) + .date(today) + .slot(timeSlot) + .build(); + } + + public static WeatherCacheKey forFuture( + final GridPoint gridPoint, final LocalDate future, final TimeSlot timeSlot + ) { + return WeatherCacheKey.builder() + .isToday(false) + .gridX(gridPoint.gridX()) + .gridY(gridPoint.gridY()) + .date(future) + .slot(timeSlot) + .build(); + } + + public String toRedisKey() { + if (isToday) { + return String.join(DELIMITER, + PREFIX, + TODAY_PREFIX, + String.valueOf(gridX), + String.valueOf(gridY), + date.toString(), + slot.name() + ); + } else { + return String.join(DELIMITER, + PREFIX, + FUTURE_PREFIX, + String.valueOf(gridX), + String.valueOf(gridY), + date.toString(), + slot.name() + ); + } + } + +} diff --git a/src/main/java/com/und/server/weather/dto/request/WeatherRequest.java b/src/main/java/com/und/server/weather/dto/request/WeatherRequest.java new file mode 100644 index 00000000..ef9fc72b --- /dev/null +++ b/src/main/java/com/und/server/weather/dto/request/WeatherRequest.java @@ -0,0 +1,28 @@ +package com.und.server.weather.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Weather request") +public record WeatherRequest( + + @Schema(description = "Latitude", example = "37.5663") + @NotNull(message = "Latitude must not be null") + @DecimalMin(value = "-90.0", message = "Latitude must be at least -90 degrees") + @DecimalMax(value = "90.0", message = "Latitude must be at most 90 degrees") + @Digits(integer = 3, fraction = 6, + message = "Latitude must have at most 3 integer digits and 6 decimal places") + Double latitude, + + @Schema(description = "Longitude", example = "126.9779") + @NotNull(message = "Longitude must not be null") + @DecimalMin(value = "-180.0", message = "Longitude must be at least -180 degrees") + @DecimalMax(value = "180.0", message = "Longitude must be at most 180 degrees") + @Digits(integer = 3, fraction = 6, + message = "Longitude must have at most 3 integer digits and 6 decimal places") + Double longitude + +) { } diff --git a/src/main/java/com/und/server/weather/dto/response/WeatherResponse.java b/src/main/java/com/und/server/weather/dto/response/WeatherResponse.java new file mode 100644 index 00000000..26db0fef --- /dev/null +++ b/src/main/java/com/und/server/weather/dto/response/WeatherResponse.java @@ -0,0 +1,46 @@ +package com.und.server.weather.dto.response; + +import com.und.server.weather.constants.FineDustType; +import com.und.server.weather.constants.UvType; +import com.und.server.weather.constants.WeatherType; +import com.und.server.weather.dto.cache.WeatherCacheData; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "Weather(Weather, FindDust, UV) response") +public record WeatherResponse( + + @Schema(description = "Weather condition", example = "RAIN") + WeatherType weather, + + @Schema(description = "FineDust condition", example = "BAD") + FineDustType fineDust, + + @Schema(description = "UV condition", example = "VERY_LOW") + UvType uv + +) { + + public static WeatherResponse from( + final WeatherType weather, + final FineDustType fineDust, + final UvType uvIndex + ) { + return WeatherResponse.builder() + .weather(weather) + .fineDust(fineDust) + .uv(uvIndex) + .build(); + } + + public static WeatherResponse from(final WeatherCacheData weatherCacheData) { + return WeatherResponse.builder() + .weather(weatherCacheData.weather()) + .fineDust(weatherCacheData.fineDust()) + .uv(weatherCacheData.uv()) + .build(); + } + +} diff --git a/src/main/java/com/und/server/weather/exception/KmaApiException.java b/src/main/java/com/und/server/weather/exception/KmaApiException.java new file mode 100644 index 00000000..73514478 --- /dev/null +++ b/src/main/java/com/und/server/weather/exception/KmaApiException.java @@ -0,0 +1,18 @@ +package com.und.server.weather.exception; + +import com.und.server.common.exception.ErrorResult; + +import lombok.Getter; + +@Getter +public class KmaApiException extends WeatherException { + + public KmaApiException(ErrorResult errorResult) { + super(errorResult); + } + + public KmaApiException(ErrorResult errorResult, Throwable cause) { + super(errorResult, cause); + } + +} diff --git a/src/main/java/com/und/server/weather/exception/WeatherErrorResult.java b/src/main/java/com/und/server/weather/exception/WeatherErrorResult.java new file mode 100644 index 00000000..03446e25 --- /dev/null +++ b/src/main/java/com/und/server/weather/exception/WeatherErrorResult.java @@ -0,0 +1,52 @@ +package com.und.server.weather.exception; + +import org.springframework.http.HttpStatus; + +import com.und.server.common.exception.ErrorResult; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum WeatherErrorResult implements ErrorResult { + + INVALID_COORDINATES( + HttpStatus.BAD_REQUEST, "Invalid location coordinates"), + DATE_OUT_OF_RANGE( + HttpStatus.BAD_REQUEST, "Date is out of range (maximum +3 days)"), + WEATHER_SERVICE_ERROR( + HttpStatus.SERVICE_UNAVAILABLE, "An error occurred while processing weather service"), + WEATHER_SERVICE_TIMEOUT( + HttpStatus.GATEWAY_TIMEOUT, "Weather service request timed out"), + + KMA_API_ERROR( + HttpStatus.SERVICE_UNAVAILABLE, "Failed to call KMA weather API"), + KMA_TIMEOUT( + HttpStatus.GATEWAY_TIMEOUT, "KMA API request timed out"), + KMA_BAD_REQUEST( + HttpStatus.BAD_REQUEST, "Invalid request to KMA weather API"), + KMA_SERVER_ERROR( + HttpStatus.BAD_GATEWAY, "KMA API server error"), + KMA_RATE_LIMIT( + HttpStatus.TOO_MANY_REQUESTS, "KMA API rate limit exceeded"), + KMA_PARSE_ERROR( + HttpStatus.INTERNAL_SERVER_ERROR, "Failed to parse KMA API response"), + + OPEN_METEO_API_ERROR( + HttpStatus.SERVICE_UNAVAILABLE, "Failed to call Open-Meteo API"), + OPEN_METEO_TIMEOUT( + HttpStatus.GATEWAY_TIMEOUT, "Open-Meteo API request timed out"), + OPEN_METEO_BAD_REQUEST( + HttpStatus.BAD_REQUEST, "Invalid request to Open-Meteo API"), + OPEN_METEO_SERVER_ERROR( + HttpStatus.BAD_GATEWAY, "Open-Meteo API server error"), + OPEN_METEO_RATE_LIMIT( + HttpStatus.TOO_MANY_REQUESTS, "Open-Meteo API rate limit exceeded"), + OPEN_METEO_PARSE_ERROR( + HttpStatus.INTERNAL_SERVER_ERROR, "Failed to parse Open-Meteo API response"); + + private final HttpStatus httpStatus; + private final String message; + +} diff --git a/src/main/java/com/und/server/weather/exception/WeatherException.java b/src/main/java/com/und/server/weather/exception/WeatherException.java new file mode 100644 index 00000000..8cf28c5f --- /dev/null +++ b/src/main/java/com/und/server/weather/exception/WeatherException.java @@ -0,0 +1,19 @@ +package com.und.server.weather.exception; + +import com.und.server.common.exception.ErrorResult; +import com.und.server.common.exception.ServerException; + +import lombok.Getter; + +@Getter +public class WeatherException extends ServerException { + + public WeatherException(ErrorResult errorResult) { + super(errorResult); + } + + public WeatherException(ErrorResult errorResult, Throwable cause) { + super(errorResult, cause); + } + +} diff --git a/src/main/java/com/und/server/weather/infrastructure/KmaApiFacade.java b/src/main/java/com/und/server/weather/infrastructure/KmaApiFacade.java new file mode 100644 index 00000000..ee669fd8 --- /dev/null +++ b/src/main/java/com/und/server/weather/infrastructure/KmaApiFacade.java @@ -0,0 +1,80 @@ +package com.und.server.weather.infrastructure; + +import java.time.LocalDate; + +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientResponseException; + +import com.und.server.weather.config.WeatherProperties; +import com.und.server.weather.constants.TimeSlot; +import com.und.server.weather.constants.WeatherType; +import com.und.server.weather.dto.GridPoint; +import com.und.server.weather.exception.KmaApiException; +import com.und.server.weather.exception.WeatherErrorResult; +import com.und.server.weather.infrastructure.client.KmaWeatherClient; +import com.und.server.weather.infrastructure.dto.KmaWeatherResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +public class KmaApiFacade { + + private final KmaWeatherClient kmaWeatherClient; + private final WeatherProperties weatherProperties; + + public KmaWeatherResponse callWeatherApi( + final GridPoint gridPoint, + final TimeSlot timeSlot, + final LocalDate date + ) { + final String baseDate = WeatherType.getBaseDate(timeSlot, date).format(WeatherType.KMA_DATE_FORMATTER); + final String baseTime = WeatherType.getBaseTime(timeSlot); + + try { + return kmaWeatherClient.getVilageForecast( + weatherProperties.kma().serviceKey(), + 1, + 1000, + "JSON", + baseDate, baseTime, + gridPoint.gridX(), gridPoint.gridY() + ); + } catch (ResourceAccessException e) { + log.error("KMA timeout/network error baseDate={} baseTime={} grid=({},{}), slot={}", + baseDate, baseTime, gridPoint.gridX(), gridPoint.gridY(), timeSlot, e); + throw new KmaApiException(WeatherErrorResult.KMA_TIMEOUT, e); + + } catch (HttpClientErrorException e) { + log.error("KMA 4xx error baseDate={} baseTime={} grid=({},{}), slot={}, status={}", + baseDate, baseTime, gridPoint.gridX(), gridPoint.gridY(), timeSlot, e.getStatusCode().value(), e); + throw new KmaApiException(WeatherErrorResult.KMA_BAD_REQUEST, e); + + } catch (HttpServerErrorException e) { + log.error("KMA 5xx error baseDate={} baseTime={} grid=({},{}), slot={}, status={}", + baseDate, baseTime, gridPoint.gridX(), gridPoint.gridY(), timeSlot, e.getStatusCode().value(), e); + throw new KmaApiException(WeatherErrorResult.KMA_SERVER_ERROR, e); + + } catch (RestClientResponseException e) { + if (e.getStatusCode().value() == 429) { + log.error("KMA 429(rate limit) baseDate={} baseTime={} grid=({},{}), slot={}", + baseDate, baseTime, gridPoint.gridX(), gridPoint.gridY(), timeSlot, e); + throw new KmaApiException(WeatherErrorResult.KMA_RATE_LIMIT, e); + } + log.error("KMA response error baseDate={} baseTime={} grid=({},{}), slot={}, status={}", + baseDate, baseTime, gridPoint.gridX(), gridPoint.gridY(), timeSlot, e.getStatusCode().value(), e); + throw new KmaApiException(WeatherErrorResult.KMA_API_ERROR, e); + + } catch (Exception e) { + log.error("KMA call failed(others) baseDate={} baseTime={} grid=({},{}), slot={}", + baseDate, baseTime, gridPoint.gridX(), gridPoint.gridY(), timeSlot, e); + throw new KmaApiException(WeatherErrorResult.KMA_API_ERROR, e); + } + } + +} diff --git a/src/main/java/com/und/server/weather/infrastructure/OpenMeteoApiFacade.java b/src/main/java/com/und/server/weather/infrastructure/OpenMeteoApiFacade.java new file mode 100644 index 00000000..398f5b51 --- /dev/null +++ b/src/main/java/com/und/server/weather/infrastructure/OpenMeteoApiFacade.java @@ -0,0 +1,102 @@ +package com.und.server.weather.infrastructure; + +import java.time.LocalDate; + +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientResponseException; + +import com.und.server.weather.constants.FineDustType; +import com.und.server.weather.constants.UvType; +import com.und.server.weather.constants.WeatherType; +import com.und.server.weather.exception.WeatherErrorResult; +import com.und.server.weather.exception.WeatherException; +import com.und.server.weather.infrastructure.client.OpenMeteoClient; +import com.und.server.weather.infrastructure.client.OpenMeteoKmaClient; +import com.und.server.weather.infrastructure.dto.OpenMeteoResponse; +import com.und.server.weather.infrastructure.dto.OpenMeteoWeatherResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +public class OpenMeteoApiFacade { + + private final OpenMeteoClient openMeteoClient; + private final OpenMeteoKmaClient openMeteoKmaClient; + + public OpenMeteoResponse callDustUvApi( + final Double latitude, final Double longitude, + final LocalDate date + ) { + final String variables = String.join( + ",", + FineDustType.OPEN_METEO_VARIABLES, + UvType.OPEN_METEO_VARIABLES + ); + try { + return openMeteoClient.getForecast( + latitude, + longitude, + variables, + date.toString(), + date.toString(), + "Asia/Seoul" + ); + } catch (ResourceAccessException e) { + log.error("Open-Meteo timeout/network error lat={}, lon={}, date={}", + latitude, longitude, date, e); + throw new WeatherException(WeatherErrorResult.OPEN_METEO_TIMEOUT, e); + + } catch (HttpClientErrorException e) { + log.error("Open-Meteo 4xx error lat={}, lon={}, date={}, status={}", + latitude, longitude, date, e.getStatusCode().value(), e); + throw new WeatherException(WeatherErrorResult.OPEN_METEO_BAD_REQUEST, e); + + } catch (HttpServerErrorException e) { + log.error("Open-Meteo 5xx error lat={}, lon={}, date={}, status={}", + latitude, longitude, date, e.getStatusCode().value(), e); + throw new WeatherException(WeatherErrorResult.OPEN_METEO_SERVER_ERROR, e); + + } catch (RestClientResponseException e) { + if (e.getStatusCode().value() == 429) { + log.error("Open-Meteo 429(rate limit) lat={}, lon={}, date={}", + latitude, longitude, date, e); + throw new WeatherException(WeatherErrorResult.OPEN_METEO_RATE_LIMIT, e); + } + log.error("Open-Meteo response error lat={}, lon={}, date={}, status={}", + latitude, longitude, date, e.getStatusCode().value(), e); + throw new WeatherException(WeatherErrorResult.OPEN_METEO_API_ERROR, e); + + } catch (Exception e) { + log.error("Open-Meteo call failed(others) lat={}, lon={}, date={}", + latitude, longitude, date, e); + throw new WeatherException(WeatherErrorResult.OPEN_METEO_API_ERROR, e); + } + } + + public OpenMeteoWeatherResponse callWeatherApi( + final Double latitude, final Double longitude, + final LocalDate date + ) { + try { + return openMeteoKmaClient.getWeatherForecast( + latitude, + longitude, + WeatherType.OPEN_METEO_VARIABLES, + date.toString(), + date.toString(), + "Asia/Seoul" + ); + } catch (Exception e) { + log.error("Open-Meteo KMA call failed lat={}, lon={}, date={}", + latitude, longitude, date, e); + throw new WeatherException(WeatherErrorResult.OPEN_METEO_API_ERROR, e); + } + } + +} diff --git a/src/main/java/com/und/server/weather/infrastructure/client/KmaWeatherClient.java b/src/main/java/com/und/server/weather/infrastructure/client/KmaWeatherClient.java new file mode 100644 index 00000000..667b8877 --- /dev/null +++ b/src/main/java/com/und/server/weather/infrastructure/client/KmaWeatherClient.java @@ -0,0 +1,42 @@ +package com.und.server.weather.infrastructure.client; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.und.server.weather.infrastructure.dto.KmaWeatherResponse; + +@FeignClient( + name = "kmaWeatherClient", + url = "${weather.kma.base-url}" +) +public interface KmaWeatherClient { + + /** + * 기상청 단기예보 조회 + * + * @param serviceKey 공공데이터포털에서 받은 인증키 (디코딩) + * @param pageNo 페이지번호 (기본값: 1) + * @param numOfRows 한 페이지 결과 수 (기본값: 1000) + * @param dataType 요청자료형식 (XML, JSON) (기본값: JSON) + * @param baseDate 발표일자 (YYYYMMDD) + * @param baseTime 발표시각 (HHMM) + * @param nx 예보지점의 X 좌표값 + * @param ny 예보지점의 Y 좌표값 + * @return 기상청 단기예보 응답 + */ + @GetMapping("/getVilageFcst") + KmaWeatherResponse getVilageForecast( + + @RequestParam("serviceKey") String serviceKey, + @RequestParam("pageNo") Integer pageNo, + @RequestParam("numOfRows") Integer numOfRows, + @RequestParam("dataType") String dataType, + @RequestParam("base_date") String baseDate, + @RequestParam("base_time") String baseTime, + @RequestParam("nx") Integer nx, + @RequestParam("ny") Integer ny + + ); + +} diff --git a/src/main/java/com/und/server/weather/infrastructure/client/OpenMeteoClient.java b/src/main/java/com/und/server/weather/infrastructure/client/OpenMeteoClient.java new file mode 100644 index 00000000..f4093c87 --- /dev/null +++ b/src/main/java/com/und/server/weather/infrastructure/client/OpenMeteoClient.java @@ -0,0 +1,38 @@ +package com.und.server.weather.infrastructure.client; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.und.server.weather.infrastructure.dto.OpenMeteoResponse; + +@FeignClient( + name = "openMeteoClient", + url = "${weather.open-meteo.base-url}" +) +public interface OpenMeteoClient { + + /** + * Open-Meteo 대기질 및 자외선 예보 조회 + * + * @param latitude 위도 + * @param longitude 경도 + * @param hourly 시간별 데이터 변수들 (pm2_5,pm10,uv_index) + * @param startDate 시작 날짜 (YYYY-MM-DD) + * @param endDate 종료 날짜 (YYYY-MM-DD) + * @param timezone 시간대 (Asia/Seoul) + * @return Open-Meteo 대기질 및 자외선 응답 + */ + @GetMapping("/air-quality") + OpenMeteoResponse getForecast( + + @RequestParam("latitude") Double latitude, + @RequestParam("longitude") Double longitude, + @RequestParam("hourly") String hourly, + @RequestParam("start_date") String startDate, + @RequestParam("end_date") String endDate, + @RequestParam("timezone") String timezone + + ); + +} diff --git a/src/main/java/com/und/server/weather/infrastructure/client/OpenMeteoKmaClient.java b/src/main/java/com/und/server/weather/infrastructure/client/OpenMeteoKmaClient.java new file mode 100644 index 00000000..d06c0e58 --- /dev/null +++ b/src/main/java/com/und/server/weather/infrastructure/client/OpenMeteoKmaClient.java @@ -0,0 +1,38 @@ +package com.und.server.weather.infrastructure.client; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.und.server.weather.infrastructure.dto.OpenMeteoWeatherResponse; + +@FeignClient( + name = "openMeteoKmaClient", + url = "${weather.open-meteo-kma.base-url}" +) +public interface OpenMeteoKmaClient { + + /** + * Open-Meteo KMA 날씨 예보 조회 + * + * @param latitude 위도 + * @param longitude 경도 + * @param hourly 시간별 데이터 변수들 (weathercode,temperature_2m,precipitation_probability) + * @param startDate 시작 날짜 (YYYY-MM-DD) + * @param endDate 종료 날짜 (YYYY-MM-DD) + * @param timezone 시간대 (Asia/Seoul) + * @return Open-Meteo KMA 날씨 응답 + */ + @GetMapping("/forecast") + OpenMeteoWeatherResponse getWeatherForecast( + + @RequestParam("latitude") Double latitude, + @RequestParam("longitude") Double longitude, + @RequestParam("hourly") String hourly, + @RequestParam("start_date") String startDate, + @RequestParam("end_date") String endDate, + @RequestParam("timezone") String timezone + + ); + +} diff --git a/src/main/java/com/und/server/weather/infrastructure/dto/KmaWeatherResponse.java b/src/main/java/com/und/server/weather/infrastructure/dto/KmaWeatherResponse.java new file mode 100644 index 00000000..ff28652f --- /dev/null +++ b/src/main/java/com/und/server/weather/infrastructure/dto/KmaWeatherResponse.java @@ -0,0 +1,51 @@ +package com.und.server.weather.infrastructure.dto; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record KmaWeatherResponse( + + @JsonProperty("response") Response response + +) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Response( + @JsonProperty("header") Header header, + @JsonProperty("body") Body body + ) { } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Header( + @JsonProperty("resultCode") String resultCode, + @JsonProperty("resultMsg") String resultMsg + ) { } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Body( + @JsonProperty("dataType") String dataType, + @JsonProperty("items") Items items, + @JsonProperty("totalCount") Integer totalCount + ) { } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Items( + @JsonProperty("item") List item + ) { } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record WeatherItem( + @JsonProperty("baseDate") String baseDate, + @JsonProperty("baseTime") String baseTime, + @JsonProperty("category") String category, + @JsonProperty("fcstDate") String fcstDate, + @JsonProperty("fcstTime") String fcstTime, + @JsonProperty("fcstValue") String fcstValue, + @JsonProperty("nx") Integer nx, + @JsonProperty("ny") Integer ny + ) { } + +} diff --git a/src/main/java/com/und/server/weather/infrastructure/dto/OpenMeteoResponse.java b/src/main/java/com/und/server/weather/infrastructure/dto/OpenMeteoResponse.java new file mode 100644 index 00000000..55c87389 --- /dev/null +++ b/src/main/java/com/und/server/weather/infrastructure/dto/OpenMeteoResponse.java @@ -0,0 +1,35 @@ +package com.und.server.weather.infrastructure.dto; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record OpenMeteoResponse( + + @JsonProperty("latitude") Double latitude, + @JsonProperty("longitude") Double longitude, + @JsonProperty("timezone") String timezone, + @JsonProperty("hourly_units") HourlyUnits hourlyUnits, + @JsonProperty("hourly") Hourly hourly + +) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record HourlyUnits( + @JsonProperty("time") String time, + @JsonProperty("pm2_5") String pm25, + @JsonProperty("pm10") String pm10, + @JsonProperty("uv_index") String uvIndex + ) { } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Hourly( + @JsonProperty("time") List time, + @JsonProperty("pm2_5") List pm25, + @JsonProperty("pm10") List pm10, + @JsonProperty("uv_index") List uvIndex + ) { } + +} diff --git a/src/main/java/com/und/server/weather/infrastructure/dto/OpenMeteoWeatherResponse.java b/src/main/java/com/und/server/weather/infrastructure/dto/OpenMeteoWeatherResponse.java new file mode 100644 index 00000000..8ab9c719 --- /dev/null +++ b/src/main/java/com/und/server/weather/infrastructure/dto/OpenMeteoWeatherResponse.java @@ -0,0 +1,31 @@ +package com.und.server.weather.infrastructure.dto; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record OpenMeteoWeatherResponse( + + @JsonProperty("latitude") Double latitude, + @JsonProperty("longitude") Double longitude, + @JsonProperty("timezone") String timezone, + @JsonProperty("hourly_units") HourlyUnits hourlyUnits, + @JsonProperty("hourly") Hourly hourly + +) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record HourlyUnits( + @JsonProperty("time") String time, + @JsonProperty("weathercode") String weathercode + ) { } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Hourly( + @JsonProperty("time") List time, + @JsonProperty("weathercode") List weathercode + ) { } + +} diff --git a/src/main/java/com/und/server/weather/service/FineDustExtractor.java b/src/main/java/com/und/server/weather/service/FineDustExtractor.java new file mode 100644 index 00000000..bb607b8a --- /dev/null +++ b/src/main/java/com/und/server/weather/service/FineDustExtractor.java @@ -0,0 +1,116 @@ +package com.und.server.weather.service; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.stereotype.Component; + +import com.und.server.weather.constants.FineDustType; +import com.und.server.weather.infrastructure.dto.OpenMeteoResponse; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class FineDustExtractor { + + public Map extractDustForHours( + final OpenMeteoResponse openMeteoResponse, + final List targetHours, + final LocalDate date + ) { + Map result = new HashMap<>(); + + if (!isValidInput(openMeteoResponse, targetHours)) { + return result; + } + + final List times = openMeteoResponse.hourly().time(); + final List pm10Values = openMeteoResponse.hourly().pm10(); + final List pm25Values = openMeteoResponse.hourly().pm25(); + + if (!isValidData(times, pm10Values, pm25Values)) { + return result; + } + + final Set targetSet = Set.copyOf(targetHours); + final String targetDateStr = date.toString(); + + for (int i = 0; i < times.size(); i++) { + processItem(times.get(i), i, targetDateStr, targetSet, pm10Values, pm25Values, result); + } + + return result; + } + + private void processItem( + final String timeStr, + final int index, + final String targetDateStr, + final Set targetSet, + final List pm10Values, + final List pm25Values, + final Map result + ) { + Integer hour = parseHour(timeStr, targetDateStr); + if (hour == null || !targetSet.contains(hour)) { + return; + } + + FineDustType dust = convertToFineDustType(index, pm10Values, pm25Values); + if (dust != null) { + result.put(hour, dust); + } + } + + private Integer parseHour(final String timeStr, final String targetDateStr) { + if (timeStr == null || !timeStr.startsWith(targetDateStr)) { + return null; + } + try { + return Integer.parseInt(timeStr.substring(11, 13)); + } catch (NumberFormatException | StringIndexOutOfBoundsException e) { + return null; + } + } + + private FineDustType convertToFineDustType( + final int index, + final List pm10Values, + final List pm25Values + ) { + if (index >= pm10Values.size() || index >= pm25Values.size()) { + return null; + } + + final Double pm10 = pm10Values.get(index); + final Double pm25 = pm25Values.get(index); + if (pm10 == null || pm25 == null) { + return null; + } + + final FineDustType pm10Level = FineDustType.fromPm10Concentration(pm10); + final FineDustType pm25Level = FineDustType.fromPm25Concentration(pm25); + + return FineDustType.getWorst(List.of(pm10Level, pm25Level)); + } + + private boolean isValidInput(final OpenMeteoResponse response, final List targetHours) { + if (response == null || response.hourly() == null) { + return false; + } + if (targetHours == null || targetHours.isEmpty()) { + return false; + } + return true; + } + + private boolean isValidData( + final List times, final List pm10Values, final List pm25Values) { + return times != null && pm10Values != null && pm25Values != null; + } + +} diff --git a/src/main/java/com/und/server/weather/service/FutureWeatherDecisionSelector.java b/src/main/java/com/und/server/weather/service/FutureWeatherDecisionSelector.java new file mode 100644 index 00000000..f4bc0a31 --- /dev/null +++ b/src/main/java/com/und/server/weather/service/FutureWeatherDecisionSelector.java @@ -0,0 +1,38 @@ +package com.und.server.weather.service; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.und.server.weather.constants.FineDustType; +import com.und.server.weather.constants.UvType; +import com.und.server.weather.constants.WeatherType; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class FutureWeatherDecisionSelector { + + public WeatherType calculateWorstWeather(final List weatherTypes) { + if (weatherTypes == null || weatherTypes.isEmpty()) { + return WeatherType.DEFAULT; + } + return WeatherType.getWorst(weatherTypes); + } + + public FineDustType calculateWorstFineDust(final List fineDustTypes) { + if (fineDustTypes == null || fineDustTypes.isEmpty()) { + return FineDustType.DEFAULT; + } + return FineDustType.getWorst(fineDustTypes); + } + + public UvType calculateWorstUv(final List uvTypes) { + if (uvTypes == null || uvTypes.isEmpty()) { + return UvType.DEFAULT; + } + return UvType.getWorst(uvTypes); + } + +} diff --git a/src/main/java/com/und/server/weather/service/KmaWeatherExtractor.java b/src/main/java/com/und/server/weather/service/KmaWeatherExtractor.java new file mode 100644 index 00000000..f1f757e5 --- /dev/null +++ b/src/main/java/com/und/server/weather/service/KmaWeatherExtractor.java @@ -0,0 +1,130 @@ +package com.und.server.weather.service; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.stereotype.Component; + +import com.und.server.weather.constants.WeatherType; +import com.und.server.weather.infrastructure.dto.KmaWeatherResponse; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class KmaWeatherExtractor { + + private static final String CAT_PTY = "PTY"; + private static final String CAT_SKY = "SKY"; + + public Map extractWeatherForHours( + final KmaWeatherResponse weatherResponse, + final List targetHours, + final LocalDate date + ) { + Map result = new HashMap<>(); + + if (!isValidInput(weatherResponse, targetHours)) { + return result; + } + + final Set targetSet = Set.copyOf(targetHours); + final String targetDateStr = date.format(WeatherType.KMA_DATE_FORMATTER); + final List items = + weatherResponse.response().body().items().item(); + + for (KmaWeatherResponse.WeatherItem item : items) { + processItem(item, targetDateStr, targetSet, result); + } + + return result; + } + + private void processItem( + KmaWeatherResponse.WeatherItem item, + String targetDateStr, + Set targetSet, + Map result + ) { + if (!isSupportedCategory(item.category())) { + return; + } + if (!targetDateStr.equals(item.fcstDate())) { + return; + } + + Integer hour = parseHour(item.fcstTime()); + if (hour == null || !targetSet.contains(hour)) { + return; + } + + WeatherType weather = convertToWeatherType(item.category(), item.fcstValue()); + if (weather == null || weather == WeatherType.DEFAULT) { + return; + } + + if (CAT_PTY.equals(item.category())) { + result.put(hour, weather); + } else if (!result.containsKey(hour)) { + result.put(hour, weather); + } + } + + private boolean isSupportedCategory(String category) { + return CAT_PTY.equals(category) || CAT_SKY.equals(category); + } + + private Integer parseHour(String fcstTime) { + if (fcstTime == null) { + return null; + } + try { + return Integer.parseInt(fcstTime) / 100; + } catch (NumberFormatException e) { + return null; + } + } + + private WeatherType convertToWeatherType(final String category, final String fcstValue) { + if (fcstValue == null) { + return null; + } + try { + int value = Integer.parseInt(fcstValue); + return switch (category) { + + case CAT_PTY -> WeatherType.fromPtyValue(value); + case CAT_SKY -> WeatherType.fromSkyValue(value); + + default -> WeatherType.DEFAULT; + }; + } catch (NumberFormatException e) { + return WeatherType.DEFAULT; + } + } + + private boolean isValidInput(KmaWeatherResponse response, List targetHours) { + if (!isValidResponse(response)) { + return false; + } + if (targetHours == null || targetHours.isEmpty()) { + return false; + } + var items = response.response().body().items().item(); + if (items == null || items.isEmpty()) { + return false; + } + return true; + } + + private boolean isValidResponse(final KmaWeatherResponse weatherResponse) { + return weatherResponse != null + && weatherResponse.response() != null + && weatherResponse.response().body() != null + && weatherResponse.response().body().items() != null; + } + +} diff --git a/src/main/java/com/und/server/weather/service/OpenMeteoWeatherExtractor.java b/src/main/java/com/und/server/weather/service/OpenMeteoWeatherExtractor.java new file mode 100644 index 00000000..9420a5dc --- /dev/null +++ b/src/main/java/com/und/server/weather/service/OpenMeteoWeatherExtractor.java @@ -0,0 +1,87 @@ +package com.und.server.weather.service; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.stereotype.Component; + +import com.und.server.weather.constants.WeatherType; +import com.und.server.weather.infrastructure.dto.OpenMeteoWeatherResponse; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class OpenMeteoWeatherExtractor { + + public Map extractWeatherForHours( + final OpenMeteoWeatherResponse weatherResponse, + final List targetHours, + final LocalDate date + ) { + Map result = new HashMap<>(); + + if (!isValidResponse(weatherResponse) || targetHours == null || targetHours.isEmpty()) { + return result; + } + + List times = weatherResponse.hourly().time(); + List weatherCodes = weatherResponse.hourly().weathercode(); + + if (!isValidData(times, weatherCodes)) { + return result; + } + + final var targetSet = Set.copyOf(targetHours); + final String targetDateStr = date.toString(); + + for (int i = 0; i < times.size(); i++) { + final String timeStr = times.get(i); + if (timeStr == null || !timeStr.startsWith(targetDateStr)) { + continue; + } + + final int hour; + try { + hour = Integer.parseInt(timeStr.substring(11, 13)); + } catch (NumberFormatException e) { + continue; + } + + if (!targetSet.contains(hour)) { + continue; + } + + final WeatherType weather = convertToWeatherType(i, weatherCodes); + if (weather != null) { + result.put(hour, weather); + } + } + return result; + } + + private WeatherType convertToWeatherType(final int index, final List weatherCodes) { + if (index >= weatherCodes.size()) { + return null; + } + + final Integer weatherCode = weatherCodes.get(index); + if (weatherCode == null) { + return null; + } + + return WeatherType.fromOpenMeteoCode(weatherCode); + } + + private boolean isValidResponse(final OpenMeteoWeatherResponse weatherResponse) { + return weatherResponse != null && weatherResponse.hourly() != null; + } + + private boolean isValidData(final List times, final List weatherCodes) { + return times != null && weatherCodes != null; + } + +} diff --git a/src/main/java/com/und/server/weather/service/UvIndexExtractor.java b/src/main/java/com/und/server/weather/service/UvIndexExtractor.java new file mode 100644 index 00000000..3dab4492 --- /dev/null +++ b/src/main/java/com/und/server/weather/service/UvIndexExtractor.java @@ -0,0 +1,105 @@ +package com.und.server.weather.service; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.stereotype.Component; + +import com.und.server.weather.constants.UvType; +import com.und.server.weather.infrastructure.dto.OpenMeteoResponse; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class UvIndexExtractor { + + public Map extractUvForHours( + final OpenMeteoResponse openMeteoResponse, + final List targetHours, + final LocalDate date + ) { + Map result = new HashMap<>(); + + if (!isValidInput(openMeteoResponse, targetHours)) { + return result; + } + + final List times = openMeteoResponse.hourly().time(); + final List uvIndexValues = openMeteoResponse.hourly().uvIndex(); + + if (!isValidData(times, uvIndexValues)) { + return result; + } + + final Set targetSet = Set.copyOf(targetHours); + final String targetDateStr = date.toString(); + + for (int i = 0; i < times.size(); i++) { + processItem(times.get(i), i, targetDateStr, targetSet, uvIndexValues, result); + } + + return result; + } + + private void processItem( + final String timeStr, + final int index, + final String targetDateStr, + final Set targetSet, + final List uvIndexValues, + final Map result + ) { + Integer hour = parseHour(timeStr, targetDateStr); + if (hour == null || !targetSet.contains(hour)) { + return; + } + + UvType uv = convertToUvType(index, uvIndexValues); + if (uv != null) { + result.put(hour, uv); + } + } + + private Integer parseHour(final String timeStr, final String targetDateStr) { + if (timeStr == null || !timeStr.startsWith(targetDateStr)) { + return null; + } + try { + return Integer.parseInt(timeStr.substring(11, 13)); + } catch (NumberFormatException | StringIndexOutOfBoundsException e) { + return null; + } + } + + private UvType convertToUvType(final int index, final List uvIndexValues) { + if (index >= uvIndexValues.size()) { + return null; + } + + final Double uvIndex = uvIndexValues.get(index); + if (uvIndex == null) { + return null; + } + + return UvType.fromUvIndex(uvIndex); + } + + private boolean isValidInput(final OpenMeteoResponse response, final List targetHours) { + if (response == null || response.hourly() == null) { + return false; + } + if (targetHours == null || targetHours.isEmpty()) { + return false; + } + return true; + } + + private boolean isValidData(final List times, final List uvIndexValues) { + return times != null && uvIndexValues != null; + } + +} diff --git a/src/main/java/com/und/server/weather/service/WeatherApiService.java b/src/main/java/com/und/server/weather/service/WeatherApiService.java new file mode 100644 index 00000000..3495994e --- /dev/null +++ b/src/main/java/com/und/server/weather/service/WeatherApiService.java @@ -0,0 +1,173 @@ +package com.und.server.weather.service; + +import java.time.LocalDate; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +import com.und.server.weather.constants.TimeSlot; +import com.und.server.weather.dto.GridPoint; +import com.und.server.weather.dto.OpenMeteoWeatherApiResultDto; +import com.und.server.weather.dto.WeatherApiResultDto; +import com.und.server.weather.dto.request.WeatherRequest; +import com.und.server.weather.exception.KmaApiException; +import com.und.server.weather.exception.WeatherErrorResult; +import com.und.server.weather.exception.WeatherException; +import com.und.server.weather.infrastructure.KmaApiFacade; +import com.und.server.weather.infrastructure.OpenMeteoApiFacade; +import com.und.server.weather.infrastructure.dto.KmaWeatherResponse; +import com.und.server.weather.infrastructure.dto.OpenMeteoResponse; +import com.und.server.weather.infrastructure.dto.OpenMeteoWeatherResponse; +import com.und.server.weather.util.GridConverter; + +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +public class WeatherApiService { + + private static final long API_TIMEOUT_SEC = 5; + private final KmaApiFacade kmaApiFacade; + private final OpenMeteoApiFacade openMeteoApiFacade; + private final Executor weatherExecutor; + + public WeatherApiService( + KmaApiFacade kmaApiFacade, + OpenMeteoApiFacade openMeteoApiFacade, + @Qualifier("weatherExecutor") Executor weatherExecutor + ) { + this.kmaApiFacade = kmaApiFacade; + this.openMeteoApiFacade = openMeteoApiFacade; + this.weatherExecutor = weatherExecutor; + } + + + public WeatherApiResultDto callTodayWeather( + final WeatherRequest weatherRequest, + final TimeSlot timeSlot, + final LocalDate today + ) { + final Double latitude = weatherRequest.latitude(); + final Double longitude = weatherRequest.longitude(); + final GridPoint gridPoint = GridConverter.convertToApiGrid(latitude, longitude); + + CompletableFuture weatherFuture = + CompletableFuture.supplyAsync(() -> kmaApiFacade.callWeatherApi(gridPoint, timeSlot, today), + weatherExecutor) + .orTimeout(API_TIMEOUT_SEC, TimeUnit.SECONDS); + + CompletableFuture openMeteoFuture = + CompletableFuture.supplyAsync(() -> openMeteoApiFacade.callDustUvApi(latitude, longitude, today), + weatherExecutor) + .orTimeout(API_TIMEOUT_SEC, TimeUnit.SECONDS); + + try { + KmaWeatherResponse weatherData = weatherFuture.join(); + OpenMeteoResponse dustUvData = openMeteoFuture.join(); + return WeatherApiResultDto.from(weatherData, dustUvData); + + } catch (CompletionException e) { + Throwable cause = e.getCause(); + if (cause instanceof WeatherException we) { + throw we; + } + if (cause instanceof TimeoutException) { + log.error("KMA API timeout during today slot data processing", cause); + throw new KmaApiException(WeatherErrorResult.KMA_TIMEOUT, cause); + } + log.error("Unexpected error during today slot data processing", cause); + throw new WeatherException(WeatherErrorResult.WEATHER_SERVICE_ERROR, cause); + + } catch (Exception e) { + log.error("General error during today slot data processing", e); + throw new WeatherException(WeatherErrorResult.WEATHER_SERVICE_ERROR, e); + } + } + + + public WeatherApiResultDto callFutureWeather( + final WeatherRequest weatherRequest, + final TimeSlot timeSlot, + final LocalDate today, + final LocalDate targetDate + ) { + final Double latitude = weatherRequest.latitude(); + final Double longitude = weatherRequest.longitude(); + final GridPoint gridPoint = GridConverter.convertToApiGrid(latitude, longitude); + + CompletableFuture weatherFuture = + CompletableFuture.supplyAsync(() -> kmaApiFacade.callWeatherApi(gridPoint, timeSlot, today), + weatherExecutor) + .orTimeout(API_TIMEOUT_SEC, TimeUnit.SECONDS); + + CompletableFuture openMeteoFuture = + CompletableFuture.supplyAsync(() -> openMeteoApiFacade.callDustUvApi(latitude, longitude, targetDate), + weatherExecutor) + .orTimeout(API_TIMEOUT_SEC, TimeUnit.SECONDS); + + try { + KmaWeatherResponse weatherData = weatherFuture.join(); + OpenMeteoResponse dustUvData = openMeteoFuture.join(); + return WeatherApiResultDto.from(weatherData, dustUvData); + + } catch (CompletionException e) { + Throwable cause = e.getCause(); + if (cause instanceof WeatherException we) { + throw we; + } + if (cause instanceof TimeoutException) { + log.error("KMA API timeout during future day data processing", cause); + throw new KmaApiException(WeatherErrorResult.KMA_TIMEOUT, cause); + } + log.error("Unexpected error during future day data processing", cause); + throw new WeatherException(WeatherErrorResult.WEATHER_SERVICE_ERROR, cause); + + } catch (Exception e) { + log.error("General error during future day data processing", e); + throw new WeatherException(WeatherErrorResult.WEATHER_SERVICE_ERROR, e); + } + } + + + public OpenMeteoWeatherApiResultDto callOpenMeteoFallBackWeather( + final WeatherRequest weatherRequest, + final LocalDate targetDate + ) { + final Double latitude = weatherRequest.latitude(); + final Double longitude = weatherRequest.longitude(); + + CompletableFuture weatherFuture = + CompletableFuture.supplyAsync(() -> openMeteoApiFacade.callWeatherApi(latitude, longitude, targetDate), + weatherExecutor) + .orTimeout(API_TIMEOUT_SEC, TimeUnit.SECONDS); + + CompletableFuture openMeteoFuture = + CompletableFuture.supplyAsync(() -> openMeteoApiFacade.callDustUvApi(latitude, longitude, targetDate), + weatherExecutor) + .orTimeout(API_TIMEOUT_SEC, TimeUnit.SECONDS); + + try { + OpenMeteoWeatherResponse weatherData = weatherFuture.join(); + OpenMeteoResponse dustUvData = openMeteoFuture.join(); + return OpenMeteoWeatherApiResultDto.from(weatherData, dustUvData); + + } catch (CompletionException e) { + Throwable cause = e.getCause(); + if (cause instanceof WeatherException we) { + throw we; + } + log.error("Unexpected error during Open-Meteo KMA future day data processing", cause); + throw new WeatherException(WeatherErrorResult.WEATHER_SERVICE_ERROR, cause); + + } catch (Exception e) { + log.error("General error during Open-Meteo KMA future day data processing", e); + throw new WeatherException(WeatherErrorResult.WEATHER_SERVICE_ERROR, e); + } + } + +} diff --git a/src/main/java/com/und/server/weather/service/WeatherCacheService.java b/src/main/java/com/und/server/weather/service/WeatherCacheService.java new file mode 100644 index 00000000..729ce1c4 --- /dev/null +++ b/src/main/java/com/und/server/weather/service/WeatherCacheService.java @@ -0,0 +1,200 @@ +package com.und.server.weather.service; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import com.und.server.weather.constants.TimeSlot; +import com.und.server.weather.dto.OpenMeteoWeatherApiResultDto; +import com.und.server.weather.dto.WeatherApiResultDto; +import com.und.server.weather.dto.cache.WeatherCacheData; +import com.und.server.weather.dto.request.WeatherRequest; +import com.und.server.weather.exception.KmaApiException; +import com.und.server.weather.util.CacheSerializer; +import com.und.server.weather.util.WeatherKeyGenerator; +import com.und.server.weather.util.WeatherTtlCalculator; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +public class WeatherCacheService { + + private final RedisTemplate redisTemplate; + private final WeatherApiService weatherApiService; + private final WeatherDecisionService weatherDecisionService; + private final WeatherKeyGenerator keyGenerator; + private final WeatherTtlCalculator ttlCalculator; + private final CacheSerializer cacheSerializer; + + + public WeatherCacheData getTodayWeatherCache( + final WeatherRequest weatherRequest, final LocalDateTime nowDateTime + ) { + Double latitude = weatherRequest.latitude(); + Double longitude = weatherRequest.longitude(); + LocalDate nowDate = nowDateTime.toLocalDate(); + + TimeSlot currentSlot = TimeSlot.getCurrentSlot(nowDateTime); + String cacheKey = keyGenerator.generateTodayKey(latitude, longitude, nowDate, currentSlot); + String hourKey = keyGenerator.generateTodayHourFieldKey(nowDateTime); + + WeatherCacheData cached = getTodayFromCache(cacheKey, hourKey); + if (cached != null && cached.isValid()) { + return cached; + } + + WeatherApiResultDto weatherApiResult; + try { + weatherApiResult = weatherApiService.callTodayWeather(weatherRequest, currentSlot, nowDate); + } catch (KmaApiException e) { + log.error("KMA API failed, falling back to Open-Meteo KMA", e); + return handleTodayFallback(weatherRequest, currentSlot, nowDateTime, cacheKey, hourKey); + } + + Map newData = + weatherDecisionService.getTodayWeatherCacheData(weatherApiResult, currentSlot, nowDate); + + Duration ttl = ttlCalculator.calculateTtl(currentSlot, nowDateTime); + saveTodayCache(cacheKey, newData, ttl); + + return newData.get(hourKey); + + } + + + public WeatherCacheData getFutureWeatherCache( + final WeatherRequest weatherRequest, + final LocalDateTime nowDateTime, + final LocalDate targetDate + ) { + Double latitude = weatherRequest.latitude(); + Double longitude = weatherRequest.longitude(); + + TimeSlot currentSlot = TimeSlot.getCurrentSlot(nowDateTime); + String cacheKey = keyGenerator.generateFutureKey(latitude, longitude, targetDate, currentSlot); + + WeatherCacheData cached = getFutureFromCache(cacheKey); + if (cached != null && cached.isValid()) { + return cached; + } + + WeatherApiResultDto weatherApiResult; + try { + weatherApiResult = weatherApiService.callFutureWeather( + weatherRequest, currentSlot, nowDateTime.toLocalDate(), targetDate); + } catch (KmaApiException e) { + log.error("KMA API failed, falling back to Open-Meteo KMA", e); + return handleFutureFallback(weatherRequest, currentSlot, nowDateTime, targetDate, cacheKey); + } + + WeatherCacheData futureWeatherCacheData = + weatherDecisionService.getFutureWeatherCacheData(weatherApiResult, targetDate); + + Duration ttl = ttlCalculator.calculateTtl(currentSlot, nowDateTime); + saveFutureCache(cacheKey, futureWeatherCacheData, ttl); + + return futureWeatherCacheData; + + } + + + private WeatherCacheData handleTodayFallback( + final WeatherRequest weatherRequest, + final TimeSlot currentSlot, + final LocalDateTime nowDateTime, + final String cacheKey, + final String hourKey + ) { + LocalDate nowDate = nowDateTime.toLocalDate(); + OpenMeteoWeatherApiResultDto fallbackResult; + try { + fallbackResult = weatherApiService.callOpenMeteoFallBackWeather(weatherRequest, nowDate); + } catch (Exception e) { + log.error("Today Fallback also failed", e); + throw e; + } + + Map newData = + weatherDecisionService.getTodayWeatherCacheDataFallback(fallbackResult, currentSlot, nowDate); + + Duration ttl = ttlCalculator.calculateTtl(currentSlot, nowDateTime); + saveTodayCache(cacheKey, newData, ttl); + + return newData.get(hourKey); + } + + private WeatherCacheData handleFutureFallback( + final WeatherRequest weatherRequest, + final TimeSlot currentSlot, + final LocalDateTime nowDateTime, + final LocalDate targetDate, + final String cacheKey + ) { + OpenMeteoWeatherApiResultDto fallbackResult; + try { + fallbackResult = weatherApiService.callOpenMeteoFallBackWeather(weatherRequest, targetDate); + } catch (Exception e) { + log.error("Future Fallback also failed", e); + throw e; + } + + WeatherCacheData futureWeatherCacheData = + weatherDecisionService.getFutureWeatherCacheDataFallback(fallbackResult, targetDate); + + Duration ttl = ttlCalculator.calculateTtl(currentSlot, nowDateTime); + saveFutureCache(cacheKey, futureWeatherCacheData, ttl); + + return futureWeatherCacheData; + } + + private WeatherCacheData getTodayFromCache(final String cacheKey, final String hourKey) { + Object cachedData = redisTemplate.opsForHash().get(cacheKey, hourKey); + if (cachedData == null) { + return null; + } + return cacheSerializer.deserializeWeatherCacheDataFromHash((String) cachedData); + } + + private WeatherCacheData getFutureFromCache(final String cacheKey) { + String cachedJson = redisTemplate.opsForValue().get(cacheKey); + if (cachedJson == null) { + return null; + } + return cacheSerializer.deserializeWeatherCacheData(cachedJson); + } + + private void saveTodayCache( + final String cacheKey, + final Map data, + final Duration ttl + ) { + Map hashData = cacheSerializer.serializeWeatherCacheDataToHash(data); + if (hashData.isEmpty()) { + return; + } + + redisTemplate.opsForHash().putAll(cacheKey, hashData); + redisTemplate.expire(cacheKey, ttl); + } + + private void saveFutureCache( + final String cacheKey, + final WeatherCacheData data, + final Duration ttl + ) { + String json = cacheSerializer.serializeWeatherCacheData(data); + if (json == null) { + return; + } + + redisTemplate.opsForValue().set(cacheKey, json, ttl); + } + +} diff --git a/src/main/java/com/und/server/weather/service/WeatherDecisionService.java b/src/main/java/com/und/server/weather/service/WeatherDecisionService.java new file mode 100644 index 00000000..8b00b1b0 --- /dev/null +++ b/src/main/java/com/und/server/weather/service/WeatherDecisionService.java @@ -0,0 +1,152 @@ +package com.und.server.weather.service; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; + +import com.und.server.weather.constants.FineDustType; +import com.und.server.weather.constants.TimeSlot; +import com.und.server.weather.constants.UvType; +import com.und.server.weather.constants.WeatherType; +import com.und.server.weather.dto.OpenMeteoWeatherApiResultDto; +import com.und.server.weather.dto.WeatherApiResultDto; +import com.und.server.weather.dto.cache.WeatherCacheData; +import com.und.server.weather.infrastructure.dto.KmaWeatherResponse; +import com.und.server.weather.infrastructure.dto.OpenMeteoResponse; +import com.und.server.weather.infrastructure.dto.OpenMeteoWeatherResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +public class WeatherDecisionService { + + private final KmaWeatherExtractor kmaWeatherExtractor; + private final OpenMeteoWeatherExtractor openMeteoWeatherExtractor; + private final FineDustExtractor fineDustExtractor; + private final UvIndexExtractor uvIndexExtractor; + private final FutureWeatherDecisionSelector futureWeatherDecisionSelector; + + + public Map getTodayWeatherCacheData( + final WeatherApiResultDto weatherApiResult, + final TimeSlot currentSlot, + final LocalDate today + ) { + KmaWeatherResponse kmaWeatherResponse = weatherApiResult.kmaWeatherResponse(); + OpenMeteoResponse openMeteoResponse = weatherApiResult.openMeteoResponse(); + + List slotHours = currentSlot.getForecastHours(); + + Map weathersByHour = + kmaWeatherExtractor.extractWeatherForHours(kmaWeatherResponse, slotHours, today); + Map dustByHour = + fineDustExtractor.extractDustForHours(openMeteoResponse, slotHours, today); + Map uvByHour = + uvIndexExtractor.extractUvForHours(openMeteoResponse, slotHours, today); + + return processHourlyData(weathersByHour, dustByHour, uvByHour, slotHours); + } + + + public WeatherCacheData getFutureWeatherCacheData( + final WeatherApiResultDto weatherApiResult, final LocalDate targetDate + ) { + KmaWeatherResponse kmaWeatherResponse = weatherApiResult.kmaWeatherResponse(); + OpenMeteoResponse openMeteoResponse = weatherApiResult.openMeteoResponse(); + + List allHours = TimeSlot.getAllDayHours(); + + Map weatherMap = + kmaWeatherExtractor.extractWeatherForHours(kmaWeatherResponse, allHours, targetDate); + Map dustMap = + fineDustExtractor.extractDustForHours(openMeteoResponse, allHours, targetDate); + Map uvMap = + uvIndexExtractor.extractUvForHours(openMeteoResponse, allHours, targetDate); + + WeatherType worstWeather = + futureWeatherDecisionSelector.calculateWorstWeather(weatherMap.values().stream().toList()); + FineDustType worstFineDust = + futureWeatherDecisionSelector.calculateWorstFineDust(dustMap.values().stream().toList()); + UvType worstUv = + futureWeatherDecisionSelector.calculateWorstUv(uvMap.values().stream().toList()); + + return WeatherCacheData.from(worstWeather, worstFineDust, worstUv); + } + + + public Map getTodayWeatherCacheDataFallback( + final OpenMeteoWeatherApiResultDto weatherApiResult, + final TimeSlot currentSlot, + final LocalDate today + ) { + OpenMeteoWeatherResponse openMeteoWeatherResponse = weatherApiResult.openMeteoWeatherResponse(); + OpenMeteoResponse openMeteoResponse = weatherApiResult.openMeteoResponse(); + + List slotHours = currentSlot.getForecastHours(); + + Map weathersByHour = + openMeteoWeatherExtractor.extractWeatherForHours(openMeteoWeatherResponse, slotHours, today); + Map dustByHour = + fineDustExtractor.extractDustForHours(openMeteoResponse, slotHours, today); + Map uvByHour = + uvIndexExtractor.extractUvForHours(openMeteoResponse, slotHours, today); + + return processHourlyData(weathersByHour, dustByHour, uvByHour, slotHours); + } + + + public WeatherCacheData getFutureWeatherCacheDataFallback( + final OpenMeteoWeatherApiResultDto weatherApiResult, + final LocalDate targetDate + ) { + OpenMeteoWeatherResponse openMeteoWeatherResponse = weatherApiResult.openMeteoWeatherResponse(); + OpenMeteoResponse openMeteoResponse = weatherApiResult.openMeteoResponse(); + + List allHours = TimeSlot.getAllDayHours(); + + Map weatherMap = + openMeteoWeatherExtractor.extractWeatherForHours(openMeteoWeatherResponse, allHours, targetDate); + Map dustMap = + fineDustExtractor.extractDustForHours(openMeteoResponse, allHours, targetDate); + Map uvMap = + uvIndexExtractor.extractUvForHours(openMeteoResponse, allHours, targetDate); + + WeatherType worstWeather = + futureWeatherDecisionSelector.calculateWorstWeather(weatherMap.values().stream().toList()); + FineDustType worstFineDust = + futureWeatherDecisionSelector.calculateWorstFineDust(dustMap.values().stream().toList()); + UvType worstUv = + futureWeatherDecisionSelector.calculateWorstUv(uvMap.values().stream().toList()); + + return WeatherCacheData.from(worstWeather, worstFineDust, worstUv); + } + + + private Map processHourlyData( + final Map weathersByHour, + final Map dustByHour, + final Map uvByHour, + final List targetHours + ) { + Map hourlyData = new HashMap<>(); + + for (int hour : targetHours) { + WeatherType weather = weathersByHour.getOrDefault(hour, WeatherType.DEFAULT); + FineDustType dust = dustByHour.getOrDefault(hour, FineDustType.DEFAULT); + UvType uv = uvByHour.getOrDefault(hour, UvType.DEFAULT); + + WeatherCacheData weatherCacheData = WeatherCacheData.from(weather, dust, uv); + + hourlyData.put(String.format("%02d", hour), weatherCacheData); + } + + return hourlyData; + } + +} diff --git a/src/main/java/com/und/server/weather/service/WeatherService.java b/src/main/java/com/und/server/weather/service/WeatherService.java new file mode 100644 index 00000000..8ab7d68c --- /dev/null +++ b/src/main/java/com/und/server/weather/service/WeatherService.java @@ -0,0 +1,103 @@ +package com.und.server.weather.service; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; + +import org.springframework.stereotype.Service; + +import com.und.server.weather.dto.cache.WeatherCacheData; +import com.und.server.weather.dto.request.WeatherRequest; +import com.und.server.weather.dto.response.WeatherResponse; +import com.und.server.weather.exception.WeatherErrorResult; +import com.und.server.weather.exception.WeatherException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +public class WeatherService { + + private static final int MAX_FUTURE_DATE = 3; + private final WeatherCacheService weatherCacheService; + private final Clock clock; + + + public WeatherResponse getWeatherInfo( + final WeatherRequest weatherRequest, final LocalDate date, final ZoneId timezone + ) { + LocalDateTime nowDateTime = LocalDateTime.now(clock.withZone(timezone)); + LocalDate today = nowDateTime.toLocalDate(); + + validateLocation(weatherRequest); + validateDate(date, today); + + boolean isToday = date.equals(today); + try { + if (isToday) { + return getTodayWeather(weatherRequest, nowDateTime); + } else { + return getFutureWeather(weatherRequest, nowDateTime, date); + } + } catch (WeatherException e) { + throw new WeatherException(WeatherErrorResult.WEATHER_SERVICE_ERROR); + } + } + + + private WeatherResponse getTodayWeather( + final WeatherRequest weatherRequest, final LocalDateTime nowDateTime + ) { + WeatherCacheData todayWeatherCache = + weatherCacheService.getTodayWeatherCache(weatherRequest, nowDateTime); + + if (todayWeatherCache == null) { + return WeatherResponse.from(WeatherCacheData.getDefault()); + } + if (!todayWeatherCache.isValid()) { + return WeatherResponse.from(todayWeatherCache.getValidDefault()); + } + + return WeatherResponse.from(todayWeatherCache); + } + + private WeatherResponse getFutureWeather( + final WeatherRequest weatherRequest, + final LocalDateTime nowDateTime, + final LocalDate targetDate + ) { + WeatherCacheData futureWeatherCacheData = + weatherCacheService.getFutureWeatherCache(weatherRequest, nowDateTime, targetDate); + + if (futureWeatherCacheData == null) { + return WeatherResponse.from(WeatherCacheData.getDefault()); + } + if (!futureWeatherCacheData.isValid()) { + return WeatherResponse.from(futureWeatherCacheData.getValidDefault()); + } + + return WeatherResponse.from(futureWeatherCacheData); + } + + private void validateLocation(final WeatherRequest request) { + if (request.latitude() < -90 + || request.latitude() > 90 + || request.longitude() < -180 + || request.longitude() > 180 + ) { + throw new WeatherException(WeatherErrorResult.INVALID_COORDINATES); + } + } + + private void validateDate(final LocalDate requestDate, final LocalDate today) { + LocalDate maxDate = today.plusDays(MAX_FUTURE_DATE); + + if (requestDate.isBefore(today) || requestDate.isAfter(maxDate)) { + throw new WeatherException(WeatherErrorResult.DATE_OUT_OF_RANGE); + } + } + +} diff --git a/src/main/java/com/und/server/weather/util/CacheSerializer.java b/src/main/java/com/und/server/weather/util/CacheSerializer.java new file mode 100644 index 00000000..f69208b7 --- /dev/null +++ b/src/main/java/com/und/server/weather/util/CacheSerializer.java @@ -0,0 +1,66 @@ +package com.und.server.weather.util; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.und.server.weather.dto.cache.WeatherCacheData; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class CacheSerializer { + + private final ObjectMapper objectMapper; + + public CacheSerializer() { + this.objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + } + + public String serializeWeatherCacheData(final WeatherCacheData data) { + try { + return objectMapper.writeValueAsString(data); + } catch (JsonProcessingException e) { + log.error("WeatherCacheData serialization failed", e); + return null; + } + } + + public WeatherCacheData deserializeWeatherCacheData(final String json) { + try { + return objectMapper.readValue(json, WeatherCacheData.class); + } catch (JsonProcessingException e) { + log.error("WeatherCacheData deserialization failed: {}", json, e); + return null; + } + } + + public Map serializeWeatherCacheDataToHash(final Map hourlyData) { + Map hashData = new HashMap<>(); + + for (Map.Entry entry : hourlyData.entrySet()) { + try { + String json = objectMapper.writeValueAsString(entry.getValue()); + hashData.put(entry.getKey(), json); + } catch (JsonProcessingException e) { + log.error("WeatherCacheData Hash serialization failed: {}", entry.getKey(), e); + } + } + return hashData; + } + + public WeatherCacheData deserializeWeatherCacheDataFromHash(final String json) { + try { + return objectMapper.readValue(json, WeatherCacheData.class); + } catch (JsonProcessingException e) { + log.error("WeatherCacheData Hash deserialization failed: {}", json, e); + return null; + } + } + +} diff --git a/src/main/java/com/und/server/weather/util/GridConverter.java b/src/main/java/com/und/server/weather/util/GridConverter.java new file mode 100644 index 00000000..15f005bc --- /dev/null +++ b/src/main/java/com/und/server/weather/util/GridConverter.java @@ -0,0 +1,69 @@ +package com.und.server.weather.util; + +import org.springframework.stereotype.Component; + +import com.und.server.weather.dto.GridPoint; + +@Component +public class GridConverter { + + private static final double RE = 6371.00877; + private static final double KMA_API_GRID = 5.0; + private static final double SLAT1 = 30.0; + private static final double SLAT2 = 60.0; + private static final double OLON = 126.0; + private static final double OLAT = 38.0; + private static final double XO = 43; + private static final double YO = 136; + + private static final double DEGRAD = Math.PI / 180.0; + + public static GridPoint convertToCacheGrid( + final double latitude, final double longitude, final double grid + ) { + return convertToGrid(latitude, longitude, grid); + } + + public static GridPoint convertToApiGrid( + final double latitude, final double longitude + ) { + return convertToGrid(latitude, longitude, KMA_API_GRID); + } + + private static GridPoint convertToGrid( + final double latitude, final double longitude, final double grid + ) { + double re = RE / grid; + double slat1 = SLAT1 * DEGRAD; + double slat2 = SLAT2 * DEGRAD; + double olon = OLON * DEGRAD; + double olat = OLAT * DEGRAD; + + double sn = Math.tan(Math.PI * 0.25 + slat2 * 0.5) / Math.tan(Math.PI * 0.25 + slat1 * 0.5); + sn = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn); + + double sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5); + sf = Math.pow(sf, sn) * Math.cos(slat1) / sn; + + double ro = Math.tan(Math.PI * 0.25 + olat * 0.5); + ro = re * sf / Math.pow(ro, sn); + + double ra = Math.tan(Math.PI * 0.25 + latitude * DEGRAD * 0.5); + ra = re * sf / Math.pow(ra, sn); + + double theta = longitude * DEGRAD - olon; + if (theta > Math.PI) { + theta -= 2.0 * Math.PI; + } + if (theta < -Math.PI) { + theta += 2.0 * Math.PI; + } + theta *= sn; + + int gridX = (int) Math.floor(ra * Math.sin(theta) + XO + 0.5); + int gridY = (int) Math.floor(ro - ra * Math.cos(theta) + YO + 0.5); + + return GridPoint.from(gridX, gridY); + } + +} diff --git a/src/main/java/com/und/server/weather/util/WeatherKeyGenerator.java b/src/main/java/com/und/server/weather/util/WeatherKeyGenerator.java new file mode 100644 index 00000000..0d7a5f8b --- /dev/null +++ b/src/main/java/com/und/server/weather/util/WeatherKeyGenerator.java @@ -0,0 +1,47 @@ +package com.und.server.weather.util; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.springframework.stereotype.Component; + +import com.und.server.weather.constants.TimeSlot; +import com.und.server.weather.dto.GridPoint; +import com.und.server.weather.dto.cache.WeatherCacheKey; + +@Component +public class WeatherKeyGenerator { + + private static final double CACHE_GRID = 10.0; + + public String generateTodayKey( + final Double latitude, final Double longitude, + final LocalDate today, + final TimeSlot slot + ) { + GridPoint gridPoint = convertToGrid(latitude, longitude); + WeatherCacheKey cacheKey = WeatherCacheKey.forToday(gridPoint, today, slot); + + return cacheKey.toRedisKey(); + } + + public String generateFutureKey( + final Double latitude, final Double longitude, + final LocalDate requestDate, + final TimeSlot slot + ) { + GridPoint gridPoint = convertToGrid(latitude, longitude); + WeatherCacheKey cacheKey = WeatherCacheKey.forFuture(gridPoint, requestDate, slot); + + return cacheKey.toRedisKey(); + } + + public String generateTodayHourFieldKey(final LocalDateTime dateTime) { + return String.format("%02d", dateTime.getHour()); + } + + private GridPoint convertToGrid(final Double latitude, final Double longitude) { + return GridConverter.convertToCacheGrid(latitude, longitude, CACHE_GRID); + } + +} diff --git a/src/main/java/com/und/server/weather/util/WeatherTtlCalculator.java b/src/main/java/com/und/server/weather/util/WeatherTtlCalculator.java new file mode 100644 index 00000000..d192a1f6 --- /dev/null +++ b/src/main/java/com/und/server/weather/util/WeatherTtlCalculator.java @@ -0,0 +1,40 @@ +package com.und.server.weather.util; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.LocalTime; + +import org.springframework.stereotype.Component; + +import com.und.server.weather.constants.TimeSlot; + +@Component +public class WeatherTtlCalculator { + + public Duration calculateTtl(final TimeSlot timeSlot, final LocalDateTime nowDateTime) { + LocalTime currentTime = nowDateTime.toLocalTime(); + + int endHour = timeSlot.getEndHour(); + LocalTime deleteTime; + + if (endHour == 24) { + deleteTime = LocalTime.of(0, 0); + } else { + deleteTime = LocalTime.of(endHour, 0); + } + + if (timeSlot == TimeSlot.SLOT_21_24 && currentTime.getHour() >= 21) { + LocalDateTime nextDayMidnight = nowDateTime.toLocalDate() + .plusDays(1) + .atTime(0, 0); + return Duration.between(nowDateTime, nextDayMidnight); + } + + if (currentTime.isBefore(deleteTime)) { + return Duration.between(currentTime, deleteTime); + } else { + return Duration.ZERO; + } + } + +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 6d998b96..0f546e2b 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -10,7 +10,7 @@ spring: # RDB datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://${SPRING_DATASOURCE_ENDPOINT}:${SPRING_DATASOURCE_PORT}/${SPRING_DATASOURCE_DATABASE_NAME}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + url: jdbc:mysql://${SPRING_DATASOURCE_ENDPOINT}:${SPRING_DATASOURCE_PORT}/${SPRING_DATASOURCE_DATABASE_NAME}?serverTimezone=UTC&useUnicode=true&characterEncoding=UTF-8 username: ${SPRING_DATASOURCE_USERNAME} hikari: max-lifetime: 1190000 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index d9b928c9..ea7c8c52 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -10,7 +10,7 @@ spring: # RDB datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://${SPRING_DATASOURCE_ENDPOINT}:${SPRING_DATASOURCE_PORT}/${SPRING_DATASOURCE_DATABASE_NAME}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + url: jdbc:mysql://${SPRING_DATASOURCE_ENDPOINT}:${SPRING_DATASOURCE_PORT}/${SPRING_DATASOURCE_DATABASE_NAME}?serverTimezone=UTC&useUnicode=true&characterEncoding=UTF-8 username: ${SPRING_DATASOURCE_USERNAME} hikari: max-lifetime: 1190000 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 14963c90..d197ec44 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,8 +12,12 @@ server: oauth: kakao: base-url: https://kauth.kakao.com - app-key: ${OAUTH_KAKAO_APP_KEY} public-key-url: /.well-known/jwks.json + app-key: ${OAUTH_KAKAO_APP_KEY} + apple: + base-url: https://appleid.apple.com + public-key-url: /auth/keys + app-id: ${OAUTH_APPLE_APP_ID} # JWT jwt: @@ -29,3 +33,13 @@ observability: prometheus: username: ${PROMETHEUS_USERNAME} password: ${PROMETHEUS_PASSWORD} + +# Weather APIs +weather: + kma: + base-url: https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0 + service-key: ${KMA_SERVICE_KEY} + open-meteo: + base-url: https://air-quality-api.open-meteo.com/v1 + open-meteo-kma: + base-url: https://api.open-meteo.com/v1 diff --git a/src/main/resources/db/migration/V1__create_member_table.sql b/src/main/resources/db/migration/V1__create_member_table.sql index 40116bb9..c5e16026 100644 --- a/src/main/resources/db/migration/V1__create_member_table.sql +++ b/src/main/resources/db/migration/V1__create_member_table.sql @@ -1,7 +1,10 @@ CREATE TABLE member ( id BIGINT AUTO_INCREMENT PRIMARY KEY, - nickname VARCHAR(255), + nickname VARCHAR(255) NOT NULL DEFAULT '워리', kakao_id VARCHAR(255), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + apple_id VARCHAR(255), + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + CONSTRAINT uk_kakao_id UNIQUE (kakao_id), + CONSTRAINT uk_apple_id UNIQUE (apple_id) ); diff --git a/src/main/resources/db/migration/V2__create_notification_table.sql b/src/main/resources/db/migration/V2__create_notification_table.sql new file mode 100644 index 00000000..16a07203 --- /dev/null +++ b/src/main/resources/db/migration/V2__create_notification_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE notification ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + is_active BOOLEAN NOT NULL, + notification_type VARCHAR(20) NOT NULL, + notification_method_type VARCHAR(20), + days_of_week VARCHAR(50), + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + CONSTRAINT chk_notification_type CHECK (notification_type IN ('TIME', 'LOCATION')), + CONSTRAINT chk_notification_method_type CHECK (notification_method_type IN ('PUSH', 'ALARM')) +); diff --git a/src/main/resources/db/migration/V3__create_time_notification_table.sql b/src/main/resources/db/migration/V3__create_time_notification_table.sql new file mode 100644 index 00000000..70aeba0f --- /dev/null +++ b/src/main/resources/db/migration/V3__create_time_notification_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE time_notification ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + notification_id BIGINT NOT NULL, + start_hour INT NOT NULL, + start_minute INT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + CONSTRAINT fk_time_notification_notification FOREIGN KEY (notification_id) + REFERENCES notification(id) ON DELETE CASCADE, + CONSTRAINT chk_time_start_hour CHECK (start_hour >= 0 AND start_hour <= 23), + CONSTRAINT chk_time_start_minute CHECK (start_minute >= 0 AND start_minute <= 59) +); diff --git a/src/main/resources/db/migration/V4__create_location_notification_table.sql b/src/main/resources/db/migration/V4__create_location_notification_table.sql new file mode 100644 index 00000000..c02a0fec --- /dev/null +++ b/src/main/resources/db/migration/V4__create_location_notification_table.sql @@ -0,0 +1,23 @@ +CREATE TABLE location_notification ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + notification_id BIGINT NOT NULL, + latitude DECIMAL(9,6) NOT NULL, + longitude DECIMAL(9,6) NOT NULL, + tracking_radius_type VARCHAR(20) NOT NULL, + start_hour INT NOT NULL, + start_minute INT NOT NULL, + end_hour INT NOT NULL, + end_minute INT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + CONSTRAINT fk_location_notification_notification FOREIGN KEY (notification_id) + REFERENCES notification(id) ON DELETE CASCADE, + CONSTRAINT chk_tracking_radius_type + CHECK (tracking_radius_type IN ('M_100', 'M_500', 'KM_1', 'KM_2', 'KM_3', 'KM_4')), + CONSTRAINT chk_start_hour CHECK (start_hour >= 0 AND start_hour <= 23), + CONSTRAINT chk_start_minute CHECK (start_minute >= 0 AND start_minute <= 59), + CONSTRAINT chk_end_hour CHECK (end_hour >= 0 AND end_hour <= 23), + CONSTRAINT chk_end_minute CHECK (end_minute >= 0 AND end_minute <= 59), + CONSTRAINT chk_latitude CHECK (latitude >= -90.0 AND latitude <= 90.0), + CONSTRAINT chk_longitude CHECK (longitude >= -180.0 AND longitude <= 180.0) +); diff --git a/src/main/resources/db/migration/V5__create_scenario_table.sql b/src/main/resources/db/migration/V5__create_scenario_table.sql new file mode 100644 index 00000000..16d9e3dd --- /dev/null +++ b/src/main/resources/db/migration/V5__create_scenario_table.sql @@ -0,0 +1,14 @@ +CREATE TABLE scenario ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + scenario_name VARCHAR(10) NOT NULL, + memo VARCHAR(15), + scenario_order INT NOT NULL, + notification_id BIGINT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + CONSTRAINT fk_scenario_member FOREIGN KEY (member_id) REFERENCES member(id) ON DELETE CASCADE, + CONSTRAINT fk_scenario_notification FOREIGN KEY (notification_id) REFERENCES notification(id) ON DELETE CASCADE, + CONSTRAINT uk_scenario_notification UNIQUE (notification_id), + CONSTRAINT chk_scenario_order CHECK (scenario_order >= 0 AND scenario_order <= 10000000) +); diff --git a/src/main/resources/db/migration/V6__create_mission_table.sql b/src/main/resources/db/migration/V6__create_mission_table.sql new file mode 100644 index 00000000..bb965727 --- /dev/null +++ b/src/main/resources/db/migration/V6__create_mission_table.sql @@ -0,0 +1,14 @@ +CREATE TABLE mission ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + scenario_id BIGINT NOT NULL, + content VARCHAR(10) NOT NULL, + is_checked BOOLEAN NOT NULL, + mission_order INT, + use_date DATE, + mission_type VARCHAR(20) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + CONSTRAINT fk_mission_scenario FOREIGN KEY (scenario_id) REFERENCES scenario(id) ON DELETE CASCADE, + CONSTRAINT chk_mission_type CHECK (mission_type IN ('BASIC', 'TODAY')), + CONSTRAINT chk_mission_order CHECK (mission_order >= 0 AND mission_order <= 10000000) +); diff --git a/src/main/resources/db/migration/V7__create_terms_table.sql b/src/main/resources/db/migration/V7__create_terms_table.sql new file mode 100644 index 00000000..2768c72c --- /dev/null +++ b/src/main/resources/db/migration/V7__create_terms_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE terms ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + terms_of_service_agreed BOOLEAN NOT NULL DEFAULT FALSE, + privacy_policy_agreed BOOLEAN NOT NULL DEFAULT FALSE, + is_over_14 BOOLEAN NOT NULL DEFAULT FALSE, + event_push_agreed BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + CONSTRAINT fk_terms_member FOREIGN KEY (member_id) REFERENCES member(id) ON DELETE CASCADE, + CONSTRAINT uk_terms_member_id UNIQUE (member_id) +); diff --git a/src/main/resources/db/migration/V8__add_parent_mission_id.sql b/src/main/resources/db/migration/V8__add_parent_mission_id.sql new file mode 100644 index 00000000..5b8678d0 --- /dev/null +++ b/src/main/resources/db/migration/V8__add_parent_mission_id.sql @@ -0,0 +1,6 @@ +ALTER TABLE mission + ADD COLUMN parent_mission_id BIGINT NULL; + +ALTER TABLE mission + ADD CONSTRAINT fk_mission_parent_mission + FOREIGN KEY (parent_mission_id) REFERENCES mission(id) ON DELETE CASCADE; diff --git a/src/test/java/com/und/server/controller/AuthControllerTest.java b/src/test/java/com/und/server/auth/controller/AuthControllerTest.java similarity index 60% rename from src/test/java/com/und/server/controller/AuthControllerTest.java rename to src/test/java/com/und/server/auth/controller/AuthControllerTest.java index 34f7170f..37a96c21 100644 --- a/src/test/java/com/und/server/controller/AuthControllerTest.java +++ b/src/test/java/com/und/server/auth/controller/AuthControllerTest.java @@ -1,8 +1,10 @@ -package com.und.server.controller; +package com.und.server.auth.controller; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -22,15 +24,17 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import com.fasterxml.jackson.databind.ObjectMapper; -import com.und.server.dto.AuthRequest; -import com.und.server.dto.AuthResponse; -import com.und.server.dto.HandshakeRequest; -import com.und.server.dto.HandshakeResponse; -import com.und.server.dto.RefreshTokenRequest; -import com.und.server.exception.GlobalExceptionHandler; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; -import com.und.server.service.AuthService; +import com.und.server.auth.dto.request.AuthRequest; +import com.und.server.auth.dto.request.NonceRequest; +import com.und.server.auth.dto.request.RefreshTokenRequest; +import com.und.server.auth.dto.response.AuthResponse; +import com.und.server.auth.dto.response.NonceResponse; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.auth.filter.AuthMemberArgumentResolver; +import com.und.server.auth.service.AuthService; +import com.und.server.common.exception.CommonErrorResult; +import com.und.server.common.exception.GlobalExceptionHandler; +import com.und.server.common.exception.ServerException; @ExtendWith(MockitoExtension.class) class AuthControllerTest { @@ -41,22 +45,26 @@ class AuthControllerTest { @Mock private AuthService authService; + @Mock + private AuthMemberArgumentResolver authMemberArgumentResolver; + private MockMvc mockMvc; private final ObjectMapper objectMapper = new ObjectMapper(); @BeforeEach void init() { mockMvc = MockMvcBuilders.standaloneSetup(authController) + .setCustomArgumentResolvers(authMemberArgumentResolver) .setControllerAdvice(new GlobalExceptionHandler()) .build(); } @Test - @DisplayName("Fails handshake with bad request when provider is null") - void Given_HandshakeRequestWithNullProvider_When_Handshake_Then_ReturnsBadRequest() throws Exception { + @DisplayName("Fails to generate nonce with bad request when provider is null") + void Given_NonceRequestWithNullProvider_When_GenerateNonce_Then_ReturnsBadRequest() throws Exception { // given final String url = "/v1/auth/nonce"; - final HandshakeRequest request = new HandshakeRequest(null); + final NonceRequest request = new NonceRequest(null); final String requestBody = objectMapper.writeValueAsString(request); // when @@ -68,21 +76,21 @@ void Given_HandshakeRequestWithNullProvider_When_Handshake_Then_ReturnsBadReques // then resultActions.andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(ServerErrorResult.INVALID_PARAMETER.name())) - .andExpect(jsonPath("$.message[0]").value("Provider must not be null")); + .andExpect(jsonPath("$.code").value(CommonErrorResult.INVALID_PARAMETER.name())) + .andExpect(jsonPath("$.message[0]").value("Provider name must not be blank")); } @Test - @DisplayName("Fails handshake when provider is unknown") - void Given_HandshakeRequestWithUnknownProvider_When_Handshake_Then_ReturnsErrorResponse() throws Exception { + @DisplayName("Fails to generate nonce when provider is unknown") + void Given_NonceRequestWithUnknownProvider_When_GenerateNonce_Then_ReturnsErrorResponse() throws Exception { // given final String url = "/v1/auth/nonce"; - final HandshakeRequest request = new HandshakeRequest("GOOGLE"); + final NonceRequest request = new NonceRequest("facebook"); final String requestBody = objectMapper.writeValueAsString(request); - final ServerErrorResult errorResult = ServerErrorResult.INVALID_PROVIDER; + final AuthErrorResult errorResult = AuthErrorResult.INVALID_PROVIDER; doThrow(new ServerException(errorResult)) - .when(authService).handshake(request); + .when(authService).generateNonce(request); // when final ResultActions resultActions = mockMvc.perform( @@ -98,14 +106,14 @@ void Given_HandshakeRequestWithUnknownProvider_When_Handshake_Then_ReturnsErrorR } @Test - @DisplayName("Succeeds handshake and returns nonce for a valid request") - void Given_ValidHandshakeRequest_When_Handshake_Then_ReturnsOkWithNonce() throws Exception { + @DisplayName("Succeeds in generating nonce and returns nonce for a valid Kakao request") + void Given_ValidKakaoNonceRequest_When_GenerateNonce_Then_ReturnsCreatedWithNonce() throws Exception { // given final String url = "/v1/auth/nonce"; - final HandshakeRequest request = new HandshakeRequest("kakao"); - final HandshakeResponse response = new HandshakeResponse("generated-nonce"); + final NonceRequest request = new NonceRequest("kakao"); + final NonceResponse response = new NonceResponse("generated-nonce"); - doReturn(response).when(authService).handshake(request); + doReturn(response).when(authService).generateNonce(request); // when final ResultActions resultActions = mockMvc.perform( @@ -115,10 +123,32 @@ void Given_ValidHandshakeRequest_When_Handshake_Then_ReturnsOkWithNonce() throws ); // then - resultActions.andExpect(status().isOk()) + resultActions.andExpect(status().isCreated()) .andExpect(jsonPath("$.nonce").value("generated-nonce")); } + @Test + @DisplayName("Succeeds in generating nonce and returns nonce for a valid Apple request") + void Given_ValidAppleNonceRequest_When_GenerateNonce_Then_ReturnsCreatedWithNonce() throws Exception { + // given + final String url = "/v1/auth/nonce"; + final NonceRequest request = new NonceRequest("apple"); + final NonceResponse response = new NonceResponse("generated-nonce-for-apple"); + + doReturn(response).when(authService).generateNonce(request); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.post(url) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions.andExpect(status().isCreated()) + .andExpect(jsonPath("$.nonce").value("generated-nonce-for-apple")); + } + @Test @DisplayName("Fails login with bad request when provider is null") void Given_LoginRequestWithNullProvider_When_Login_Then_ReturnsBadRequest() throws Exception { @@ -136,8 +166,8 @@ void Given_LoginRequestWithNullProvider_When_Login_Then_ReturnsBadRequest() thro // then resultActions.andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(ServerErrorResult.INVALID_PARAMETER.name())) - .andExpect(jsonPath("$.message[0]").value("Provider must not be null")); + .andExpect(jsonPath("$.code").value(CommonErrorResult.INVALID_PARAMETER.name())) + .andExpect(jsonPath("$.message[0]").value("Provider name must not be blank")); } @Test @@ -157,8 +187,8 @@ void Given_LoginRequestWithNullIdToken_When_Login_Then_ReturnsBadRequest() throw // then resultActions.andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(ServerErrorResult.INVALID_PARAMETER.name())) - .andExpect(jsonPath("$.message[0]").value("ID Token must not be null")); + .andExpect(jsonPath("$.code").value(CommonErrorResult.INVALID_PARAMETER.name())) + .andExpect(jsonPath("$.message[0]").value("ID Token must not be blank")); } @Test @@ -166,9 +196,9 @@ void Given_LoginRequestWithNullIdToken_When_Login_Then_ReturnsBadRequest() throw void Given_LoginRequestWithUnknownProvider_When_Login_Then_ReturnsErrorResponse() throws Exception { // given final String url = "/v1/auth/login"; - final AuthRequest request = new AuthRequest("GOOGLE", "dummy.id.token"); + final AuthRequest request = new AuthRequest("facebook", "dummy.id.token"); final String requestBody = objectMapper.writeValueAsString(request); - final ServerErrorResult errorResult = ServerErrorResult.INVALID_PROVIDER; + final AuthErrorResult errorResult = AuthErrorResult.INVALID_PROVIDER; doThrow(new ServerException(errorResult)) .when(authService).login(request); @@ -193,7 +223,7 @@ void Given_LoginRequest_When_ServiceThrowsUnknownException_Then_ReturnsInternalS final String url = "/v1/auth/login"; final AuthRequest request = new AuthRequest("kakao", "dummy.id.token"); final String requestBody = objectMapper.writeValueAsString(request); - final ServerErrorResult errorResult = ServerErrorResult.UNKNOWN_EXCEPTION; + final CommonErrorResult errorResult = CommonErrorResult.UNKNOWN_EXCEPTION; doThrow(new RuntimeException("A wild unexpected error appeared!")) .when(authService).login(request); @@ -212,8 +242,8 @@ void Given_LoginRequest_When_ServiceThrowsUnknownException_Then_ReturnsInternalS } @Test - @DisplayName("Succeeds login and issues tokens for a valid request") - void Given_ValidLoginRequest_When_Login_Then_ReturnsOkWithTokens() throws Exception { + @DisplayName("Succeeds login and issues tokens for a valid Kakao request") + void Given_ValidKakaoLoginRequest_When_Login_Then_ReturnsOkWithTokens() throws Exception { // given final String url = "/v1/auth/login"; final AuthRequest authRequest = new AuthRequest("kakao", "dummy.id.token"); @@ -250,6 +280,43 @@ void Given_ValidLoginRequest_When_Login_Then_ReturnsOkWithTokens() throws Except assertThat(response.refreshTokenExpiresIn()).isEqualTo(20000); } + @Test + @DisplayName("Succeeds login and issues tokens for a valid Apple request") + void Given_ValidAppleLoginRequest_When_Login_Then_ReturnsOkWithTokens() throws Exception { + // given + final String url = "/v1/auth/login"; + final AuthRequest authRequest = new AuthRequest("apple", "dummy.id.token"); + final AuthResponse authResponse = new AuthResponse( + "Bearer", + "dummy.access.token", + 10000, + "dummy.refresh.token", + 20000 + ); + + doReturn(authResponse).when(authService).login(authRequest); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.post(url) + .content(objectMapper.writeValueAsString(authRequest)) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + final AuthResponse response = objectMapper.readValue( + resultActions + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8), AuthResponse.class + ); + + resultActions.andExpect(status().isOk()); + assertThat(response.tokenType()).isEqualTo("Bearer"); + assertThat(response.accessToken()).isEqualTo("dummy.access.token"); + assertThat(response.refreshToken()).isEqualTo("dummy.refresh.token"); + } + @Test @DisplayName("Fails token refresh with bad request when access token is null") void Given_RefreshTokenRequestWithNullAccessToken_When_ReissueTokens_Then_ReturnsBadRequest() throws Exception { @@ -267,8 +334,8 @@ void Given_RefreshTokenRequestWithNullAccessToken_When_ReissueTokens_Then_Return // then resultActions.andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(ServerErrorResult.INVALID_PARAMETER.name())) - .andExpect(jsonPath("$.message[0]").value("Access Token must not be null")); + .andExpect(jsonPath("$.code").value(CommonErrorResult.INVALID_PARAMETER.name())) + .andExpect(jsonPath("$.message[0]").value("Access Token must not be blank")); } @Test @@ -288,13 +355,13 @@ void Given_RefreshTokenRequestWithNullRefreshToken_When_ReissueTokens_Then_Retur // then resultActions.andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(ServerErrorResult.INVALID_PARAMETER.name())) - .andExpect(jsonPath("$.message[0]").value("Refresh Token must not be null")); + .andExpect(jsonPath("$.code").value(CommonErrorResult.INVALID_PARAMETER.name())) + .andExpect(jsonPath("$.message[0]").value("Refresh Token must not be blank")); } @Test @DisplayName("Succeeds token refresh for a valid request") - void Given_ValidRefreshTokenRequest_When_ReissueTokens_Then_ReturnsOkWithNewTokens() throws Exception { + void Given_ValidRefreshTokenRequest_When_ReissueTokens_Then_ReturnsCreatedWithNewTokens() throws Exception { // given final String url = "/v1/auth/tokens"; final RefreshTokenRequest refreshTokenRequest = new RefreshTokenRequest( @@ -325,10 +392,52 @@ void Given_ValidRefreshTokenRequest_When_ReissueTokens_Then_ReturnsOkWithNewToke .getContentAsString(StandardCharsets.UTF_8), AuthResponse.class ); - resultActions.andExpect(status().isOk()); + resultActions.andExpect(status().isCreated()); assertThat(response.tokenType()).isEqualTo("Bearer"); assertThat(response.accessToken()).isEqualTo("new.access.token"); assertThat(response.refreshToken()).isEqualTo("new.refresh.token"); } + @Test + @DisplayName("Fails logout and returns unauthorized when user is not authenticated") + void Given_UnauthenticatedUser_When_Logout_Then_ReturnsUnauthorized() throws Exception { + // given + final String url = "/v1/auth/logout"; + final AuthErrorResult errorResult = AuthErrorResult.UNAUTHORIZED_ACCESS; + + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doThrow(new ServerException(errorResult)) + .when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.delete(url) + ); + + // then + resultActions.andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(errorResult.name())) + .andExpect(jsonPath("$.message").value(errorResult.getMessage())); + } + + @Test + @DisplayName("Succeeds logout and returns no content") + void Given_AuthenticatedUser_When_Logout_Then_ReturnsNoContent() throws Exception { + // given + final String url = "/v1/auth/logout"; + final Long memberId = 1L; + + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.delete(url) + ); + + // then + resultActions.andExpect(status().isNoContent()); + verify(authService).logout(memberId); + } + } diff --git a/src/test/java/com/und/server/dto/OidcPublicKeysTest.java b/src/test/java/com/und/server/auth/dto/OidcPublicKeysTest.java similarity index 84% rename from src/test/java/com/und/server/dto/OidcPublicKeysTest.java rename to src/test/java/com/und/server/auth/dto/OidcPublicKeysTest.java index 50d4d31b..f56e7123 100644 --- a/src/test/java/com/und/server/dto/OidcPublicKeysTest.java +++ b/src/test/java/com/und/server/auth/dto/OidcPublicKeysTest.java @@ -1,4 +1,4 @@ -package com.und.server.dto; +package com.und.server.auth.dto; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -8,8 +8,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.common.exception.ServerException; class OidcPublicKeysTest { @@ -23,7 +23,7 @@ void Given_MismatchedKid_When_MatchingKey_Then_ThrowsServerException() { // when & then assertThatThrownBy(() -> oidcPublicKeys.matchingKey("kid3", "RS256")) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.PUBLIC_KEY_NOT_FOUND); + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.PUBLIC_KEY_NOT_FOUND); } @Test @@ -36,7 +36,7 @@ void Given_MismatchedAlg_When_MatchingKey_Then_ThrowsServerException() { // when & then assertThatThrownBy(() -> oidcPublicKeys.matchingKey("kid1", "RS512")) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.PUBLIC_KEY_NOT_FOUND); + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.PUBLIC_KEY_NOT_FOUND); } @Test diff --git a/src/test/java/com/und/server/auth/filter/AuthMemberArgumentResolverTest.java b/src/test/java/com/und/server/auth/filter/AuthMemberArgumentResolverTest.java new file mode 100644 index 00000000..d086bdb8 --- /dev/null +++ b/src/test/java/com/und/server/auth/filter/AuthMemberArgumentResolverTest.java @@ -0,0 +1,123 @@ +package com.und.server.auth.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doReturn; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.common.exception.ServerException; + +@ExtendWith(MockitoExtension.class) +class AuthMemberArgumentResolverTest { + + @InjectMocks + private AuthMemberArgumentResolver authMemberArgumentResolver; + + @Mock + private MethodParameter parameter; + + @Mock + private SecurityContext securityContext; + + @BeforeEach + void init() { + SecurityContextHolder.setContext(securityContext); + } + + @Test + @DisplayName("Supports parameter with @AuthMember and Long type") + void Given_AuthMemberAnnotationAndLongType_When_SupportsParameter_Then_ReturnsTrue() { + // given + doReturn(true).when(parameter).hasParameterAnnotation(AuthMember.class); + doReturn(Long.class).when(parameter).getParameterType(); + + // when + final boolean result = authMemberArgumentResolver.supportsParameter(parameter); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("Does not support parameter without @AuthMember annotation") + void Given_NoAuthMemberAnnotation_When_SupportsParameter_Then_ReturnsFalse() { + // given + doReturn(false).when(parameter).hasParameterAnnotation(AuthMember.class); + + // when + final boolean result = authMemberArgumentResolver.supportsParameter(parameter); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("Does not support parameter with @AuthMember but not Long type") + void Given_AuthMemberAnnotationButNotLongType_When_SupportsParameter_Then_ReturnsFalse() { + // given + doReturn(true).when(parameter).hasParameterAnnotation(AuthMember.class); + doReturn(String.class).when(parameter).getParameterType(); + + // when + final boolean result = authMemberArgumentResolver.supportsParameter(parameter); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("Throws ServerException when authentication is null") + void Given_NullAuthentication_When_ResolveArgument_Then_ThrowsServerException() { + // given + doReturn(null).when(securityContext).getAuthentication(); + + // when & then + assertThatThrownBy(() -> authMemberArgumentResolver.resolveArgument(parameter, null, null, null)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.UNAUTHORIZED_ACCESS); + } + + @Test + @DisplayName("Throws ServerException when principal is not a Long") + void Given_InvalidPrincipalType_When_ResolveArgument_Then_ThrowsServerException() { + // given + final String invalidPrincipal = "not-a-long"; + final UsernamePasswordAuthenticationToken authentication + = new UsernamePasswordAuthenticationToken(invalidPrincipal, null); + doReturn(authentication).when(securityContext).getAuthentication(); + + // when & then + assertThatThrownBy(() -> authMemberArgumentResolver.resolveArgument(parameter, null, null, null)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.UNAUTHORIZED_ACCESS); + } + + @Test + @DisplayName("Resolves memberId successfully when principal is a Long") + void Given_ValidPrincipal_When_ResolveArgument_Then_ReturnsMemberId() { + // given + final Long memberId = 1L; + final UsernamePasswordAuthenticationToken authentication + = new UsernamePasswordAuthenticationToken(memberId, null); + doReturn(authentication).when(securityContext).getAuthentication(); + + // when + final Object result = authMemberArgumentResolver.resolveArgument(parameter, null, null, null); + + // then + assertThat(result).isEqualTo(memberId); + } + +} diff --git a/src/test/java/com/und/server/security/CustomAuthenticationEntryPointTest.java b/src/test/java/com/und/server/auth/filter/CustomAuthenticationEntryPointTest.java similarity index 88% rename from src/test/java/com/und/server/security/CustomAuthenticationEntryPointTest.java rename to src/test/java/com/und/server/auth/filter/CustomAuthenticationEntryPointTest.java index 7d6eac4f..a31173c4 100644 --- a/src/test/java/com/und/server/security/CustomAuthenticationEntryPointTest.java +++ b/src/test/java/com/und/server/auth/filter/CustomAuthenticationEntryPointTest.java @@ -1,4 +1,4 @@ -package com.und.server.security; +package com.und.server.auth.filter; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -10,8 +10,8 @@ import org.springframework.security.core.AuthenticationException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.und.server.dto.ErrorResponse; -import com.und.server.exception.ServerErrorResult; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.common.dto.response.ErrorResponse; class CustomAuthenticationEntryPointTest { @@ -30,7 +30,7 @@ void Given_AuthenticationFailure_When_Commence_Then_WritesUnauthorizedErrorRespo final MockHttpServletRequest request = new MockHttpServletRequest(); final MockHttpServletResponse response = new MockHttpServletResponse(); final AuthenticationException authException = mock(AuthenticationException.class); - final ServerErrorResult expectedError = ServerErrorResult.UNAUTHORIZED_ACCESS; + final AuthErrorResult expectedError = AuthErrorResult.UNAUTHORIZED_ACCESS; // when customAuthenticationEntryPoint.commence(request, response, authException); diff --git a/src/test/java/com/und/server/security/JwtAuthenticationFilterTest.java b/src/test/java/com/und/server/auth/filter/JwtAuthenticationFilterTest.java similarity index 88% rename from src/test/java/com/und/server/security/JwtAuthenticationFilterTest.java rename to src/test/java/com/und/server/auth/filter/JwtAuthenticationFilterTest.java index 7acbf599..4549cb6d 100644 --- a/src/test/java/com/und/server/security/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/und/server/auth/filter/JwtAuthenticationFilterTest.java @@ -1,11 +1,12 @@ -package com.und.server.security; +package com.und.server.auth.filter; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import java.io.IOException; @@ -21,10 +22,10 @@ import org.springframework.security.core.context.SecurityContextHolder; import com.fasterxml.jackson.databind.ObjectMapper; -import com.und.server.dto.ErrorResponse; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; -import com.und.server.jwt.JwtProvider; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.auth.jwt.JwtProvider; +import com.und.server.common.dto.response.ErrorResponse; +import com.und.server.common.exception.ServerException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -64,8 +65,8 @@ void Given_ExpiredTokenOnProtectedRoute_When_Filter_Then_ErrorResponseIsSetAndCh request.setServletPath(protectedPath); request.addHeader("Authorization", "Bearer " + token); - final ServerErrorResult expectedError = ServerErrorResult.EXPIRED_TOKEN; - when(jwtProvider.getAuthentication(token)).thenThrow(new ServerException(expectedError)); + final AuthErrorResult expectedError = AuthErrorResult.EXPIRED_TOKEN; + doThrow(new ServerException(expectedError)).when(jwtProvider).getAuthentication(token); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); @@ -94,8 +95,8 @@ void Given_TokenWithInvalidSignature_When_Filter_Then_ErrorResponseIsSetAndChain request.setServletPath(path); request.addHeader("Authorization", "Bearer " + token); - final ServerErrorResult expectedError = ServerErrorResult.INVALID_TOKEN_SIGNATURE; - when(jwtProvider.getAuthentication(token)).thenThrow(new ServerException(expectedError)); + final AuthErrorResult expectedError = AuthErrorResult.INVALID_TOKEN_SIGNATURE; + doThrow(new ServerException(expectedError)).when(jwtProvider).getAuthentication(token); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); @@ -124,8 +125,8 @@ void Given_ExpiredTokenOnLoginPath_When_Filter_Then_ErrorResponseIsSetAndChainSt request.setServletPath(loginPath); request.addHeader("Authorization", "Bearer " + token); - final ServerErrorResult expectedError = ServerErrorResult.EXPIRED_TOKEN; - when(jwtProvider.getAuthentication(token)).thenThrow(new ServerException(expectedError)); + final AuthErrorResult expectedError = AuthErrorResult.EXPIRED_TOKEN; + doThrow(new ServerException(expectedError)).when(jwtProvider).getAuthentication(token); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); @@ -149,8 +150,8 @@ void Given_ExpiredTokenOnTokenReissuePath_When_Filter_Then_ChainContinues() thro request.setServletPath(permissivePath); request.addHeader("Authorization", "Bearer " + token); - final ServerErrorResult expectedError = ServerErrorResult.EXPIRED_TOKEN; - when(jwtProvider.getAuthentication(token)).thenThrow(new ServerException(expectedError)); + final AuthErrorResult expectedError = AuthErrorResult.EXPIRED_TOKEN; + doThrow(new ServerException(expectedError)).when(jwtProvider).getAuthentication(token); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); @@ -206,7 +207,7 @@ void Given_ValidToken_When_Filter_Then_AuthenticationIsSetInContext() throws Ser request.addHeader("Authorization", "Bearer " + token); final Authentication authentication = mock(Authentication.class); - when(jwtProvider.getAuthentication(token)).thenReturn(authentication); + doReturn(authentication).when(jwtProvider).getAuthentication(token); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); diff --git a/src/test/java/com/und/server/jwt/JwtPropertiesTest.java b/src/test/java/com/und/server/auth/jwt/JwtPropertiesTest.java similarity index 97% rename from src/test/java/com/und/server/jwt/JwtPropertiesTest.java rename to src/test/java/com/und/server/auth/jwt/JwtPropertiesTest.java index c007bcf6..a47778ff 100644 --- a/src/test/java/com/und/server/jwt/JwtPropertiesTest.java +++ b/src/test/java/com/und/server/auth/jwt/JwtPropertiesTest.java @@ -1,4 +1,4 @@ -package com.und.server.jwt; +package com.und.server.auth.jwt; import static org.assertj.core.api.Assertions.*; diff --git a/src/test/java/com/und/server/jwt/JwtProviderTest.java b/src/test/java/com/und/server/auth/jwt/JwtProviderTest.java similarity index 62% rename from src/test/java/com/und/server/jwt/JwtProviderTest.java rename to src/test/java/com/und/server/auth/jwt/JwtProviderTest.java index 85a22b62..7cfcbccc 100644 --- a/src/test/java/com/und/server/jwt/JwtProviderTest.java +++ b/src/test/java/com/und/server/auth/jwt/JwtProviderTest.java @@ -1,9 +1,10 @@ -package com.und.server.jwt; +package com.und.server.auth.jwt; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.doReturn; +import java.nio.charset.StandardCharsets; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.PublicKey; @@ -22,12 +23,16 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.core.Authentication; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; -import com.und.server.oauth.IdTokenPayload; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.common.exception.ServerException; +import com.und.server.common.util.ProfileManager; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.WeakKeyException; @ExtendWith(MockitoExtension.class) class JwtProviderTest { @@ -35,6 +40,9 @@ class JwtProviderTest { @Mock private JwtProperties jwtProperties; + @Mock + private ProfileManager profileManager; + private JwtProvider jwtProvider; private final SecretKey secretKey = Jwts.SIG.HS256.key().build(); @@ -45,7 +53,7 @@ class JwtProviderTest { @BeforeEach void init() { - jwtProvider = new JwtProvider(jwtProperties); + jwtProvider = new JwtProvider(jwtProperties, profileManager); } @Test @@ -57,7 +65,7 @@ void Given_InvalidFormatToken_When_GetDecodedHeader_Then_ThrowsServerException() // when & then assertThatThrownBy(() -> jwtProvider.getDecodedHeader(invalidToken)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_TOKEN); + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.INVALID_TOKEN); } @Test @@ -70,7 +78,7 @@ void Given_MalformedBase64HeaderToken_When_GetDecodedHeader_Then_ThrowsServerExc // when & then assertThatThrownBy(() -> jwtProvider.getDecodedHeader(token)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_TOKEN); + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.INVALID_TOKEN); } @Test @@ -82,7 +90,7 @@ void Given_TokenWithoutNonce_When_ExtractNonce_Then_ThrowsServerException() { // when & then assertThatThrownBy(() -> jwtProvider.extractNonce(invalidToken)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_TOKEN); + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.INVALID_TOKEN); } @Test @@ -106,6 +114,7 @@ void Given_ValidTokenWithNonce_When_ExtractNonce_Then_ReturnsCorrectNonce() { @DisplayName("Throws ServerException when audience does not match") void Given_OidcToken_When_ParseWithMismatchedAudience_Then_ThrowsServerException() throws Exception { // given + doReturn(false).when(profileManager).isProdOrStgProfile(); final String wrongAudience = "wrong-client"; final KeyPair keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); final PublicKey publicKey = keyPair.getPublic(); @@ -123,7 +132,7 @@ void Given_OidcToken_When_ParseWithMismatchedAudience_Then_ThrowsServerException assertThatThrownBy(() -> { jwtProvider.parseOidcIdToken(token, issuer, wrongAudience, publicKey); }).isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_TOKEN); + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.INVALID_TOKEN); } @Test @@ -143,13 +152,12 @@ void Given_ValidOidcIdToken_When_ParseOidcIdToken_Then_ReturnsCorrectPayload() t .compact(); // when - final IdTokenPayload payload = jwtProvider.parseOidcIdToken( + final String providerId = jwtProvider.parseOidcIdToken( token, issuer, audience, keyPair.getPublic() ); // then - assertThat(payload.providerId()).isEqualTo(subject); - assertThat(payload.nickname()).isEqualTo("Chori"); + assertThat(providerId).isEqualTo(subject); } @Test @@ -164,7 +172,9 @@ void Given_MemberId_When_GenerateAccessToken_Then_TokenContainsValidClaims() { // when final String token = jwtProvider.generateAccessToken(memberId); - final Claims claims = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload(); + final Claims claims = Jwts.parser() + .verifyWith(secretKey).build() + .parseSignedClaims(token).getPayload(); // then assertThat(claims.getSubject()).isEqualTo(memberId.toString()); @@ -184,7 +194,9 @@ void Given_MemberId_When_GenerateAccessToken_Then_IssuedAtClaimIsCloseToCurrentT // when final String token = jwtProvider.generateAccessToken(memberId); - final Claims claims = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload(); + final Claims claims = Jwts.parser() + .verifyWith(secretKey).build() + .parseSignedClaims(token).getPayload(); final LocalDateTime afterGeneration = LocalDateTime.now(); // then @@ -210,7 +222,9 @@ void Given_MemberId_When_GenerateAccessToken_Then_ExpirationIsCorrect() { // when final String token = jwtProvider.generateAccessToken(memberId); - final Claims claims = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload(); + final Claims claims = Jwts.parser() + .verifyWith(secretKey).build() + .parseSignedClaims(token).getPayload(); // then final Date issuedAt = claims.getIssuedAt(); @@ -233,7 +247,7 @@ void Given_ValidToken_When_GetDecodedHeader_Then_ReturnsHeaderMap() { final Map header = jwtProvider.getDecodedHeader(token); // then - assertThat(header.get("alg")).isNotBlank(); + assertThat(header).containsEntry("alg", "HS256"); } @Test @@ -276,26 +290,28 @@ void Given_ExpiredToken_When_GetMemberIdFromToken_Then_ThrowsServerException() { // when & then assertThatThrownBy(() -> jwtProvider.getMemberIdFromToken(token)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.EXPIRED_TOKEN); + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.EXPIRED_TOKEN); } @Test @DisplayName("Throws ServerException when token structure is invalid") void Given_MalformedToken_When_GetMemberIdFromToken_Then_ThrowsServerException() { // given + doReturn(false).when(profileManager).isProdOrStgProfile(); doReturn(secretKey).when(jwtProperties).secretKey(); final String malformedToken = "this.is.not.a.jwt"; // when & then assertThatThrownBy(() -> jwtProvider.getMemberIdFromToken(malformedToken)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.MALFORMED_TOKEN); + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.MALFORMED_TOKEN); } @Test @DisplayName("Throws ServerException when token signature is invalid") void Given_TokenWithInvalidSignature_When_GetMemberIdFromToken_Then_ThrowsServerException() { // given + doReturn(false).when(profileManager).isProdOrStgProfile(); doReturn(secretKey).when(jwtProperties).secretKey(); final SecretKey anotherKey = Jwts.SIG.HS256.key().build(); final String token = Jwts.builder().subject("1").signWith(anotherKey).compact(); @@ -303,7 +319,98 @@ void Given_TokenWithInvalidSignature_When_GetMemberIdFromToken_Then_ThrowsServer // when & then assertThatThrownBy(() -> jwtProvider.getMemberIdFromToken(token)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_TOKEN_SIGNATURE); + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.INVALID_TOKEN_SIGNATURE); + } + + @Test + @DisplayName("Throws ServerException when the verification key is too weak for the token's algorithm") + void Given_TokenWithStrongAlgAndProviderWithWeakKey_When_GetMemberIdFromToken_Then_ThrowsTokenKeyErrorException() { + doReturn(false).when(profileManager).isProdOrStgProfile(); + final SecretKey weakKey = Keys.hmacShaKeyFor( + "this-key-is-definitely-not-long-enough".getBytes(StandardCharsets.UTF_8)); + doReturn(weakKey).when(jwtProperties).secretKey(); + + final SecretKey strongSigningKey = Jwts.SIG.HS512.key().build(); + final String tokenWithStrongAlg = Jwts.builder() + .subject("1") + .signWith(strongSigningKey) + .compact(); + + // when & then + assertThatThrownBy(() -> jwtProvider.getMemberIdFromToken(tokenWithStrongAlg)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.WEAK_TOKEN_KEY) + .cause().isInstanceOf(WeakKeyException.class); + } + + @Test + @DisplayName("Throws ServerException when token uses an unsupported feature (e.g., compression)") + void Given_TokenWithUnsupportedFeature_When_GetMemberIdFromToken_Then_ThrowsUnsupportedTokenException() { + // given + doReturn(false).when(profileManager).isProdOrStgProfile(); + doReturn(secretKey).when(jwtProperties).secretKey(); + final String unsupportedToken = Jwts.builder() + .header() + .add("zip", "BOGUS") + .and() + .subject("1") + .signWith(secretKey) + .compact(); + + // when & then + assertThatThrownBy(() -> jwtProvider.getMemberIdFromToken(unsupportedToken)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.UNSUPPORTED_TOKEN) + .cause().isInstanceOf(UnsupportedJwtException.class); + } + + @Test + @DisplayName("Throws generic UNAUTHORIZED_ACCESS for any token error") + void Given_MalformedToken_When_GetMemberIdFromToken_Then_ThrowsGenericUnauthorizedAccessException() { + // given + doReturn(true).when(profileManager).isProdOrStgProfile(); + doReturn(secretKey).when(jwtProperties).secretKey(); + final String malformedToken = "this.is.not.a.jwt"; + + // when & then + assertThatThrownBy(() -> jwtProvider.getMemberIdFromToken(malformedToken)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.UNAUTHORIZED_ACCESS) + // Verify that the cause is the original exception + .cause().isInstanceOf(MalformedJwtException.class); + } + + @Test + @DisplayName("Throws ServerException when token subject is not a valid Long") + void Given_TokenWithNonNumericSubject_When_GetMemberIdFromToken_Then_ThrowsServerException() { + // given + doReturn(secretKey).when(jwtProperties).secretKey(); + final String token = Jwts.builder() + .subject("not-a-long") + .signWith(secretKey) + .compact(); + + // when & then + assertThatThrownBy(() -> jwtProvider.getMemberIdFromToken(token)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.INVALID_TOKEN) + .cause().isInstanceOf(NumberFormatException.class); + } + + @Test + @DisplayName("Throws ServerException when token subject is null") + void Given_TokenWithNullSubject_When_GetMemberIdFromToken_Then_ThrowsServerException() { + // given + doReturn(secretKey).when(jwtProperties).secretKey(); + final String token = Jwts.builder() + .claim("dummy", "value") + .signWith(secretKey) + .compact(); + + // when & then + assertThatThrownBy(() -> jwtProvider.getMemberIdFromToken(token)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.INVALID_TOKEN); } @Test @@ -325,24 +432,27 @@ void Given_ValidToken_When_GetMemberIdFromToken_Then_ReturnsCorrectMemberId() { } @Test - @DisplayName("Throws ServerException when trying to get member ID from a non-expired access token") - void Given_NonExpiredToken_When_GetMemberIdFromExpiredAccessToken_Then_ThrowsServerException() { + @DisplayName("Parses a non-expired token for reissue and returns correct info") + void Given_NonExpiredToken_When_ParseTokenForReissue_Then_ReturnsInfoWithIsExpiredFalse() { // given doReturn(secretKey).when(jwtProperties).secretKey(); doReturn(issuer).when(jwtProperties).issuer(); doReturn(3600).when(jwtProperties).accessTokenExpireTime(); - final String token = jwtProvider.generateAccessToken(1L); + final Long memberId = 1L; + final String token = jwtProvider.generateAccessToken(memberId); - // when & then - assertThatThrownBy(() -> jwtProvider.getMemberIdFromExpiredAccessToken(token)) - .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_TOKEN); + // when + final ParsedTokenInfo tokenInfo = jwtProvider.parseTokenForReissue(token); + + // then + assertThat(tokenInfo.memberId()).isEqualTo(memberId); + assertThat(tokenInfo.isExpired()).isFalse(); } @Test - @DisplayName("Gets member ID from an expired access token successfully") - void Given_ExpiredToken_When_GetMemberIdFromExpiredAccessToken_Then_ReturnsCorrectMemberId() { + @DisplayName("Parses an expired token for reissue and returns correct info") + void Given_ExpiredToken_When_ParseTokenForReissue_Then_ReturnsInfoWithIsExpiredTrue() { // given doReturn(secretKey).when(jwtProperties).secretKey(); @@ -350,19 +460,15 @@ void Given_ExpiredToken_When_GetMemberIdFromExpiredAccessToken_Then_ReturnsCorre final Date now = new Date(); final Date issuedAt = new Date(now.getTime() - 10000); final Date expiredAt = new Date(now.getTime() - 5000); - final String token = Jwts.builder() - .subject(memberId.toString()) - .issuer(issuer) - .issuedAt(issuedAt) - .expiration(expiredAt) - .signWith(secretKey) - .compact(); + final String token = Jwts.builder().subject(memberId.toString()).issuer(issuer).issuedAt(issuedAt) + .expiration(expiredAt).signWith(secretKey).compact(); // when - final Long extractedId = jwtProvider.getMemberIdFromExpiredAccessToken(token); + final ParsedTokenInfo tokenInfo = jwtProvider.parseTokenForReissue(token); // then - assertThat(extractedId).isEqualTo(memberId); + assertThat(tokenInfo.memberId()).isEqualTo(memberId); + assertThat(tokenInfo.isExpired()).isTrue(); } } diff --git a/src/test/java/com/und/server/auth/oauth/AppleProviderTest.java b/src/test/java/com/und/server/auth/oauth/AppleProviderTest.java new file mode 100644 index 00000000..1b8f5317 --- /dev/null +++ b/src/test/java/com/und/server/auth/oauth/AppleProviderTest.java @@ -0,0 +1,63 @@ +package com.und.server.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; + +import java.security.PublicKey; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.auth.dto.OidcPublicKeys; +import com.und.server.auth.jwt.JwtProvider; + +@ExtendWith(MockitoExtension.class) +class AppleProviderTest { + + @Mock + private JwtProvider jwtProvider; + + @Mock + private PublicKeyProvider publicKeyProvider; + + @Mock + private OidcPublicKeys oidcPublicKeys; + + @Mock + private PublicKey publicKey; + + private AppleProvider appleProvider; + + private final String token = "dummyToken"; + private final String appleBaseUrl = "https://appleid.apple.com"; + private final String appleAppId = "dummyAppId"; + private final String providerId = "dummyId"; + + @BeforeEach + void init() { + appleProvider = new AppleProvider(jwtProvider, publicKeyProvider, appleBaseUrl, appleAppId); + } + + @Test + @DisplayName("Successfully retrieves the Provider ID from a valid token") + void Given_ValidToken_When_GetProviderId_Then_ReturnsCorrectProviderId() { + // given + final Map decodedHeader = Map.of("alg", "RS256", "kid", "key1"); + + doReturn(decodedHeader).when(jwtProvider).getDecodedHeader(token); + doReturn(publicKey).when(publicKeyProvider).generatePublicKey(decodedHeader, oidcPublicKeys); + doReturn(providerId).when(jwtProvider).parseOidcIdToken(token, appleBaseUrl, appleAppId, publicKey); + + // when + final String actualProviderId = appleProvider.getProviderId(token, oidcPublicKeys); + + // then + assertThat(actualProviderId).isEqualTo(providerId); + } + +} diff --git a/src/test/java/com/und/server/oauth/KakaoProviderTest.java b/src/test/java/com/und/server/auth/oauth/KakaoProviderTest.java similarity index 56% rename from src/test/java/com/und/server/oauth/KakaoProviderTest.java rename to src/test/java/com/und/server/auth/oauth/KakaoProviderTest.java index 0cc4122b..00abf9ee 100644 --- a/src/test/java/com/und/server/oauth/KakaoProviderTest.java +++ b/src/test/java/com/und/server/auth/oauth/KakaoProviderTest.java @@ -1,7 +1,7 @@ -package com.und.server.oauth; +package com.und.server.auth.oauth; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doReturn; import java.security.PublicKey; import java.util.Map; @@ -13,8 +13,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.und.server.dto.OidcPublicKeys; -import com.und.server.jwt.JwtProvider; +import com.und.server.auth.dto.OidcPublicKeys; +import com.und.server.auth.jwt.JwtProvider; @ExtendWith(MockitoExtension.class) class KakaoProviderTest { @@ -37,7 +37,6 @@ class KakaoProviderTest { private final String kakaoBaseUrl = "https://kauth.kakao.com"; private final String kakaoAppKey = "dummyAppKey"; private final String providerId = "dummyId"; - private final String nickname = "dummyNickname"; @BeforeEach void init() { @@ -45,21 +44,20 @@ void init() { } @Test - @DisplayName("Successfully retrieves the ID token payload from a valid token") - void Given_ValidToken_When_GetIdTokenPayload_Then_ReturnsCorrectPayload() { + @DisplayName("Successfully retrieves the Provider ID from a valid token") + void Given_ValidToken_When_GetProviderId_Then_ReturnsCorrectProviderId() { // given final Map decodedHeader = Map.of("alg", "RS256", "kid", "key1"); - final IdTokenPayload expectedPayload = new IdTokenPayload(providerId, nickname); - when(jwtProvider.getDecodedHeader(token)).thenReturn(decodedHeader); - when(publicKeyProvider.generatePublicKey(decodedHeader, oidcPublicKeys)).thenReturn(publicKey); - when(jwtProvider.parseOidcIdToken(token, kakaoBaseUrl, kakaoAppKey, publicKey)).thenReturn(expectedPayload); + doReturn(decodedHeader).when(jwtProvider).getDecodedHeader(token); + doReturn(publicKey).when(publicKeyProvider).generatePublicKey(decodedHeader, oidcPublicKeys); + doReturn(providerId).when(jwtProvider).parseOidcIdToken(token, kakaoBaseUrl, kakaoAppKey, publicKey); // when - final IdTokenPayload actualPayload = kakaoProvider.getIdTokenPayload(token, oidcPublicKeys); + final String actualProviderId = kakaoProvider.getProviderId(token, oidcPublicKeys); // then - assertThat(actualPayload).isEqualTo(expectedPayload); + assertThat(actualProviderId).isEqualTo(providerId); } } diff --git a/src/test/java/com/und/server/oauth/OidcClientFactoryTest.java b/src/test/java/com/und/server/auth/oauth/OidcClientFactoryTest.java similarity index 53% rename from src/test/java/com/und/server/oauth/OidcClientFactoryTest.java rename to src/test/java/com/und/server/auth/oauth/OidcClientFactoryTest.java index f29ea20a..a444522a 100644 --- a/src/test/java/com/und/server/oauth/OidcClientFactoryTest.java +++ b/src/test/java/com/und/server/auth/oauth/OidcClientFactoryTest.java @@ -1,4 +1,4 @@ -package com.und.server.oauth; +package com.und.server.auth.oauth; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -10,8 +10,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.common.exception.ServerException; @ExtendWith(MockitoExtension.class) class OidcClientFactoryTest { @@ -19,11 +19,14 @@ class OidcClientFactoryTest { @Mock private KakaoClient kakaoClient; + @Mock + private AppleClient appleClient; + private OidcClientFactory oidcClientFactory; @BeforeEach void init() { - oidcClientFactory = new OidcClientFactory(kakaoClient); + oidcClientFactory = new OidcClientFactory(kakaoClient, appleClient); } @Test @@ -31,13 +34,13 @@ void init() { void Given_NullProvider_When_GetOidcClient_Then_ThrowsServerException() { // when & then assertThatThrownBy(() -> oidcClientFactory.getOidcClient(null)) - .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_PROVIDER); + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.INVALID_PROVIDER); } @Test - @DisplayName("Returns the correct OIDC client for a given provider") - void Given_ValidProvider_When_GetOidcClient_Then_ReturnsCorrectClient() { + @DisplayName("Returns the Kakao client for the KAKAO provider") + void Given_KakaoProvider_When_GetOidcClient_Then_ReturnsKakaoClient() { // when final OidcClient client = oidcClientFactory.getOidcClient(Provider.KAKAO); @@ -45,4 +48,14 @@ void Given_ValidProvider_When_GetOidcClient_Then_ReturnsCorrectClient() { assertThat(client).isEqualTo(kakaoClient); } + @Test + @DisplayName("Returns the Apple client for the APPLE provider") + void Given_AppleProvider_When_GetOidcClient_Then_ReturnsAppleClient() { + // when + final OidcClient client = oidcClientFactory.getOidcClient(Provider.APPLE); + + // then + assertThat(client).isEqualTo(appleClient); + } + } diff --git a/src/test/java/com/und/server/auth/oauth/OidcProviderFactoryTest.java b/src/test/java/com/und/server/auth/oauth/OidcProviderFactoryTest.java new file mode 100644 index 00000000..fd6fa5ac --- /dev/null +++ b/src/test/java/com/und/server/auth/oauth/OidcProviderFactoryTest.java @@ -0,0 +1,75 @@ +package com.und.server.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doReturn; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.auth.dto.OidcPublicKeys; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.common.exception.ServerException; + +@ExtendWith(MockitoExtension.class) +class OidcProviderFactoryTest { + + @Mock + private KakaoProvider kakaoProvider; + + @Mock + private AppleProvider appleProvider; + + @Mock + private OidcPublicKeys oidcPublicKeys; + + private OidcProviderFactory factory; + + private final String token = "dummyToken"; + private final String providerId = "dummyId"; + + @BeforeEach + void init() { + factory = new OidcProviderFactory(kakaoProvider, appleProvider); + } + + @Test + @DisplayName("Throws an exception when the provider is null") + void Given_NullProvider_When_GetProviderId_Then_ThrowsServerException() { + // when & then + assertThatThrownBy(() -> factory.getProviderId(null, token, oidcPublicKeys)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.INVALID_PROVIDER); + } + + @Test + @DisplayName("Retrieves Provider ID successfully for the Kakao provider") + void Given_KakaoProvider_When_GetProviderId_Then_ReturnsCorrectProviderId() { + // given + doReturn(providerId).when(kakaoProvider).getProviderId(token, oidcPublicKeys); + + // when + final String actualProviderId = factory.getProviderId(Provider.KAKAO, token, oidcPublicKeys); + + // then + assertThat(actualProviderId).isEqualTo(providerId); + } + + @Test + @DisplayName("Retrieves Provider ID successfully for the Apple provider") + void Given_AppleProvider_When_GetProviderId_Then_ReturnsCorrectProviderId() { + // given + doReturn(providerId).when(appleProvider).getProviderId(token, oidcPublicKeys); + + // when + final String actualProviderId = factory.getProviderId(Provider.APPLE, token, oidcPublicKeys); + + // then + assertThat(actualProviderId).isEqualTo(providerId); + } + +} diff --git a/src/test/java/com/und/server/oauth/PublicKeyProviderTest.java b/src/test/java/com/und/server/auth/oauth/PublicKeyProviderTest.java similarity index 86% rename from src/test/java/com/und/server/oauth/PublicKeyProviderTest.java rename to src/test/java/com/und/server/auth/oauth/PublicKeyProviderTest.java index 4484831f..8f499b09 100644 --- a/src/test/java/com/und/server/oauth/PublicKeyProviderTest.java +++ b/src/test/java/com/und/server/auth/oauth/PublicKeyProviderTest.java @@ -1,4 +1,4 @@ -package com.und.server.oauth; +package com.und.server.auth.oauth; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -13,10 +13,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.und.server.dto.OidcPublicKey; -import com.und.server.dto.OidcPublicKeys; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; +import com.und.server.auth.dto.OidcPublicKey; +import com.und.server.auth.dto.OidcPublicKeys; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.common.exception.ServerException; @ExtendWith(MockitoExtension.class) class PublicKeyProviderTest { @@ -42,7 +42,7 @@ void Given_InvalidOidcKeyComponents_When_GeneratePublicKey_Then_ThrowsServerExce // then assertThatThrownBy(() -> publicKeyProvider.generatePublicKey(header, oidcPublicKeys)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_PUBLIC_KEY); + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.INVALID_PUBLIC_KEY); } @Test diff --git a/src/test/java/com/und/server/auth/repository/NonceRepositoryTest.java b/src/test/java/com/und/server/auth/repository/NonceRepositoryTest.java new file mode 100644 index 00000000..97e4a5f8 --- /dev/null +++ b/src/test/java/com/und/server/auth/repository/NonceRepositoryTest.java @@ -0,0 +1,144 @@ +package com.und.server.auth.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; + +import com.und.server.auth.entity.Nonce; +import com.und.server.auth.oauth.Provider; + +@DataRedisTest +class NonceRepositoryTest { + + @Autowired + private NonceRepository nonceRepository; + + @Test + @DisplayName("Saves a Kakao nonce and verifies the returned entity") + void Given_KakaoNonce_When_Save_Then_ReturnsSavedNonce() { + // given + final String nonceValue = UUID.randomUUID().toString(); + final Provider provider = Provider.KAKAO; + final Nonce nonce = Nonce.builder() + .value(nonceValue) + .provider(provider) + .build(); + + // when + final Nonce savedNonce = nonceRepository.save(nonce); + + // then + assertThat(savedNonce).isNotNull(); + assertThat(savedNonce.getValue()).isEqualTo(nonceValue); + assertThat(savedNonce.getProvider()).isEqualTo(provider); + } + + @Test + @DisplayName("Saves an Apple nonce and verifies the returned entity") + void Given_AppleNonce_When_Save_Then_ReturnsSavedNonce() { + // given + final String nonceValue = UUID.randomUUID().toString(); + final Provider provider = Provider.APPLE; + final Nonce nonce = Nonce.builder() + .value(nonceValue) + .provider(provider) + .build(); + + // when + final Nonce savedNonce = nonceRepository.save(nonce); + + // then + assertThat(savedNonce).isNotNull(); + assertThat(savedNonce.getValue()).isEqualTo(nonceValue); + assertThat(savedNonce.getProvider()).isEqualTo(provider); + } + + @Test + @DisplayName("Finds an existing Kakao nonce by its ID") + void Given_ExistingKakaoNonce_When_FindById_Then_ReturnsCorrectNonce() { + // given + final String nonceValue = UUID.randomUUID().toString(); + final Provider provider = Provider.KAKAO; + final Nonce nonce = Nonce.builder() + .value(nonceValue) + .provider(provider) + .build(); + nonceRepository.save(nonce); + + // when + final Optional foundNonceOptional = nonceRepository.findById(nonceValue); + + // then + assertThat(foundNonceOptional).isPresent().hasValueSatisfying(foundNonce -> { + assertThat(foundNonce.getValue()).isEqualTo(nonceValue); + assertThat(foundNonce.getProvider()).isEqualTo(provider); + }); + } + + @Test + @DisplayName("Finds an existing Apple nonce by its ID") + void Given_ExistingAppleNonce_When_FindById_Then_ReturnsCorrectNonce() { + // given + final String nonceValue = UUID.randomUUID().toString(); + final Provider provider = Provider.APPLE; + final Nonce nonce = Nonce.builder() + .value(nonceValue) + .provider(provider) + .build(); + nonceRepository.save(nonce); + + // when + final Optional foundNonceOptional = nonceRepository.findById(nonceValue); + + // then + assertThat(foundNonceOptional).isPresent().hasValueSatisfying(foundNonce -> { + assertThat(foundNonce.getValue()).isEqualTo(nonceValue); + assertThat(foundNonce.getProvider()).isEqualTo(provider); + }); + } + + @Test + @DisplayName("Deletes an existing Kakao nonce successfully") + void Given_ExistingKakaoNonce_When_DeleteById_Then_NonceIsRemoved() { + // given + final String nonceValue = UUID.randomUUID().toString(); + final Provider provider = Provider.KAKAO; + final Nonce nonce = Nonce.builder() + .value(nonceValue) + .provider(provider) + .build(); + nonceRepository.save(nonce); + + // when + nonceRepository.deleteById(nonceValue); + + // then + assertThat(nonceRepository.findById(nonceValue)).isNotPresent(); + } + + @Test + @DisplayName("Deletes an existing Apple nonce successfully") + void Given_ExistingAppleNonce_When_DeleteById_Then_NonceIsRemoved() { + // given + final String nonceValue = UUID.randomUUID().toString(); + final Provider provider = Provider.APPLE; + final Nonce nonce = Nonce.builder() + .value(nonceValue) + .provider(provider) + .build(); + nonceRepository.save(nonce); + + // when + nonceRepository.deleteById(nonceValue); + + // then + assertThat(nonceRepository.findById(nonceValue)).isNotPresent(); + } + +} diff --git a/src/test/java/com/und/server/auth/repository/RefreshTokenRepositoryTest.java b/src/test/java/com/und/server/auth/repository/RefreshTokenRepositoryTest.java new file mode 100644 index 00000000..4c88a858 --- /dev/null +++ b/src/test/java/com/und/server/auth/repository/RefreshTokenRepositoryTest.java @@ -0,0 +1,81 @@ +package com.und.server.auth.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; + +import com.und.server.auth.entity.RefreshToken; + +@DataRedisTest +class RefreshTokenRepositoryTest { + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Test + @DisplayName("Saves a refresh token and verifies its properties") + void Given_RefreshTokenDetails_When_SaveToken_Then_TokenIsPersistedCorrectly() { + // given + final Long memberId = 1L; + final String value = UUID.randomUUID().toString(); + final RefreshToken token = RefreshToken.builder() + .memberId(memberId) + .value(value) + .build(); + + // when + final RefreshToken result = refreshTokenRepository.save(token); + + // then + assertThat(result.getMemberId()).isEqualTo(memberId); + assertThat(result.getValue()).isEqualTo(value); + } + + @Test + @DisplayName("Finds a refresh token by its member ID") + void Given_ExistingRefreshToken_When_FindById_Then_ReturnsCorrectToken() { + // given + final Long memberId = 1L; + final String value = UUID.randomUUID().toString(); + final RefreshToken token = RefreshToken.builder() + .memberId(memberId) + .value(value) + .build(); + refreshTokenRepository.save(token); + + // when + final Optional foundToken = refreshTokenRepository.findById(memberId); + + // then + assertThat(foundToken).isPresent().hasValueSatisfying(result -> { + assertThat(result.getMemberId()).isEqualTo(memberId); + assertThat(result.getValue()).isEqualTo(value); + }); + } + + @Test + @DisplayName("Deletes an existing refresh token by its ID") + void Given_ExistingRefreshToken_When_DeleteById_Then_TokenIsRemoved() { + // given + final Long memberId = 1L; + final String value = UUID.randomUUID().toString(); + final RefreshToken token = RefreshToken.builder() + .memberId(memberId) + .value(value) + .build(); + final RefreshToken savedToken = refreshTokenRepository.save(token); + + // when + refreshTokenRepository.deleteById(savedToken.getMemberId()); + + // then + assertThat(refreshTokenRepository.findById(savedToken.getMemberId())).isNotPresent(); + } + +} diff --git a/src/test/java/com/und/server/auth/service/AuthServiceTest.java b/src/test/java/com/und/server/auth/service/AuthServiceTest.java new file mode 100644 index 00000000..4551f4a0 --- /dev/null +++ b/src/test/java/com/und/server/auth/service/AuthServiceTest.java @@ -0,0 +1,460 @@ +package com.und.server.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.auth.dto.OidcPublicKeys; +import com.und.server.auth.dto.request.AuthRequest; +import com.und.server.auth.dto.request.NonceRequest; +import com.und.server.auth.dto.request.RefreshTokenRequest; +import com.und.server.auth.dto.response.AuthResponse; +import com.und.server.auth.dto.response.NonceResponse; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.auth.jwt.JwtProperties; +import com.und.server.auth.jwt.JwtProvider; +import com.und.server.auth.jwt.ParsedTokenInfo; +import com.und.server.auth.oauth.OidcClient; +import com.und.server.auth.oauth.OidcClientFactory; +import com.und.server.auth.oauth.OidcProviderFactory; +import com.und.server.auth.oauth.Provider; +import com.und.server.common.dto.request.TestAuthRequest; +import com.und.server.common.exception.ServerException; +import com.und.server.common.util.ProfileManager; +import com.und.server.member.entity.Member; +import com.und.server.member.exception.MemberErrorResult; +import com.und.server.member.service.MemberService; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @InjectMocks + private AuthService authService; + + @Mock + private MemberService memberService; + + @Mock + private OidcClientFactory oidcClientFactory; + + @Mock + private OidcProviderFactory oidcProviderFactory; + + @Mock + private JwtProvider jwtProvider; + + @Mock + private JwtProperties jwtProperties; + + @Mock + private RefreshTokenService refreshTokenService; + + @Mock + private NonceService nonceService; + + @Mock + private ProfileManager profileManager; + + private final Long memberId = 1L; + private final String idToken = "dummy.id.token"; + private final String accessToken = "dummy.access.token"; + private final String refreshToken = "dummy.refresh.token"; + private final Integer accessTokenExpireTime = 3600; + private final Integer refreshTokenExpireTime = 7200; + + @Test + @DisplayName("Issues tokens for an existing Kakao member for testing purposes") + void Given_ExistingKakaoMemberForTest_When_IssueTokensForTest_Then_Succeeds() { + // given + final String providerId = "kakao-test-id"; + final TestAuthRequest request = new TestAuthRequest("kakao", providerId); + final Member existingMember = Member.builder().id(memberId).kakaoId(providerId).build(); + doReturn(existingMember).when(memberService).findOrCreateMember(any(Provider.class), any(String.class)); + setupTokenIssuance(accessToken, refreshToken); + + // when + final AuthResponse response = authService.issueTokensForTest(request); + + // then + verify(memberService).findOrCreateMember(any(Provider.class), any(String.class)); + verify(refreshTokenService).saveRefreshToken(memberId, refreshToken); + assertThat(response.accessToken()).isEqualTo(accessToken); + assertThat(response.refreshToken()).isEqualTo(refreshToken); + } + + @Test + @DisplayName("Creates a new Kakao member and issues tokens for testing purposes") + void Given_NewKakaoMemberForTest_When_IssueTokensForTest_Then_CreatesMemberAndSucceeds() { + // given + final String providerId = "kakao-test-id"; + final TestAuthRequest request = new TestAuthRequest("kakao", providerId); + final Member newMember = Member.builder().id(memberId).kakaoId(providerId).build(); + doReturn(newMember).when(memberService).findOrCreateMember(any(Provider.class), any(String.class)); + setupTokenIssuance(accessToken, refreshToken); + + // when + final AuthResponse response = authService.issueTokensForTest(request); + + // then + verify(memberService).findOrCreateMember(any(Provider.class), any(String.class)); + verify(refreshTokenService).saveRefreshToken(memberId, refreshToken); + assertThat(response.accessToken()).isEqualTo(accessToken); + assertThat(response.refreshToken()).isEqualTo(refreshToken); + } + + @Test + @DisplayName("Throws an exception on nonce generation with an invalid provider") + void Given_InvalidProvider_When_GenerateNonce_Then_ThrowsException() { + // given + final NonceRequest nonceRequest = new NonceRequest("facebook"); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> authService.generateNonce(nonceRequest)); + + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER); + } + + @Test + @DisplayName("Returns a nonce on a successful generation for Kakao") + void Given_KakaoProvider_When_GenerateNonce_Then_ReturnsNonce() { + // given + final String nonce = "generated-nonce"; + final String providerName = "kakao"; + final NonceRequest nonceRequest = new NonceRequest(providerName); + + doReturn(nonce).when(nonceService).generateNonceValue(); + doNothing().when(nonceService).saveNonce(nonce, Provider.KAKAO); + + // when + final NonceResponse response = authService.generateNonce(nonceRequest); + + // then + verify(nonceService).generateNonceValue(); + verify(nonceService).saveNonce(nonce, Provider.KAKAO); + assertThat(response.nonce()).isEqualTo(nonce); + } + + @Test + @DisplayName("Returns a nonce on a successful generation for Apple") + void Given_AppleProvider_When_GenerateNonce_Then_ReturnsNonce() { + // given + final String nonce = "generated-nonce"; + final String providerName = "apple"; + final NonceRequest nonceRequest = new NonceRequest(providerName); + + doReturn(nonce).when(nonceService).generateNonceValue(); + doNothing().when(nonceService).saveNonce(nonce, Provider.APPLE); + + // when + final NonceResponse response = authService.generateNonce(nonceRequest); + + // then + verify(nonceService).generateNonceValue(); + verify(nonceService).saveNonce(nonce, Provider.APPLE); + assertThat(response.nonce()).isEqualTo(nonce); + } + + @Test + @DisplayName("Throws an exception on login with an invalid provider") + void Given_InvalidProvider_When_Login_Then_ThrowsException() { + // given + final AuthRequest authRequest = new AuthRequest("facebook", idToken); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> authService.login(authRequest)); + + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER); + } + + @Test + @DisplayName("Issues tokens successfully when a registered Kakao member logs in") + void Given_RegisteredKakaoMember_When_Login_Then_IssuesTokensSuccessfully() { + // given + final AuthRequest authRequest = new AuthRequest("kakao", idToken); + final String providerId = "kakao-id-123"; + final OidcClient oidcClient = mock(OidcClient.class); + final OidcPublicKeys keys = mock(OidcPublicKeys.class); + final Member member = Member.builder().id(memberId).kakaoId(providerId).build(); + + doReturn("nonce").when(jwtProvider).extractNonce(idToken); + doNothing().when(nonceService).verifyNonce("nonce", Provider.KAKAO); + doReturn(oidcClient).when(oidcClientFactory).getOidcClient(Provider.KAKAO); + doReturn(keys).when(oidcClient).getOidcPublicKeys(); + doReturn(providerId).when(oidcProviderFactory).getProviderId(Provider.KAKAO, idToken, keys); + doReturn(member).when(memberService).findOrCreateMember(Provider.KAKAO, providerId); + setupTokenIssuance(accessToken, refreshToken); + + // when + final AuthResponse response = authService.login(authRequest); + + // then + verify(nonceService).verifyNonce("nonce", Provider.KAKAO); + verify(refreshTokenService).saveRefreshToken(memberId, refreshToken); + assertThat(response.tokenType()).isEqualTo("Bearer"); + assertThat(response.accessToken()).isEqualTo(accessToken); + assertThat(response.refreshToken()).isEqualTo(refreshToken); + assertThat(response.accessTokenExpiresIn()).isEqualTo(accessTokenExpireTime); + assertThat(response.refreshTokenExpiresIn()).isEqualTo(refreshTokenExpireTime); + } + + @Test + @DisplayName("Creates a new Kakao member and issues tokens on the first login") + void Given_NewKakaoMember_When_Login_Then_CreatesMemberAndIssuesTokens() { + // given + final AuthRequest authRequest = new AuthRequest("kakao", idToken); + final String providerId = "kakao-id-123"; + final OidcClient oidcClient = mock(OidcClient.class); + final OidcPublicKeys keys = mock(OidcPublicKeys.class); + final Member newMember = Member.builder().id(memberId).kakaoId(providerId).build(); + + doReturn("nonce").when(jwtProvider).extractNonce(idToken); + doReturn(oidcClient).when(oidcClientFactory).getOidcClient(Provider.KAKAO); + doReturn(keys).when(oidcClient).getOidcPublicKeys(); + doReturn(providerId).when(oidcProviderFactory).getProviderId(Provider.KAKAO, idToken, keys); + doReturn(newMember).when(memberService).findOrCreateMember(Provider.KAKAO, providerId); + setupTokenIssuance(accessToken, refreshToken); + + // when + final AuthResponse response = authService.login(authRequest); + + // then + verify(nonceService).verifyNonce("nonce", Provider.KAKAO); + verify(refreshTokenService).saveRefreshToken(memberId, refreshToken); + assertThat(response.accessToken()).isEqualTo(accessToken); + assertThat(response.refreshToken()).isEqualTo(refreshToken); + } + + @Test + @DisplayName("Issues tokens successfully when a registered Apple member logs in") + void Given_RegisteredAppleMember_When_Login_Then_IssuesTokensSuccessfully() { + // given + final AuthRequest authRequest = new AuthRequest("apple", idToken); + final String providerId = "apple-id-123"; + final OidcClient oidcClient = mock(OidcClient.class); + final OidcPublicKeys keys = mock(OidcPublicKeys.class); + final Member member = Member.builder().id(memberId).appleId(providerId).build(); + + doReturn("nonce").when(jwtProvider).extractNonce(idToken); + doNothing().when(nonceService).verifyNonce("nonce", Provider.APPLE); + doReturn(oidcClient).when(oidcClientFactory).getOidcClient(Provider.APPLE); + doReturn(keys).when(oidcClient).getOidcPublicKeys(); + doReturn(providerId).when(oidcProviderFactory).getProviderId(Provider.APPLE, idToken, keys); + doReturn(member).when(memberService).findOrCreateMember(Provider.APPLE, providerId); + setupTokenIssuance(accessToken, refreshToken); + + // when + final AuthResponse response = authService.login(authRequest); + + // then + verify(nonceService).verifyNonce("nonce", Provider.APPLE); + verify(refreshTokenService).saveRefreshToken(memberId, refreshToken); + assertThat(response.tokenType()).isEqualTo("Bearer"); + assertThat(response.accessToken()).isEqualTo(accessToken); + assertThat(response.refreshToken()).isEqualTo(refreshToken); + assertThat(response.accessTokenExpiresIn()).isEqualTo(accessTokenExpireTime); + assertThat(response.refreshTokenExpiresIn()).isEqualTo(refreshTokenExpireTime); + } + + @Test + @DisplayName("Creates a new Apple member and issues tokens on the first login") + void Given_NewAppleMember_When_Login_Then_CreatesMemberAndIssuesTokens() { + // given + final AuthRequest authRequest = new AuthRequest("apple", idToken); + final String providerId = "apple-id-123"; + final OidcClient oidcClient = mock(OidcClient.class); + final OidcPublicKeys keys = mock(OidcPublicKeys.class); + final Member newMember = Member.builder().id(memberId).appleId(providerId).build(); + + doReturn("nonce").when(jwtProvider).extractNonce(idToken); + doReturn(oidcClient).when(oidcClientFactory).getOidcClient(Provider.APPLE); + doReturn(keys).when(oidcClient).getOidcPublicKeys(); + doReturn(providerId).when(oidcProviderFactory).getProviderId(Provider.APPLE, idToken, keys); + doReturn(newMember).when(memberService).findOrCreateMember(Provider.APPLE, providerId); + setupTokenIssuance(accessToken, refreshToken); + + // when + final AuthResponse response = authService.login(authRequest); + + // then + verify(nonceService).verifyNonce("nonce", Provider.APPLE); + verify(refreshTokenService).saveRefreshToken(memberId, refreshToken); + assertThat(response.accessToken()).isEqualTo(accessToken); + assertThat(response.refreshToken()).isEqualTo(refreshToken); + } + + @Test + @DisplayName("Throws an exception on token reissue if the token contains an invalid member ID") + void Given_TokenWithInvalidMemberId_When_ReissueTokens_Then_ThrowsExceptionAndDoesNotDeleteToken() { + // given + final Long nullMemberId = null; + final ParsedTokenInfo invalidTokenInfo = new ParsedTokenInfo(nullMemberId, true); + final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); + + doReturn(invalidTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); + doThrow(new ServerException(MemberErrorResult.INVALID_MEMBER_ID)) + .when(memberService).checkMemberExists(nullMemberId); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> authService.reissueTokens(request)); + + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); + // Crucially, we should not attempt to delete a refresh token with a null ID. + verify(refreshTokenService, never()).deleteRefreshToken(any()); + verify(refreshTokenService, never()).verifyRefreshToken(any(), any()); + } + + @Test + @DisplayName("Throws an exception on token reissue if the member does not exist") + void Given_NonExistentMember_When_ReissueTokens_Then_ThrowsExceptionAndDeletesToken() { + // given + final ParsedTokenInfo expiredTokenInfo = new ParsedTokenInfo(memberId, true); + final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); + + doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); + doThrow(new ServerException(MemberErrorResult.MEMBER_NOT_FOUND)) + .when(memberService).checkMemberExists(memberId); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> authService.reissueTokens(request)); + + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); + verify(refreshTokenService).deleteRefreshToken(memberId); + verify(refreshTokenService, never()).verifyRefreshToken(any(), any()); + } + + @Test + @DisplayName("Throws an exception when reissuing tokens with a mismatched refresh token") + void Given_MismatchedRefreshToken_When_ReissueTokens_Then_ThrowsException() { + // given + final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, "wrong.refresh.token"); + final ParsedTokenInfo expiredTokenInfo = new ParsedTokenInfo(memberId, true); + + doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); + doNothing().when(memberService).checkMemberExists(memberId); + doThrow(new ServerException(AuthErrorResult.INVALID_TOKEN)) + .when(refreshTokenService).verifyRefreshToken(memberId, "wrong.refresh.token"); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> authService.reissueTokens(request)); + + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); + } + + @Test + @DisplayName("Throws an exception on token reissue if no refresh token is stored") + void Given_NoStoredRefreshToken_When_ReissueTokens_Then_ThrowsException() { + // given + final ParsedTokenInfo expiredTokenInfo = new ParsedTokenInfo(memberId, true); + final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); + + doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); + doNothing().when(memberService).checkMemberExists(memberId); + doThrow(new ServerException(AuthErrorResult.INVALID_TOKEN)) + .when(refreshTokenService).verifyRefreshToken(memberId, refreshToken); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> authService.reissueTokens(request)); + + verify(jwtProvider, never()).generateAccessToken(any()); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); + } + + @Test + @DisplayName("Reissues tokens successfully with a valid refresh token") + void Given_ValidRefreshToken_When_ReissueTokens_Then_Succeeds() { + // given + final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); + final String newAccessToken = "new-access-token"; + final String newRefreshToken = "new-refresh-token"; + final ParsedTokenInfo expiredTokenInfo = new ParsedTokenInfo(memberId, true); + + doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); + doNothing().when(memberService).checkMemberExists(memberId); + doNothing().when(refreshTokenService).verifyRefreshToken(memberId, refreshToken); + setupTokenIssuance(newAccessToken, newRefreshToken); + + // when + final AuthResponse response = authService.reissueTokens(request); + + // then + verify(refreshTokenService).saveRefreshToken(memberId, newRefreshToken); + assertThat(response.accessToken()).isEqualTo(newAccessToken); + assertThat(response.refreshToken()).isEqualTo(newRefreshToken); + assertThat(response.accessTokenExpiresIn()).isEqualTo(accessTokenExpireTime); + assertThat(response.refreshTokenExpiresIn()).isEqualTo(refreshTokenExpireTime); + } + + @Test + @DisplayName("Throws INVALID_TOKEN and deletes refresh token for a non-expired token on prod/stg profiles") + void Given_NonExpiredTokenOnProd_When_ReissueTokens_Then_ThrowsInvalidToken() { + // given + final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); + final ParsedTokenInfo nonExpiredTokenInfo = new ParsedTokenInfo(memberId, false); + + doReturn(nonExpiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); + doReturn(true).when(profileManager).isProdOrStgProfile(); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> authService.reissueTokens(request)); + + verify(refreshTokenService).deleteRefreshToken(memberId); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); + } + + @Test + @DisplayName("Throws NOT_EXPIRED_TOKEN and deletes refresh token for a non-expired token on dev/local profiles") + void Given_NonExpiredTokenOnDev_When_ReissueTokens_Then_ThrowsNotExpiredToken() { + // given + final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); + final ParsedTokenInfo nonExpiredTokenInfo = new ParsedTokenInfo(memberId, false); + + doReturn(nonExpiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); + doReturn(false).when(profileManager).isProdOrStgProfile(); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> authService.reissueTokens(request)); + + verify(refreshTokenService).deleteRefreshToken(memberId); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.NOT_EXPIRED_TOKEN); + } + + @Test + @DisplayName("Deletes refresh token on logout") + void Given_MemberId_When_Logout_Then_DeletesRefreshToken() { + // when + authService.logout(memberId); + + // then + verify(refreshTokenService).deleteRefreshToken(memberId); + } + + private void setupTokenIssuance(final String newAccessToken, final String newRefreshToken) { + doReturn(newAccessToken).when(jwtProvider).generateAccessToken(memberId); + doReturn(newRefreshToken).when(refreshTokenService).generateRefreshToken(); + doReturn("Bearer").when(jwtProperties).type(); + doReturn(accessTokenExpireTime).when(jwtProperties).accessTokenExpireTime(); + doReturn(refreshTokenExpireTime).when(jwtProperties).refreshTokenExpireTime(); + } + +} diff --git a/src/test/java/com/und/server/auth/service/NonceServiceTest.java b/src/test/java/com/und/server/auth/service/NonceServiceTest.java new file mode 100644 index 00000000..b9b704f1 --- /dev/null +++ b/src/test/java/com/und/server/auth/service/NonceServiceTest.java @@ -0,0 +1,168 @@ +package com.und.server.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.auth.entity.Nonce; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.auth.oauth.Provider; +import com.und.server.auth.repository.NonceRepository; +import com.und.server.common.exception.ServerException; + +@ExtendWith(MockitoExtension.class) +class NonceServiceTest { + + @InjectMocks + private NonceService nonceService; + + @Mock + private NonceRepository nonceRepository; + + private final String nonceValue = "test-nonce"; + + @Test + @DisplayName("Generates a new nonce in UUID format") + void Given_Nothing_When_GenerateNonceValue_Then_ReturnsUuid() { + // when + final String generatedNonce = nonceService.generateNonceValue(); + + // then + assertThat(generatedNonce).isNotNull(); + assertDoesNotThrow(() -> UUID.fromString(generatedNonce)); + } + + @Test + @DisplayName("Throws an exception when validating with a null nonce value") + void Given_NullNonceValue_When_VerifyNonce_Then_ThrowsException() { + // when & then + final Provider provider = Provider.KAKAO; + final ServerException exception = assertThrows(ServerException.class, + () -> nonceService.verifyNonce(null, provider)); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_NONCE); + } + + @Test + @DisplayName("Throws an exception when validating with a null provider") + void Given_NullProvider_When_VerifyNonce_Then_ThrowsException() { + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> nonceService.verifyNonce(nonceValue, null)); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER); + } + + @Test + @DisplayName("Throws an exception when saving with a null nonce value") + void Given_NullNonceValue_When_SaveNonce_Then_ThrowsException() { + // when & then + final Provider provider = Provider.KAKAO; + final ServerException exception = assertThrows(ServerException.class, + () -> nonceService.saveNonce(null, provider)); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_NONCE); + } + + @Test + @DisplayName("Throws an exception when saving with a null provider") + void Given_NullProvider_When_SaveNonce_Then_ThrowsException() { + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> nonceService.saveNonce(nonceValue, null)); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER); + } + + @Test + @DisplayName("Succeeds validation for a valid Kakao nonce") + void Given_ValidKakaoNonce_When_VerifyNonce_Then_SucceedsAndDeletesNonce() { + // given + final Provider provider = Provider.KAKAO; + final Nonce savedNonce = Nonce.builder().value(nonceValue).provider(provider).build(); + doReturn(Optional.of(savedNonce)).when(nonceRepository).findById(nonceValue); + + // when & then + assertDoesNotThrow(() -> nonceService.verifyNonce(nonceValue, provider)); + verify(nonceRepository).deleteById(nonceValue); + } + + @Test + @DisplayName("Succeeds validation for a valid Apple nonce") + void Given_ValidAppleNonce_When_VerifyNonce_Then_SucceedsAndDeletesNonce() { + // given + final Provider provider = Provider.APPLE; + final Nonce savedNonce = Nonce.builder().value(nonceValue).provider(provider).build(); + doReturn(Optional.of(savedNonce)).when(nonceRepository).findById(nonceValue); + + // when & then + assertDoesNotThrow(() -> nonceService.verifyNonce(nonceValue, provider)); + verify(nonceRepository).deleteById(nonceValue); + } + + @Test + @DisplayName("Throws an exception for a non-existent nonce") + void Given_NonExistentNonce_When_VerifyNonce_Then_ThrowsException() { + // given + doReturn(Optional.empty()).when(nonceRepository).findById(nonceValue); + final Provider provider = Provider.KAKAO; + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> nonceService.verifyNonce(nonceValue, provider)); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_NONCE); + } + + @Test + @DisplayName("Throws an exception for a nonce with a mismatched provider") + void Given_MismatchedProvider_When_VerifyNonce_Then_ThrowsException() { + // given + // Nonce is saved with KAKAO provider + final Nonce savedNonce = Nonce.builder().value(nonceValue).provider(Provider.KAKAO).build(); + doReturn(Optional.of(savedNonce)).when(nonceRepository).findById(nonceValue); + + // but validation is attempted with a different provider + final Provider differentProvider = Provider.APPLE; + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> nonceService.verifyNonce(nonceValue, differentProvider)); + + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_NONCE); + // The nonce should not be deleted if the provider does not match + verify(nonceRepository, never()).deleteById(nonceValue); + } + + @Test + @DisplayName("Saves a Kakao nonce successfully") + void Given_KakaoNonce_When_SaveNonce_Then_SavesToRepository() { + // when + final Provider provider = Provider.KAKAO; + nonceService.saveNonce(nonceValue, provider); + + // then + verify(nonceRepository).save(any(Nonce.class)); + } + + @Test + @DisplayName("Saves an Apple nonce successfully") + void Given_AppleNonce_When_SaveNonce_Then_SavesToRepository() { + // when + final Provider provider = Provider.APPLE; + nonceService.saveNonce(nonceValue, provider); + + // then + verify(nonceRepository).save(any(Nonce.class)); + } + +} diff --git a/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java b/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java new file mode 100644 index 00000000..db9f549e --- /dev/null +++ b/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java @@ -0,0 +1,194 @@ +package com.und.server.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; + +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.auth.entity.RefreshToken; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.auth.repository.RefreshTokenRepository; +import com.und.server.common.exception.ServerException; +import com.und.server.member.exception.MemberErrorResult; + +@ExtendWith(MockitoExtension.class) +class RefreshTokenServiceTest { + + @InjectMocks + private RefreshTokenService refreshTokenService; + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + private final Long memberId = 1L; + private final String refreshTokenValue = "test-refresh-token"; + + @Test + @DisplayName("Generates a new refresh token in UUID format") + void Given_Nothing_When_GenerateRefreshToken_Then_ReturnsUuid() { + // when + final String generatedToken = refreshTokenService.generateRefreshToken(); + + // then + assertThat(generatedToken).isNotNull(); + assertDoesNotThrow(() -> UUID.fromString(generatedToken)); + } + + @Test + @DisplayName("Saves a refresh token to the repository") + void Given_MemberIdAndToken_When_SaveRefreshToken_Then_CallsRepositorySave() { + // given + final ArgumentCaptor captor = ArgumentCaptor.forClass(RefreshToken.class); + + // when + refreshTokenService.saveRefreshToken(memberId, refreshTokenValue); + + // then + verify(refreshTokenRepository).save(captor.capture()); + final RefreshToken capturedToken = captor.getValue(); + + assertThat(capturedToken.getMemberId()).isEqualTo(memberId); + assertThat(capturedToken.getValue()).isEqualTo(refreshTokenValue); + } + + @Test + @DisplayName("Throws an exception when saving a null token") + void Given_NullToken_When_SaveRefreshToken_Then_ThrowsException() { + // when + final ServerException exception = assertThrows(ServerException.class, + () -> refreshTokenService.saveRefreshToken(memberId, null)); + + // then + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); + } + + @Test + @DisplayName("Throws an exception when validating with a null token") + void Given_NullToken_When_VerifyRefreshToken_Then_ThrowsException() { + // when + final ServerException exception = assertThrows(ServerException.class, + () -> refreshTokenService.verifyRefreshToken(memberId, null)); + + // then + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); + } + + @Test + @DisplayName("Throws an exception when saving a refresh token with a null member ID") + void Given_NullMemberId_When_SaveRefreshToken_Then_ThrowsException() { + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> refreshTokenService.saveRefreshToken(null, refreshTokenValue)); + + assertThat(exception.getErrorResult()).isEqualTo(MemberErrorResult.INVALID_MEMBER_ID); + } + + @Test + @DisplayName("Throws an exception when validating a refresh token with a null member ID") + void Given_NullMemberId_When_VerifyRefreshToken_Then_ThrowsException() { + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> refreshTokenService.verifyRefreshToken(null, refreshTokenValue)); + + assertThat(exception.getErrorResult()).isEqualTo(MemberErrorResult.INVALID_MEMBER_ID); + } + + @Test + @DisplayName("Throws an exception when deleting a refresh token with a null member ID") + void Given_NullMemberId_When_DeleteRefreshToken_Then_ThrowsException() { + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> refreshTokenService.deleteRefreshToken(null)); + + assertThat(exception.getErrorResult()).isEqualTo(MemberErrorResult.INVALID_MEMBER_ID); + } + + @Test + @DisplayName("Succeeds validation if the provided token matches the stored one") + void Given_MatchingToken_When_VerifyRefreshToken_Then_Succeeds() { + // given + final RefreshToken savedToken = RefreshToken.builder() + .memberId(memberId) + .value(refreshTokenValue) + .build(); + doReturn(Optional.of(savedToken)).when(refreshTokenRepository).findById(memberId); + + // when & then + assertDoesNotThrow(() -> refreshTokenService.verifyRefreshToken(memberId, refreshTokenValue)); + } + + @Test + @DisplayName("Throws an exception and deletes the token if it does not match") + void Given_MismatchedToken_When_VerifyRefreshToken_Then_ThrowsExceptionAndDeletes() { + // given + final RefreshToken savedToken = RefreshToken.builder() + .memberId(memberId) + .value(refreshTokenValue) + .build(); + doReturn(Optional.of(savedToken)).when(refreshTokenRepository).findById(memberId); + + // when + final ServerException exception = assertThrows(ServerException.class, + () -> refreshTokenService.verifyRefreshToken(memberId, "wrong-token")); + + // then + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); + verify(refreshTokenRepository).deleteById(memberId); + } + + @Test + @DisplayName("Throws an exception if the stored token value is null") + void Given_StoredTokenWithValueNull_When_VerifyRefreshToken_Then_ThrowsException() { + // given + final RefreshToken savedTokenWithNullValue = RefreshToken.builder() + .memberId(memberId) + .value(null) + .build(); + doReturn(Optional.of(savedTokenWithNullValue)).when(refreshTokenRepository).findById(memberId); + + // when + final ServerException exception = assertThrows(ServerException.class, + () -> refreshTokenService.verifyRefreshToken(memberId, refreshTokenValue)); + + // then + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); + verify(refreshTokenRepository).deleteById(memberId); + } + + @Test + @DisplayName("Throws an exception if no token is stored for validation") + void Given_NoStoredToken_When_VerifyRefreshToken_Then_ThrowsException() { + // given + doReturn(Optional.empty()).when(refreshTokenRepository).findById(memberId); + + // when + final ServerException exception = assertThrows(ServerException.class, + () -> refreshTokenService.verifyRefreshToken(memberId, refreshTokenValue)); + + // then + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); + verify(refreshTokenRepository).deleteById(memberId); + } + + @Test + @DisplayName("Deletes a refresh token") + void Given_MemberId_When_DeleteRefreshToken_Then_CallsRepositoryDelete() { + // when + refreshTokenService.deleteRefreshToken(memberId); + + // then + verify(refreshTokenRepository).deleteById(memberId); + } +} diff --git a/src/test/java/com/und/server/common/controller/TestControllerTest.java b/src/test/java/com/und/server/common/controller/TestControllerTest.java new file mode 100644 index 00000000..93d7f5ca --- /dev/null +++ b/src/test/java/com/und/server/common/controller/TestControllerTest.java @@ -0,0 +1,258 @@ +package com.und.server.common.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.und.server.auth.dto.response.AuthResponse; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.auth.filter.AuthMemberArgumentResolver; +import com.und.server.auth.service.AuthService; +import com.und.server.common.dto.request.TestAuthRequest; +import com.und.server.common.exception.GlobalExceptionHandler; +import com.und.server.common.exception.ServerException; +import com.und.server.member.dto.response.MemberResponse; +import com.und.server.member.entity.Member; +import com.und.server.member.exception.MemberErrorResult; +import com.und.server.member.service.MemberService; +import com.und.server.terms.dto.response.TermsAgreementResponse; +import com.und.server.terms.service.TermsService; + +@ExtendWith(MockitoExtension.class) +class TestControllerTest { + + @InjectMocks + private TestController testController; + + @Mock + private MemberService memberService; + + @Mock + private AuthService authService; + + @Mock + private TermsService termsService; + + @Mock + private AuthMemberArgumentResolver authMemberArgumentResolver; + + private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void init() { + mockMvc = MockMvcBuilders.standaloneSetup(testController) + .setCustomArgumentResolvers(authMemberArgumentResolver) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + @DisplayName("Issues tokens for an existing member") + void Given_ExistingMember_When_LoginWithoutProviderId_Then_ReturnsCreatedWithTokens() throws Exception { + // given + final String url = "/v1/test/access"; + final TestAuthRequest request = new TestAuthRequest("kakao", "dummy.provider.id"); + final AuthResponse expectedResponse = new AuthResponse( + "Bearer", + "access-token", + 3600, + "refresh-token", + 7200 + ); + doReturn(expectedResponse).when(authService).issueTokensForTest(request); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + final AuthResponse actualResponse = objectMapper.readValue( + resultActions + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8), AuthResponse.class + ); + + resultActions.andExpect(status().isCreated()); + assertThat(actualResponse).usingRecursiveComparison().isEqualTo(expectedResponse); + } + + @Test + @DisplayName("Creates a new member and issues tokens when member does not exist") + void Given_NonExistingMember_When_LoginWithoutProviderId_Then_CreatesMemberAndReturns()throws Exception { + // given + final String url = "/v1/test/access"; + final TestAuthRequest request = new TestAuthRequest("kakao", "provider-id-456"); + final AuthResponse expectedResponse = new AuthResponse( + "Bearer", + "new-access-token", + 3600, + "new-refresh-token", + 7200 + ); + + doReturn(expectedResponse).when(authService).issueTokensForTest(request); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + final AuthResponse actualResponse = objectMapper.readValue( + resultActions + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8), AuthResponse.class + ); + + resultActions.andExpect(status().isCreated()); + assertThat(actualResponse).usingRecursiveComparison().isEqualTo(expectedResponse); + } + + @Test + @DisplayName("Fails to greet and returns not found when the authenticated member is not found") + void Given_AuthenticatedUserNotFoundInDb_When_Greet_Then_ReturnsNotFound() throws Exception { + // given + final String url = "/v1/test/hello"; + final Long memberId = 3L; + + doThrow(new ServerException(MemberErrorResult.MEMBER_NOT_FOUND)).when(memberService).findMemberById(memberId); + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + + // when + final ResultActions result = mockMvc.perform( + MockMvcRequestBuilders.get(url) + ); + + // then + result.andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(MemberErrorResult.MEMBER_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").value(MemberErrorResult.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("Fails to greet and returns unauthorized when user is not authenticated") + void Given_UnauthenticatedUser_When_Greet_Then_ReturnsUnauthorized() throws Exception { + // given + final String url = "/v1/test/hello"; + final AuthErrorResult errorResult = AuthErrorResult.UNAUTHORIZED_ACCESS; + + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doThrow(new ServerException(errorResult)) + .when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + + // when + final ResultActions result = mockMvc.perform( + MockMvcRequestBuilders.get(url) + ); + + // then + result.andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(errorResult.name())) + .andExpect(jsonPath("$.message").value(errorResult.getMessage())); + } + + @Test + @DisplayName("Returns a personalized greeting for an authenticated user with a nickname") + void Given_AuthenticatedUserWithNickname_When_Greet_Then_ReturnsOkWithPersonalizedMessage() throws Exception { + // given + final String url = "/v1/test/hello"; + final Long memberId = 1L; + final Member member = Member.builder().id(memberId).nickname("Chori").build(); + + doReturn(member).when(memberService).findMemberById(memberId); + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + + // when + final ResultActions result = mockMvc.perform( + MockMvcRequestBuilders.get(url) + ); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Hello, Chori!")); + } + + @Test + @DisplayName("Retrieves all members and returns them as a list of MemberResponse DTOs") + void Given_ExistingMembers_When_GetMemberList_Then_ReturnsListOfMemberResponses() throws Exception { + // given + final String url = "/v1/test/members"; + final List expectedResponse = List.of( + new MemberResponse(1L, "user1", "dummyKakaoId", null, null, null), + new MemberResponse(2L, "user2", null, "dummyAppleId", null, null) + ); + doReturn(expectedResponse).when(memberService).getMemberList(); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.get(url) + ); + + // then + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].id").value(1L)) + .andExpect(jsonPath("$[0].nickname").value("user1")) + .andExpect(jsonPath("$[1].id").value(2L)) + .andExpect(jsonPath("$[1].nickname").value("user2")); + } + + @Test + @DisplayName("Returns a list of terms agreements when terms exist") + void Given_TermsExist_When_GetTermsList_Then_ReturnsOkWithTermsList() throws Exception { + // given + final String url = "/v1/test/terms"; + final List expectedResponse = List.of( + new TermsAgreementResponse(1L, 101L, true, true, true, false), + new TermsAgreementResponse(2L, 102L, true, true, true, true) + ); + doReturn(expectedResponse).when(termsService).getTermsList(); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.get(url) + ); + + // then + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].id").value(1L)) + .andExpect(jsonPath("$[0].memberId").value(101L)) + .andExpect(jsonPath("$[0].termsOfServiceAgreed").value(true)) + .andExpect(jsonPath("$[1].id").value(2L)) + .andExpect(jsonPath("$[1].memberId").value(102L)) + .andExpect(jsonPath("$[1].eventPushAgreed").value(true)); + } +} diff --git a/src/test/java/com/und/server/common/util/ProfileManagerTest.java b/src/test/java/com/und/server/common/util/ProfileManagerTest.java new file mode 100644 index 00000000..137faa9a --- /dev/null +++ b/src/test/java/com/und/server/common/util/ProfileManagerTest.java @@ -0,0 +1,74 @@ +package com.und.server.common.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.env.Environment; + +@ExtendWith(MockitoExtension.class) +class ProfileManagerTest { + + @InjectMocks + private ProfileManager profileManager; + + @Mock + private Environment environment; + + @Test + @DisplayName("Returns true when 'prod' profile is active") + void Given_ProdProfile_When_IsProdOrStgProfile_Then_ReturnsTrue() { + // given + doReturn(new String[] {"prod"}).when(environment).getActiveProfiles(); + + // when + final boolean result = profileManager.isProdOrStgProfile(); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("Returns true when 'stg' profile is active") + void Given_StgProfile_When_IsProdOrStgProfile_Then_ReturnsTrue() { + // given + doReturn(new String[] {"stg"}).when(environment).getActiveProfiles(); + + // when + final boolean result = profileManager.isProdOrStgProfile(); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("Returns false when a non-prod/stg profile is active") + void Given_DevProfile_When_IsProdOrStgProfile_Then_ReturnsFalse() { + // given + doReturn(new String[] {"dev"}).when(environment).getActiveProfiles(); + + // when + final boolean result = profileManager.isProdOrStgProfile(); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("Returns false when no profiles are active") + void Given_NoActiveProfiles_When_IsProdOrStgProfile_Then_ReturnsFalse() { + // given + doReturn(new String[] {}).when(environment).getActiveProfiles(); + + // when + final boolean result = profileManager.isProdOrStgProfile(); + + // then + assertThat(result).isFalse(); + } +} diff --git a/src/test/java/com/und/server/controller/TestControllerTest.java b/src/test/java/com/und/server/controller/TestControllerTest.java deleted file mode 100644 index d886eab0..00000000 --- a/src/test/java/com/und/server/controller/TestControllerTest.java +++ /dev/null @@ -1,189 +0,0 @@ -package com.und.server.controller; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.doReturn; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import java.nio.charset.StandardCharsets; -import java.util.Optional; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.MediaType; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.und.server.dto.AuthResponse; -import com.und.server.dto.TestAuthRequest; -import com.und.server.entity.Member; -import com.und.server.exception.GlobalExceptionHandler; -import com.und.server.exception.ServerErrorResult; -import com.und.server.repository.MemberRepository; -import com.und.server.service.AuthService; - -@ExtendWith(MockitoExtension.class) -class TestControllerTest { - - @InjectMocks - private TestController testController; - - @Mock - private MemberRepository memberRepository; - - @Mock - private AuthService authService; - - private MockMvc mockMvc; - private final ObjectMapper objectMapper = new ObjectMapper(); - - @BeforeEach - void init() { - mockMvc = MockMvcBuilders.standaloneSetup(testController) - .setControllerAdvice(new GlobalExceptionHandler()) - .build(); - } - - @Test - @DisplayName("Issues tokens for an existing member") - void Given_ExistingMember_When_RequestAccessToken_Then_ReturnsOkWithTokens() throws Exception { - // given - final String url = "/v1/test/access"; - final TestAuthRequest request = new TestAuthRequest("kakao", "dummy.provider.id", "Chori"); - final AuthResponse expectedResponse = new AuthResponse( - "Bearer", - "access-token", - 3600, - "refresh-token", - 7200 - ); - doReturn(expectedResponse).when(authService).issueTokensForTest(request); - - // when - final ResultActions resultActions = mockMvc.perform( - MockMvcRequestBuilders.post(url) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - final AuthResponse actualResponse = objectMapper.readValue( - resultActions - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8), AuthResponse.class - ); - - resultActions.andExpect(status().isOk()); - assertThat(actualResponse).usingRecursiveComparison().isEqualTo(expectedResponse); - } - - @Test - @DisplayName("Creates a new member and issues tokens when member does not exist") - void Given_NonExistingMember_When_RequestAccessToken_Then_CreatesMemberAndReturnsOkWithTokens() throws Exception { - // given - final String url = "/v1/test/access"; - final TestAuthRequest request = new TestAuthRequest("kakao", "provider-id-456", "Newbie"); - final AuthResponse expectedResponse = new AuthResponse( - "Bearer", - "new-access-token", - 3600, - "new-refresh-token", - 7200 - ); - - doReturn(expectedResponse).when(authService).issueTokensForTest(request); - - // when - final ResultActions resultActions = mockMvc.perform( - MockMvcRequestBuilders.post(url) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - final AuthResponse actualResponse = objectMapper.readValue( - resultActions - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8), AuthResponse.class - ); - - resultActions.andExpect(status().isOk()); - assertThat(actualResponse).usingRecursiveComparison().isEqualTo(expectedResponse); - } - - @Test - @DisplayName("Fails to greet and returns unauthorized when the authenticated member is not found") - void Given_AuthenticatedUserNotFoundInDb_When_Greet_Then_ReturnsUnauthorized() throws Exception { - // given - final String url = "/v1/test/hello"; - final Long memberId = 3L; - - doReturn(Optional.empty()).when(memberRepository).findById(memberId); - final Authentication auth = new UsernamePasswordAuthenticationToken(memberId, null); - - // when - final ResultActions result = mockMvc.perform( - MockMvcRequestBuilders.get(url).principal(auth) - ); - - // then - result.andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.code").value(ServerErrorResult.MEMBER_NOT_FOUND.name())) - .andExpect(jsonPath("$.message").value(ServerErrorResult.MEMBER_NOT_FOUND.getMessage())); - } - - @Test - @DisplayName("Returns a personalized greeting for an authenticated user with a nickname") - void Given_AuthenticatedUserWithNickname_When_Greet_Then_ReturnsOkWithPersonalizedMessage() throws Exception { - // given - final String url = "/v1/test/hello"; - final Long memberId = 1L; - final Member member = Member.builder().id(memberId).nickname("Chori").build(); - - doReturn(Optional.of(member)).when(memberRepository).findById(memberId); - final Authentication auth = new UsernamePasswordAuthenticationToken(memberId, null); - - // when - final ResultActions result = mockMvc.perform( - MockMvcRequestBuilders.get(url).principal(auth) - ); - - // then - result.andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Hello, Chori!")); - } - - @Test - @DisplayName("Returns a default greeting for an authenticated user without a nickname") - void Given_AuthenticatedUserWithoutNickname_When_Greet_Then_ReturnsOkWithDefaultMessage() throws Exception { - // given - final String url = "/v1/test/hello"; - final Long memberId = 2L; - final Member member = Member.builder().id(memberId).nickname(null).build(); - - doReturn(Optional.of(member)).when(memberRepository).findById(memberId); - final Authentication auth = new UsernamePasswordAuthenticationToken(memberId, null); - - // when - final ResultActions result = mockMvc.perform( - MockMvcRequestBuilders.get(url).principal(auth) - ); - - // then - result.andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Hello, Member!")); - } - -} diff --git a/src/test/java/com/und/server/member/controller/MemberControllerTest.java b/src/test/java/com/und/server/member/controller/MemberControllerTest.java new file mode 100644 index 00000000..03f46ab1 --- /dev/null +++ b/src/test/java/com/und/server/member/controller/MemberControllerTest.java @@ -0,0 +1,176 @@ +package com.und.server.member.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.auth.filter.AuthMemberArgumentResolver; +import com.und.server.common.exception.CommonErrorResult; +import com.und.server.common.exception.GlobalExceptionHandler; +import com.und.server.common.exception.ServerException; +import com.und.server.member.dto.request.NicknameRequest; +import com.und.server.member.dto.response.MemberResponse; +import com.und.server.member.service.MemberService; + +@ExtendWith(MockitoExtension.class) +class MemberControllerTest { + + @InjectMocks + private MemberController memberController; + + @Mock + private MemberService memberService; + + @Mock + private AuthMemberArgumentResolver authMemberArgumentResolver; + + private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void init() { + mockMvc = MockMvcBuilders.standaloneSetup(memberController) + .setCustomArgumentResolvers(authMemberArgumentResolver) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + @DisplayName("Fails to update nickname with bad request when nickname is null") + void Given_NullNickname_When_UpdateNickname_Then_ReturnsBadRequest() throws Exception { + // given + final String url = "/v1/member/nickname"; + final NicknameRequest request = new NicknameRequest(null); + final String requestBody = objectMapper.writeValueAsString(request); + final Long memberId = 1L; + + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.patch(url) + .content(requestBody) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(CommonErrorResult.INVALID_PARAMETER.name())) + .andExpect(jsonPath("$.message[0]").value("Nickname must not be blank")); + } + + @Test + @DisplayName("Fails to update nickname and returns unauthorized when user is not authenticated") + void Given_UnauthenticatedUser_When_UpdateNickname_Then_ReturnsUnauthorized() throws Exception { + // given + final String url = "/v1/member/nickname"; + final NicknameRequest request = new NicknameRequest("new-nickname"); + final String requestBody = objectMapper.writeValueAsString(request); + final AuthErrorResult errorResult = AuthErrorResult.UNAUTHORIZED_ACCESS; + + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doThrow(new ServerException(errorResult)) + .when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.patch(url) + .content(requestBody) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions.andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(errorResult.name())) + .andExpect(jsonPath("$.message").value(errorResult.getMessage())); + } + + @Test + @DisplayName("Succeeds in updating nickname for an authenticated user") + void Given_AuthenticatedUser_When_UpdateNickname_Then_ReturnsOkWithUpdatedInfo() throws Exception { + // given + final String url = "/v1/member/nickname"; + final Long memberId = 1L; + final String newNickname = "new-nickname"; + final NicknameRequest request = new NicknameRequest(newNickname); + final MemberResponse response = new MemberResponse(memberId, newNickname, null, null, null, null); + + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + doReturn(response).when(memberService).updateNickname(memberId, request); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.patch(url) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(memberId)) + .andExpect(jsonPath("$.nickname").value(newNickname)); + } + + @Test + @DisplayName("Fails to delete member and returns unauthorized when user is not authenticated") + void Given_UnauthenticatedUser_When_DeleteMember_Then_ReturnsUnauthorized() throws Exception { + // given + final String url = "/v1/member"; + final AuthErrorResult errorResult = AuthErrorResult.UNAUTHORIZED_ACCESS; + + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doThrow(new ServerException(errorResult)) + .when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.delete(url) + ); + + // then + resultActions.andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(errorResult.name())) + .andExpect(jsonPath("$.message").value(errorResult.getMessage())); + } + + @Test + @DisplayName("Succeeds in deleting member for an authenticated user") + void Given_AuthenticatedUser_When_DeleteMember_Then_ReturnsNoContent() throws Exception { + // given + final String url = "/v1/member"; + final Long memberId = 1L; + + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.delete(url) + ); + + // then + resultActions.andExpect(status().isNoContent()); + verify(memberService).deleteMemberById(memberId); + } + +} diff --git a/src/test/java/com/und/server/member/dto/MemberResponseTest.java b/src/test/java/com/und/server/member/dto/MemberResponseTest.java new file mode 100644 index 00000000..f4dd4a01 --- /dev/null +++ b/src/test/java/com/und/server/member/dto/MemberResponseTest.java @@ -0,0 +1,57 @@ +package com.und.server.member.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.und.server.member.dto.response.MemberResponse; +import com.und.server.member.entity.Member; + +class MemberResponseTest { + + @Test + @DisplayName("Correctly converts a Kakao Member entity to a MemberResponse DTO") + void Given_KakaoMemberEntity_When_From_Then_ReturnsCorrectMemberResponse() { + // given + final Member member = Member.builder() + .id(1L) + .nickname("KakaoUser") + .kakaoId("1234567890") + .build(); + + // when + final MemberResponse response = MemberResponse.from(member); + + // then + assertThat(response.id()).isEqualTo(member.getId()); + assertThat(response.nickname()).isEqualTo(member.getNickname()); + assertThat(response.kakaoId()).isEqualTo(member.getKakaoId()); + assertThat(response.appleId()).isNull(); + assertThat(response.createdAt()).isEqualTo(member.getCreatedAt()); + assertThat(response.updatedAt()).isEqualTo(member.getUpdatedAt()); + } + + @Test + @DisplayName("Correctly converts an Apple Member entity to a MemberResponse DTO") + void Given_AppleMemberEntity_When_From_Then_ReturnsCorrectMemberResponse() { + // given + final Member member = Member.builder() + .id(2L) + .nickname("AppleUser") + .appleId("000123.abc.456def") + .build(); + + // when + final MemberResponse response = MemberResponse.from(member); + + // then + assertThat(response.id()).isEqualTo(member.getId()); + assertThat(response.nickname()).isEqualTo(member.getNickname()); + assertThat(response.kakaoId()).isNull(); + assertThat(response.appleId()).isEqualTo(member.getAppleId()); + assertThat(response.createdAt()).isEqualTo(member.getCreatedAt()); + assertThat(response.updatedAt()).isEqualTo(member.getUpdatedAt()); + } + +} diff --git a/src/test/java/com/und/server/member/repository/MemberRepositoryTest.java b/src/test/java/com/und/server/member/repository/MemberRepositoryTest.java new file mode 100644 index 00000000..45de8422 --- /dev/null +++ b/src/test/java/com/und/server/member/repository/MemberRepositoryTest.java @@ -0,0 +1,102 @@ +package com.und.server.member.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import com.und.server.member.entity.Member; + +@DataJpaTest +class MemberRepositoryTest { + + @Autowired + private MemberRepository memberRepository; + + @Test + @DisplayName("Saves a member with a Kakao ID and verifies its properties") + void Given_MemberWithKakaoId_When_SaveMember_Then_MemberIsPersistedWithCorrectDetails() { + // given + final Member member = Member.builder() + .nickname("KakaoUser") + .kakaoId("kakao-id-123") + .build(); + + // when + final Member result = memberRepository.save(member); + + // then + assertThat(result.getId()).isNotNull(); + assertThat(result.getNickname()).isEqualTo("KakaoUser"); + assertThat(result.getKakaoId()).isEqualTo("kakao-id-123"); + assertThat(result.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("Finds a member by their Kakao ID") + void Given_ExistingMemberWithKakaoId_When_FindByKakaoId_Then_ReturnsCorrectMember() { + // given + final Member member = Member.builder() + .nickname("KakaoUser") + .kakaoId("kakao-id-123") + .build(); + memberRepository.save(member); + + // when + final Optional foundMember = memberRepository.findByKakaoId("kakao-id-123"); + + // then + assertThat(foundMember).isPresent().hasValueSatisfying(result -> { + assertThat(result.getId()).isNotNull(); + assertThat(result.getNickname()).isEqualTo("KakaoUser"); + assertThat(result.getKakaoId()).isEqualTo("kakao-id-123"); + assertThat(result.getCreatedAt()).isNotNull(); + }); + } + + @Test + @DisplayName("Saves a member with an Apple ID and verifies its properties") + void Given_MemberWithAppleId_When_SaveMember_Then_MemberIsPersistedWithCorrectDetails() { + // given + final Member member = Member.builder() + .nickname("AppleUser") + .appleId("apple-id-123") + .build(); + + // when + final Member result = memberRepository.save(member); + + // then + assertThat(result.getId()).isNotNull(); + assertThat(result.getNickname()).isEqualTo("AppleUser"); + assertThat(result.getAppleId()).isEqualTo("apple-id-123"); + assertThat(result.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("Finds a member by their Apple ID") + void Given_ExistingMemberWithAppleId_When_FindByAppleId_Then_ReturnsCorrectMember() { + // given + final Member member = Member.builder() + .nickname("AppleUser") + .appleId("apple-id-123") + .build(); + memberRepository.save(member); + + // when + final Optional foundMember = memberRepository.findByAppleId("apple-id-123"); + + // then + assertThat(foundMember).isPresent().hasValueSatisfying(result -> { + assertThat(result.getId()).isNotNull(); + assertThat(result.getNickname()).isEqualTo("AppleUser"); + assertThat(result.getAppleId()).isEqualTo("apple-id-123"); + assertThat(result.getCreatedAt()).isNotNull(); + }); + } + +} diff --git a/src/test/java/com/und/server/member/service/MemberServiceTest.java b/src/test/java/com/und/server/member/service/MemberServiceTest.java new file mode 100644 index 00000000..cfddd446 --- /dev/null +++ b/src/test/java/com/und/server/member/service/MemberServiceTest.java @@ -0,0 +1,287 @@ +package com.und.server.member.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.auth.exception.AuthErrorResult; +import com.und.server.auth.oauth.Provider; +import com.und.server.auth.service.RefreshTokenService; +import com.und.server.common.exception.ServerException; +import com.und.server.member.dto.request.NicknameRequest; +import com.und.server.member.dto.response.MemberResponse; +import com.und.server.member.entity.Member; +import com.und.server.member.exception.MemberErrorResult; +import com.und.server.member.repository.MemberRepository; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + @InjectMocks + private MemberService memberService; + + @Mock + private MemberRepository memberRepository; + + @Mock + private RefreshTokenService refreshTokenService; + + private final Long memberId = 1L; + private final String providerId = "test-provider-id"; + private final String nickname = "test-nickname"; + + @Test + @DisplayName("Finds an existing member with a Kakao ID") + void Given_ExistingKakaoMember_When_FindOrCreateMember_Then_ReturnsExistingMember() { + // given + final Provider kakaoProvider = Provider.KAKAO; + final Member existingMember = Member.builder() + .id(memberId) + .kakaoId(providerId) + .nickname(nickname) + .build(); + + doReturn(Optional.of(existingMember)).when(memberRepository).findByKakaoId(providerId); + + // when + final Member foundMember = memberService.findOrCreateMember(kakaoProvider, providerId); + + // then + verify(memberRepository).findByKakaoId(providerId); + verify(memberRepository, never()).save(any(Member.class)); + assertThat(foundMember).isEqualTo(existingMember); + } + + @Test + @DisplayName("Creates a new member with a Kakao ID") + void Given_NonExistingKakaoMember_When_FindOrCreateMember_Then_CreatesAndReturnsNewMember() { + // given + final Provider kakaoProvider = Provider.KAKAO; + final Member newMember = Member.builder() + .id(memberId) + .kakaoId(providerId) + .nickname(nickname) + .build(); + + doReturn(Optional.empty()).when(memberRepository).findByKakaoId(providerId); + doReturn(newMember).when(memberRepository).save(any(Member.class)); + + // when + final Member createdMember = memberService.findOrCreateMember(kakaoProvider, providerId); + + // then + verify(memberRepository).findByKakaoId(providerId); + verify(memberRepository).save(any(Member.class)); + assertThat(createdMember).isEqualTo(newMember); + } + + @Test + @DisplayName("Finds an existing member with an Apple ID") + void Given_ExistingAppleMember_When_FindOrCreateMember_Then_ReturnsExistingMember() { + // given + final Provider appleProvider = Provider.APPLE; + final Member existingMember = Member.builder() + .id(memberId) + .appleId(providerId) + .nickname(nickname) + .build(); + + doReturn(Optional.of(existingMember)).when(memberRepository).findByAppleId(providerId); + + // when + final Member foundMember = memberService.findOrCreateMember(appleProvider, providerId); + + // then + verify(memberRepository).findByAppleId(providerId); + verify(memberRepository, never()).save(any(Member.class)); + assertThat(foundMember).isEqualTo(existingMember); + } + + @Test + @DisplayName("Creates a new member with an Apple ID") + void Given_NonExistingAppleMember_When_FindOrCreateMember_Then_CreatesAndReturnsNewMember() { + // given + final Provider appleProvider = Provider.APPLE; + final Member newMember = Member.builder() + .id(memberId) + .appleId(providerId) + .nickname(nickname) + .build(); + + doReturn(Optional.empty()).when(memberRepository).findByAppleId(providerId); + doReturn(newMember).when(memberRepository).save(any(Member.class)); + + // when + final Member createdMember = memberService.findOrCreateMember(appleProvider, providerId); + + // then + verify(memberRepository).findByAppleId(providerId); + verify(memberRepository).save(any(Member.class)); + assertThat(createdMember).isEqualTo(newMember); + } + + @Test + @DisplayName("Throws an exception when finding or creating a member with a null provider") + void Given_NullProvider_When_FindOrCreateMember_Then_ThrowsException() { + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> memberService.findOrCreateMember(null, providerId)); + + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER); + } + + @Test + @DisplayName("Throws an exception when finding or creating a member with a null provider ID") + void Given_NullProviderId_When_FindOrCreateMember_Then_ThrowsException() { + // when & then + final Provider provider = Provider.KAKAO; + final ServerException exception = assertThrows(ServerException.class, + () -> memberService.findOrCreateMember(provider, null)); + + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER_ID); + } + + @Test + @DisplayName("Throws an exception when finding a non-existent member by ID") + void Given_NonExistingMemberId_When_FindMemberById_Then_ThrowsException() { + // given + doReturn(Optional.empty()).when(memberRepository).findById(memberId); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> memberService.findMemberById(memberId)); + + assertThat(exception.getErrorResult()).isEqualTo(MemberErrorResult.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("Throws an exception when finding a member with a null ID") + void Given_NullMemberId_When_FindMemberById_Then_ThrowsException() { + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> memberService.findMemberById(null)); + + assertThat(exception.getErrorResult()).isEqualTo(MemberErrorResult.INVALID_MEMBER_ID); + } + + @Test + @DisplayName("Throws an exception when validating a null member ID") + void Given_NullMemberId_When_CheckMemberExists_Then_ThrowsException() { + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> memberService.checkMemberExists(null)); + assertThat(exception.getErrorResult()).isEqualTo(MemberErrorResult.INVALID_MEMBER_ID); + } + + @Test + @DisplayName("Throws an exception when validating a non-existent member") + void Given_NonExistentMemberId_When_CheckMemberExists_Then_ThrowsException() { + // given + doReturn(false).when(memberRepository).existsById(memberId); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> memberService.checkMemberExists(memberId)); + assertThat(exception.getErrorResult()).isEqualTo(MemberErrorResult.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("Does not throw an exception when validating an existing member") + void Given_ExistingMemberId_When_CheckMemberExists_Then_Succeeds() { + // given + doReturn(true).when(memberRepository).existsById(memberId); + + // when & then + assertDoesNotThrow(() -> memberService.checkMemberExists(memberId)); + verify(memberRepository).existsById(memberId); + } + + @Test + @DisplayName("Updates a member's nickname successfully") + void Given_ValidNickname_When_UpdateNickname_Then_SucceedsAndReturnsUpdatedResponse() { + // given + final String newNickname = "new-nickname"; + final NicknameRequest request = new NicknameRequest(newNickname); + final Member member = Member.builder() + .id(memberId) + .kakaoId(providerId) + .nickname("old-nickname") + .build(); + + doReturn(Optional.of(member)).when(memberRepository).findById(memberId); + + // when + final MemberResponse response = memberService.updateNickname(memberId, request); + + // then + verify(memberRepository).findById(memberId); + assertThat(member.getNickname()).isEqualTo(newNickname); + assertThat(response.id()).isEqualTo(memberId); + assertThat(response.nickname()).isEqualTo(newNickname); + } + + @Test + @DisplayName("Retrieves all members and returns them as a list of MemberResponse DTOs") + void Given_ExistingMembers_When_GetMemberList_Then_ReturnsListOfMemberResponses() { + // given + final Member member1 = Member.builder().id(1L).kakaoId("kakao1").nickname("user1").build(); + final Member member2 = Member.builder().id(2L).kakaoId("kakao2").nickname("user2").build(); + doReturn(List.of(member1, member2)).when(memberRepository).findAll(); + + // when + final List memberList = memberService.getMemberList(); + + // then + assertThat(memberList).hasSize(2); + assertThat(memberList.get(0).id()).isEqualTo(1L); + assertThat(memberList.get(1).id()).isEqualTo(2L); + verify(memberRepository).findAll(); + } + + @Test + @DisplayName("Deletes a member and their refresh token by ID when the member exists") + void Given_ExistingMemberId_When_DeleteMemberById_Then_DeletesMemberAndRefreshToken() { + // given + final Long memberIdToDelete = 1L; + doReturn(true).when(memberRepository).existsById(memberIdToDelete); + + // when + memberService.deleteMemberById(memberIdToDelete); + + // then + verify(memberRepository).existsById(memberIdToDelete); + verify(refreshTokenService).deleteRefreshToken(memberIdToDelete); + verify(memberRepository).deleteById(memberIdToDelete); + } + + @Test + @DisplayName("Throws an exception when deleting a non-existent member") + void Given_NonExistentMemberId_When_DeleteMemberById_Then_ThrowsException() { + // given + final Long memberIdToDelete = 1L; + doReturn(false).when(memberRepository).existsById(memberIdToDelete); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> memberService.deleteMemberById(memberIdToDelete)); + + assertThat(exception.getErrorResult()).isEqualTo(MemberErrorResult.MEMBER_NOT_FOUND); + verify(refreshTokenService, never()).deleteRefreshToken(any()); + verify(memberRepository, never()).deleteById(any()); + } + +} diff --git a/src/test/java/com/und/server/notification/constants/NotificationMethodTypeTest.java b/src/test/java/com/und/server/notification/constants/NotificationMethodTypeTest.java new file mode 100644 index 00000000..4bd5c10b --- /dev/null +++ b/src/test/java/com/und/server/notification/constants/NotificationMethodTypeTest.java @@ -0,0 +1,32 @@ +package com.und.server.notification.constants; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class NotificationMethodTypeTest { + + @Test + void fromValue_null_returnsNull() { + assertThat(NotificationMethodType.fromValue(null)).isNull(); + } + + @Test + void fromValue_lowercase_returnsAlarm() { + assertThat(NotificationMethodType.fromValue("alarm")).isEqualTo(NotificationMethodType.ALARM); + } + + @Test + void fromValue_uppercase_returnsPush() { + assertThat(NotificationMethodType.fromValue("PUSH")).isEqualTo(NotificationMethodType.PUSH); + } + + @Test + void fromValue_invalid_throwsException() { + assertThrows(IllegalArgumentException.class, () -> NotificationMethodType.fromValue("invalid")); + } + +} + + diff --git a/src/test/java/com/und/server/notification/constants/NotificationTypeTest.java b/src/test/java/com/und/server/notification/constants/NotificationTypeTest.java new file mode 100644 index 00000000..5cb3cff5 --- /dev/null +++ b/src/test/java/com/und/server/notification/constants/NotificationTypeTest.java @@ -0,0 +1,32 @@ +package com.und.server.notification.constants; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class NotificationTypeTest { + + @Test + void fromValue_null_returnsNull() { + assertThat(NotificationType.fromValue(null)).isNull(); + } + + @Test + void fromValue_lowercase_returnsTime() { + assertThat(NotificationType.fromValue("time")).isEqualTo(NotificationType.TIME); + } + + @Test + void fromValue_uppercase_returnsLocation() { + assertThat(NotificationType.fromValue("LOCATION")).isEqualTo(NotificationType.LOCATION); + } + + @Test + void fromValue_invalid_throwsException() { + assertThrows(IllegalArgumentException.class, () -> NotificationType.fromValue("invalid")); + } + +} + + diff --git a/src/test/java/com/und/server/notification/controller/NotificationControllerTest.java b/src/test/java/com/und/server/notification/controller/NotificationControllerTest.java new file mode 100644 index 00000000..8928cd9a --- /dev/null +++ b/src/test/java/com/und/server/notification/controller/NotificationControllerTest.java @@ -0,0 +1,267 @@ +package com.und.server.notification.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import com.und.server.notification.dto.response.ScenarioNotificationListResponse; +import com.und.server.notification.dto.response.ScenarioNotificationResponse; +import com.und.server.notification.exception.NotificationCacheErrorResult; +import com.und.server.notification.exception.NotificationCacheException; +import com.und.server.notification.service.NotificationCacheService; +import com.und.server.notification.service.NotificationService; + +@ExtendWith(MockitoExtension.class) +class NotificationControllerTest { + + @InjectMocks + private NotificationController notificationController; + + @Mock + private NotificationCacheService notificationCacheService; + + @Mock + private NotificationService notificationService; + + private final Long memberId = 1L; + private final Long scenarioId = 10L; + + + @Test + void Given_ValidRequest_When_GetScenarioNotifications_Then_ReturnNotificationList() { + // given + ScenarioNotificationListResponse expectedResponse = ScenarioNotificationListResponse.from( + "1234567890", + List.of( + ScenarioNotificationResponse.builder() + .scenarioId(1L) + .scenarioName("아침 루틴") + .memo("아침에 할 일들") + .notificationCondition(null) + .build(), + ScenarioNotificationResponse.builder() + .scenarioId(2L) + .scenarioName("저녁 루틴") + .memo("저녁에 할 일들") + .notificationCondition(null) + .build() + ) + ); + + given(notificationCacheService.getScenariosNotificationCache(memberId)) + .willReturn(expectedResponse); + + // when + ResponseEntity response = + notificationController.getScenarioNotifications(memberId, null); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedResponse); + assertThat(response.getHeaders().getFirst("ETag")).isEqualTo("1234567890"); + verify(notificationCacheService).getScenariosNotificationCache(memberId); + } + + + @Test + void Given_ValidEtag_When_GetScenarioNotifications_Then_Return304NotModified() { + // given + String ifNoneMatch = "1234567890"; + ScenarioNotificationListResponse expectedResponse = ScenarioNotificationListResponse.from( + "1234567890", + List.of() + ); + + given(notificationCacheService.getScenariosNotificationCache(memberId)) + .willReturn(expectedResponse); + + // when + ResponseEntity response = + notificationController.getScenarioNotifications(memberId, ifNoneMatch); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_MODIFIED); + assertThat(response.getBody()).isNull(); + verify(notificationCacheService).getScenariosNotificationCache(memberId); + } + + + @Test + void Given_DifferentEtag_When_GetScenarioNotifications_Then_Return200WithData() { + // given + String ifNoneMatch = "old-etag"; + ScenarioNotificationListResponse expectedResponse = ScenarioNotificationListResponse.from( + "new-etag", + List.of( + ScenarioNotificationResponse.builder() + .scenarioId(1L) + .scenarioName("업데이트된 루틴") + .memo("새로운 메모") + .notificationCondition(null) + .build() + ) + ); + + given(notificationCacheService.getScenariosNotificationCache(memberId)) + .willReturn(expectedResponse); + + // when + ResponseEntity response = + notificationController.getScenarioNotifications(memberId, ifNoneMatch); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedResponse); + assertThat(response.getHeaders().getFirst("ETag")).isEqualTo("new-etag"); + verify(notificationCacheService).getScenariosNotificationCache(memberId); + } + + + @Test + void Given_EmptyNotificationList_When_GetScenarioNotifications_Then_ReturnEmptyList() { + // given + ScenarioNotificationListResponse expectedResponse = ScenarioNotificationListResponse.from( + "1234567890", + List.of() + ); + + given(notificationCacheService.getScenariosNotificationCache(memberId)) + .willReturn(expectedResponse); + + // when + ResponseEntity response = + notificationController.getScenarioNotifications(memberId, null); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().scenarios()).isEmpty(); + assertThat(response.getHeaders().getFirst("ETag")).isEqualTo("1234567890"); + verify(notificationCacheService).getScenariosNotificationCache(memberId); + } + + + @Test + void Given_ValidRequest_When_GetSingleScenarioNotification_Then_ReturnNotification() { + // given + ScenarioNotificationResponse expectedResponse = ScenarioNotificationResponse.builder() + .scenarioId(scenarioId) + .scenarioName("아침 루틴") + .memo("아침에 할 일들") + .notificationCondition(null) + .build(); + + given(notificationCacheService.getSingleScenarioNotificationCache(memberId, scenarioId)) + .willReturn(expectedResponse); + + // when + ResponseEntity response = + notificationController.getSingleScenarioNotification(memberId, scenarioId); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedResponse); + verify(notificationCacheService).getSingleScenarioNotificationCache(memberId, scenarioId); + } + + + @Test + void Given_NonExistentScenario_When_GetSingleScenarioNotification_Then_ThrowNotFoundException() { + // given + given(notificationCacheService.getSingleScenarioNotificationCache(memberId, scenarioId)) + .willThrow( + new NotificationCacheException(NotificationCacheErrorResult.CACHE_NOT_FOUND_SCENARIO_NOTIFICATION)); + + // when & then + assertThatThrownBy(() -> + notificationController.getSingleScenarioNotification(memberId, scenarioId) + ).isInstanceOf(NotificationCacheException.class) + .hasFieldOrPropertyWithValue("errorResult", + NotificationCacheErrorResult.CACHE_NOT_FOUND_SCENARIO_NOTIFICATION); + + verify(notificationCacheService).getSingleScenarioNotificationCache(memberId, scenarioId); + } + + + @Test + void Given_DifferentScenarioId_When_GetSingleScenarioNotification_Then_ReturnCorrectNotification() { + // given + Long differentScenarioId = 20L; + ScenarioNotificationResponse expectedResponse = ScenarioNotificationResponse.builder() + .scenarioId(differentScenarioId) + .scenarioName("다른 루틴") + .memo("다른 메모") + .notificationCondition(null) + .build(); + + given(notificationCacheService.getSingleScenarioNotificationCache(memberId, differentScenarioId)) + .willReturn(expectedResponse); + + // when + ResponseEntity response = + notificationController.getSingleScenarioNotification(memberId, differentScenarioId); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().scenarioId()).isEqualTo(differentScenarioId); + assertThat(response.getBody().scenarioName()).isEqualTo("다른 루틴"); + verify(notificationCacheService).getSingleScenarioNotificationCache(memberId, differentScenarioId); + } + + + @Test + void Given_ValidRequest_When_UpdateNotificationActive_Then_ReturnNoContent() { + // given + Boolean isActive = true; + + // when + ResponseEntity response = notificationController.updateNotificationActive(memberId, isActive); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(response.getBody()).isNull(); + verify(notificationService).updateNotificationActiveStatus(memberId, isActive); + } + + + @Test + void Given_ValidRequestWithFalse_When_UpdateNotificationActive_Then_ReturnNoContent() { + // given + Boolean isActive = false; + + // when + ResponseEntity response = notificationController.updateNotificationActive(memberId, isActive); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(response.getBody()).isNull(); + verify(notificationService).updateNotificationActiveStatus(memberId, isActive); + } + + + @Test + void Given_DifferentMemberId_When_UpdateNotificationActive_Then_ReturnNoContent() { + // given + Long differentMemberId = 2L; + Boolean isActive = true; + + // when + ResponseEntity response = notificationController.updateNotificationActive(differentMemberId, isActive); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(response.getBody()).isNull(); + verify(notificationService).updateNotificationActiveStatus(differentMemberId, isActive); + } + +} diff --git a/src/test/java/com/und/server/notification/dto/request/NotificationRequestTest.java b/src/test/java/com/und/server/notification/dto/request/NotificationRequestTest.java new file mode 100644 index 00000000..2c49488b --- /dev/null +++ b/src/test/java/com/und/server/notification/dto/request/NotificationRequestTest.java @@ -0,0 +1,208 @@ +package com.und.server.notification.dto.request; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.und.server.notification.constants.NotificationMethodType; +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.entity.Notification; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; + +class NotificationRequestTest { + + private final ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + private final Validator validator = factory.getValidator(); + + @Test + void Given_ActiveNotificationWithValidFields_When_Validate_Then_Success() { + // given + NotificationRequest request = NotificationRequest.builder() + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeekOrdinal(List.of(0, 1, 2)) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isEmpty(); + } + + @Test + void Given_ActiveNotificationWithoutMethodType_When_Validate_Then_Fail() { + // given + NotificationRequest request = NotificationRequest.builder() + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(null) + .daysOfWeekOrdinal(List.of(0, 1, 2)) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()) + .contains("Notification method and days required when isActive is true"); + } + + @Test + void Given_ActiveNotificationWithoutDays_When_Validate_Then_Fail() { + // given + NotificationRequest request = NotificationRequest.builder() + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeekOrdinal(null) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()) + .contains("Notification method and days required when isActive is true"); + } + + @Test + void Given_ActiveNotificationWithEmptyDays_When_Validate_Then_Fail() { + // given + NotificationRequest request = NotificationRequest.builder() + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeekOrdinal(List.of()) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()) + .contains("Notification method and days required when isActive is true"); + } + + @Test + void Given_InactiveNotificationWithoutFields_When_Validate_Then_Success() { + // given + NotificationRequest request = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .notificationMethodType(null) + .daysOfWeekOrdinal(null) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isEmpty(); + } + + @Test + void Given_InactiveNotificationWithEmptyDays_When_Validate_Then_Success() { + // given + NotificationRequest request = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .notificationMethodType(null) + .daysOfWeekOrdinal(List.of()) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isEmpty(); + } + + @Test + void Given_InactiveNotificationWithMethodType_When_Validate_Then_Fail() { + // given + NotificationRequest request = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeekOrdinal(null) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()) + .contains("Notification method and days not allowed when isActive is false"); + } + + @Test + void Given_InactiveNotificationWithDays_When_Validate_Then_Fail() { + // given + NotificationRequest request = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .notificationMethodType(null) + .daysOfWeekOrdinal(List.of(0, 1)) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()) + .contains("Notification method and days not allowed when isActive is false"); + } + + @Test + void Given_ValidNotificationRequest_When_ToEntity_Then_ReturnNotification() { + // given + NotificationRequest request = NotificationRequest.builder() + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeekOrdinal(List.of(0, 1, 2)) + .build(); + + // when + Notification notification = request.toEntity(); + + // then + assertThat(notification.getIsActive()).isTrue(); + assertThat(notification.getNotificationType()).isEqualTo(NotificationType.TIME); + assertThat(notification.getNotificationMethodType()).isEqualTo(NotificationMethodType.PUSH); + } + + @Test + void Given_InactiveNotificationRequest_When_ToEntity_Then_ReturnInactiveNotification() { + // given + NotificationRequest request = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.LOCATION) + .notificationMethodType(null) + .daysOfWeekOrdinal(null) + .build(); + + // when + Notification notification = request.toEntity(); + + // then + assertThat(notification.getIsActive()).isFalse(); + assertThat(notification.getNotificationType()).isEqualTo(NotificationType.LOCATION); + assertThat(notification.getNotificationMethodType()).isNull(); + } + +} diff --git a/src/test/java/com/und/server/notification/dto/request/TimeNotificationRequestTest.java b/src/test/java/com/und/server/notification/dto/request/TimeNotificationRequestTest.java new file mode 100644 index 00000000..b35c4277 --- /dev/null +++ b/src/test/java/com/und/server/notification/dto/request/TimeNotificationRequestTest.java @@ -0,0 +1,43 @@ +package com.und.server.notification.dto.request; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.entity.Notification; +import com.und.server.notification.entity.TimeNotification; + +class TimeNotificationRequestTest { + + @Test + void constructor_nullType_defaultsToTime() { + TimeNotificationRequest req = TimeNotificationRequest.builder() + .notificationType(null) + .startHour(10) + .startMinute(15) + .build(); + + assertThat(req.notificationType()).isEqualTo(NotificationType.TIME); + } + + @Test + void toEntity_mapsFields() { + Notification notification = Notification.builder().id(1L).build(); + + TimeNotificationRequest req = TimeNotificationRequest.builder() + .notificationType(NotificationType.TIME) + .startHour(8) + .startMinute(45) + .build(); + + TimeNotification entity = req.toEntity(notification); + + assertThat(entity.getNotification()).isEqualTo(notification); + assertThat(entity.getStartHour()).isEqualTo(8); + assertThat(entity.getStartMinute()).isEqualTo(45); + } + +} + + diff --git a/src/test/java/com/und/server/notification/dto/response/ScenarioNotificationResponseTest.java b/src/test/java/com/und/server/notification/dto/response/ScenarioNotificationResponseTest.java new file mode 100644 index 00000000..fbbd3f40 --- /dev/null +++ b/src/test/java/com/und/server/notification/dto/response/ScenarioNotificationResponseTest.java @@ -0,0 +1,195 @@ +package com.und.server.notification.dto.response; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.und.server.notification.constants.NotificationMethodType; +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.cache.NotificationCacheData; +import com.und.server.notification.entity.Notification; +import com.und.server.scenario.entity.Scenario; + + +class ScenarioNotificationResponseTest { + + private final Long scenarioId = 1L; + private final String scenarioName = "테스트 루틴"; + private final String memo = "테스트 메모"; + private final Long notificationId = 2L; + private final NotificationType notificationType = NotificationType.TIME; + private final NotificationMethodType notificationMethodType = NotificationMethodType.PUSH; + private final List daysOfWeekOrdinal = List.of(1, 2, 3, 4, 5); + + + @Test + void Given_ValidNotificationCacheData_When_From_Then_ReturnScenarioNotificationResponse() { + // given + NotificationCacheData cacheData = NotificationCacheData.builder() + .scenarioId(scenarioId) + .scenarioName(scenarioName) + .scenarioMemo(memo) + .notificationId(notificationId) + .notificationType(notificationType) + .notificationMethodType(notificationMethodType) + .daysOfWeekOrdinal(daysOfWeekOrdinal) + .conditionJson("{\"notificationType\":\"TIME\",\"startHour\":9,\"startMinute\":30}") + .build(); + + TimeNotificationResponse timeNotificationResponse = TimeNotificationResponse.builder() + .notificationType(NotificationType.TIME) + .startHour(9) + .startMinute(30) + .build(); + + // when + ScenarioNotificationResponse result = ScenarioNotificationResponse.from(cacheData, timeNotificationResponse); + + // then + assertThat(result.scenarioId()).isEqualTo(scenarioId); + assertThat(result.scenarioName()).isEqualTo(scenarioName); + assertThat(result.memo()).isEqualTo(memo); + assertThat(result.notificationId()).isEqualTo(notificationId); + assertThat(result.notificationType()).isEqualTo(notificationType); + assertThat(result.notificationMethodType()).isEqualTo(notificationMethodType); + assertThat(result.daysOfWeekOrdinal()).isEqualTo(daysOfWeekOrdinal); + assertThat(result.notificationCondition()).isEqualTo(timeNotificationResponse); + } + + + @Test + void Given_ValidScenarioAndNotificationCondition_When_From_Then_ReturnScenarioNotificationResponse() { + // given + Notification notification = Notification.builder() + .id(notificationId) + .notificationType(notificationType) + .notificationMethodType(notificationMethodType) + .daysOfWeek("1,2,3,4,5") + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName(scenarioName) + .memo(memo) + .notification(notification) + .build(); + + TimeNotificationResponse timeNotificationResponse = TimeNotificationResponse.builder() + .notificationType(NotificationType.TIME) + .startHour(10) + .startMinute(0) + .build(); + + // when + ScenarioNotificationResponse result = ScenarioNotificationResponse.from(scenario, timeNotificationResponse); + + // then + assertThat(result.scenarioId()).isEqualTo(scenarioId); + assertThat(result.scenarioName()).isEqualTo(scenarioName); + assertThat(result.memo()).isEqualTo(memo); + assertThat(result.notificationId()).isEqualTo(notificationId); + assertThat(result.notificationType()).isEqualTo(notificationType); + assertThat(result.notificationMethodType()).isEqualTo(notificationMethodType); + assertThat(result.daysOfWeekOrdinal()).isEqualTo(daysOfWeekOrdinal); + assertThat(result.notificationCondition()).isEqualTo(timeNotificationResponse); + } + + + @Test + void Given_ValidBuilder_When_Build_Then_ReturnScenarioNotificationResponse() { + // given + TimeNotificationResponse timeNotificationResponse = TimeNotificationResponse.builder() + .notificationType(NotificationType.TIME) + .startHour(12) + .startMinute(30) + .build(); + + // when + ScenarioNotificationResponse result = ScenarioNotificationResponse.builder() + .scenarioId(scenarioId) + .scenarioName(scenarioName) + .memo(memo) + .notificationId(notificationId) + .notificationType(notificationType) + .notificationMethodType(notificationMethodType) + .daysOfWeekOrdinal(daysOfWeekOrdinal) + .notificationCondition(timeNotificationResponse) + .build(); + + // then + assertThat(result.scenarioId()).isEqualTo(scenarioId); + assertThat(result.scenarioName()).isEqualTo(scenarioName); + assertThat(result.memo()).isEqualTo(memo); + assertThat(result.notificationId()).isEqualTo(notificationId); + assertThat(result.notificationType()).isEqualTo(notificationType); + assertThat(result.notificationMethodType()).isEqualTo(notificationMethodType); + assertThat(result.daysOfWeekOrdinal()).isEqualTo(daysOfWeekOrdinal); + assertThat(result.notificationCondition()).isEqualTo(timeNotificationResponse); + } + + + @Test + void Given_EmptyDaysOfWeekOrdinal_When_FromNotificationCacheData_Then_ReturnEmptyList() { + // given + NotificationCacheData cacheData = NotificationCacheData.builder() + .scenarioId(scenarioId) + .scenarioName(scenarioName) + .scenarioMemo(memo) + .notificationId(notificationId) + .notificationType(notificationType) + .notificationMethodType(notificationMethodType) + .daysOfWeekOrdinal(List.of()) + .conditionJson("{\"notificationType\":\"TIME\",\"startHour\":9,\"startMinute\":30}") + .build(); + + TimeNotificationResponse timeNotificationResponse = TimeNotificationResponse.builder() + .notificationType(NotificationType.TIME) + .startHour(9) + .startMinute(30) + .build(); + + // when + ScenarioNotificationResponse result = ScenarioNotificationResponse.from(cacheData, timeNotificationResponse); + + // then + assertThat(result.daysOfWeekOrdinal()).isEmpty(); + assertThat(result.scenarioId()).isEqualTo(scenarioId); + assertThat(result.scenarioName()).isEqualTo(scenarioName); + } + + + @Test + void Given_DifferentNotificationMethodType_When_FromScenario_Then_ReturnCorrectMethodType() { + // given + Notification notification = Notification.builder() + .id(notificationId) + .notificationType(notificationType) + .notificationMethodType(NotificationMethodType.ALARM) + .daysOfWeek("1,2,3,4,5") + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName(scenarioName) + .memo(memo) + .notification(notification) + .build(); + + TimeNotificationResponse timeNotificationResponse = TimeNotificationResponse.builder() + .notificationType(NotificationType.TIME) + .startHour(15) + .startMinute(45) + .build(); + + // when + ScenarioNotificationResponse result = ScenarioNotificationResponse.from(scenario, timeNotificationResponse); + + // then + assertThat(result.notificationMethodType()).isEqualTo(NotificationMethodType.ALARM); + assertThat(result.scenarioId()).isEqualTo(scenarioId); + assertThat(result.scenarioName()).isEqualTo(scenarioName); + } + +} diff --git a/src/test/java/com/und/server/notification/event/ActiveUpdateEventListenerTest.java b/src/test/java/com/und/server/notification/event/ActiveUpdateEventListenerTest.java new file mode 100644 index 00000000..875ae624 --- /dev/null +++ b/src/test/java/com/und/server/notification/event/ActiveUpdateEventListenerTest.java @@ -0,0 +1,116 @@ +package com.und.server.notification.event; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.notification.service.NotificationCacheService; + +@ExtendWith(MockitoExtension.class) +class ActiveUpdateEventListenerTest { + + @InjectMocks + private ActiveUpdateEventListener activeUpdateEventListener; + + @Mock + private NotificationCacheService notificationCacheService; + + private final Long memberId = 1L; + + + @Test + void Given_ActiveUpdateEventWithTrue_When_HandleActiveUpdate_Then_RefreshCacheFromDatabase() { + // given + ActiveUpdateEvent event = new ActiveUpdateEvent(memberId, true); + + // when + activeUpdateEventListener.handleActiveUpdate(event); + + // then + verify(notificationCacheService).refreshCacheFromDatabase(memberId); + } + + + @Test + void Given_ActiveUpdateEventWithFalse_When_HandleActiveUpdate_Then_DeleteMemberAllCache() { + // given + ActiveUpdateEvent event = new ActiveUpdateEvent(memberId, false); + + // when + activeUpdateEventListener.handleActiveUpdate(event); + + // then + verify(notificationCacheService).deleteMemberAllCache(memberId); + } + + + @Test + void Given_ActiveUpdateEventWithDifferentMemberId_When_HandleActiveUpdate_Then_RefreshCacheFromDatabase() { + // given + Long differentMemberId = 2L; + ActiveUpdateEvent event = new ActiveUpdateEvent(differentMemberId, true); + + // when + activeUpdateEventListener.handleActiveUpdate(event); + + // then + verify(notificationCacheService).refreshCacheFromDatabase(differentMemberId); + } + + + @Test + void Given_ActiveUpdateEventWithDifferentMemberIdAndFalse_When_HandleActiveUpdate_Then_DeleteMemberAllCache() { + // given + Long differentMemberId = 3L; + ActiveUpdateEvent event = new ActiveUpdateEvent(differentMemberId, false); + + // when + activeUpdateEventListener.handleActiveUpdate(event); + + // then + verify(notificationCacheService).deleteMemberAllCache(differentMemberId); + } + + + @Test + void Given_ExceptionOccursWhenActiveTrue_When_HandleActiveUpdate_Then_DeleteMemberAllCache() { + // given + ActiveUpdateEvent event = new ActiveUpdateEvent(memberId, true); + + doThrow(new RuntimeException("Cache refresh failed")) + .when(notificationCacheService).refreshCacheFromDatabase(anyLong()); + + // when + activeUpdateEventListener.handleActiveUpdate(event); + + // then + verify(notificationCacheService).refreshCacheFromDatabase(memberId); + verify(notificationCacheService).deleteMemberAllCache(memberId); + } + + + @Test + void Given_ExceptionOccursWhenActiveFalse_When_HandleActiveUpdate_Then_DeleteMemberAllCache() { + // given + ActiveUpdateEvent event = new ActiveUpdateEvent(memberId, false); + + doAnswer(invocation -> { + throw new RuntimeException("Cache delete failed"); + }).doNothing().when(notificationCacheService).deleteMemberAllCache(anyLong()); + + // when + activeUpdateEventListener.handleActiveUpdate(event); + + // then + verify(notificationCacheService, times(2)).deleteMemberAllCache(memberId); + } + +} diff --git a/src/test/java/com/und/server/notification/event/ActiveUpdateEventTest.java b/src/test/java/com/und/server/notification/event/ActiveUpdateEventTest.java new file mode 100644 index 00000000..e87d6b7b --- /dev/null +++ b/src/test/java/com/und/server/notification/event/ActiveUpdateEventTest.java @@ -0,0 +1,101 @@ +package com.und.server.notification.event; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class ActiveUpdateEventTest { + + private final Long memberId = 1L; + + + @Test + void Given_ValidMemberIdAndActiveTrue_When_CreateActiveUpdateEvent_Then_CreateEventWithCorrectValues() { + // given + boolean isActive = true; + + // when + ActiveUpdateEvent event = new ActiveUpdateEvent(memberId, isActive); + + // then + assertThat(event.memberId()).isEqualTo(memberId); + assertThat(event.isActive()).isTrue(); + } + + + @Test + void Given_ValidMemberIdAndActiveFalse_When_CreateActiveUpdateEvent_Then_CreateEventWithCorrectValues() { + // given + boolean isActive = false; + + // when + ActiveUpdateEvent event = new ActiveUpdateEvent(memberId, isActive); + + // then + assertThat(event.memberId()).isEqualTo(memberId); + assertThat(event.isActive()).isFalse(); + } + + + @Test + void Given_DifferentMemberId_When_CreateActiveUpdateEvent_Then_CreateEventWithCorrectValues() { + // given + Long differentMemberId = 2L; + boolean isActive = true; + + // when + ActiveUpdateEvent event = new ActiveUpdateEvent(differentMemberId, isActive); + + // then + assertThat(event.memberId()).isEqualTo(differentMemberId); + assertThat(event.isActive()).isTrue(); + } + + + @Test + void Given_SameValues_When_CreateTwoActiveUpdateEvents_Then_EventsAreEqual() { + // given + boolean isActive = true; + + // when + ActiveUpdateEvent event1 = new ActiveUpdateEvent(memberId, isActive); + ActiveUpdateEvent event2 = new ActiveUpdateEvent(memberId, isActive); + + // then + assertThat(event1).isEqualTo(event2); + assertThat(event1.hashCode()).isEqualTo(event2.hashCode()); + } + + + @Test + void Given_DifferentValues_When_CreateTwoActiveUpdateEvents_Then_EventsAreNotEqual() { + // given + boolean isActive1 = true; + boolean isActive2 = false; + + // when + ActiveUpdateEvent event1 = new ActiveUpdateEvent(memberId, isActive1); + ActiveUpdateEvent event2 = new ActiveUpdateEvent(memberId, isActive2); + + // then + assertThat(event1).isNotEqualTo(event2); + assertThat(event1.hashCode()).isNotEqualTo(event2.hashCode()); + } + + + @Test + void Given_ActiveUpdateEvent_When_ToString_Then_ReturnStringRepresentation() { + // given + boolean isActive = true; + ActiveUpdateEvent event = new ActiveUpdateEvent(memberId, isActive); + + // when + String toString = event.toString(); + + // then + assertThat(toString).contains("ActiveUpdateEvent"); + assertThat(toString).contains("memberId=" + memberId); + assertThat(toString).contains("isActive=" + isActive); + } + +} diff --git a/src/test/java/com/und/server/notification/event/NotificationEventPublisherTest.java b/src/test/java/com/und/server/notification/event/NotificationEventPublisherTest.java new file mode 100644 index 00000000..c291f1c7 --- /dev/null +++ b/src/test/java/com/und/server/notification/event/NotificationEventPublisherTest.java @@ -0,0 +1,206 @@ +package com.und.server.notification.event; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import com.und.server.notification.entity.Notification; +import com.und.server.scenario.entity.Scenario; + + +@ExtendWith(MockitoExtension.class) +class NotificationEventPublisherTest { + + @InjectMocks + private NotificationEventPublisher notificationEventPublisher; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private final Long memberId = 1L; + private final Long scenarioId = 1L; + + + @Test + void Given_ValidMemberIdAndScenario_When_PublishCreateEvent_Then_PublishScenarioCreateEvent() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("새로운 루틴") + .memo("새로운 메모") + .notification(notification) + .build(); + + // when + notificationEventPublisher.publishCreateEvent(memberId, scenario); + + // then + verify(eventPublisher).publishEvent(any(ScenarioCreateEvent.class)); + } + + + @Test + void Given_ValidMemberIdAndScenario_When_PublishUpdateEvent_Then_PublishScenarioUpdateEvent() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("업데이트된 루틴") + .memo("업데이트된 메모") + .notification(notification) + .build(); + + Boolean isOldScenarioNotificationActive = false; + + // when + notificationEventPublisher.publishUpdateEvent(memberId, scenario, + isOldScenarioNotificationActive); + + // then + verify(eventPublisher).publishEvent(any(ScenarioUpdateEvent.class)); + } + + + @Test + void Given_ValidMemberIdAndScenarioId_When_PublishDeleteEvent_Then_PublishScenarioDeleteEvent() { + // given + Boolean isNotificationActive = true; + + // when + notificationEventPublisher.publishDeleteEvent(memberId, scenarioId, + isNotificationActive); + + // then + verify(eventPublisher).publishEvent(any(ScenarioDeleteEvent.class)); + } + + + @Test + void Given_ValidMemberIdAndScenarioWithNullNotification_When_PublishCreateEvent_Then_PublishScenarioCreateEvent() { + // given + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("알림 없는 루틴") + .memo("알림 없는 메모") + .notification(null) + .build(); + + // when + notificationEventPublisher.publishCreateEvent(memberId, scenario); + + // then + verify(eventPublisher).publishEvent(any(ScenarioCreateEvent.class)); + } + + + @Test + void Given_ValidMemberIdAndScenarioWithInactiveNoti_When_PublishUpdateEvent_Then_PublishScenarioUpdateEvent() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(false) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("비활성화된 루틴") + .memo("비활성화된 메모") + .notification(notification) + .build(); + + Boolean isOldScenarioNotificationActive = true; + + // when + notificationEventPublisher.publishUpdateEvent(memberId, scenario, + isOldScenarioNotificationActive); + + // then + verify(eventPublisher).publishEvent(any(ScenarioUpdateEvent.class)); + } + + + @Test + void Given_ValidMemberIdAndScenarioIdWithInactiveNoti_When_PublishDeleteEvent_Then_PublishScenarioDeleteEvent() { + // given + Boolean isNotificationActive = false; + + // when + notificationEventPublisher.publishDeleteEvent(memberId, scenarioId, + isNotificationActive); + + // then + verify(eventPublisher).publishEvent(any(ScenarioDeleteEvent.class)); + } + + + @Test + void Given_DifferentMemberIdAndScenarioId_When_PublishDeleteEvent_Then_PublishScenarioDeleteEvent() { + // given + Long differentMemberId = 2L; + Long differentScenarioId = 3L; + Boolean isNotificationActive = true; + + // when + notificationEventPublisher.publishDeleteEvent(differentMemberId, differentScenarioId, + isNotificationActive); + + // then + verify(eventPublisher).publishEvent(any(ScenarioDeleteEvent.class)); + } + + + @Test + void Given_ValidMemberIdAndActiveTrue_When_PublishActiveUpdateEvent_Then_PublishActiveUpdateEvent() { + // given + boolean isActive = true; + + // when + notificationEventPublisher.publishActiveUpdateEvent(memberId, isActive); + + // then + verify(eventPublisher).publishEvent(any(ActiveUpdateEvent.class)); + } + + + @Test + void Given_ValidMemberIdAndActiveFalse_When_PublishActiveUpdateEvent_Then_PublishActiveUpdateEvent() { + // given + boolean isActive = false; + + // when + notificationEventPublisher.publishActiveUpdateEvent(memberId, isActive); + + // then + verify(eventPublisher).publishEvent(any(ActiveUpdateEvent.class)); + } + + + @Test + void Given_DifferentMemberId_When_PublishActiveUpdateEvent_Then_PublishActiveUpdateEvent() { + // given + Long differentMemberId = 2L; + boolean isActive = true; + + // when + notificationEventPublisher.publishActiveUpdateEvent(differentMemberId, isActive); + + // then + verify(eventPublisher).publishEvent(any(ActiveUpdateEvent.class)); + } + +} diff --git a/src/test/java/com/und/server/notification/event/ScenarioCreateEventListenerTest.java b/src/test/java/com/und/server/notification/event/ScenarioCreateEventListenerTest.java new file mode 100644 index 00000000..9b20cb9b --- /dev/null +++ b/src/test/java/com/und/server/notification/event/ScenarioCreateEventListenerTest.java @@ -0,0 +1,157 @@ +package com.und.server.notification.event; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.notification.entity.Notification; +import com.und.server.notification.service.NotificationCacheService; +import com.und.server.scenario.entity.Scenario; + + +@ExtendWith(MockitoExtension.class) +class ScenarioCreateEventListenerTest { + + @InjectMocks + private ScenarioCreateEventListener scenarioCreateEventListener; + + @Mock + private NotificationCacheService notificationCacheService; + + private final Long memberId = 1L; + private final Long scenarioId = 1L; + + + @Test + void Given_ValidScenarioWithActiveNotification_When_HandleCreate_Then_UpdateCache() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("테스트 루틴") + .memo("테스트 메모") + .notification(notification) + .build(); + + ScenarioCreateEvent event = new ScenarioCreateEvent(memberId, scenario); + + // when + scenarioCreateEventListener.handleCreate(event); + + // then + verify(notificationCacheService).updateCache(eq(memberId), eq(scenario)); + } + + + @Test + void Given_ValidScenarioWithInactiveNotification_When_HandleCreate_Then_DoNothing() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(false) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("테스트 루틴") + .memo("테스트 메모") + .notification(notification) + .build(); + + ScenarioCreateEvent event = new ScenarioCreateEvent(memberId, scenario); + + // when + scenarioCreateEventListener.handleCreate(event); + + // then + verify(notificationCacheService, never()).updateCache(anyLong(), any()); + } + + + @Test + void Given_ValidScenarioWithNullNotification_When_HandleCreate_Then_DoNothing() { + // given + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("테스트 루틴") + .memo("테스트 메모") + .notification(null) + .build(); + + ScenarioCreateEvent event = new ScenarioCreateEvent(memberId, scenario); + + // when + scenarioCreateEventListener.handleCreate(event); + + // then + verify(notificationCacheService, never()).updateCache(anyLong(), any()); + } + + + @Test + void Given_ExceptionOccurs_When_HandleCreate_Then_DeleteMemberAllCache() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("테스트 루틴") + .memo("테스트 메모") + .notification(notification) + .build(); + + ScenarioCreateEvent event = new ScenarioCreateEvent(memberId, scenario); + + doThrow(new RuntimeException("Cache update failed")) + .when(notificationCacheService).updateCache(anyLong(), any()); + + // when + scenarioCreateEventListener.handleCreate(event); + + // then + verify(notificationCacheService).updateCache(eq(memberId), eq(scenario)); + verify(notificationCacheService).deleteMemberAllCache(eq(memberId)); + } + + + @Test + void Given_ValidScenarioWithActiveNotification_When_HandleCreate_Then_ProcessWithNotification() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("아침 루틴") + .memo("아침에 할 일들") + .notification(notification) + .build(); + + ScenarioCreateEvent event = new ScenarioCreateEvent(memberId, scenario); + + // when + scenarioCreateEventListener.handleCreate(event); + + // then + verify(notificationCacheService).updateCache(eq(memberId), eq(scenario)); + } + +} diff --git a/src/test/java/com/und/server/notification/event/ScenarioDeleteEventListenerTest.java b/src/test/java/com/und/server/notification/event/ScenarioDeleteEventListenerTest.java new file mode 100644 index 00000000..75c3d316 --- /dev/null +++ b/src/test/java/com/und/server/notification/event/ScenarioDeleteEventListenerTest.java @@ -0,0 +1,101 @@ +package com.und.server.notification.event; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.notification.service.NotificationCacheService; + + +@ExtendWith(MockitoExtension.class) +class ScenarioDeleteEventListenerTest { + + @InjectMocks + private ScenarioDeleteEventListener scenarioDeleteEventListener; + + @Mock + private NotificationCacheService notificationCacheService; + + private final Long memberId = 1L; + private final Long scenarioId = 1L; + + + @Test + void Given_ValidScenarioWithActiveNotification_When_HandleDelete_Then_DeleteCache() { + // given + ScenarioDeleteEvent event = new ScenarioDeleteEvent(memberId, scenarioId, true); + + // when + scenarioDeleteEventListener.handleDelete(event); + + // then + verify(notificationCacheService).deleteCache(eq(memberId), eq(scenarioId)); + } + + + @Test + void Given_ValidScenarioWithInactiveNotification_When_HandleDelete_Then_DoNothing() { + // given + ScenarioDeleteEvent event = new ScenarioDeleteEvent(memberId, scenarioId, false); + + // when + scenarioDeleteEventListener.handleDelete(event); + + // then + verify(notificationCacheService, never()).deleteCache(anyLong(), anyLong()); + } + + + @Test + void Given_ExceptionOccurs_When_HandleDelete_Then_DeleteMemberAllCache() { + // given + ScenarioDeleteEvent event = new ScenarioDeleteEvent(memberId, scenarioId, true); + + doThrow(new RuntimeException("Cache delete failed")) + .when(notificationCacheService).deleteCache(anyLong(), anyLong()); + + // when + scenarioDeleteEventListener.handleDelete(event); + + // then + verify(notificationCacheService).deleteCache(eq(memberId), eq(scenarioId)); + verify(notificationCacheService).deleteMemberAllCache(eq(memberId)); + } + + + @Test + void Given_ValidScenarioWithActiveNotification_When_HandleDelete_Then_ProcessWithNotification() { + // given + ScenarioDeleteEvent event = new ScenarioDeleteEvent(memberId, scenarioId, true); + + // when + scenarioDeleteEventListener.handleDelete(event); + + // then + verify(notificationCacheService).deleteCache(eq(memberId), eq(scenarioId)); + } + + + @Test + void Given_DifferentMemberIdAndScenarioId_When_HandleDelete_Then_DeleteCorrectCache() { + // given + Long differentMemberId = 2L; + Long differentScenarioId = 3L; + ScenarioDeleteEvent event = new ScenarioDeleteEvent(differentMemberId, differentScenarioId, true); + + // when + scenarioDeleteEventListener.handleDelete(event); + + // then + verify(notificationCacheService).deleteCache(eq(differentMemberId), eq(differentScenarioId)); + } + +} diff --git a/src/test/java/com/und/server/notification/event/ScenarioUpdateEventListenerTest.java b/src/test/java/com/und/server/notification/event/ScenarioUpdateEventListenerTest.java new file mode 100644 index 00000000..2f05eec4 --- /dev/null +++ b/src/test/java/com/und/server/notification/event/ScenarioUpdateEventListenerTest.java @@ -0,0 +1,209 @@ +package com.und.server.notification.event; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.notification.entity.Notification; +import com.und.server.notification.service.NotificationCacheService; +import com.und.server.scenario.entity.Scenario; + + +@ExtendWith(MockitoExtension.class) +class ScenarioUpdateEventListenerTest { + + @InjectMocks + private ScenarioUpdateEventListener scenarioUpdateEventListener; + + @Mock + private NotificationCacheService notificationCacheService; + + private final Long memberId = 1L; + private final Long scenarioId = 1L; + + + @Test + void Given_ValidScenarioWithActiveNotification_When_HandleUpdate_Then_UpdateCache() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("업데이트된 루틴") + .memo("업데이트된 메모") + .notification(notification) + .build(); + + ScenarioUpdateEvent event = new ScenarioUpdateEvent(memberId, scenario, false); + + // when + scenarioUpdateEventListener.handleUpdate(event); + + // then + verify(notificationCacheService, never()).deleteCache(anyLong(), anyLong()); + verify(notificationCacheService).updateCache(eq(memberId), eq(scenario)); + } + + + @Test + void Given_ValidScenarioWithInactiveNotificationAndOldNotificationWasActive_When_HandleUpdate_Then_DeleteCache() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(false) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("비활성화된 루틴") + .memo("비활성화된 메모") + .notification(notification) + .build(); + + ScenarioUpdateEvent event = new ScenarioUpdateEvent(memberId, scenario, true); + + // when + scenarioUpdateEventListener.handleUpdate(event); + + // then + verify(notificationCacheService).deleteCache(eq(memberId), eq(scenarioId)); + verify(notificationCacheService, never()).updateCache(anyLong(), any()); + } + + + @Test + void Given_ValidScenarioWithInactiveNotificationAndOldNotificationWasInactive_When_HandleUpdate_Then_DoNothing() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(false) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("비활성화된 루틴") + .memo("비활성화된 메모") + .notification(notification) + .build(); + + ScenarioUpdateEvent event = new ScenarioUpdateEvent(memberId, scenario, false); + + // when + scenarioUpdateEventListener.handleUpdate(event); + + // then + verify(notificationCacheService, never()).deleteCache(anyLong(), anyLong()); + verify(notificationCacheService, never()).updateCache(anyLong(), any()); + } + + + @Test + void Given_ValidScenarioWithNullNotificationAndOldNotificationWasActive_When_HandleUpdate_Then_DeleteCache() { + // given + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("알림 없는 루틴") + .memo("알림 없는 메모") + .notification(null) + .build(); + + ScenarioUpdateEvent event = new ScenarioUpdateEvent(memberId, scenario, true); + + // when + scenarioUpdateEventListener.handleUpdate(event); + + // then + verify(notificationCacheService).deleteCache(eq(memberId), eq(scenarioId)); + verify(notificationCacheService, never()).updateCache(anyLong(), any()); + } + + + @Test + void Given_ValidScenarioWithNullNotificationAndOldNotificationWasInactive_When_HandleUpdate_Then_DoNothing() { + // given + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("알림 없는 루틴") + .memo("알림 없는 메모") + .notification(null) + .build(); + + ScenarioUpdateEvent event = new ScenarioUpdateEvent(memberId, scenario, false); + + // when + scenarioUpdateEventListener.handleUpdate(event); + + // then + verify(notificationCacheService, never()).deleteCache(anyLong(), anyLong()); + verify(notificationCacheService, never()).updateCache(anyLong(), any()); + } + + + @Test + void Given_ExceptionOccurs_When_HandleUpdate_Then_DeleteMemberAllCache() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("예외 발생 루틴") + .memo("예외 발생 메모") + .notification(notification) + .build(); + + ScenarioUpdateEvent event = new ScenarioUpdateEvent(memberId, scenario, false); + + doThrow(new RuntimeException("Cache operation failed")) + .when(notificationCacheService).updateCache(anyLong(), any()); + + // when + scenarioUpdateEventListener.handleUpdate(event); + + // then + verify(notificationCacheService, never()).deleteCache(anyLong(), anyLong()); + verify(notificationCacheService).updateCache(eq(memberId), eq(scenario)); + verify(notificationCacheService).deleteMemberAllCache(eq(memberId)); + } + + + @Test + void Given_ValidScenarioWithActiveNotification_When_HandleUpdate_Then_ProcessWithNotification() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("저녁 루틴") + .memo("저녁에 할 일들") + .notification(notification) + .build(); + + ScenarioUpdateEvent event = new ScenarioUpdateEvent(memberId, scenario, false); + + // when + scenarioUpdateEventListener.handleUpdate(event); + + // then + verify(notificationCacheService, never()).deleteCache(anyLong(), anyLong()); + verify(notificationCacheService).updateCache(eq(memberId), eq(scenario)); + } + +} diff --git a/src/test/java/com/und/server/notification/service/NotificationCacheServiceTest.java b/src/test/java/com/und/server/notification/service/NotificationCacheServiceTest.java new file mode 100644 index 00000000..b52d6307 --- /dev/null +++ b/src/test/java/com/und/server/notification/service/NotificationCacheServiceTest.java @@ -0,0 +1,447 @@ +package com.und.server.notification.service; + +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.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.RedisSystemException; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.cache.NotificationCacheData; +import com.und.server.notification.dto.response.ScenarioNotificationListResponse; +import com.und.server.notification.dto.response.ScenarioNotificationResponse; +import com.und.server.notification.dto.response.TimeNotificationResponse; +import com.und.server.notification.entity.Notification; +import com.und.server.notification.exception.NotificationCacheErrorResult; +import com.und.server.notification.exception.NotificationCacheException; +import com.und.server.notification.util.NotificationCacheKeyGenerator; +import com.und.server.notification.util.NotificationCacheSerializer; +import com.und.server.scenario.entity.Scenario; +import com.und.server.scenario.service.ScenarioNotificationService; + + +@ExtendWith(MockitoExtension.class) +class NotificationCacheServiceTest { + + @InjectMocks + private NotificationCacheService notificationCacheService; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Mock + private HashOperations hashOperations; + + @Mock + private NotificationCacheKeyGenerator keyGenerator; + + @Mock + private NotificationCacheSerializer serializer; + + @Mock + private NotificationConditionSelector notificationConditionSelector; + + @Mock + private ScenarioNotificationService scenarioNotificationService; + + private final Long memberId = 1L; + private final Long scenarioId = 10L; + private final String cacheKey = "notif:1"; + private final String etagKey = "notif:etag:1"; + + + @BeforeEach + void setUp() { + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); + lenient().when(redisTemplate.opsForHash()).thenReturn(hashOperations); + } + + + @Test + void Given_CacheHit_When_GetScenariosNotificationCache_Then_ReturnCachedData() { + // given + String etag = "1234567890"; + Map cacheData = new HashMap<>(); + cacheData.put("10", "serialized_cache_data"); + + NotificationCacheData notificationCacheData = NotificationCacheData.builder() + .scenarioId(scenarioId) + .scenarioName("아침 루틴") + .scenarioMemo("아침에 할 일들") + .build(); + + given(keyGenerator.generateNotificationCacheKey(memberId)).willReturn(cacheKey); + given(keyGenerator.generateEtagKey(memberId)).willReturn(etagKey); + given(valueOperations.get(etagKey)).willReturn(etag); + given(hashOperations.entries(cacheKey)).willReturn(cacheData); + given(serializer.deserialize("serialized_cache_data")).willReturn(notificationCacheData); + given(serializer.parseCondition(notificationCacheData)).willReturn(null); + + // when + ScenarioNotificationListResponse result = notificationCacheService.getScenariosNotificationCache(memberId); + + // then + assertThat(result.etag()).isEqualTo(etag); + assertThat(result.scenarios()).hasSize(1); + assertThat(result.scenarios().get(0).scenarioId()).isEqualTo(scenarioId); + verify(scenarioNotificationService, never()).getScenarioNotifications(any()); + } + + + @Test + void Given_CacheMiss_When_GetScenariosNotificationCache_Then_ReturnFromDatabase() { + // given + ScenarioNotificationResponse dbResponse = ScenarioNotificationResponse.builder() + .scenarioId(scenarioId) + .scenarioName("아침 루틴") + .memo("아침에 할 일들") + .notificationCondition(null) + .build(); + + given(keyGenerator.generateNotificationCacheKey(memberId)).willReturn(cacheKey); + given(keyGenerator.generateEtagKey(memberId)).willReturn(etagKey); + given(valueOperations.get(etagKey)).willReturn(null); + given(scenarioNotificationService.getScenarioNotifications(memberId)).willReturn(List.of(dbResponse)); + + // when + ScenarioNotificationListResponse result = notificationCacheService.getScenariosNotificationCache(memberId); + + // then + assertThat(result.scenarios()).hasSize(1); + assertThat(result.scenarios().get(0).scenarioId()).isEqualTo(scenarioId); + verify(scenarioNotificationService).getScenarioNotifications(memberId); + } + + + @Test + void Given_ValidScenario_When_GetSingleScenarioNotificationCache_Then_ReturnNotification() { + // given + String cachedValue = "serialized_cache_data"; + NotificationCacheData notificationCacheData = NotificationCacheData.builder() + .scenarioId(scenarioId) + .scenarioName("아침 루틴") + .scenarioMemo("아침에 할 일들") + .build(); + + given(keyGenerator.generateNotificationCacheKey(memberId)).willReturn(cacheKey); + given(keyGenerator.generateEtagKey(memberId)).willReturn(etagKey); + given(valueOperations.get(etagKey)).willReturn("1234567890"); + given(hashOperations.get(cacheKey, scenarioId.toString())).willReturn(cachedValue); + given(serializer.deserialize(cachedValue)).willReturn(notificationCacheData); + given(serializer.parseCondition(notificationCacheData)).willReturn(null); + + // when + ScenarioNotificationResponse result = + notificationCacheService.getSingleScenarioNotificationCache(memberId, scenarioId); + + // then + assertThat(result).isNotNull(); + assertThat(result.scenarioId()).isEqualTo(scenarioId); + assertThat(result.scenarioName()).isEqualTo("아침 루틴"); + } + + + @Test + void Given_NonExistentScenario_When_GetSingleScenarioNotificationCache_Then_ThrowNotFoundException() { + // given + given(keyGenerator.generateNotificationCacheKey(memberId)).willReturn(cacheKey); + given(keyGenerator.generateEtagKey(memberId)).willReturn(etagKey); + given(valueOperations.get(etagKey)).willReturn("1234567890"); + given(hashOperations.get(cacheKey, scenarioId.toString())).willReturn(null); + + // when & then + assertThatThrownBy(() -> + notificationCacheService.getSingleScenarioNotificationCache(memberId, scenarioId) + ).isInstanceOf(NotificationCacheException.class) + .hasFieldOrPropertyWithValue("errorResult", + NotificationCacheErrorResult.CACHE_NOT_FOUND_SCENARIO_NOTIFICATION); + } + + + @Test + void Given_ValidScenario_When_UpdateCache_Then_UpdateSuccessfully() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("업데이트된 루틴") + .memo("업데이트된 메모") + .notification(notification) + .build(); + + given(keyGenerator.generateNotificationCacheKey(memberId)).willReturn(cacheKey); + given(keyGenerator.generateEtagKey(memberId)).willReturn(etagKey); + given(valueOperations.get(etagKey)).willReturn("1234567890"); + given(notificationConditionSelector.findNotificationCondition(notification)).willReturn(null); + given(serializer.serializeCondition(null)).willReturn("serialized_condition"); + given(serializer.serialize(any())).willReturn("serialized_cache_data"); + + // when + notificationCacheService.updateCache(memberId, scenario); + + // then + verify(hashOperations).put(eq(cacheKey), eq(scenarioId.toString()), eq("serialized_cache_data")); + verify(redisTemplate).expire(eq(cacheKey), anyLong(), any(TimeUnit.class)); + verify(valueOperations).set(eq(etagKey), anyString()); + verify(redisTemplate).expire(eq(etagKey), anyLong(), any(TimeUnit.class)); + } + + + @Test + void Given_ValidScenario_When_DeleteCache_Then_DeleteSuccessfully() { + // given + given(keyGenerator.generateNotificationCacheKey(memberId)).willReturn(cacheKey); + given(keyGenerator.generateEtagKey(memberId)).willReturn(etagKey); + given(valueOperations.get(etagKey)).willReturn("1234567890"); + + // when + notificationCacheService.deleteCache(memberId, scenarioId); + + // then + verify(hashOperations).delete(cacheKey, scenarioId.toString()); + verify(valueOperations).set(eq(etagKey), anyString()); + verify(redisTemplate).expire(eq(etagKey), anyLong(), any(TimeUnit.class)); + } + + + @Test + void Given_ValidMember_When_DeleteMemberAllCache_Then_DeleteAllCache() { + // given + given(keyGenerator.generateNotificationCacheKey(memberId)).willReturn(cacheKey); + given(keyGenerator.generateEtagKey(memberId)).willReturn(etagKey); + + // when + notificationCacheService.deleteMemberAllCache(memberId); + + // then + verify(redisTemplate).delete(cacheKey); + verify(redisTemplate).delete(etagKey); + } + + + @Test + void Given_EmptyCacheData_When_GetScenariosNotificationCache_Then_ReturnEmptyList() { + // given + String etag = "1234567890"; + Map cacheData = new HashMap<>(); + + given(keyGenerator.generateNotificationCacheKey(memberId)).willReturn(cacheKey); + given(keyGenerator.generateEtagKey(memberId)).willReturn(etagKey); + given(valueOperations.get(etagKey)).willReturn(etag); + given(hashOperations.entries(cacheKey)).willReturn(cacheData); + + // when + ScenarioNotificationListResponse result = notificationCacheService.getScenariosNotificationCache(memberId); + + // then + assertThat(result.etag()).isEqualTo(etag); + assertThat(result.scenarios()).isEmpty(); + } + + + @Test + void Given_EmptyDatabaseResponse_When_GetScenariosNotificationCache_Then_ReturnEmptyList() { + // given + given(keyGenerator.generateNotificationCacheKey(memberId)).willReturn(cacheKey); + given(keyGenerator.generateEtagKey(memberId)).willReturn(etagKey); + given(valueOperations.get(etagKey)).willReturn(null); + given(scenarioNotificationService.getScenarioNotifications(memberId)).willReturn(List.of()); + + // when + ScenarioNotificationListResponse result = notificationCacheService.getScenariosNotificationCache(memberId); + + // then + assertThat(result.scenarios()).isEmpty(); + verify(scenarioNotificationService).getScenarioNotifications(memberId); + } + + + @Test + void Given_RedisException_When_GetScenariosNotificationCache_Then_ThrowRuntimeException() { + // given + given(keyGenerator.generateNotificationCacheKey(memberId)).willReturn(cacheKey); + given(keyGenerator.generateEtagKey(memberId)).willReturn(etagKey); + doThrow(new RuntimeException("Redis connection failed")).when(valueOperations).get(anyString()); + + // when & then + assertThatThrownBy(() -> notificationCacheService.getScenariosNotificationCache(memberId)) + .isInstanceOf(RuntimeException.class) + .hasMessage("Redis connection failed"); + } + + + @Test + void Given_RedisException_When_GetSingleScenarioNotificationCache_Then_ThrowNotificationCacheException() { + // given + given(keyGenerator.generateEtagKey(memberId)).willReturn(etagKey); + doThrow(new RedisSystemException("Redis connection failed", new RuntimeException())).when(valueOperations) + .get(etagKey); + + // when & then + assertThatThrownBy(() -> notificationCacheService.getSingleScenarioNotificationCache(memberId, scenarioId)) + .isInstanceOf(NotificationCacheException.class) + .hasFieldOrPropertyWithValue("errorResult", NotificationCacheErrorResult.CACHE_FETCH_SINGLE_FAILED); + } + + + @Test + void Given_RedisException_When_UpdateCache_Then_ThrowRuntimeException() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("에러 발생 루틴") + .memo("에러 발생 메모") + .notification(notification) + .build(); + + given(keyGenerator.generateNotificationCacheKey(memberId)).willReturn(cacheKey); + given(keyGenerator.generateEtagKey(memberId)).willReturn(etagKey); + given(valueOperations.get(etagKey)).willReturn("1234567890"); + given(notificationConditionSelector.findNotificationCondition(notification)).willReturn(null); + given(serializer.serializeCondition(null)).willReturn("serialized_condition"); + given(serializer.serialize(any())).willReturn("serialized_cache_data"); + doThrow(new RuntimeException("Redis connection failed")).when(hashOperations) + .put(anyString(), anyString(), anyString()); + + // when & then + assertThatThrownBy(() -> notificationCacheService.updateCache(memberId, scenario)) + .isInstanceOf(RuntimeException.class) + .hasMessage("Redis connection failed"); + } + + + @Test + void Given_RedisException_When_DeleteCache_Then_ThrowRuntimeException() { + // given + given(keyGenerator.generateNotificationCacheKey(memberId)).willReturn(cacheKey); + given(keyGenerator.generateEtagKey(memberId)).willReturn(etagKey); + given(valueOperations.get(etagKey)).willReturn("1234567890"); + doThrow(new RuntimeException("Redis connection failed")).when(hashOperations).delete(anyString(), anyString()); + + // when & then + assertThatThrownBy(() -> notificationCacheService.deleteCache(memberId, scenarioId)) + .isInstanceOf(RuntimeException.class) + .hasMessage("Redis connection failed"); + } + + + @Test + void Given_RedisException_When_DeleteMemberAllCache_Then_LogError() { + // given + given(keyGenerator.generateNotificationCacheKey(memberId)).willReturn(cacheKey); + given(keyGenerator.generateEtagKey(memberId)).willReturn(etagKey); + doThrow(new RedisSystemException("Redis connection failed", new RuntimeException())).when(redisTemplate) + .delete(anyString()); + + // when & then + notificationCacheService.deleteMemberAllCache(memberId); + // 예외가 발생해도 로그만 남기고 정상 종료되어야 함 + } + + + @Test + void Given_NotificationCondition_When_UpdateCache_Then_IncludeCondition() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .scenarioName("조건 포함 루틴") + .memo("조건 포함 메모") + .notification(notification) + .build(); + + TimeNotificationResponse condition = TimeNotificationResponse.builder() + .notificationType(NotificationType.TIME) + .startHour(12) + .startMinute(30) + .build(); + + given(keyGenerator.generateNotificationCacheKey(memberId)).willReturn(cacheKey); + given(keyGenerator.generateEtagKey(memberId)).willReturn(etagKey); + given(valueOperations.get(etagKey)).willReturn("1234567890"); + given(notificationConditionSelector.findNotificationCondition(notification)).willReturn(condition); + given(serializer.serializeCondition(condition)).willReturn("serialized_condition"); + given(serializer.serialize(any())).willReturn("serialized_cache_data"); + + // when + notificationCacheService.updateCache(memberId, scenario); + + // then + verify(notificationConditionSelector).findNotificationCondition(notification); + verify(serializer).serializeCondition(condition); + verify(hashOperations).put(eq(cacheKey), eq(scenarioId.toString()), eq("serialized_cache_data")); + } + + + @Test + void Given_NotificationCondition_When_GetSingleScenarioNotificationCache_Then_IncludeCondition() { + // given + String cachedValue = "serialized_cache_data"; + NotificationCacheData notificationCacheData = NotificationCacheData.builder() + .scenarioId(scenarioId) + .scenarioName("조건 포함 루틴") + .scenarioMemo("조건 포함 메모") + .build(); + + TimeNotificationResponse condition = TimeNotificationResponse.builder() + .notificationType(NotificationType.TIME) + .startHour(12) + .startMinute(30) + .build(); + + given(keyGenerator.generateNotificationCacheKey(memberId)).willReturn(cacheKey); + given(keyGenerator.generateEtagKey(memberId)).willReturn(etagKey); + given(valueOperations.get(etagKey)).willReturn("1234567890"); + given(hashOperations.get(cacheKey, scenarioId.toString())).willReturn(cachedValue); + given(serializer.deserialize(cachedValue)).willReturn(notificationCacheData); + given(serializer.parseCondition(notificationCacheData)).willReturn(condition); + + // when + ScenarioNotificationResponse result = + notificationCacheService.getSingleScenarioNotificationCache(memberId, scenarioId); + + // then + assertThat(result).isNotNull(); + assertThat(result.scenarioId()).isEqualTo(scenarioId); + assertThat(result.notificationCondition()).isEqualTo(condition); + } + +} diff --git a/src/test/java/com/und/server/notification/service/NotificationConditionSelectorTest.java b/src/test/java/com/und/server/notification/service/NotificationConditionSelectorTest.java new file mode 100644 index 00000000..6c57a441 --- /dev/null +++ b/src/test/java/com/und/server/notification/service/NotificationConditionSelectorTest.java @@ -0,0 +1,250 @@ +package com.und.server.notification.service; + +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.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.common.exception.ServerException; +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.request.NotificationConditionRequest; +import com.und.server.notification.dto.request.TimeNotificationRequest; +import com.und.server.notification.dto.response.NotificationConditionResponse; +import com.und.server.notification.dto.response.TimeNotificationResponse; +import com.und.server.notification.entity.Notification; +import com.und.server.notification.exception.NotificationErrorResult; + +@ExtendWith(MockitoExtension.class) +class NotificationConditionSelectorTest { + + @Mock + private NotificationConditionService timeNotificationService; + + @Mock + private NotificationConditionService locationNotificationService; + + @Mock + private Notification notification; + + @Mock + private NotificationConditionRequest conditionRequest; + + @InjectMocks + private NotificationConditionSelector selector; + + private List services; + + + @BeforeEach + void setUp() { + services = Arrays.asList(timeNotificationService, locationNotificationService); + selector = new NotificationConditionSelector(services); + } + + + @Test + void Given_SupportedNotificationType_When_FindNotificationInfoByType_Then_ReturnNotificationInfoDto() { + // given + NotificationType notifType = NotificationType.TIME; + NotificationConditionResponse expectedDto = TimeNotificationResponse.builder() + .startHour(9) + .startMinute(0) + .build(); + + when(notification.getNotificationType()).thenReturn(notifType); + when(timeNotificationService.supports(notifType)).thenReturn(true); + when(timeNotificationService.findNotificationInfoByType(notification)).thenReturn(expectedDto); + + // when + NotificationConditionResponse result = selector.findNotificationCondition(notification); + + // then + assertThat(result).isEqualTo(expectedDto); + verify(timeNotificationService).supports(notifType); + verify(timeNotificationService).findNotificationInfoByType(notification); + } + + + @Test + void Given_UnsupportedNotificationType_When_FindNotificationInfoByType_Then_ThrowServerException() { + // given + NotificationType notifType = NotificationType.LOCATION; + + when(notification.getNotificationType()).thenReturn(notifType); + when(timeNotificationService.supports(notifType)).thenReturn(false); + when(locationNotificationService.supports(notifType)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> selector.findNotificationCondition(notification)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", NotificationErrorResult.UNSUPPORTED_NOTIFICATION); + } + + + @Test + void Given_SupportedNotificationType_When_AddNotificationCondition_Then_InvokeService() { + // given + NotificationType notifType = NotificationType.TIME; + TimeNotificationRequest timeRequest = TimeNotificationRequest.builder() + .startHour(9) + .startMinute(0) + .build(); + + when(notification.getNotificationType()).thenReturn(notifType); + when(timeNotificationService.supports(notifType)).thenReturn(true); + + // when + selector.addNotificationCondition(notification, timeRequest); + + // then + verify(timeNotificationService).supports(notifType); + verify(timeNotificationService).addNotificationCondition(notification, timeRequest); + } + + + @Test + void Given_UnsupportedNotificationType_When_AddNotificationCondition_Then_ThrowServerException() { + // given + NotificationType notifType = NotificationType.LOCATION; + + when(notification.getNotificationType()).thenReturn(notifType); + when(timeNotificationService.supports(notifType)).thenReturn(false); + when(locationNotificationService.supports(notifType)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> selector.addNotificationCondition(notification, conditionRequest)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", NotificationErrorResult.UNSUPPORTED_NOTIFICATION); + } + + + @Test + void Given_SupportedNotificationType_When_DeleteNotificationCondition_Then_InvokeService() { + // given + NotificationType notifType = NotificationType.TIME; + Long notificationId = 1L; + + when(timeNotificationService.supports(notifType)).thenReturn(true); + + // when + selector.deleteNotificationCondition(notifType, notificationId); + + // then + verify(timeNotificationService).supports(notifType); + verify(timeNotificationService).deleteNotificationCondition(notificationId); + } + + + @Test + void Given_UnsupportedNotificationType_When_DeleteNotificationCondition_Then_ThrowServerException() { + // given + NotificationType notifType = NotificationType.LOCATION; + Long notificationId = 1L; + + when(timeNotificationService.supports(notifType)).thenReturn(false); + when(locationNotificationService.supports(notifType)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> selector.deleteNotificationCondition(notifType, notificationId)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", NotificationErrorResult.UNSUPPORTED_NOTIFICATION); + } + + + @Test + void Given_SupportedNotificationType_When_UpdateNotificationCondition_Then_InvokeService() { + // given + NotificationType notifType = NotificationType.TIME; + TimeNotificationRequest timeRequest = TimeNotificationRequest.builder() + .startHour(9) + .startMinute(0) + .build(); + + when(notification.getNotificationType()).thenReturn(notifType); + when(timeNotificationService.supports(notifType)).thenReturn(true); + + // when + selector.updateNotificationCondition(notification, timeRequest); + + // then + verify(timeNotificationService).supports(notifType); + verify(timeNotificationService).updateNotificationCondition(notification, timeRequest); + } + + + @Test + void Given_UnsupportedNotificationType_When_UpdateNotificationCondition_Then_ThrowServerException() { + // given + NotificationType notifType = NotificationType.LOCATION; + + when(notification.getNotificationType()).thenReturn(notifType); + when(timeNotificationService.supports(notifType)).thenReturn(false); + when(locationNotificationService.supports(notifType)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> selector.updateNotificationCondition(notification, conditionRequest)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", NotificationErrorResult.UNSUPPORTED_NOTIFICATION); + } + + + @Test + void Given_MultipleServices_When_FirstServiceSupports_Then_UseFirstService() { + // given + NotificationType notifType = NotificationType.TIME; + NotificationConditionResponse expectedDto = TimeNotificationResponse.builder() + .startHour(9) + .startMinute(0) + .build(); + + when(notification.getNotificationType()).thenReturn(notifType); + when(timeNotificationService.supports(notifType)).thenReturn(true); + when(timeNotificationService.findNotificationInfoByType(notification)).thenReturn(expectedDto); + + // when + NotificationConditionResponse result = selector.findNotificationCondition(notification); + + // then + assertThat(result).isEqualTo(expectedDto); + verify(timeNotificationService).supports(notifType); + verify(timeNotificationService).findNotificationInfoByType(notification); + verify(locationNotificationService, org.mockito.Mockito.never()).supports(any()); + } + + + @Test + void Given_MultipleServices_When_FirstServiceNotSupports_Then_UseSecondService() { + // given + NotificationType notifType = NotificationType.LOCATION; + NotificationConditionResponse expectedDto = TimeNotificationResponse.builder() + .startHour(9) + .startMinute(0) + .build(); + + when(notification.getNotificationType()).thenReturn(notifType); + when(timeNotificationService.supports(notifType)).thenReturn(false); + when(locationNotificationService.supports(notifType)).thenReturn(true); + when(locationNotificationService.findNotificationInfoByType(notification)).thenReturn(expectedDto); + + // when + NotificationConditionResponse result = selector.findNotificationCondition(notification); + + // then + assertThat(result).isEqualTo(expectedDto); + verify(timeNotificationService).supports(notifType); + verify(locationNotificationService).supports(notifType); + verify(locationNotificationService).findNotificationInfoByType(notification); + } + +} diff --git a/src/test/java/com/und/server/notification/service/NotificationServiceTest.java b/src/test/java/com/und/server/notification/service/NotificationServiceTest.java new file mode 100644 index 00000000..46ce066a --- /dev/null +++ b/src/test/java/com/und/server/notification/service/NotificationServiceTest.java @@ -0,0 +1,711 @@ +package com.und.server.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.notification.constants.NotificationMethodType; +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.request.NotificationRequest; +import com.und.server.notification.dto.request.TimeNotificationRequest; +import com.und.server.notification.dto.response.NotificationConditionResponse; +import com.und.server.notification.dto.response.TimeNotificationResponse; +import com.und.server.notification.entity.Notification; +import com.und.server.notification.event.NotificationEventPublisher; +import com.und.server.notification.repository.NotificationRepository; +import com.und.server.scenario.entity.Scenario; +import com.und.server.scenario.repository.ScenarioRepository; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock + private NotificationRepository notificationRepository; + + @Mock + private NotificationConditionSelector notificationConditionSelector; + + @Mock + private ScenarioRepository scenarioRepository; + + @Mock + private NotificationEventPublisher notificationEventPublisher; + + @InjectMocks + private NotificationService notificationService; + + + @Test + void Given_ActiveNotification_When_FindNotificationDetails_Then_ReturnNotificationInfoDto() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + + NotificationConditionResponse expectedInfo = TimeNotificationResponse.builder() + .startHour(9) + .startMinute(0) + .build(); + when(notificationConditionSelector.findNotificationCondition(notification)) + .thenReturn(expectedInfo); + + // when + NotificationConditionResponse result = notificationService.findNotificationDetails(notification); + + // then + assertThat(result).isEqualTo(expectedInfo); + verify(notificationConditionSelector).findNotificationCondition(notification); + } + + + @Test + void Given_NotificationRequestAndCondition_When_AddNotification_Then_SaveNotificationAndAddCondition() { + // given + NotificationRequest notificationInfo = NotificationRequest.builder() + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeekOrdinal(List.of(0, 1, 2)) + .build(); + + TimeNotificationRequest conditionInfo = TimeNotificationRequest.builder() + .startHour(9) + .startMinute(0) + .build(); + + Notification savedNotification = Notification.builder() + .id(1L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .isActive(true) + .build(); + + when(notificationRepository.save(any(Notification.class))) + .thenReturn(savedNotification); + + // when + Notification result = notificationService.addNotification(notificationInfo, conditionInfo); + + // then + assertThat(result) + .isNotNull() + .satisfies(r -> assertThat(r.getNotificationType()).isEqualTo(NotificationType.TIME)) + .satisfies(r -> assertThat(r.getNotificationMethodType()).isEqualTo(NotificationMethodType.PUSH)) + .satisfies(r -> assertThat(r.getIsActive()).isTrue()); + verify(notificationRepository).save(any(Notification.class)); + verify(notificationConditionSelector) + .addNotificationCondition(any(Notification.class), eq(conditionInfo)); + } + + + @Test + void Given_ActiveNotificationAndSameType_When_UpdateNotification_Then_UpdateNotificationAndCondition() { + // given + Notification oldNotification = Notification.builder() + .id(1L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .isActive(true) + .build(); + + NotificationRequest notificationInfo = NotificationRequest.builder() + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.ALARM) + .daysOfWeekOrdinal(List.of(0, 1, 2, 3)) + .build(); + + TimeNotificationRequest conditionInfo = TimeNotificationRequest.builder() + .startHour(10) + .startMinute(30) + .build(); + + // when + notificationService.updateNotification(oldNotification, notificationInfo, conditionInfo); + + // then + assertThat(oldNotification) + .satisfies(n -> assertThat(n.getNotificationType()).isEqualTo(NotificationType.TIME)) + .satisfies(n -> assertThat(n.getNotificationMethodType()).isEqualTo(NotificationMethodType.ALARM)) + .satisfies(n -> assertThat(n.isActive()).isTrue()); + verify(notificationConditionSelector) + .updateNotificationCondition(oldNotification, conditionInfo); + } + + + @Test + void Given_ActiveNotificationAndDifferentType_When_UpdateNotification_Then_DeleteOldAndAddNew() { + // given + Notification oldNotification = Notification.builder() + .id(1L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .isActive(true) + .build(); + + NotificationRequest notificationInfo = NotificationRequest.builder() + .isActive(true) + .notificationType(NotificationType.LOCATION) + .notificationMethodType(NotificationMethodType.ALARM) + .daysOfWeekOrdinal(List.of(0, 1, 2)) + .build(); + + TimeNotificationRequest conditionInfo = TimeNotificationRequest.builder() + .startHour(9) + .startMinute(0) + .build(); + + // when + notificationService.updateNotification(oldNotification, notificationInfo, conditionInfo); + + // then + assertThat(oldNotification) + .satisfies(n -> assertThat(n.getNotificationType()).isEqualTo(NotificationType.LOCATION)) + .satisfies(n -> assertThat(n.getNotificationMethodType()).isEqualTo(NotificationMethodType.ALARM)) + .satisfies(n -> assertThat(n.isActive()).isTrue()); + verify(notificationConditionSelector).deleteNotificationCondition( + NotificationType.TIME, oldNotification.getId()); + verify(notificationConditionSelector) + .addNotificationCondition(oldNotification, conditionInfo); + } + + + @Test + void Given_ActiveNotificationAndInactive_When_UpdateNotification_Then_DeleteCondition() { + // given + Notification oldNotification = Notification.builder() + .id(1L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .isActive(true) + .build(); + + NotificationRequest notificationRequest = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .build(); + + // when + notificationService.updateNotification(oldNotification, notificationRequest, null); + + // then + assertThat(oldNotification) + .satisfies(n -> assertThat(n.isActive()).isFalse()) + .satisfies(n -> assertThat(n.getNotificationMethodType()).isNull()); + verify(notificationConditionSelector) + .deleteNotificationCondition(NotificationType.TIME, oldNotification.getId()); + } + + @Test + void Given_NotificationType_When_AddWithoutNotification_Then_CreateInactiveNotification() { + // given + NotificationType type = NotificationType.TIME; + Notification saved = Notification.builder() + .id(10L) + .notificationType(type) + .isActive(false) + .build(); + + when(notificationRepository.save(any(Notification.class))).thenReturn(saved); + + // when + NotificationRequest request = NotificationRequest.builder() + .isActive(false) + .notificationType(type) + .build(); + + Notification result = notificationService.addNotification(request, null); + + // then + assertThat(result) + .satisfies(r -> assertThat(r.getNotificationType()).isEqualTo(type)) + .satisfies(r -> assertThat(r.getIsActive()).isFalse()); + verify(notificationRepository).save(any(Notification.class)); + } + + @Test + void Given_Notification_When_DeleteNotification_Then_DeletesCondition() { + // given + Notification notification = Notification.builder() + .id(5L) + .notificationType(NotificationType.LOCATION) + .isActive(true) + .build(); + + // when + notificationService.deleteNotification(notification); + + // then + verify(notificationConditionSelector) + .deleteNotificationCondition(NotificationType.LOCATION, 5L); + } + + + @Test + void Given_NotificationWithDaysOfWeek_When_FindNotificationDetails_Then_ReturnNotificationWithDays() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .daysOfWeek("0,1,2,3,4,5,6") + .build(); + + NotificationConditionResponse expectedInfo = TimeNotificationResponse.builder() + .startHour(9) + .startMinute(0) + .build(); + when(notificationConditionSelector.findNotificationCondition(notification)) + .thenReturn(expectedInfo); + + // when + NotificationConditionResponse result = notificationService.findNotificationDetails(notification); + + // then + assertThat(result).isEqualTo(expectedInfo); + assertThat(notification) + .satisfies(n -> assertThat(n.isEveryDay()).isTrue()) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).hasSize(7)) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).containsExactlyInAnyOrder(0, 1, 2, 3, 4, 5, 6)); + verify(notificationConditionSelector).findNotificationCondition(notification); + } + + + @Test + void Given_NotificationWithSpecificDays_When_FindNotificationDetails_Then_ReturnNotificationWithSpecificDays() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .daysOfWeek("0,2,4") + .build(); + + NotificationConditionResponse expectedInfo = TimeNotificationResponse.builder() + .startHour(9) + .startMinute(0) + .build(); + when(notificationConditionSelector.findNotificationCondition(notification)) + .thenReturn(expectedInfo); + + // when + NotificationConditionResponse result = notificationService.findNotificationDetails(notification); + + // then + assertThat(result).isEqualTo(expectedInfo); + assertThat(notification) + .satisfies(n -> assertThat(n.isEveryDay()).isFalse()) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).hasSize(3)) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).containsExactlyInAnyOrder(0, 2, 4)); + verify(notificationConditionSelector).findNotificationCondition(notification); + } + + + @Test + void Given_NotificationWithEmptyDays_When_FindNotificationDetails_Then_ReturnNotificationWithEmptyDays() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .daysOfWeek("") + .build(); + + NotificationConditionResponse expectedInfo = TimeNotificationResponse.builder() + .startHour(9) + .startMinute(0) + .build(); + when(notificationConditionSelector.findNotificationCondition(notification)) + .thenReturn(expectedInfo); + + // when + NotificationConditionResponse result = notificationService.findNotificationDetails(notification); + + // then + assertThat(result).isEqualTo(expectedInfo); + assertThat(notification) + .satisfies(n -> assertThat(n.isEveryDay()).isFalse()) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).isEmpty()); + verify(notificationConditionSelector).findNotificationCondition(notification); + } + + + @Test + void Given_NotificationWithNullDays_When_FindNotificationDetails_Then_ReturnNotificationWithEmptyDays() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .daysOfWeek(null) + .build(); + + NotificationConditionResponse expectedInfo = TimeNotificationResponse.builder() + .startHour(9) + .startMinute(0) + .build(); + when(notificationConditionSelector.findNotificationCondition(notification)) + .thenReturn(expectedInfo); + + // when + NotificationConditionResponse result = notificationService.findNotificationDetails(notification); + + // then + assertThat(result).isEqualTo(expectedInfo); + assertThat(notification) + .satisfies(n -> assertThat(n.isEveryDay()).isFalse()) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).isEmpty()); + verify(notificationConditionSelector).findNotificationCondition(notification); + } + + + @Test + void Given_ActiveNotification_When_UpdateDaysOfWeekOrdinal_Then_UpdateSuccessfully() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .daysOfWeek("0,1,2") + .build(); + + List newDays = List.of(1, 3, 5); + + // when + notification.updateDaysOfWeekOrdinal(newDays); + + // then + assertThat(notification) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).hasSize(3)) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).containsExactlyInAnyOrder(1, 3, 5)) + .satisfies(n -> assertThat(n.isEveryDay()).isFalse()); + } + + + @Test + void Given_InactiveNotification_When_UpdateDaysOfWeekOrdinal_Then_SetToNull() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(false) + .notificationType(NotificationType.TIME) + .daysOfWeek("0,1,2") + .build(); + + List newDays = List.of(1, 3, 5); + + // when + notification.updateDaysOfWeekOrdinal(newDays); + + // then + assertThat(notification) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).isEmpty()) + .satisfies(n -> assertThat(n.isEveryDay()).isFalse()); + } + + + @Test + void Given_ActiveNotification_When_UpdateDaysOfWeekOrdinalWithNull_Then_SetToNull() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .daysOfWeek("0,1,2") + .build(); + + // when + notification.updateDaysOfWeekOrdinal(null); + + // then + assertThat(notification) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).isEmpty()) + .satisfies(n -> assertThat(n.isEveryDay()).isFalse()); + } + + + @Test + void Given_ActiveNotification_When_UpdateDaysOfWeekOrdinalWithEmpty_Then_SetToNull() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .daysOfWeek("0,1,2") + .build(); + + // when + notification.updateDaysOfWeekOrdinal(List.of()); + + // then + assertThat(notification) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).isEmpty()) + .satisfies(n -> assertThat(n.isEveryDay()).isFalse()); + } + + + @Test + void Given_Notification_When_UpdateDaysOfWeekOrdinalWithEmptyList_Then_SetToNull() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .daysOfWeek("0,1,2") + .build(); + + // when + notification.updateDaysOfWeekOrdinal(List.of()); + + // then + assertThat(notification) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).isEmpty()) + .satisfies(n -> assertThat(n.isEveryDay()).isFalse()); + } + + + @Test + void Given_Notification_When_DeactivateNotification_Then_SetMethodTypeToNull() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .build(); + + // when + notification.deactivate(); + + // then + assertThat(notification) + .satisfies(n -> assertThat(n.getNotificationMethodType()).isNull()) + .satisfies(n -> assertThat(n.isActive()).isFalse()); + } + + + @Test + void Given_MemberWithActiveNotis_When_UpdateNotiActiveStatusToFalse_Then_DeactivateNotisAndPublishEvent() { + // given + Long memberId = 1L; + Boolean isActive = false; + + Notification notification1 = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeek("0,1,2") + .build(); + + Notification notification2 = Notification.builder() + .id(2L) + .isActive(true) + .notificationType(NotificationType.LOCATION) + .notificationMethodType(NotificationMethodType.ALARM) + .daysOfWeek("0,1,2,3,4") + .build(); + + Scenario scenario1 = Scenario.builder() + .id(1L) + .notification(notification1) + .build(); + + Scenario scenario2 = Scenario.builder() + .id(2L) + .notification(notification2) + .build(); + + when(scenarioRepository.findByMemberId(memberId)) + .thenReturn(List.of(scenario1, scenario2)); + + // when + notificationService.updateNotificationActiveStatus(memberId, isActive); + + // then + assertThat(notification1.isActive()).isFalse(); + assertThat(notification2.isActive()).isFalse(); + verify(notificationEventPublisher).publishActiveUpdateEvent(memberId, isActive); + } + + + @Test + void Given_MemberWithInactiveNotis_When_UpdateNotiActiveStatusToTrue_Then_ActivateNotisAndPublishEvent() { + // given + Long memberId = 1L; + Boolean isActive = true; + + Notification notification1 = Notification.builder() + .id(1L) + .isActive(false) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeek("0,1,2") + .build(); + + Notification notification2 = Notification.builder() + .id(2L) + .isActive(false) + .notificationType(NotificationType.LOCATION) + .notificationMethodType(NotificationMethodType.ALARM) + .daysOfWeek("0,1,2,3,4") + .build(); + + Scenario scenario1 = Scenario.builder() + .id(1L) + .notification(notification1) + .build(); + + Scenario scenario2 = Scenario.builder() + .id(2L) + .notification(notification2) + .build(); + + when(scenarioRepository.findByMemberId(memberId)) + .thenReturn(List.of(scenario1, scenario2)); + + // when + notificationService.updateNotificationActiveStatus(memberId, isActive); + + // then + assertThat(notification1.isActive()).isTrue(); + assertThat(notification2.isActive()).isTrue(); + verify(notificationEventPublisher).publishActiveUpdateEvent(memberId, isActive); + } + + + @Test + void Given_MemberWithMixedNotis_When_UpdateNotiActiveStatusToFalse_Then_OnlyDeactivateActiveNotis() { + // given + Long memberId = 1L; + Boolean isActive = false; + + Notification activeNotification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeek("0,1,2") + .build(); + + Notification inactiveNotification = Notification.builder() + .id(2L) + .isActive(false) + .notificationType(NotificationType.LOCATION) + .notificationMethodType(NotificationMethodType.ALARM) + .daysOfWeek("0,1,2,3,4") + .build(); + + Scenario scenario1 = Scenario.builder() + .id(1L) + .notification(activeNotification) + .build(); + + Scenario scenario2 = Scenario.builder() + .id(2L) + .notification(inactiveNotification) + .build(); + + when(scenarioRepository.findByMemberId(memberId)) + .thenReturn(List.of(scenario1, scenario2)); + + // when + notificationService.updateNotificationActiveStatus(memberId, isActive); + + // then + assertThat(activeNotification.isActive()).isFalse(); + assertThat(inactiveNotification.isActive()).isFalse(); // Should remain false + verify(notificationEventPublisher).publishActiveUpdateEvent(memberId, isActive); + } + + + @Test + void Given_MemberWithNoScenarios_When_UpdateNotificationActiveStatus_Then_DoNothing() { + // given + Long memberId = 1L; + Boolean isActive = true; + + when(scenarioRepository.findByMemberId(memberId)) + .thenReturn(List.of()); + + // when + notificationService.updateNotificationActiveStatus(memberId, isActive); + + // then + verify(notificationEventPublisher, never()).publishActiveUpdateEvent(anyLong(), anyBoolean()); + } + + + @Test + void Given_MemberWithScenariosButNoNotifications_When_UpdateNotificationActiveStatus_Then_PublishEvent() { + // given + Long memberId = 1L; + Boolean isActive = true; + + Scenario scenario1 = Scenario.builder() + .id(1L) + .notification(null) + .build(); + + Scenario scenario2 = Scenario.builder() + .id(2L) + .notification(null) + .build(); + + when(scenarioRepository.findByMemberId(memberId)) + .thenReturn(List.of(scenario1, scenario2)); + + // when + notificationService.updateNotificationActiveStatus(memberId, isActive); + + // then + verify(notificationEventPublisher).publishActiveUpdateEvent(memberId, isActive); + } + + + @Test + void Given_MemberWithNotisWithoutConditions_When_UpdateNotiActiveStatusToTrue_Then_ActivateNotis() { + // given + Long memberId = 1L; + Boolean isActive = true; + + Notification notification = Notification.builder() + .id(1L) + .isActive(false) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeek("0,1,2") + .build(); + + Scenario scenario = Scenario.builder() + .id(1L) + .notification(notification) + .build(); + + when(scenarioRepository.findByMemberId(memberId)) + .thenReturn(List.of(scenario)); + + // when + notificationService.updateNotificationActiveStatus(memberId, isActive); + + // then + assertThat(notification.isActive()).isTrue(); + verify(notificationEventPublisher).publishActiveUpdateEvent(memberId, isActive); + } + +} diff --git a/src/test/java/com/und/server/notification/service/TimeNotificationServiceTest.java b/src/test/java/com/und/server/notification/service/TimeNotificationServiceTest.java new file mode 100644 index 00000000..78f7e6c4 --- /dev/null +++ b/src/test/java/com/und/server/notification/service/TimeNotificationServiceTest.java @@ -0,0 +1,261 @@ +package com.und.server.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.request.TimeNotificationRequest; +import com.und.server.notification.dto.response.NotificationConditionResponse; +import com.und.server.notification.dto.response.TimeNotificationResponse; +import com.und.server.notification.entity.Notification; +import com.und.server.notification.entity.TimeNotification; +import com.und.server.notification.repository.TimeNotificationRepository; + +@ExtendWith(MockitoExtension.class) +class TimeNotificationServiceTest { + + @Mock + private TimeNotificationRepository timeNotifRepository; + + @InjectMocks + private TimeNotificationService timeNotificationService; + + + @Test + void Given_TimeNotifType_When_Supports_Then_ReturnTrue() { + // given + NotificationType timeType = NotificationType.TIME; + + // when + boolean result = timeNotificationService.supports(timeType); + + // then + assertThat(result).isTrue(); + } + + + @Test + void Given_LocationNotifType_When_Supports_Then_ReturnFalse() { + // given + NotificationType locationType = NotificationType.LOCATION; + + // when + boolean result = timeNotificationService.supports(locationType); + + // then + assertThat(result).isFalse(); + } + + + @Test + void Given_EverydayNotification_When_FindNotificationInfoByType_Then_ReturnEverydayTrue() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .daysOfWeek("0,1,2,3,4,5,6") + .build(); + + TimeNotification timeNotification = TimeNotification.builder() + .id(10L) + .startHour(9) + .startMinute(0) + .build(); + + when(timeNotifRepository.findByNotificationId(notification.getId())) + .thenReturn(timeNotification); + + // when + NotificationConditionResponse result = timeNotificationService.findNotificationInfoByType(notification); + + // then + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(TimeNotificationResponse.class); + TimeNotificationResponse timeResponse = (TimeNotificationResponse) result; + assertThat(timeResponse.startHour()).isEqualTo(9); + assertThat(timeResponse.startMinute()).isEqualTo(0); + } + + + @Test + void Given_SpecificDaysNotification_When_FindNotificationInfoByType_Then_ReturnDayList() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .daysOfWeek("0,2") + .build(); + + TimeNotification timeNotification = TimeNotification.builder() + .id(10L) + .startHour(10) + .startMinute(30) + .build(); + + when(timeNotifRepository.findByNotificationId(notification.getId())) + .thenReturn(timeNotification); + + // when + NotificationConditionResponse result = timeNotificationService.findNotificationInfoByType(notification); + + // then + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(TimeNotificationResponse.class); + TimeNotificationResponse timeResponse = (TimeNotificationResponse) result; + assertThat(timeResponse.startHour()).isEqualTo(10); + assertThat(timeResponse.startMinute()).isEqualTo(30); + } + + + @Test + void Given_InactiveNotification_When_FindNotificationInfoByType_Then_ReturnNull() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(false) + .notificationType(NotificationType.TIME) + .build(); + + // when + NotificationConditionResponse result = timeNotificationService.findNotificationInfoByType(notification); + + // then + assertThat(result).isNull(); + } + + + @Test + void Given_ActiveNotification_When_AddNotificationCondition_Then_SaveTimeNotification() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + + TimeNotificationRequest request = TimeNotificationRequest.builder() + .startHour(9) + .startMinute(0) + .build(); + + TimeNotification savedTimeNotification = TimeNotification.builder() + .id(1L) + .notification(notification) + .startHour(9) + .startMinute(0) + .build(); + + when(timeNotifRepository.save(any(TimeNotification.class))) + .thenReturn(savedTimeNotification); + + // when + timeNotificationService.addNotificationCondition(notification, request); + + // then + verify(timeNotifRepository).save(any(TimeNotification.class)); + } + + + @Test + void Given_InactiveNotification_When_AddNotificationCondition_Then_DoNothing() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(false) + .notificationType(NotificationType.TIME) + .build(); + + TimeNotificationRequest request = TimeNotificationRequest.builder() + .startHour(9) + .startMinute(0) + .build(); + + // when + timeNotificationService.addNotificationCondition(notification, request); + + // then + // verify no interaction with repository + } + + + @Test + void Given_ExistingTimeNotification_When_UpdateNotificationCondition_Then_UpdateTimeCondition() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + + TimeNotificationRequest request = TimeNotificationRequest.builder() + .startHour(10) + .startMinute(30) + .build(); + + TimeNotification existingTimeNotification = TimeNotification.builder() + .id(1L) + .notification(notification) + .startHour(9) + .startMinute(0) + .build(); + + when(timeNotifRepository.findByNotificationId(notification.getId())) + .thenReturn(existingTimeNotification); + + // when + timeNotificationService.updateNotificationCondition(notification, request); + + // then + assertThat(existingTimeNotification.getStartHour()).isEqualTo(10); + assertThat(existingTimeNotification.getStartMinute()).isEqualTo(30); + } + + + @Test + void Given_NoExistingTimeNotification_When_UpdateNotificationCondition_Then_AddNewTimeNotification() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + + TimeNotificationRequest request = TimeNotificationRequest.builder() + .startHour(10) + .startMinute(30) + .build(); + + when(timeNotifRepository.findByNotificationId(notification.getId())) + .thenReturn(null); + + // when + timeNotificationService.updateNotificationCondition(notification, request); + + // then + verify(timeNotifRepository).save(any(TimeNotification.class)); + } + + + @Test + void Given_NotificationId_When_DeleteNotificationCondition_Then_DeleteByNotificationId() { + // given + Long notificationId = 1L; + + // when + timeNotificationService.deleteNotificationCondition(notificationId); + + // then + verify(timeNotifRepository).deleteByNotificationId(notificationId); + } + +} diff --git a/src/test/java/com/und/server/notification/util/NotificationCacheKeyGeneratorTest.java b/src/test/java/com/und/server/notification/util/NotificationCacheKeyGeneratorTest.java new file mode 100644 index 00000000..3aa9f1db --- /dev/null +++ b/src/test/java/com/und/server/notification/util/NotificationCacheKeyGeneratorTest.java @@ -0,0 +1,129 @@ +package com.und.server.notification.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class NotificationCacheKeyGeneratorTest { + + private NotificationCacheKeyGenerator notificationCacheKeyGenerator; + + @BeforeEach + void setUp() { + notificationCacheKeyGenerator = new NotificationCacheKeyGenerator(); + } + + + @Test + void Given_ValidMemberId_When_GenerateNotificationCacheKey_Then_ReturnCorrectKey() { + // given + Long memberId = 1L; + + // when + String result = notificationCacheKeyGenerator.generateNotificationCacheKey(memberId); + + // then + assertThat(result).isEqualTo("notif:1"); + } + + + @Test + void Given_ValidMemberId_When_GenerateEtagKey_Then_ReturnCorrectKey() { + // given + Long memberId = 1L; + + // when + String result = notificationCacheKeyGenerator.generateEtagKey(memberId); + + // then + assertThat(result).isEqualTo("notif:etag:1"); + } + + + @Test + void Given_DifferentMemberIds_When_GenerateNotificationCacheKey_Then_ReturnDifferentKeys() { + // given + Long memberId1 = 1L; + Long memberId2 = 2L; + + // when + String result1 = notificationCacheKeyGenerator.generateNotificationCacheKey(memberId1); + String result2 = notificationCacheKeyGenerator.generateNotificationCacheKey(memberId2); + + // then + assertThat(result1).isEqualTo("notif:1"); + assertThat(result2).isEqualTo("notif:2"); + assertThat(result1).isNotEqualTo(result2); + } + + + @Test + void Given_DifferentMemberIds_When_GenerateEtagKey_Then_ReturnDifferentKeys() { + // given + Long memberId1 = 1L; + Long memberId2 = 2L; + + // when + String result1 = notificationCacheKeyGenerator.generateEtagKey(memberId1); + String result2 = notificationCacheKeyGenerator.generateEtagKey(memberId2); + + // then + assertThat(result1).isEqualTo("notif:etag:1"); + assertThat(result2).isEqualTo("notif:etag:2"); + assertThat(result1).isNotEqualTo(result2); + } + + + @Test + void Given_LargeMemberId_When_GenerateNotificationCacheKey_Then_ReturnCorrectKey() { + // given + Long memberId = 999999L; + + // when + String result = notificationCacheKeyGenerator.generateNotificationCacheKey(memberId); + + // then + assertThat(result).isEqualTo("notif:999999"); + } + + + @Test + void Given_LargeMemberId_When_GenerateEtagKey_Then_ReturnCorrectKey() { + // given + Long memberId = 999999L; + + // when + String result = notificationCacheKeyGenerator.generateEtagKey(memberId); + + // then + assertThat(result).isEqualTo("notif:etag:999999"); + } + + + @Test + void Given_ZeroMemberId_When_GenerateNotificationCacheKey_Then_ReturnCorrectKey() { + // given + Long memberId = 0L; + + // when + String result = notificationCacheKeyGenerator.generateNotificationCacheKey(memberId); + + // then + assertThat(result).isEqualTo("notif:0"); + } + + + @Test + void Given_ZeroMemberId_When_GenerateEtagKey_Then_ReturnCorrectKey() { + // given + Long memberId = 0L; + + // when + String result = notificationCacheKeyGenerator.generateEtagKey(memberId); + + // then + assertThat(result).isEqualTo("notif:etag:0"); + } + +} diff --git a/src/test/java/com/und/server/notification/util/NotificationCacheSerializerTest.java b/src/test/java/com/und/server/notification/util/NotificationCacheSerializerTest.java new file mode 100644 index 00000000..bf976d05 --- /dev/null +++ b/src/test/java/com/und/server/notification/util/NotificationCacheSerializerTest.java @@ -0,0 +1,235 @@ +package com.und.server.notification.util; + +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.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.und.server.notification.constants.NotificationMethodType; +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.cache.NotificationCacheData; +import com.und.server.notification.dto.response.NotificationConditionResponse; +import com.und.server.notification.dto.response.TimeNotificationResponse; +import com.und.server.notification.exception.NotificationCacheException; + + +@ExtendWith(MockitoExtension.class) +class NotificationCacheSerializerTest { + + @Mock + private ObjectMapper objectMapper; + + private NotificationCacheSerializer notificationCacheSerializer; + + @BeforeEach + void setUp() { + notificationCacheSerializer = new NotificationCacheSerializer(objectMapper); + } + + + @Test + void Given_ValidNotificationCacheData_When_Serialize_Then_ReturnJsonString() throws JsonProcessingException { + // given + NotificationCacheData cacheData = NotificationCacheData.builder() + .scenarioId(1L) + .scenarioName("테스트 루틴") + .scenarioMemo("테스트 메모") + .notificationId(1L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeekOrdinal(List.of(1, 2, 3, 4, 5)) + .conditionJson("{\"notificationType\":\"TIME\",\"startHour\":9,\"startMinute\":30}") + .build(); + + String expectedJson = "{\"scenarioId\":1,\"scenarioName\":\"테스트 루틴\"}"; + when(objectMapper.writeValueAsString(cacheData)).thenReturn(expectedJson); + + // when + String result = notificationCacheSerializer.serialize(cacheData); + + // then + assertThat(result).isEqualTo(expectedJson); + } + + + @Test + void Given_ValidJson_When_Deserialize_Then_ReturnNotificationCacheData() throws JsonProcessingException { + // given + String json = "{\"scenarioId\":1,\"scenarioName\":\"테스트 루틴\"}"; + NotificationCacheData expectedData = NotificationCacheData.builder() + .scenarioId(1L) + .scenarioName("테스트 루틴") + .build(); + + when(objectMapper.readValue(json, NotificationCacheData.class)).thenReturn(expectedData); + + // when + NotificationCacheData result = notificationCacheSerializer.deserialize(json); + + // then + assertThat(result).isEqualTo(expectedData); + } + + + @Test + void Given_ValidNotificationCacheData_When_ParseCondition_Then_ReturnNotificationConditionResponse() + throws JsonProcessingException { + // given + NotificationCacheData cacheData = NotificationCacheData.builder() + .conditionJson("{\"notificationType\":\"TIME\",\"startHour\":9,\"startMinute\":30}") + .build(); + + TimeNotificationResponse expectedCondition = TimeNotificationResponse.builder() + .notificationType(NotificationType.TIME) + .startHour(9) + .startMinute(30) + .build(); + + when(objectMapper.readValue(cacheData.conditionJson(), NotificationConditionResponse.class)) + .thenReturn(expectedCondition); + + // when + NotificationConditionResponse result = notificationCacheSerializer.parseCondition(cacheData); + + // then + assertThat(result).isEqualTo(expectedCondition); + } + + + @Test + void Given_ValidNotificationConditionResponse_When_SerializeCondition_Then_ReturnJsonString() + throws JsonProcessingException { + // given + TimeNotificationResponse condition = TimeNotificationResponse.builder() + .notificationType(NotificationType.TIME) + .startHour(9) + .startMinute(30) + .build(); + + String expectedJson = "{\"notificationType\":\"TIME\",\"startHour\":9,\"startMinute\":30}"; + when(objectMapper.writeValueAsString(condition)).thenReturn(expectedJson); + + // when + String result = notificationCacheSerializer.serializeCondition(condition); + + // then + assertThat(result).isEqualTo(expectedJson); + } + + + @Test + void Given_JsonProcessingException_When_Serialize_Then_ThrowNotificationCacheException() + throws JsonProcessingException { + // given + NotificationCacheData cacheData = NotificationCacheData.builder() + .scenarioId(1L) + .scenarioName("테스트 루틴") + .build(); + + doThrow(new JsonProcessingException("Serialization failed") { + }).when(objectMapper).writeValueAsString(any()); + + // when & then + assertThatThrownBy(() -> notificationCacheSerializer.serialize(cacheData)) + .isInstanceOf(NotificationCacheException.class); + } + + + @Test + void Given_JsonProcessingException_When_Deserialize_Then_ThrowNotificationCacheException() + throws JsonProcessingException { + // given + String json = "invalid json"; + + doThrow(new JsonProcessingException("Deserialization failed") { + }).when(objectMapper).readValue(anyString(), eq(NotificationCacheData.class)); + + // when & then + assertThatThrownBy(() -> notificationCacheSerializer.deserialize(json)) + .isInstanceOf(NotificationCacheException.class); + } + + + @Test + void Given_JsonProcessingException_When_ParseCondition_Then_ThrowNotificationCacheException() + throws JsonProcessingException { + // given + NotificationCacheData cacheData = NotificationCacheData.builder() + .conditionJson("invalid json") + .build(); + + doThrow(new JsonProcessingException("Condition parsing failed") { + }).when(objectMapper).readValue(anyString(), eq(NotificationConditionResponse.class)); + + // when & then + assertThatThrownBy(() -> notificationCacheSerializer.parseCondition(cacheData)) + .isInstanceOf(NotificationCacheException.class); + } + + + @Test + void Given_JsonProcessingException_When_SerializeCondition_Then_ThrowNotificationCacheException() + throws JsonProcessingException { + // given + TimeNotificationResponse condition = TimeNotificationResponse.builder() + .notificationType(NotificationType.TIME) + .startHour(9) + .startMinute(30) + .build(); + + doThrow(new JsonProcessingException("Condition serialization failed") { + }).when(objectMapper).writeValueAsString(any()); + + // when & then + assertThatThrownBy(() -> notificationCacheSerializer.serializeCondition(condition)) + .isInstanceOf(NotificationCacheException.class); + } + + + @Test + void Given_NullNotificationCacheData_When_Serialize_Then_ReturnJsonString() throws JsonProcessingException { + // given + NotificationCacheData cacheData = null; + String expectedJson = "null"; + when(objectMapper.writeValueAsString(cacheData)).thenReturn(expectedJson); + + // when + String result = notificationCacheSerializer.serialize(cacheData); + + // then + assertThat(result).isEqualTo(expectedJson); + } + + + @Test + void Given_NullJson_When_Deserialize_Then_ReturnNotificationCacheData() throws JsonProcessingException { + // given + String json = null; + NotificationCacheData expectedData = NotificationCacheData.builder() + .scenarioId(1L) + .scenarioName("테스트 루틴") + .build(); + + when(objectMapper.readValue(json, NotificationCacheData.class)).thenReturn(expectedData); + + // when + NotificationCacheData result = notificationCacheSerializer.deserialize(json); + + // then + assertThat(result).isEqualTo(expectedData); + } + +} diff --git a/src/test/java/com/und/server/oauth/OidcProviderFactoryTest.java b/src/test/java/com/und/server/oauth/OidcProviderFactoryTest.java deleted file mode 100644 index 20229a3f..00000000 --- a/src/test/java/com/und/server/oauth/OidcProviderFactoryTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.und.server.oauth; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.when; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.und.server.dto.OidcPublicKeys; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; - -@ExtendWith(MockitoExtension.class) -class OidcProviderFactoryTest { - - @Mock - private KakaoProvider kakaoProvider; - - @Mock - private OidcPublicKeys oidcPublicKeys; - - private OidcProviderFactory factory; - - private final String token = "dummyToken"; - private final String providerId = "dummyId"; - private final String nickname = "dummyNickname"; - - @BeforeEach - void init() { - factory = new OidcProviderFactory(kakaoProvider); - } - - @Test - @DisplayName("Throws an exception when the provider is null") - void Given_NullProvider_When_GetIdTokenPayload_Then_ThrowsServerException() { - // when & then - assertThatThrownBy(() -> factory.getIdTokenPayload(null, token, oidcPublicKeys)) - .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_PROVIDER); - } - - @Test - @DisplayName("Retrieves ID token payload successfully for a given provider") - void Given_ValidProvider_When_GetIdTokenPayload_Then_ReturnsCorrectPayload() { - // given - final IdTokenPayload expectedPayload = new IdTokenPayload(providerId, nickname); - - when(kakaoProvider.getIdTokenPayload(token, oidcPublicKeys)).thenReturn(expectedPayload); - - // when - final IdTokenPayload actualPayload = factory.getIdTokenPayload(Provider.KAKAO, token, oidcPublicKeys); - - // then - assertThat(actualPayload).isEqualTo(expectedPayload); - } - -} diff --git a/src/test/java/com/und/server/repository/MemberRepositoryTest.java b/src/test/java/com/und/server/repository/MemberRepositoryTest.java deleted file mode 100644 index b4d6f56c..00000000 --- a/src/test/java/com/und/server/repository/MemberRepositoryTest.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.und.server.repository; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.Optional; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; - -import com.und.server.entity.Member; - -@DataJpaTest -class MemberRepositoryTest { - - @Autowired - private MemberRepository memberRepository; - - @Test - @DisplayName("Saves a member and verifies its properties") - void Given_MemberDetails_When_SaveMember_Then_MemberIsPersistedWithCorrectDetails() { - // given - final Member member = Member.builder() - .nickname("Chori") - .kakaoId("166959") - .build(); - - // when - final Member result = memberRepository.save(member); - - // then - assertThat(result.getId()).isNotNull(); - assertThat(result.getNickname()).isEqualTo("Chori"); - assertThat(result.getKakaoId()).isEqualTo("166959"); - assertThat(result.getCreatedAt()).isNotNull(); - } - - @Test - @DisplayName("Finds a member by their Kakao ID") - void Given_ExistingMember_When_FindByKakaoId_Then_ReturnsCorrectMember() { - // given - final Member member = Member.builder() - .nickname("Chori") - .kakaoId("166959") - .build(); - memberRepository.save(member); - - // when - final Optional foundMember = memberRepository.findByKakaoId("166959"); - - // then - assertThat(foundMember).isPresent().hasValueSatisfying(result -> { - assertThat(result.getId()).isNotNull(); - assertThat(result.getNickname()).isEqualTo("Chori"); - assertThat(result.getKakaoId()).isEqualTo("166959"); - assertThat(result.getCreatedAt()).isNotNull(); - }); - } - -} diff --git a/src/test/java/com/und/server/repository/RefreshTokenRepositoryTest.java b/src/test/java/com/und/server/repository/RefreshTokenRepositoryTest.java deleted file mode 100644 index ac365fb2..00000000 --- a/src/test/java/com/und/server/repository/RefreshTokenRepositoryTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.und.server.repository; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.Optional; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; - -import com.und.server.entity.RefreshToken; - -@DataRedisTest -class RefreshTokenRepositoryTest { - - @Autowired - RefreshTokenRepository refreshTokenRepository; - - @Test - @DisplayName("Saves a refresh token and verifies its properties") - void Given_RefreshTokenDetails_When_SaveToken_Then_TokenIsPersistedCorrectly() { - // given - final RefreshToken token = RefreshToken.builder() - .memberId(1L) - .refreshToken("uuid") - .build(); - - // when - final RefreshToken result = refreshTokenRepository.save(token); - - // then - assertThat(result.getMemberId()).isEqualTo(1L); - assertThat(result.getRefreshToken()).isEqualTo("uuid"); - } - - @Test - @DisplayName("Finds a refresh token by its member ID") - void Given_ExistingRefreshToken_When_FindById_Then_ReturnsCorrectToken() { - // given - final RefreshToken token = RefreshToken.builder() - .memberId(1L) - .refreshToken("uuid") - .build(); - refreshTokenRepository.save(token); - - // when - final Optional foundToken = refreshTokenRepository.findById(1L); - - // then - assertThat(foundToken).isPresent().hasValueSatisfying(result -> { - assertThat(result.getMemberId()).isEqualTo(1L); - assertThat(result.getRefreshToken()).isEqualTo("uuid"); - }); - } - -} diff --git a/src/test/java/com/und/server/scenario/constants/MissionSearchTypeTest.java b/src/test/java/com/und/server/scenario/constants/MissionSearchTypeTest.java new file mode 100644 index 00000000..87bc689f --- /dev/null +++ b/src/test/java/com/und/server/scenario/constants/MissionSearchTypeTest.java @@ -0,0 +1,215 @@ +package com.und.server.scenario.constants; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.und.server.common.exception.ServerException; +import com.und.server.scenario.exception.ScenarioErrorResult; + +@DisplayName("MissionSearchType 테스트") +class MissionSearchTypeTest { + + @Test + @DisplayName("TODAY 타입의 rangeDays가 0인지 확인") + void Given_TodayType_When_GetRangeDays_Then_ReturnZero() { + // when + int rangeDays = MissionSearchType.TODAY.getRangeDays(); + + // then + assertThat(rangeDays).isEqualTo(0); + } + + @Test + @DisplayName("PAST 타입의 rangeDays가 7인지 확인") + void Given_PastType_When_GetRangeDays_Then_ReturnSeven() { + // when + int rangeDays = MissionSearchType.PAST.getRangeDays(); + + // then + assertThat(rangeDays).isEqualTo(14); + } + + @Test + @DisplayName("FUTURE 타입의 rangeDays가 7인지 확인") + void Given_FutureType_When_GetRangeDays_Then_ReturnSeven() { + // when + int rangeDays = MissionSearchType.FUTURE.getRangeDays(); + + // then + assertThat(rangeDays).isEqualTo(14); + } + + @Test + @DisplayName("오늘 날짜로 요청하면 TODAY 타입을 반환") + void Given_TodayDate_When_GetMissionSearchType_Then_ReturnToday() { + // given + LocalDate today = LocalDate.of(2024, 1, 15); + LocalDate requestDate = today; + + // when + MissionSearchType result = MissionSearchType.getMissionSearchType(today, requestDate); + + // then + assertThat(result).isEqualTo(MissionSearchType.TODAY); + } + + @Test + @DisplayName("null 날짜로 요청하면 TODAY 타입을 반환") + void Given_NullDate_When_GetMissionSearchType_Then_ReturnToday() { + // given + LocalDate today = LocalDate.of(2024, 1, 15); + LocalDate requestDate = null; + + // when + MissionSearchType result = MissionSearchType.getMissionSearchType(today, requestDate); + + // then + assertThat(result).isEqualTo(MissionSearchType.TODAY); + } + + @Test + @DisplayName("어제 날짜로 요청하면 PAST 타입을 반환") + void Given_YesterdayDate_When_GetMissionSearchType_Then_ReturnPast() { + // given + LocalDate today = LocalDate.of(2024, 1, 15); + LocalDate requestDate = today.minusDays(1); + + // when + MissionSearchType result = MissionSearchType.getMissionSearchType(today, requestDate); + + // then + assertThat(result).isEqualTo(MissionSearchType.PAST); + } + + @Test + @DisplayName("7일 전 날짜로 요청하면 PAST 타입을 반환") + void Given_SevenDaysAgoDate_When_GetMissionSearchType_Then_ReturnPast() { + // given + LocalDate today = LocalDate.of(2024, 1, 15); + LocalDate requestDate = today.minusDays(7); + + // when + MissionSearchType result = MissionSearchType.getMissionSearchType(today, requestDate); + + // then + assertThat(result).isEqualTo(MissionSearchType.PAST); + } + + @Test + @DisplayName("8일 전 날짜로 요청하면 예외 발생") + void Given_EightDaysAgoDate_When_GetMissionSearchType_Then_ThrowException() { + // given + LocalDate today = LocalDate.of(2024, 1, 15); + LocalDate requestDate = today.minusDays(40); + + // when & then + assertThatThrownBy(() -> MissionSearchType.getMissionSearchType(today, requestDate)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.INVALID_MISSION_FOUND_DATE.getMessage()); + } + + @Test + @DisplayName("내일 날짜로 요청하면 FUTURE 타입을 반환") + void Given_TomorrowDate_When_GetMissionSearchType_Then_ReturnFuture() { + // given + LocalDate today = LocalDate.of(2024, 1, 15); + LocalDate requestDate = today.plusDays(1); + + // when + MissionSearchType result = MissionSearchType.getMissionSearchType(today, requestDate); + + // then + assertThat(result).isEqualTo(MissionSearchType.FUTURE); + } + + @Test + @DisplayName("7일 후 날짜로 요청하면 FUTURE 타입을 반환") + void Given_SevenDaysLaterDate_When_GetMissionSearchType_Then_ReturnFuture() { + // given + LocalDate today = LocalDate.of(2024, 1, 15); + LocalDate requestDate = today.plusDays(7); + + // when + MissionSearchType result = MissionSearchType.getMissionSearchType(today, requestDate); + + // then + assertThat(result).isEqualTo(MissionSearchType.FUTURE); + } + + @Test + @DisplayName("8일 후 날짜로 요청하면 예외 발생") + void Given_EightDaysLaterDate_When_GetMissionSearchType_Then_ThrowException() { + // given + LocalDate today = LocalDate.of(2024, 1, 15); + LocalDate requestDate = today.plusDays(40); + + // when & then + assertThatThrownBy(() -> MissionSearchType.getMissionSearchType(today, requestDate)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.INVALID_MISSION_FOUND_DATE.getMessage()); + } + + @Test + @DisplayName("범위 내 과거 날짜들로 요청하면 모두 PAST 타입을 반환") + void Given_PastDatesInRange_When_GetMissionSearchType_Then_ReturnPast() { + // given + LocalDate today = LocalDate.of(2024, 1, 15); + + // when & then + for (int i = 1; i <= 7; i++) { + LocalDate requestDate = today.minusDays(i); + MissionSearchType result = MissionSearchType.getMissionSearchType(today, requestDate); + assertThat(result).isEqualTo(MissionSearchType.PAST); + } + } + + @Test + @DisplayName("범위 내 미래 날짜들로 요청하면 모두 FUTURE 타입을 반환") + void Given_FutureDatesInRange_When_GetMissionSearchType_Then_ReturnFuture() { + // given + LocalDate today = LocalDate.of(2024, 1, 15); + + // when & then + for (int i = 1; i <= 7; i++) { + LocalDate requestDate = today.plusDays(i); + MissionSearchType result = MissionSearchType.getMissionSearchType(today, requestDate); + assertThat(result).isEqualTo(MissionSearchType.FUTURE); + } + } + + @Test + @DisplayName("범위를 벗어난 과거 날짜들로 요청하면 모두 예외 발생") + void Given_PastDatesOutOfRange_When_GetMissionSearchType_Then_ThrowException() { + // given + LocalDate today = LocalDate.of(2024, 1, 15); + + // when & then + for (int i = 15; i <= 17; i++) { + LocalDate requestDate = today.minusDays(i); + assertThatThrownBy(() -> MissionSearchType.getMissionSearchType(today, requestDate)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.INVALID_MISSION_FOUND_DATE.getMessage()); + } + } + + @Test + @DisplayName("범위를 벗어난 미래 날짜들로 요청하면 모두 예외 발생") + void Given_FutureDatesOutOfRange_When_GetMissionSearchType_Then_ThrowException() { + // given + LocalDate today = LocalDate.of(2024, 1, 15); + + // when & then + for (int i = 15; i <= 17; i++) { + LocalDate requestDate = today.plusDays(i); + assertThatThrownBy(() -> MissionSearchType.getMissionSearchType(today, requestDate)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.INVALID_MISSION_FOUND_DATE.getMessage()); + } + } + +} diff --git a/src/test/java/com/und/server/scenario/constants/MissionTypeTest.java b/src/test/java/com/und/server/scenario/constants/MissionTypeTest.java new file mode 100644 index 00000000..5066799f --- /dev/null +++ b/src/test/java/com/und/server/scenario/constants/MissionTypeTest.java @@ -0,0 +1,32 @@ +package com.und.server.scenario.constants; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class MissionTypeTest { + + @Test + void fromValue_null_returnsNull() { + assertThat(MissionType.fromValue(null)).isNull(); + } + + @Test + void fromValue_lowercase_returnsBasic() { + assertThat(MissionType.fromValue("basic")).isEqualTo(MissionType.BASIC); + } + + @Test + void fromValue_uppercase_returnsToday() { + assertThat(MissionType.fromValue("TODAY")).isEqualTo(MissionType.TODAY); + } + + @Test + void fromValue_invalid_throwsException() { + assertThrows(IllegalArgumentException.class, () -> MissionType.fromValue("invalid")); + } + +} + + diff --git a/src/test/java/com/und/server/scenario/controller/MissionControllerTest.java b/src/test/java/com/und/server/scenario/controller/MissionControllerTest.java new file mode 100644 index 00000000..513088e5 --- /dev/null +++ b/src/test/java/com/und/server/scenario/controller/MissionControllerTest.java @@ -0,0 +1,202 @@ +package com.und.server.scenario.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import com.und.server.scenario.constants.MissionType; +import com.und.server.scenario.dto.request.TodayMissionRequest; +import com.und.server.scenario.dto.response.MissionGroupResponse; +import com.und.server.scenario.dto.response.MissionResponse; +import com.und.server.scenario.service.MissionService; +import com.und.server.scenario.service.ScenarioService; + +@ExtendWith(MockitoExtension.class) +class MissionControllerTest { + + @Mock + private ScenarioService scenarioService; + + @Mock + private MissionService missionService; + + @InjectMocks + private MissionController missionController; + + + @Test + void Given_ValidMemberIdAndScenarioId_When_GetMissionsByScenarioId_Then_ReturnMissionGroupResponse() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate date = LocalDate.now(); + + MissionGroupResponse expectedResponse = MissionGroupResponse.builder() + .scenarioId(scenarioId) + .basicMissions(List.of()) + .todayMissions(List.of()) + .build(); + + when(missionService.findMissionsByScenarioId(memberId, scenarioId, date)) + .thenReturn(expectedResponse); + + // when + ResponseEntity response = + missionController.getMissionsByScenarioId(memberId, scenarioId, date); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedResponse); + verify(missionService).findMissionsByScenarioId(memberId, scenarioId, date); + } + + + @Test + void Given_ValidRequest_When_AddTodayMissionToScenario_Then_ReturnCreated() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate date = LocalDate.now(); + TodayMissionRequest missionAddRequest = new TodayMissionRequest("오늘 미션"); + + MissionResponse expectedResponse = MissionResponse.builder() + .missionId(1L) + .content("오늘 미션") + .isChecked(false) + .missionType(MissionType.TODAY) + .build(); + + when(scenarioService.addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, date)) + .thenReturn(expectedResponse); + + // when + ResponseEntity response = + missionController.addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, date); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isEqualTo(expectedResponse); + verify(scenarioService).addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, date); + } + + + @Test + void Given_EmptyContentRequest_When_AddTodayMissionToScenario_Then_ReturnCreated() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate date = LocalDate.now(); + TodayMissionRequest missionAddRequest = new TodayMissionRequest(""); + + MissionResponse expectedResponse = MissionResponse.builder() + .missionId(1L) + .content("") + .isChecked(false) + .missionType(MissionType.TODAY) + .build(); + + when(scenarioService.addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, date)) + .thenReturn(expectedResponse); + + // when + ResponseEntity response = + missionController.addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, date); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isEqualTo(expectedResponse); + verify(scenarioService).addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, date); + } + + + @Test + void Given_LongContentRequest_When_AddTodayMissionToScenario_Then_ReturnCreated() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate date = LocalDate.now(); + TodayMissionRequest missionAddRequest = new TodayMissionRequest("매우 긴 미션 내용입니다"); + + MissionResponse expectedResponse = MissionResponse.builder() + .missionId(1L) + .content("매우 긴 미션 내용입니다") + .isChecked(false) + .missionType(MissionType.TODAY) + .build(); + + when(scenarioService.addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, date)) + .thenReturn(expectedResponse); + + // when + ResponseEntity response = + missionController.addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, date); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isEqualTo(expectedResponse); + verify(scenarioService).addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, date); + } + + + @Test + void Given_ValidMissionId_When_DeleteTodayMissionById_Then_ReturnNoContent() { + // given + Long memberId = 1L; + Long missionId = 1L; + + // when + ResponseEntity response = missionController.deleteTodayMissionById(memberId, missionId); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(response.getBody()).isNull(); + verify(missionService).deleteTodayMission(memberId, missionId); + } + + @Test + void Given_ValidMissionIdAndIsChecked_When_UpdateMissionCheck_Then_ReturnNoContent() { + // given + Long memberId = 1L; + Long missionId = 1L; + Boolean isChecked = true; + LocalDate date = LocalDate.of(2024, 1, 15); + + // when + ResponseEntity response = missionController.updateMissionCheck(memberId, missionId, isChecked, date); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(response.getBody()).isNull(); + verify(missionService).updateMissionCheck(memberId, missionId, isChecked, date); + } + + + @Test + void Given_ValidMissionIdAndIsUnchecked_When_UpdateMissionCheck_Then_ReturnNoContent() { + // given + Long memberId = 1L; + Long missionId = 1L; + Boolean isChecked = false; + LocalDate date = LocalDate.of(2024, 1, 15); + + // when + ResponseEntity response = missionController.updateMissionCheck(memberId, missionId, isChecked, date); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(response.getBody()).isNull(); + verify(missionService).updateMissionCheck(memberId, missionId, isChecked, date); + } + +} diff --git a/src/test/java/com/und/server/scenario/controller/ScenarioControllerTest.java b/src/test/java/com/und/server/scenario/controller/ScenarioControllerTest.java new file mode 100644 index 00000000..007f3605 --- /dev/null +++ b/src/test/java/com/und/server/scenario/controller/ScenarioControllerTest.java @@ -0,0 +1,405 @@ +package com.und.server.scenario.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.request.NotificationRequest; +import com.und.server.scenario.dto.request.ScenarioDetailRequest; +import com.und.server.scenario.dto.request.ScenarioOrderUpdateRequest; +import com.und.server.scenario.dto.response.OrderUpdateResponse; +import com.und.server.scenario.dto.response.ScenarioDetailResponse; +import com.und.server.scenario.dto.response.ScenarioResponse; +import com.und.server.scenario.service.ScenarioService; + +@ExtendWith(MockitoExtension.class) +class ScenarioControllerTest { + + @Mock + private ScenarioService scenarioService; + + @InjectMocks + private ScenarioController scenarioController; + + + @Test + void Given_ValidMemberIdAndNotifType_When_GetScenarios_Then_ReturnScenarioList() { + // given + Long memberId = 1L; + NotificationType notifType = NotificationType.TIME; + + ScenarioResponse scenario1 = ScenarioResponse.builder() + .scenarioId(1L) + .scenarioName("시나리오 1") + .build(); + + ScenarioResponse scenario2 = ScenarioResponse.builder() + .scenarioId(2L) + .scenarioName("시나리오 2") + .build(); + + List expectedScenarios = Arrays.asList(scenario1, scenario2); + + when(scenarioService.findScenariosByMemberId(memberId, notifType)) + .thenReturn(expectedScenarios); + + // when + ResponseEntity> response = scenarioController.getScenarios(memberId, notifType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedScenarios); + verify(scenarioService).findScenariosByMemberId(memberId, notifType); + } + + + @Test + void Given_ValidMemberIdAndScenarioId_When_GetScenarioDetail_Then_ReturnScenarioDetail() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + + ScenarioDetailResponse expectedDetail = ScenarioDetailResponse.builder() + .scenarioId(scenarioId) + .scenarioName("시나리오 상세") + .memo("시나리오 설명") + .build(); + + when(scenarioService.findScenarioDetailByScenarioId(memberId, scenarioId)) + .thenReturn(expectedDetail); + + // when + ResponseEntity response = scenarioController.getScenarioDetail(memberId, scenarioId); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedDetail); + verify(scenarioService).findScenarioDetailByScenarioId(memberId, scenarioId); + } + + + @Test + void Given_ValidMemberIdAndScenarioRequest_When_AddScenario_Then_ReturnCreated() { + // given + Long memberId = 1L; + Long expectedScenarioId = 123L; + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("새 시나리오") + .memo("새 시나리오 설명") + .build(); + + List expectedResponse = List.of( + ScenarioResponse.builder() + .scenarioId(expectedScenarioId) + .scenarioName("새 시나리오") + .memo("새 시나리오 설명") + .build() + ); + + when(scenarioService.addScenario(memberId, scenarioRequest)) + .thenReturn(expectedResponse); + + // when + ResponseEntity> response = scenarioController.addScenario(memberId, scenarioRequest); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isEqualTo(expectedResponse); + verify(scenarioService).addScenario(memberId, scenarioRequest); + } + + @Test + void Given_ValidMemberIdAndScenarioRequest_When_AddScenarioWithoutNotification_Then_ReturnCreated() { + // given + Long memberId = 1L; + Long expectedScenarioId = 456L; + + NotificationRequest notification = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .build(); + + ScenarioDetailRequest request = ScenarioDetailRequest.builder() + .scenarioName("새 시나리오") + .memo("메모") + .basicMissions(List.of()) + .notification(notification) + .notificationCondition(null) + .build(); + + List expectedResponse = List.of( + ScenarioResponse.builder() + .scenarioId(expectedScenarioId) + .scenarioName("새 시나리오") + .memo("메모") + .build() + ); + + when(scenarioService.addScenario(memberId, request)) + .thenReturn(expectedResponse); + + // when + ResponseEntity> response = scenarioController.addScenario(memberId, request); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isEqualTo(expectedResponse); + verify(scenarioService).addScenario(memberId, request); + } + + @Test + void Given_ValidMemberIdAndScenarioId_When_UpdateScenarioWithoutNotification_Then_ReturnOk() { + // given + Long memberId = 1L; + Long scenarioId = 2L; + + NotificationRequest notification = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.LOCATION) + .build(); + + ScenarioDetailRequest request = ScenarioDetailRequest.builder() + .scenarioName("수정 시나리오") + .memo("메모") + .basicMissions(List.of()) + .notification(notification) + .notificationCondition(null) + .build(); + + List expectedResponse = List.of( + ScenarioResponse.builder() + .scenarioId(scenarioId) + .scenarioName("수정 시나리오") + .memo("메모") + .build() + ); + + when(scenarioService.updateScenario(memberId, scenarioId, request)) + .thenReturn(expectedResponse); + + // when + ResponseEntity> response = scenarioController + .updateScenario(memberId, scenarioId, request); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedResponse); + verify(scenarioService).updateScenario(memberId, scenarioId, request); + } + + + @Test + void Given_EmptyTitleRequest_When_AddScenario_Then_ReturnCreated() { + // given + Long memberId = 1L; + Long expectedScenarioId = 789L; + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("") + .memo("빈 제목 시나리오") + .build(); + + List expectedResponse = List.of( + ScenarioResponse.builder() + .scenarioId(expectedScenarioId) + .scenarioName("") + .memo("빈 제목 시나리오") + .build() + ); + + when(scenarioService.addScenario(memberId, scenarioRequest)) + .thenReturn(expectedResponse); + + // when + ResponseEntity> response = scenarioController.addScenario(memberId, scenarioRequest); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isEqualTo(expectedResponse); + verify(scenarioService).addScenario(memberId, scenarioRequest); + } + + + @Test + void Given_ValidMemberIdAndScenarioIdAndRequest_When_UpdateScenario_Then_ReturnOk() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("수정된 시나리오") + .memo("수정된 시나리오 설명") + .build(); + + List expectedResponse = List.of( + ScenarioResponse.builder() + .scenarioId(scenarioId) + .scenarioName("수정된 시나리오") + .memo("수정된 시나리오 설명") + .build() + ); + + when(scenarioService.updateScenario(memberId, scenarioId, scenarioRequest)) + .thenReturn(expectedResponse); + + // when + ResponseEntity> response = + scenarioController.updateScenario(memberId, scenarioId, scenarioRequest); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedResponse); + verify(scenarioService).updateScenario(memberId, scenarioId, scenarioRequest); + } + + + @Test + void Given_EmptyTitleRequest_When_UpdateScenario_Then_ReturnOk() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("") + .memo("수정된 빈 제목 시나리오") + .build(); + + List expectedResponse = List.of( + ScenarioResponse.builder() + .scenarioId(scenarioId) + .scenarioName("") + .memo("수정된 빈 제목 시나리오") + .build() + ); + + when(scenarioService.updateScenario(memberId, scenarioId, scenarioRequest)) + .thenReturn(expectedResponse); + + // when + ResponseEntity> response = + scenarioController.updateScenario(memberId, scenarioId, scenarioRequest); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedResponse); + verify(scenarioService).updateScenario(memberId, scenarioId, scenarioRequest); + } + + + @Test + void Given_LongTitleRequest_When_AddScenario_Then_ReturnCreated() { + // given + Long memberId = 1L; + Long expectedScenarioId = 999L; + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("매우 긴 시나리오 제목입니다") + .memo("긴 제목 시나리오") + .build(); + + List expectedResponse = List.of( + ScenarioResponse.builder() + .scenarioId(expectedScenarioId) + .scenarioName("매우 긴 시나리오 제목입니다") + .memo("긴 제목 시나리오") + .build() + ); + + when(scenarioService.addScenario(memberId, scenarioRequest)) + .thenReturn(expectedResponse); + + // when + ResponseEntity> response = scenarioController.addScenario(memberId, scenarioRequest); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isEqualTo(expectedResponse); + verify(scenarioService).addScenario(memberId, scenarioRequest); + } + + + @Test + void Given_LongTitleRequest_When_UpdateScenario_Then_ReturnOk() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("매우 긴 수정된 시나리오 제목입니다") + .memo("긴 제목 수정 시나리오") + .build(); + + List expectedResponse = List.of( + ScenarioResponse.builder() + .scenarioId(scenarioId) + .scenarioName("매우 긴 수정된 시나리오 제목입니다") + .memo("긴 제목 수정 시나리오") + .build() + ); + + when(scenarioService.updateScenario(memberId, scenarioId, scenarioRequest)) + .thenReturn(expectedResponse); + + // when + ResponseEntity> response = + scenarioController.updateScenario(memberId, scenarioId, scenarioRequest); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedResponse); + verify(scenarioService).updateScenario(memberId, scenarioId, scenarioRequest); + } + + + @Test + void Given_ValidMemberIdAndScenarioIdAndOrderRequest_When_UpdateScenarioOrder_Then_ReturnOrderUpdateResponse() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + ScenarioOrderUpdateRequest orderRequest = ScenarioOrderUpdateRequest.builder() + .prevOrder(1000) + .nextOrder(2000) + .build(); + + OrderUpdateResponse expectedResponse = OrderUpdateResponse.builder() + .isReorder(false) + .orderUpdates(List.of()) + .build(); + + when(scenarioService.updateScenarioOrder(memberId, scenarioId, orderRequest)) + .thenReturn(expectedResponse); + + // when + ResponseEntity response = + scenarioController.updateScenarioOrder(memberId, scenarioId, orderRequest); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedResponse); + verify(scenarioService).updateScenarioOrder(memberId, scenarioId, orderRequest); + } + + + @Test + void Given_ValidMemberIdAndScenarioId_When_DeleteScenario_Then_ReturnNoContent() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + + // when + ResponseEntity response = scenarioController.deleteScenario(memberId, scenarioId); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(response.getBody()).isNull(); + verify(scenarioService).deleteScenarioWithAllMissions(memberId, scenarioId); + } + +} diff --git a/src/test/java/com/und/server/scenario/dto/request/ScenarioDetailRequestTest.java b/src/test/java/com/und/server/scenario/dto/request/ScenarioDetailRequestTest.java new file mode 100644 index 00000000..f8c98068 --- /dev/null +++ b/src/test/java/com/und/server/scenario/dto/request/ScenarioDetailRequestTest.java @@ -0,0 +1,343 @@ +package com.und.server.scenario.dto.request; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.und.server.notification.constants.NotificationMethodType; +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.request.NotificationRequest; +import com.und.server.notification.dto.request.TimeNotificationRequest; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; + +class ScenarioDetailRequestTest { + + private final ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + private final Validator validator = factory.getValidator(); + + @Test + void Given_ActiveNotificationWithCondition_When_Validate_Then_Success() { + // given + NotificationRequest notification = NotificationRequest.builder() + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeekOrdinal(List.of(0, 1, 2)) + .build(); + + TimeNotificationRequest notificationCondition = TimeNotificationRequest.builder() + .notificationType(NotificationType.TIME) + .startHour(9) + .startMinute(30) + .build(); + + BasicMissionRequest mission = BasicMissionRequest.builder() + .content("Test") + .build(); + + ScenarioDetailRequest request = ScenarioDetailRequest.builder() + .scenarioName("Test") + .memo("Test") + .basicMissions(List.of(mission)) + .notification(notification) + .notificationCondition(notificationCondition) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isEmpty(); + } + + @Test + void Given_ActiveNotificationWithoutCondition_When_Validate_Then_Fail() { + // given + NotificationRequest notification = NotificationRequest.builder() + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeekOrdinal(List.of(0, 1, 2)) + .build(); + + BasicMissionRequest mission = BasicMissionRequest.builder() + .content("Test") + .build(); + + ScenarioDetailRequest request = ScenarioDetailRequest.builder() + .scenarioName("Test") + .memo("Test") + .basicMissions(List.of(mission)) + .notification(notification) + .notificationCondition(null) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()) + .contains("Notification condition required when notification is active"); + } + + @Test + void Given_InactiveNotificationWithoutCondition_When_Validate_Then_Success() { + // given + NotificationRequest notification = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .notificationMethodType(null) + .daysOfWeekOrdinal(null) + .build(); + + BasicMissionRequest mission = BasicMissionRequest.builder() + .content("Test") + .build(); + + ScenarioDetailRequest request = ScenarioDetailRequest.builder() + .scenarioName("Test") + .memo("Test") + .basicMissions(List.of(mission)) + .notification(notification) + .notificationCondition(null) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isEmpty(); + } + + @Test + void Given_InactiveNotificationWithCondition_When_Validate_Then_Fail() { + // given + NotificationRequest notification = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .notificationMethodType(null) + .daysOfWeekOrdinal(null) + .build(); + + TimeNotificationRequest notificationCondition = TimeNotificationRequest.builder() + .notificationType(NotificationType.TIME) + .startHour(9) + .startMinute(30) + .build(); + + BasicMissionRequest mission = BasicMissionRequest.builder() + .content("Test") + .build(); + + ScenarioDetailRequest request = ScenarioDetailRequest.builder() + .scenarioName("Test") + .memo("Test") + .basicMissions(List.of(mission)) + .notification(notification) + .notificationCondition(notificationCondition) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()) + .contains("Notification condition not allowed when notification is inactive"); + } + + @Test + void Given_EmptyScenarioName_When_Validate_Then_Fail() { + // given + NotificationRequest notification = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .notificationMethodType(null) + .daysOfWeekOrdinal(null) + .build(); + + ScenarioDetailRequest request = ScenarioDetailRequest.builder() + .scenarioName("") + .memo("Test") + .basicMissions(List.of()) + .notification(notification) + .notificationCondition(null) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isNotEmpty(); + boolean hasScenarioNameError = violations.stream() + .anyMatch(v -> v.getMessage().contains("Scenario name must not be blank")); + assertThat(hasScenarioNameError).isTrue(); + } + + @Test + void Given_TooLongScenarioName_When_Validate_Then_Fail() { + // given + NotificationRequest notification = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .notificationMethodType(null) + .daysOfWeekOrdinal(null) + .build(); + + ScenarioDetailRequest request = ScenarioDetailRequest.builder() + .scenarioName("TooLongName") // 10자 초과 + .memo("Test") + .basicMissions(List.of()) + .notification(notification) + .notificationCondition(null) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isNotEmpty(); + boolean hasScenarioNameError = violations.stream() + .anyMatch(v -> v.getMessage().contains("Scenario name must be at most 10 characters")); + assertThat(hasScenarioNameError).isTrue(); + } + + @Test + void Given_TooLongMemo_When_Validate_Then_Fail() { + // given + NotificationRequest notification = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .notificationMethodType(null) + .daysOfWeekOrdinal(null) + .build(); + + ScenarioDetailRequest request = ScenarioDetailRequest.builder() + .scenarioName("Test") + .memo("TooLongMemoText23123123123") + .basicMissions(List.of()) + .notification(notification) + .notificationCondition(null) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isNotEmpty(); + boolean hasMemoError = violations.stream() + .anyMatch(v -> v.getMessage().contains("Memo must be at most 15 characters")); + assertThat(hasMemoError).isTrue(); + } + + @Test + void Given_TooManyMissions_When_Validate_Then_Fail() { + // given + NotificationRequest notification = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .notificationMethodType(null) + .daysOfWeekOrdinal(null) + .build(); + + List missions = List.of( + BasicMissionRequest.builder().content("1").build(), + BasicMissionRequest.builder().content("2").build(), + BasicMissionRequest.builder().content("3").build(), + BasicMissionRequest.builder().content("4").build(), + BasicMissionRequest.builder().content("5").build(), + BasicMissionRequest.builder().content("6").build(), + BasicMissionRequest.builder().content("7").build(), + BasicMissionRequest.builder().content("8").build(), + BasicMissionRequest.builder().content("9").build(), + BasicMissionRequest.builder().content("10").build(), + BasicMissionRequest.builder().content("11").build(), + BasicMissionRequest.builder().content("12").build(), + BasicMissionRequest.builder().content("13").build(), + BasicMissionRequest.builder().content("14").build(), + BasicMissionRequest.builder().content("15").build(), + BasicMissionRequest.builder().content("16").build(), + BasicMissionRequest.builder().content("17").build(), + BasicMissionRequest.builder().content("18").build(), + BasicMissionRequest.builder().content("19").build(), + BasicMissionRequest.builder().content("20").build(), + BasicMissionRequest.builder().content("21").build() + ); + + ScenarioDetailRequest request = ScenarioDetailRequest.builder() + .scenarioName("Test") + .memo("Test") + .basicMissions(missions) + .notification(notification) + .notificationCondition(null) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isNotEmpty(); + boolean hasMissionCountError = violations.stream() + .anyMatch(v -> v.getMessage().contains("Maximum mission count exceeded")); + assertThat(hasMissionCountError).isTrue(); + } + + @Test + void Given_ValidMinimalRequest_When_Validate_Then_Success() { + // given + NotificationRequest notification = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .notificationMethodType(null) + .daysOfWeekOrdinal(null) + .build(); + + ScenarioDetailRequest request = ScenarioDetailRequest.builder() + .scenarioName("Test") + .memo("") + .basicMissions(List.of()) + .notification(notification) + .notificationCondition(null) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isEmpty(); + } + + @Test + void Given_MaxLengthValues_When_Validate_Then_Success() { + // given + NotificationRequest notification = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .notificationMethodType(null) + .daysOfWeekOrdinal(null) + .build(); + + ScenarioDetailRequest request = ScenarioDetailRequest.builder() + .scenarioName("1234567890") + .memo("123456789012345") + .basicMissions(List.of()) + .notification(notification) + .notificationCondition(null) + .build(); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isEmpty(); + } + +} diff --git a/src/test/java/com/und/server/scenario/repository/ScenarioRepositoryCustomImplTest.java b/src/test/java/com/und/server/scenario/repository/ScenarioRepositoryCustomImplTest.java new file mode 100644 index 00000000..357b9d11 --- /dev/null +++ b/src/test/java/com/und/server/scenario/repository/ScenarioRepositoryCustomImplTest.java @@ -0,0 +1,260 @@ +package com.und.server.scenario.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.notification.constants.NotificationMethodType; +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.response.ScenarioNotificationResponse; +import com.und.server.notification.dto.response.TimeNotificationResponse; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import jakarta.persistence.TypedQuery; + + +@ExtendWith(MockitoExtension.class) +class ScenarioRepositoryCustomImplTest { + + @InjectMocks + private ScenarioRepositoryCustomImpl scenarioRepositoryCustomImpl; + + @Mock + private EntityManager entityManager; + + @Mock + private TypedQuery typedQuery; + + @Mock + private Query query; + + private final Long memberId = 1L; + + + @Test + void Given_ValidMemberId_When_FindTimeScenarioNotifications_Then_ReturnTimeNotifications() { + // given + ScenarioRepositoryCustomImpl.TimeNotificationQueryDto queryDto = + ScenarioRepositoryCustomImpl.TimeNotificationQueryDto.builder() + .scenarioId(1L) + .scenarioName("아침 루틴") + .memo("아침에 할 일들") + .notificationId(1L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeek("1,2,3,4,5") + .startHour(9) + .startMinute(30) + .build(); + + when(entityManager.createQuery(anyString(), eq(ScenarioRepositoryCustomImpl.TimeNotificationQueryDto.class))) + .thenReturn(typedQuery); + when(typedQuery.setParameter(eq("memberId"), eq(memberId))) + .thenReturn(typedQuery); + when(typedQuery.setParameter(eq("timeType"), eq(NotificationType.TIME))) + .thenReturn(typedQuery); + when(typedQuery.getResultList()) + .thenReturn(List.of(queryDto)); + + // when + List result = + scenarioRepositoryCustomImpl.findTimeScenarioNotifications(memberId); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).scenarioId()).isEqualTo(1L); + assertThat(result.get(0).scenarioName()).isEqualTo("아침 루틴"); + assertThat(result.get(0).memo()).isEqualTo("아침에 할 일들"); + assertThat(result.get(0).notificationType()).isEqualTo(NotificationType.TIME); + assertThat(result.get(0).notificationMethodType()).isEqualTo(NotificationMethodType.PUSH); + assertThat(result.get(0).daysOfWeekOrdinal()).containsExactly(1, 2, 3, 4, 5); + assertThat(result.get(0).notificationCondition()).isInstanceOf(TimeNotificationResponse.class); + } + + + @Test + void Given_ValidMemberId_When_FindTimeScenarioNotifications_Then_ReturnMultipleTimeNotifications() { + // given + ScenarioRepositoryCustomImpl.TimeNotificationQueryDto morningDto = + ScenarioRepositoryCustomImpl.TimeNotificationQueryDto.builder() + .scenarioId(1L) + .scenarioName("아침 루틴") + .memo("아침에 할 일들") + .notificationId(1L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeek("1,2,3,4,5") + .startHour(9) + .startMinute(30) + .build(); + + ScenarioRepositoryCustomImpl.TimeNotificationQueryDto eveningDto = + ScenarioRepositoryCustomImpl.TimeNotificationQueryDto.builder() + .scenarioId(2L) + .scenarioName("저녁 루틴") + .memo("저녁에 할 일들") + .notificationId(2L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.ALARM) + .daysOfWeek("1,2,3,4,5,6,7") + .startHour(18) + .startMinute(0) + .build(); + + when(entityManager.createQuery(anyString(), eq(ScenarioRepositoryCustomImpl.TimeNotificationQueryDto.class))) + .thenReturn(typedQuery); + when(typedQuery.setParameter(eq("memberId"), eq(memberId))) + .thenReturn(typedQuery); + when(typedQuery.setParameter(eq("timeType"), eq(NotificationType.TIME))) + .thenReturn(typedQuery); + when(typedQuery.getResultList()) + .thenReturn(List.of(morningDto, eveningDto)); + + // when + List result = + scenarioRepositoryCustomImpl.findTimeScenarioNotifications(memberId); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).scenarioId()).isEqualTo(1L); + assertThat(result.get(0).scenarioName()).isEqualTo("아침 루틴"); + assertThat(result.get(1).scenarioId()).isEqualTo(2L); + assertThat(result.get(1).scenarioName()).isEqualTo("저녁 루틴"); + } + + + @Test + void Given_ValidMemberId_When_FindTimeScenarioNotifications_Then_ReturnEmptyList() { + // given + when(entityManager.createQuery(anyString(), eq(ScenarioRepositoryCustomImpl.TimeNotificationQueryDto.class))) + .thenReturn(typedQuery); + when(typedQuery.setParameter(eq("memberId"), eq(memberId))) + .thenReturn(typedQuery); + when(typedQuery.setParameter(eq("timeType"), eq(NotificationType.TIME))) + .thenReturn(typedQuery); + when(typedQuery.getResultList()) + .thenReturn(List.of()); + + // when + List result = + scenarioRepositoryCustomImpl.findTimeScenarioNotifications(memberId); + + // then + assertThat(result).isEmpty(); + } + + + @Test + void Given_TimeNotificationQueryDtoWithNullDaysOfWeek_When_ToResponse_Then_ReturnEmptyDaysList() { + // given + ScenarioRepositoryCustomImpl.TimeNotificationQueryDto queryDto = + ScenarioRepositoryCustomImpl.TimeNotificationQueryDto.builder() + .scenarioId(1L) + .scenarioName("테스트 루틴") + .memo("테스트 메모") + .notificationId(1L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeek(null) + .startHour(10) + .startMinute(0) + .build(); + + // when + ScenarioNotificationResponse result = queryDto.toResponse(); + + // then + assertThat(result.daysOfWeekOrdinal()).isEmpty(); + assertThat(result.scenarioId()).isEqualTo(1L); + assertThat(result.scenarioName()).isEqualTo("테스트 루틴"); + } + + + @Test + void Given_TimeNotificationQueryDtoWithBlankDaysOfWeek_When_ToResponse_Then_ReturnEmptyDaysList() { + // given + ScenarioRepositoryCustomImpl.TimeNotificationQueryDto queryDto = + ScenarioRepositoryCustomImpl.TimeNotificationQueryDto.builder() + .scenarioId(1L) + .scenarioName("테스트 루틴") + .memo("테스트 메모") + .notificationId(1L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeek("") + .startHour(10) + .startMinute(0) + .build(); + + // when + ScenarioNotificationResponse result = queryDto.toResponse(); + + // then + assertThat(result.daysOfWeekOrdinal()).isEmpty(); + assertThat(result.scenarioId()).isEqualTo(1L); + assertThat(result.scenarioName()).isEqualTo("테스트 루틴"); + } + + + @Test + void Given_TimeNotificationQueryDtoWithSpacedDaysOfWeek_When_ToResponse_Then_ReturnCorrectDaysList() { + // given + ScenarioRepositoryCustomImpl.TimeNotificationQueryDto queryDto = + ScenarioRepositoryCustomImpl.TimeNotificationQueryDto.builder() + .scenarioId(1L) + .scenarioName("테스트 루틴") + .memo("테스트 메모") + .notificationId(1L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeek("1, 2, 3, 4, 5") + .startHour(10) + .startMinute(0) + .build(); + + // when + ScenarioNotificationResponse result = queryDto.toResponse(); + + // then + assertThat(result.daysOfWeekOrdinal()).containsExactly(1, 2, 3, 4, 5); + assertThat(result.scenarioId()).isEqualTo(1L); + assertThat(result.scenarioName()).isEqualTo("테스트 루틴"); + } + + + @Test + void Given_TimeNotificationQueryDtoWithSingleDay_When_ToResponse_Then_ReturnCorrectDaysList() { + // given + ScenarioRepositoryCustomImpl.TimeNotificationQueryDto queryDto = + ScenarioRepositoryCustomImpl.TimeNotificationQueryDto.builder() + .scenarioId(1L) + .scenarioName("테스트 루틴") + .memo("테스트 메모") + .notificationId(1L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeek("1") + .startHour(10) + .startMinute(0) + .build(); + + // when + ScenarioNotificationResponse result = queryDto.toResponse(); + + // then + assertThat(result.daysOfWeekOrdinal()).containsExactly(1); + assertThat(result.scenarioId()).isEqualTo(1L); + assertThat(result.scenarioName()).isEqualTo("테스트 루틴"); + } + +} diff --git a/src/test/java/com/und/server/scenario/scheduler/ScenarioMissionDailyJobTest.java b/src/test/java/com/und/server/scenario/scheduler/ScenarioMissionDailyJobTest.java new file mode 100644 index 00000000..682bf950 --- /dev/null +++ b/src/test/java/com/und/server/scenario/scheduler/ScenarioMissionDailyJobTest.java @@ -0,0 +1,116 @@ +package com.und.server.scenario.scheduler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.scenario.repository.MissionRepository; + +@ExtendWith(MockitoExtension.class) +class ScenarioMissionDailyJobTest { + + @Mock + private MissionRepository missionRepository; + + @InjectMocks + private ScenarioMissionDailyJob job; + + private Clock fixedClock; + + + @BeforeEach + void setUp() { + fixedClock = Clock.fixed( + LocalDate.of(2025, 9, 1).atStartOfDay(ZoneId.of("Asia/Seoul")).toInstant(), + ZoneId.of("Asia/Seoul") + ); + job = new ScenarioMissionDailyJob(missionRepository, fixedClock); + } + + + @Test + void Given_NormalCase_When_RunDailyBackupJob_Then_CloneAndResetCalled() { + // given + when(missionRepository.bulkCloneBasicToYesterday(any(LocalDate.class))).thenReturn(7); + when(missionRepository.bulkResetBasicIsChecked(any(LocalDate.class))).thenReturn(100); + + // when + job.runDailyBackupJob(); + + // then + ArgumentCaptor dateCaptor = ArgumentCaptor.forClass(LocalDate.class); + verify(missionRepository).bulkCloneBasicToYesterday(dateCaptor.capture()); + verify(missionRepository).bulkResetBasicIsChecked(any(LocalDate.class)); + + LocalDate captured = dateCaptor.getValue(); + LocalDate expectedYesterday = LocalDate.of(2025, 8, 31); + assertThat(captured).isEqualTo(expectedYesterday); + } + + + @Test + void Given_RepositoryThrows_When_RunDailyBackupJob_Then_Throws() { + // given + when(missionRepository.bulkCloneBasicToYesterday(any(LocalDate.class))) + .thenThrow(new RuntimeException("db error")); + + // then + assertThatThrownBy(() -> job.runDailyBackupJob()) + .isInstanceOf(RuntimeException.class); + } + + + @Test + void Given_ZeroDeleted_When_RunExpiredCleanupJob_Then_CallOnceAndNoThrow() { + // given + when(missionRepository.bulkDeleteExpired(any(LocalDate.class), anyInt())).thenReturn(0); + + // when & then + assertThatCode(() -> job.runExpiredMissionCleanupJob()).doesNotThrowAnyException(); + verify(missionRepository, times(1)).bulkDeleteExpired(any(LocalDate.class), anyInt()); + } + + + @Test + void Given_DefaultBatchThenSmaller_When_RunExpiredCleanupJob_Then_LoopsAndStops() { + // given + when(missionRepository.bulkDeleteExpired(any(LocalDate.class), anyInt())) + .thenReturn(10_000) + .thenReturn(5_000); + + // when + assertThatCode(() -> job.runExpiredMissionCleanupJob()).doesNotThrowAnyException(); + + // then + verify(missionRepository, times(2)).bulkDeleteExpired(any(LocalDate.class), anyInt()); + } + + + @Test + void Given_RepositoryThrows_When_RunExpiredCleanupJob_Then_NoThrow() { + // given + when(missionRepository.bulkDeleteExpired(any(LocalDate.class), anyInt())) + .thenThrow(new RuntimeException("db error")); + + // when & then + assertThatCode(() -> job.runExpiredMissionCleanupJob()).doesNotThrowAnyException(); + } + +} diff --git a/src/test/java/com/und/server/scenario/service/MissionServiceTest.java b/src/test/java/com/und/server/scenario/service/MissionServiceTest.java new file mode 100644 index 00000000..32270457 --- /dev/null +++ b/src/test/java/com/und/server/scenario/service/MissionServiceTest.java @@ -0,0 +1,1352 @@ +package com.und.server.scenario.service; + +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.ArgumentMatchers.anyList; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.und.server.common.exception.ServerException; +import com.und.server.member.entity.Member; +import com.und.server.scenario.constants.MissionType; +import com.und.server.scenario.dto.request.BasicMissionRequest; +import com.und.server.scenario.dto.request.TodayMissionRequest; +import com.und.server.scenario.dto.response.MissionGroupResponse; +import com.und.server.scenario.dto.response.MissionResponse; +import com.und.server.scenario.entity.Mission; +import com.und.server.scenario.entity.Scenario; +import com.und.server.scenario.exception.ScenarioErrorResult; +import com.und.server.scenario.repository.MissionRepository; +import com.und.server.scenario.util.MissionTypeGroupSorter; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class MissionServiceTest { + + @Mock + private MissionRepository missionRepository; + + @Mock + private MissionTypeGroupSorter missionTypeGrouper; + + @Mock + private com.und.server.scenario.util.ScenarioValidator scenarioValidator; + + @Mock + private com.und.server.scenario.util.MissionValidator missionValidator; + + @Mock + private Clock clock; + + @InjectMocks + private MissionService missionService; + + @BeforeEach + void setUp() { + // Clock 설정 + when(clock.withZone(ZoneId.of("Asia/Seoul"))).thenReturn(Clock.fixed( + LocalDate.of(2024, 1, 15).atStartOfDay(ZoneId.of("Asia/Seoul")).toInstant(), + ZoneId.of("Asia/Seoul") + )); + } + + @Test + void Given_ValidScenarioId_When_FindMissionsByScenarioId_Then_ReturnMissionGroupResponse() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate date = LocalDate.of(2024, 1, 15); + + Member member = Member.builder() + .id(memberId) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .member(member) + .build(); + + Mission basicMission = Mission.builder() + .id(1L) + .scenario(scenario) + .content("기본 미션") + .missionType(MissionType.BASIC) + .build(); + + Mission todayMission = Mission.builder() + .id(2L) + .scenario(scenario) + .content("오늘 미션") + .missionType(MissionType.TODAY) + .build(); + + List missionList = Arrays.asList(basicMission, todayMission); + List groupedBasicMissions = Arrays.asList(basicMission); + List groupedTodayMissions = Arrays.asList(todayMission); + + when(missionRepository.findTodayAndFutureMissions(memberId, scenarioId, date)).thenReturn( + missionList); + when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.BASIC)) + .thenReturn(groupedBasicMissions); + when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.TODAY)) + .thenReturn(groupedTodayMissions); + + // when + MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, date); + + // then + assertThat(result) + .isNotNull() + .satisfies(r -> assertThat(r.basicMissions()).isNotEmpty()) + .satisfies(r -> assertThat(r.todayMissions()).isNotEmpty()); + verify(missionRepository).findTodayAndFutureMissions(memberId, scenarioId, date); + verify(missionTypeGrouper).groupAndSortByType(missionList, MissionType.BASIC); + verify(missionTypeGrouper).groupAndSortByType(missionList, MissionType.TODAY); + } + + + @Test + void Given_EmptyMissionList_When_FindMissionsByScenarioId_Then_ReturnEmptyResponse() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate date = LocalDate.of(2024, 1, 15); + + when(missionRepository.findTodayAndFutureMissions(memberId, scenarioId, date)).thenReturn( + List.of()); + + // when + MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, date); + + // then + assertThat(result) + .isNotNull() + .satisfies(r -> assertThat(r.basicMissions()).isEmpty()) + .satisfies(r -> assertThat(r.todayMissions()).isEmpty()); + verify(missionRepository).findTodayAndFutureMissions(memberId, scenarioId, date); + } + + + @Test + void Given_UnauthorizedMember_When_FindMissionsByScenarioId_Then_ThrowServerException() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate date = LocalDate.of(2024, 1, 15); + + when(missionRepository.findTodayAndFutureMissions(memberId, scenarioId, date)).thenReturn( + List.of()); + + // when & then + MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, date); + + assertThat(result) + .isNotNull() + .satisfies(r -> assertThat(r.basicMissions()).isEmpty()) + .satisfies(r -> assertThat(r.todayMissions()).isEmpty()); + } + + + @Test + void Given_ScenarioAndTodayMissionRequest_When_AddTodayMission_Then_SaveMission() { + // given + Scenario scenario = Scenario.builder() + .id(1L) + .build(); + + TodayMissionRequest missionAddInfo = new TodayMissionRequest("오늘 미션"); + LocalDate date = LocalDate.of(2024, 1, 15); + + // when + missionService.addTodayMission(scenario, missionAddInfo, date); + + // then + verify(missionRepository).save(any(Mission.class)); + } + + + @Test + void Given_ScenarioAndBasicMissionList_When_AddBasicMission_Then_SaveAllMissions() { + // given + Scenario scenario = Scenario.builder() + .id(1L) + .build(); + + BasicMissionRequest mission1 = BasicMissionRequest.builder() + .content("기본 미션 1") + .build(); + + BasicMissionRequest mission2 = BasicMissionRequest.builder() + .content("기본 미션 2") + .build(); + + List missionInfoList = Arrays.asList(mission1, mission2); + + // when + missionService.addBasicMission(scenario, missionInfoList); + + // then + verify(missionRepository).saveAll(anyList()); + } + + + @Test + void Given_EmptyBasicMissionList_When_AddBasicMission_Then_DoNothing() { + // given + Scenario scenario = Scenario.builder() + .id(1L) + .build(); + + List missionInfoList = List.of(); + + // when + missionService.addBasicMission(scenario, missionInfoList); + + // then + verify(missionRepository, org.mockito.Mockito.never()).saveAll(anyList()); + } + + + @Test + void Given_ScenarioAndNewBasicMissionList_When_UpdateBasicMission_Then_DeleteOldAndSaveNew() { + // given + Scenario oldScenario = Scenario.builder() + .id(1L) + .missions(new java.util.ArrayList<>()) + .build(); + + Mission oldMission = Mission.builder() + .id(1L) + .scenario(oldScenario) + .content("기존 미션") + .missionType(MissionType.BASIC) + .missionOrder(1) + .build(); + + List oldMissionList = Arrays.asList(oldMission); + + BasicMissionRequest newMission1 = BasicMissionRequest.builder() + .content("새 미션 1") + .build(); + + BasicMissionRequest newMission2 = BasicMissionRequest.builder() + .content("새 미션 2") + .build(); + + List missionInfoList = Arrays.asList(newMission1, newMission2); + + when(missionTypeGrouper.groupAndSortByType(oldScenario.getMissions(), MissionType.BASIC)) + .thenReturn(oldMissionList); + + // when + missionService.updateBasicMission(oldScenario, missionInfoList); + + // then + verify(missionRepository, org.mockito.Mockito.times(1)).saveAll(anyList()); + } + + + @Test + void Given_ScenarioAndEmptyBasicMissionList_When_UpdateBasicMission_Then_DeleteAllOldMissions() { + // given + Scenario oldScenario = Scenario.builder() + .id(1L) + .missions(new java.util.ArrayList<>()) + .build(); + + Mission oldMission = Mission.builder() + .id(1L) + .scenario(oldScenario) + .content("기존 미션") + .missionType(MissionType.BASIC) + .missionOrder(1) + .build(); + + List oldMissionList = Arrays.asList(oldMission); + List missionInfoList = List.of(); + + when(missionTypeGrouper.groupAndSortByType(oldScenario.getMissions(), MissionType.BASIC)) + .thenReturn(oldMissionList); + + // when + missionService.updateBasicMission(oldScenario, missionInfoList); + + // then + verify(missionRepository, org.mockito.Mockito.never()).saveAll(anyList()); + } + + + @Test + void Given_ScenarioAndMixedMissionList_When_UpdateBasicMission_Then_AddUpdateAndDelete() { + // given + Scenario oldScenario = Scenario.builder() + .id(1L) + .missions(new java.util.ArrayList<>()) + .build(); + + Mission existingMission = Mission.builder() + .id(1L) + .scenario(oldScenario) + .content("기존 미션") + .missionType(MissionType.BASIC) + .missionOrder(1) + .build(); + + List oldMissionList = Arrays.asList(existingMission); + + BasicMissionRequest newMission = BasicMissionRequest.builder() + .content("새 미션") + .build(); + + BasicMissionRequest updatedMission = BasicMissionRequest.builder() + .missionId(1L) + .content("수정된 미션") + .build(); + + List missionInfoList = Arrays.asList(newMission, updatedMission); + + when(missionTypeGrouper.groupAndSortByType(oldScenario.getMissions(), MissionType.BASIC)) + .thenReturn(oldMissionList); + + // when + missionService.updateBasicMission(oldScenario, missionInfoList); + + // then + verify(missionRepository, org.mockito.Mockito.times(1)).saveAll(anyList()); + } + + @Test + void Given_ValidMissionIdAndAuthorizedMember_When_DeleteTodayMission_Then_DeleteMission() { + // given + Long memberId = 1L; + Long missionId = 1L; + + Member member = Member.builder() + .id(memberId) + .build(); + + Scenario scenario = Scenario.builder() + .id(1L) + .member(member) + .build(); + + Mission mission = Mission.builder() + .id(missionId) + .scenario(scenario) + .content("삭제할 미션") + .missionType(MissionType.TODAY) + .build(); + + when(missionRepository.findByIdAndScenarioMemberId(missionId, memberId)).thenReturn( + java.util.Optional.of(mission)); + + // when + missionService.deleteTodayMission(memberId, missionId); + + // then + verify(missionRepository).findByIdAndScenarioMemberId(missionId, memberId); + verify(missionRepository).delete(mission); + } + + @Test + void Given_NonExistentMissionId_When_DeleteTodayMission_Then_ThrowNotFoundException() { + // given + Long memberId = 1L; + Long missionId = 999L; + + when(missionRepository.findByIdAndScenarioMemberId(missionId, memberId)).thenReturn(java.util.Optional.empty()); + + // when & then + assertThatThrownBy(() -> missionService.deleteTodayMission(memberId, missionId)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", ScenarioErrorResult.NOT_FOUND_MISSION); + verify(missionRepository).findByIdAndScenarioMemberId(missionId, memberId); + verify(missionRepository, org.mockito.Mockito.never()).delete(any()); + } + + @Test + void Given_UnauthorizedMember_When_DeleteTodayMission_Then_ThrowUnauthorizedException() { + // given + Long authorizedMemberId = 1L; + Long unauthorizedMemberId = 2L; + Long missionId = 1L; + + Member authorizedMember = Member.builder() + .id(authorizedMemberId) + .build(); + + Scenario scenario = Scenario.builder() + .id(1L) + .member(authorizedMember) + .build(); + + Mission mission = Mission.builder() + .id(missionId) + .scenario(scenario) + .content("삭제할 미션") + .missionType(MissionType.TODAY) + .build(); + + when(missionRepository.findByIdAndScenarioMemberId(missionId, unauthorizedMemberId)) + .thenReturn(java.util.Optional.empty()); + + // when & then + assertThatThrownBy(() -> missionService.deleteTodayMission(unauthorizedMemberId, missionId)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", ScenarioErrorResult.NOT_FOUND_MISSION); + verify(missionRepository).findByIdAndScenarioMemberId(missionId, unauthorizedMemberId); + verify(missionRepository, org.mockito.Mockito.never()).delete(any()); + } + + @Test + void Given_ValidMissionIdAndAuthorizedMember_When_UpdateMissionCheck_Then_UpdateMissionCheckStatus() { + // given + Long memberId = 1L; + Long missionId = 1L; + Boolean isChecked = true; + LocalDate date = LocalDate.of(2024, 1, 15); + + Member member = Member.builder() + .id(memberId) + .build(); + + Scenario scenario = Scenario.builder() + .id(1L) + .member(member) + .build(); + + Mission mission = Mission.builder() + .id(missionId) + .scenario(scenario) + .content("체크할 미션") + .isChecked(false) + .missionType(MissionType.TODAY) + .build(); + + when(missionRepository.findByIdAndScenarioMemberId(missionId, memberId)).thenReturn( + java.util.Optional.of(mission)); + + // when + missionService.updateMissionCheck(memberId, missionId, isChecked, date); + + // then + verify(missionRepository).findByIdAndScenarioMemberId(missionId, memberId); + assertThat(mission.getIsChecked()).isEqualTo(isChecked); + } + + @Test + void Given_NonExistentMissionId_When_UpdateMissionCheck_Then_ThrowNotFoundException() { + // given + Long memberId = 1L; + Long missionId = 999L; + Boolean isChecked = true; + LocalDate date = LocalDate.of(2024, 1, 15); + + when(missionRepository.findByIdAndScenarioMemberId(missionId, memberId)).thenReturn(java.util.Optional.empty()); + + // when & then + assertThatThrownBy(() -> missionService.updateMissionCheck(memberId, missionId, isChecked, date)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", ScenarioErrorResult.NOT_FOUND_MISSION); + verify(missionRepository).findByIdAndScenarioMemberId(missionId, memberId); + } + + @Test + void Given_UnauthorizedMember_When_UpdateMissionCheck_Then_ThrowUnauthorizedException() { + // given + Long authorizedMemberId = 1L; + Long unauthorizedMemberId = 2L; + Long missionId = 1L; + Boolean isChecked = true; + LocalDate date = LocalDate.of(2024, 1, 15); + + Member authorizedMember = Member.builder() + .id(authorizedMemberId) + .build(); + + Scenario scenario = Scenario.builder() + .id(1L) + .member(authorizedMember) + .build(); + + Mission mission = Mission.builder() + .id(missionId) + .scenario(scenario) + .content("체크할 미션") + .isChecked(false) + .missionType(MissionType.TODAY) + .build(); + + when(missionRepository.findByIdAndScenarioMemberId(missionId, unauthorizedMemberId)) + .thenReturn(java.util.Optional.empty()); + + // when & then + assertThatThrownBy(() -> missionService.updateMissionCheck(unauthorizedMemberId, missionId, isChecked, date)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", ScenarioErrorResult.NOT_FOUND_MISSION); + verify(missionRepository).findByIdAndScenarioMemberId(missionId, unauthorizedMemberId); + } + + + @Test + void Given_ValidMissionIdAndUncheck_When_UpdateMissionCheck_Then_UpdateMissionToUnchecked() { + // given + Long memberId = 1L; + Long missionId = 1L; + Boolean isChecked = false; + LocalDate date = LocalDate.of(2024, 1, 15); + + Member member = Member.builder() + .id(memberId) + .build(); + + Scenario scenario = Scenario.builder() + .id(1L) + .member(member) + .build(); + + Mission mission = Mission.builder() + .id(missionId) + .scenario(scenario) + .content("미션") + .isChecked(true) + .build(); + + when(missionRepository.findByIdAndScenarioMemberId(missionId, memberId)) + .thenReturn(Optional.of(mission)); + + // when + missionService.updateMissionCheck(memberId, missionId, isChecked, date); + + // then + assertThat(mission.getIsChecked()).isFalse(); + verify(missionRepository).findByIdAndScenarioMemberId(missionId, memberId); + } + + + @Test + void Given_NullDate_When_FindMissionsByScenarioId_Then_UseCurrentDate() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate nullDate = null; + + Mission mission = Mission.builder() + .id(1L) + .content("미션") + .missionType(MissionType.BASIC) + .build(); + + List missionList = List.of(mission); + List groupedBasicMissions = List.of(mission); + List groupedTodayMissions = List.of(); + + when(missionRepository.findTodayAndFutureMissions(any(Long.class), + any(Long.class), any())).thenReturn(missionList); + when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.BASIC)) + .thenReturn(groupedBasicMissions); + when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.TODAY)) + .thenReturn(groupedTodayMissions); + + // when + MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, nullDate); + + // then + assertThat(result) + .isNotNull() + .satisfies(r -> assertThat(r.basicMissions()).isNotEmpty()) + .satisfies(r -> assertThat(r.todayMissions()).isEmpty()); + verify(missionRepository).findTodayAndFutureMissions(any(Long.class), any(Long.class), any()); + } + + + @Test + void Given_PastDate_When_FindMissionsByScenarioId_Then_UsePastDate() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate pastDate = LocalDate.of(2024, 1, 14); + + Mission mission = Mission.builder() + .id(1L) + .content("과거 미션") + .missionType(MissionType.TODAY) + .useDate(pastDate) + .build(); + + List missionList = List.of(mission); + List groupedBasicMissions = List.of(); + List groupedTodayMissions = List.of(mission); + + when(missionRepository.findPastMissionsByDate(memberId, scenarioId, pastDate)).thenReturn(missionList); + when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.BASIC)) + .thenReturn(groupedBasicMissions); + when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.TODAY)) + .thenReturn(groupedTodayMissions); + + // when + MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, pastDate); + + // then + assertThat(result) + .isNotNull() + .satisfies(r -> assertThat(r.basicMissions()).isEmpty()) + .satisfies(r -> assertThat(r.todayMissions()).isNotEmpty()); + verify(missionRepository).findPastMissionsByDate(memberId, scenarioId, pastDate); + } + + + @Test + void Given_FutureDate_When_FindMissionsByScenarioId_Then_UseFutureDate() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate futureDate = LocalDate.of(2024, 1, 16); + + Mission mission = Mission.builder() + .id(1L) + .content("미래 미션") + .missionType(MissionType.TODAY) + .useDate(futureDate) + .build(); + + List missionList = List.of(mission); + List groupedBasicMissions = List.of(); + List groupedTodayMissions = List.of(mission); + + when(missionRepository.findTodayAndFutureMissions(memberId, scenarioId, futureDate)).thenReturn(missionList); + when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.BASIC)) + .thenReturn(groupedBasicMissions); + when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.TODAY)) + .thenReturn(groupedTodayMissions); + + // when + MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, futureDate); + + // then + assertThat(result) + .isNotNull() + .satisfies(r -> assertThat(r.basicMissions()).isEmpty()) + .satisfies(r -> assertThat(r.todayMissions()).isNotEmpty()); + verify(missionRepository).findTodayAndFutureMissions(memberId, scenarioId, futureDate); + } + + + @Test + void Given_ScenarioAndMissionRequestWithNullMissionId_When_UpdateBasicMission_Then_AddNewMission() { + // given + Scenario oldScenario = Scenario.builder() + .id(1L) + .missions(new java.util.ArrayList<>()) + .build(); + + BasicMissionRequest newMissionRequest = BasicMissionRequest.builder() + .content("새 미션") + .build(); + + List missionInfoList = List.of(newMissionRequest); + + when(missionTypeGrouper.groupAndSortByType(oldScenario.getMissions(), MissionType.BASIC)) + .thenReturn(List.of()); + + // when + missionService.updateBasicMission(oldScenario, missionInfoList); + + // then + verify(missionRepository, org.mockito.Mockito.times(1)).saveAll(anyList()); + } + + + @Test + void Given_ScenarioAndMissionRequestWithNonExistentMissionId_When_UpdateBasicMission_Then_IgnoreMission() { + // given + Scenario oldScenario = Scenario.builder() + .id(1L) + .missions(new java.util.ArrayList<>()) + .build(); + + Mission existingMission = Mission.builder() + .id(1L) + .content("기존 미션") + .missionType(MissionType.BASIC) + .build(); + + BasicMissionRequest nonExistentMissionRequest = BasicMissionRequest.builder() + .missionId(99L) // 존재하지 않는 ID + .content("존재하지 않는 미션") + .build(); + + List missionInfoList = List.of(nonExistentMissionRequest); + List oldMissionList = List.of(existingMission); + + when(missionTypeGrouper.groupAndSortByType(oldScenario.getMissions(), MissionType.BASIC)) + .thenReturn(oldMissionList); + + // when + missionService.updateBasicMission(oldScenario, missionInfoList); + + // then + verify(missionRepository, org.mockito.Mockito.times(1)).saveAll(anyList()); + } + + @Test + void Given_ScenarioId_When_DeleteMissions_Then_DeleteAllMissions() { + // given + Long scenarioId = 1L; + + // when + missionService.deleteMissions(scenarioId); + + // then + verify(missionRepository).deleteByScenarioId(scenarioId); + } + + + @Test + void Given_EmptyMissions_When_FindMissionsByScenarioId_Then_ReturnEmptyResponse() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate date = LocalDate.of(2024, 1, 15); + + when(missionRepository.findTodayAndFutureMissions(memberId, scenarioId, date)) + .thenReturn(List.of()); + + // when + MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, date); + + // then + assertThat(result) + .isNotNull() + .satisfies(r -> assertThat(r.basicMissions()).isEmpty()) + .satisfies(r -> assertThat(r.todayMissions()).isEmpty()); + verify(missionRepository).findTodayAndFutureMissions(memberId, scenarioId, date); + } + + @Test + void Given_NullMissions_When_FindMissionsByScenarioId_Then_ReturnEmptyResponse() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate date = LocalDate.of(2024, 1, 15); + + when(missionRepository.findTodayAndFutureMissions(memberId, scenarioId, date)) + .thenReturn(null); + + // when + MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, date); + + // then + assertThat(result) + .isNotNull() + .satisfies(r -> assertThat(r.basicMissions()).isEmpty()) + .satisfies(r -> assertThat(r.todayMissions()).isEmpty()); + verify(missionRepository).findTodayAndFutureMissions(memberId, scenarioId, date); + } + + @Test + void Given_EmptyBasicMissionRequests_When_AddBasicMission_Then_ReturnEmptyList() { + // given + Scenario scenario = Scenario.builder() + .id(1L) + .build(); + + List emptyRequests = List.of(); + + // when + List result = missionService.addBasicMission(scenario, emptyRequests); + + // then + assertThat(result).isEmpty(); + verify(missionRepository, never()).saveAll(anyList()); + } + + @Test + void Given_NewMissionRequest_When_UpdateBasicMission_Then_AddNewMission() { + // given + Mission existingMission = Mission.builder() + .id(1L) + .content("기존 미션") + .missionType(MissionType.BASIC) + .build(); + + List missions = new java.util.ArrayList<>(); + missions.add(existingMission); + + Scenario scenario = Scenario.builder() + .id(1L) + .missions(missions) + .build(); + + BasicMissionRequest newMissionRequest = BasicMissionRequest.builder() + .missionId(null) // 새로운 미션 + .content("새로운 미션") + .build(); + + List requests = List.of(newMissionRequest); + + when(missionTypeGrouper.groupAndSortByType(scenario.getMissions(), MissionType.BASIC)) + .thenReturn(List.of(existingMission)); + + // when + missionService.updateBasicMission(scenario, requests); + + // then + verify(missionRepository).saveAll(anyList()); + } + + @Test + void Given_NonExistentMissionId_When_UpdateBasicMission_Then_SkipMission() { + // given + Mission existingMission = Mission.builder() + .id(1L) + .content("기존 미션") + .missionType(MissionType.BASIC) + .build(); + + List missions = new java.util.ArrayList<>(); + missions.add(existingMission); + + Scenario scenario = Scenario.builder() + .id(1L) + .missions(missions) + .build(); + + BasicMissionRequest nonExistentRequest = BasicMissionRequest.builder() + .missionId(999L) // 존재하지 않는 미션 ID + .content("존재하지 않는 미션") + .build(); + + List requests = List.of(nonExistentRequest); + + when(missionTypeGrouper.groupAndSortByType(scenario.getMissions(), MissionType.BASIC)) + .thenReturn(List.of(existingMission)); + + // when + missionService.updateBasicMission(scenario, requests); + + // then + verify(missionRepository).saveAll(anyList()); + } + + @Test + void Given_BasicMissionAndFutureDate_When_UpdateMissionCheck_Then_UpdateFutureBasicMission() { + // given + Long memberId = 1L; + Long missionId = 1L; + Boolean isChecked = true; + LocalDate futureDate = LocalDate.of(2024, 1, 16); + + Member member = Member.builder() + .id(memberId) + .build(); + + Scenario scenario = Scenario.builder() + .id(1L) + .member(member) + .build(); + + Mission mission = Mission.builder() + .id(missionId) + .scenario(scenario) + .content("기본 미션") + .isChecked(false) + .missionType(MissionType.BASIC) + .build(); + + when(missionRepository.findByIdAndScenarioMemberId(missionId, memberId)).thenReturn(Optional.of(mission)); + when(missionRepository.findByParentMissionIdAndUseDate(missionId, futureDate)) + .thenReturn(Optional.empty()); + + // when + missionService.updateMissionCheck(memberId, missionId, isChecked, futureDate); + + // then + verify(missionRepository).findByIdAndScenarioMemberId(missionId, memberId); + verify(missionRepository).findByParentMissionIdAndUseDate(missionId, futureDate); + verify(missionRepository).save(any(Mission.class)); + } + + @Test + void Given_BasicMissionAndFutureDateWithExistingChild_When_UpdateMissionCheckToTrue_Then_UpdateChildMission() { + // given + Long memberId = 1L; + Long missionId = 1L; + Boolean isChecked = true; + LocalDate futureDate = LocalDate.of(2024, 1, 16); + + Member member = Member.builder() + .id(memberId) + .build(); + + Scenario scenario = Scenario.builder() + .id(1L) + .member(member) + .build(); + + Mission mission = Mission.builder() + .id(missionId) + .scenario(scenario) + .content("기본 미션") + .isChecked(false) + .missionType(MissionType.BASIC) + .build(); + + Mission childMission = Mission.builder() + .id(2L) + .parentMissionId(missionId) + .useDate(futureDate) + .isChecked(false) + .missionType(MissionType.BASIC) + .build(); + + when(missionRepository.findByIdAndScenarioMemberId(missionId, memberId)).thenReturn(Optional.of(mission)); + when(missionRepository.findByParentMissionIdAndUseDate(missionId, futureDate)) + .thenReturn(Optional.of(childMission)); + + // when + missionService.updateMissionCheck(memberId, missionId, isChecked, futureDate); + + // then + verify(missionRepository).findByIdAndScenarioMemberId(missionId, memberId); + verify(missionRepository).findByParentMissionIdAndUseDate(missionId, futureDate); + assertThat(childMission.getIsChecked()).isTrue(); + } + + @Test + void Given_BasicMissionAndFutureDateWithExistingChild_When_UpdateMissionCheckToFalse_Then_DeleteChildMission() { + // given + Long memberId = 1L; + Long missionId = 1L; + Boolean isChecked = false; + LocalDate futureDate = LocalDate.of(2024, 1, 16); + + Member member = Member.builder() + .id(memberId) + .build(); + + Scenario scenario = Scenario.builder() + .id(1L) + .member(member) + .build(); + + Mission mission = Mission.builder() + .id(missionId) + .scenario(scenario) + .content("기본 미션") + .isChecked(false) + .missionType(MissionType.BASIC) + .build(); + + Mission childMission = Mission.builder() + .id(2L) + .parentMissionId(missionId) + .useDate(futureDate) + .isChecked(true) + .missionType(MissionType.BASIC) + .build(); + + when(missionRepository.findByIdAndScenarioMemberId(missionId, memberId)).thenReturn(Optional.of(mission)); + when(missionRepository.findByParentMissionIdAndUseDate(missionId, futureDate)) + .thenReturn(Optional.of(childMission)); + + // when + missionService.updateMissionCheck(memberId, missionId, isChecked, futureDate); + + // then + verify(missionRepository).findByIdAndScenarioMemberId(missionId, memberId); + verify(missionRepository).findByParentMissionIdAndUseDate(missionId, futureDate); + verify(missionRepository).delete(childMission); + } + + @Test + void Given_PastDate_When_FindMissionsByScenarioId_Then_UsePastMissions() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate pastDate = LocalDate.of(2024, 1, 10); + + Mission pastMission = Mission.builder() + .id(1L) + .content("과거 미션") + .missionType(MissionType.BASIC) + .useDate(pastDate) + .build(); + + List missionList = List.of(pastMission); + List groupedBasicMissions = List.of(pastMission); + List groupedTodayMissions = List.of(); + + when(missionRepository.findPastMissionsByDate(memberId, scenarioId, pastDate)) + .thenReturn(missionList); + when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.BASIC)) + .thenReturn(groupedBasicMissions); + when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.TODAY)) + .thenReturn(groupedTodayMissions); + + // when + MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, pastDate); + + // then + assertThat(result) + .isNotNull() + .satisfies(r -> assertThat(r.basicMissions()).hasSize(1)); + verify(missionRepository).findPastMissionsByDate(memberId, scenarioId, pastDate); + } + + @Test + void Given_BasicMissionsWithOverlay_When_GetFutureCheckStatusMissions_Then_ReturnOverlayStatus() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate futureDate = LocalDate.of(2024, 1, 16); + + Mission parentMission = Mission.builder() + .id(1L) + .content("부모 미션") + .missionType(MissionType.BASIC) + .parentMissionId(null) + .useDate(null) + .build(); + + Mission overlayMission = Mission.builder() + .id(2L) + .content("부모 미션") + .missionType(MissionType.BASIC) + .parentMissionId(1L) + .useDate(futureDate) + .isChecked(true) + .build(); + + List missionList = List.of(parentMission, overlayMission); + List groupedBasicMissions = List.of(parentMission, overlayMission); + List groupedTodayMissions = List.of(); + + when(missionRepository.findTodayAndFutureMissions(memberId, scenarioId, futureDate)) + .thenReturn(missionList); + when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.BASIC)) + .thenReturn(groupedBasicMissions); + when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.TODAY)) + .thenReturn(groupedTodayMissions); + + // when + MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, futureDate); + + // then + assertThat(result) + .isNotNull() + .satisfies(r -> assertThat(r.basicMissions()).hasSize(1)) + .satisfies(r -> assertThat(r.basicMissions().get(0).isChecked()).isTrue()); + verify(missionRepository).findTodayAndFutureMissions(memberId, scenarioId, futureDate); + } + + @Test + void Given_BasicMissionsWithoutOverlay_When_GetFutureCheckStatusMissions_Then_ReturnUncheckedMissions() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate futureDate = LocalDate.of(2024, 1, 16); + + Mission parentMission = Mission.builder() + .id(1L) + .content("부모 미션") + .missionType(MissionType.BASIC) + .parentMissionId(null) + .useDate(null) + .build(); + + List missionList = List.of(parentMission); + List groupedBasicMissions = List.of(parentMission); + List groupedTodayMissions = List.of(); + + when(missionRepository.findTodayAndFutureMissions(memberId, scenarioId, futureDate)) + .thenReturn(missionList); + when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.BASIC)) + .thenReturn(groupedBasicMissions); + when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.TODAY)) + .thenReturn(groupedTodayMissions); + + // when + MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, futureDate); + + // then + assertThat(result) + .isNotNull() + .satisfies(r -> assertThat(r.basicMissions()).hasSize(1)) + .satisfies(r -> assertThat(r.basicMissions().get(0).isChecked()).isFalse()); + verify(missionRepository).findTodayAndFutureMissions(memberId, scenarioId, futureDate); + } + + // Mission entity 커버리지 향상을 위한 테스트 + @Test + void Given_Mission_When_UpdateCheckStatus_Then_UpdateIsChecked() { + // given + Mission mission = Mission.builder() + .id(1L) + .content("테스트 미션") + .isChecked(false) + .missionType(MissionType.BASIC) + .build(); + + // when + mission.updateCheckStatus(true); + + // then + assertThat(mission.getIsChecked()).isTrue(); + } + + @Test + void Given_Mission_When_UpdateMissionOrder_Then_UpdateOrder() { + // given + Mission mission = Mission.builder() + .id(1L) + .content("테스트 미션") + .missionOrder(1) + .missionType(MissionType.BASIC) + .build(); + + // when + mission.updateMissionOrder(5); + + // then + assertThat(mission.getMissionOrder()).isEqualTo(5); + } + + @Test + void Given_Mission_When_CreateFutureChildMission_Then_CreateChildMission() { + // given + Scenario scenario = Scenario.builder() + .id(1L) + .build(); + + Mission parentMission = Mission.builder() + .id(1L) + .scenario(scenario) + .content("부모 미션") + .isChecked(false) + .missionType(MissionType.BASIC) + .build(); + + LocalDate futureDate = LocalDate.of(2024, 1, 16); + + // when + Mission childMission = parentMission.createFutureChildMission(true, futureDate); + + // then + assertThat(childMission.getParentMissionId()).isEqualTo(parentMission.getId()); + assertThat(childMission.getUseDate()).isEqualTo(futureDate); + assertThat(childMission.getIsChecked()).isTrue(); + assertThat(childMission.getContent()).isEqualTo(parentMission.getContent()); + assertThat(childMission.getMissionType()).isEqualTo(parentMission.getMissionType()); + assertThat(childMission.getScenario()).isEqualTo(parentMission.getScenario()); + } + + // MissionResponse 커버리지 향상을 위한 테스트 + @Test + void Given_Mission_When_From_Then_CreateMissionResponse() { + // given + Mission mission = Mission.builder() + .id(1L) + .content("테스트 미션") + .isChecked(true) + .missionType(MissionType.BASIC) + .build(); + + // when + MissionResponse response = MissionResponse.from(mission); + + // then + assertThat(response) + .satisfies(r -> assertThat(r.missionId()).isEqualTo(mission.getId())) + .satisfies(r -> assertThat(r.content()).isEqualTo(mission.getContent())) + .satisfies(r -> assertThat(r.isChecked()).isEqualTo(mission.getIsChecked())) + .satisfies(r -> assertThat(r.missionType()).isEqualTo(mission.getMissionType())); + } + + @Test + void Given_MissionAndOverrideChecked_When_FromWithOverride_Then_CreateMissionResponseWithOverride() { + // given + Mission mission = Mission.builder() + .id(1L) + .content("테스트 미션") + .isChecked(false) + .missionType(MissionType.BASIC) + .build(); + + Boolean overrideChecked = true; + + // when + MissionResponse response = MissionResponse.fromWithOverride(mission, overrideChecked); + + // then + assertThat(response) + .satisfies(r -> assertThat(r.missionId()).isEqualTo(mission.getId())) + .satisfies(r -> assertThat(r.content()).isEqualTo(mission.getContent())) + .satisfies(r -> assertThat(r.isChecked()).isEqualTo(overrideChecked)) + .satisfies(r -> assertThat(r.missionType()).isEqualTo(mission.getMissionType())); + } + + @Test + void Given_MissionList_When_ListFrom_Then_CreateMissionResponseList() { + // given + Mission mission1 = Mission.builder() + .id(1L) + .content("미션1") + .isChecked(true) + .missionType(MissionType.BASIC) + .build(); + + Mission mission2 = Mission.builder() + .id(2L) + .content("미션2") + .isChecked(false) + .missionType(MissionType.TODAY) + .build(); + + List missionList = List.of(mission1, mission2); + + // when + List responseList = MissionResponse.listFrom(missionList); + + // then + assertThat(responseList) + .hasSize(2) + .satisfies(list -> assertThat(list.get(0).missionId()).isEqualTo(mission1.getId())) + .satisfies(list -> assertThat(list.get(1).missionId()).isEqualTo(mission2.getId())); + } + + @Test + void Given_EmptyMissionList_When_ListFrom_Then_ReturnEmptyList() { + // given + List emptyList = List.of(); + + // when + List responseList = MissionResponse.listFrom(emptyList); + + // then + assertThat(responseList).isEmpty(); + } + + @Test + void Given_NullMissionList_When_ListFrom_Then_ReturnEmptyList() { + // given + List nullList = null; + + // when + List responseList = MissionResponse.listFrom(nullList); + + // then + assertThat(responseList).isEmpty(); + } + + // MissionGroupResponse 커버리지 향상을 위한 테스트 + @Test + void Given_MissionLists_When_From_Then_CreateMissionGroupResponse() { + // given + Mission basicMission = Mission.builder() + .id(1L) + .content("기본 미션") + .missionType(MissionType.BASIC) + .build(); + + Mission todayMission = Mission.builder() + .id(2L) + .content("오늘 미션") + .missionType(MissionType.TODAY) + .build(); + + List basicMissions = List.of(basicMission); + List todayMissions = List.of(todayMission); + + // when + MissionGroupResponse response = MissionGroupResponse.from(basicMissions, todayMissions); + + // then + assertThat(response) + .satisfies(r -> assertThat(r.basicMissions()).hasSize(1)) + .satisfies(r -> assertThat(r.todayMissions()).hasSize(1)) + .satisfies(r -> assertThat(r.basicMissions().get(0).missionId()).isEqualTo(basicMission.getId())) + .satisfies(r -> assertThat(r.todayMissions().get(0).missionId()).isEqualTo(todayMission.getId())); + } + + @Test + void Given_ScenarioIdAndMissionLists_When_From_Then_CreateMissionGroupResponseWithScenarioId() { + // given + Long scenarioId = 1L; + Mission basicMission = Mission.builder() + .id(1L) + .content("기본 미션") + .missionType(MissionType.BASIC) + .build(); + + Mission todayMission = Mission.builder() + .id(2L) + .content("오늘 미션") + .missionType(MissionType.TODAY) + .build(); + + List basicMissions = List.of(basicMission); + List todayMissions = List.of(todayMission); + + // when + MissionGroupResponse response = MissionGroupResponse.from(scenarioId, basicMissions, todayMissions); + + // then + assertThat(response) + .satisfies(r -> assertThat(r.scenarioId()).isEqualTo(scenarioId)) + .satisfies(r -> assertThat(r.basicMissions()).hasSize(1)) + .satisfies(r -> assertThat(r.todayMissions()).hasSize(1)); + } + + @Test + void Given_ScenarioIdAndFutureBasicAndTodayMissions_When_FutureFrom_Then_CreateFutureMissionGroupResponse() { + // given + Long scenarioId = 1L; + MissionResponse futureBasicResponse = MissionResponse.builder() + .missionId(1L) + .content("미래 기본 미션") + .isChecked(true) + .missionType(MissionType.BASIC) + .build(); + + Mission todayMission = Mission.builder() + .id(2L) + .content("오늘 미션") + .missionType(MissionType.TODAY) + .build(); + + List futureBasicResponses = List.of(futureBasicResponse); + List todayMissions = List.of(todayMission); + + // when + MissionGroupResponse response = + MissionGroupResponse.futureFrom(scenarioId, futureBasicResponses, todayMissions); + + // then + assertThat(response) + .satisfies(r -> assertThat(r.scenarioId()).isEqualTo(scenarioId)) + .satisfies(r -> assertThat(r.basicMissions()).hasSize(1)) + .satisfies(r -> assertThat(r.basicMissions().get(0).missionId()).isEqualTo(futureBasicResponse.missionId())) + .satisfies(r -> assertThat(r.todayMissions()).hasSize(1)) + .satisfies(r -> assertThat(r.todayMissions().get(0).missionId()).isEqualTo(todayMission.getId())); + } + +} diff --git a/src/test/java/com/und/server/scenario/service/ScenarioNotificationServiceTest.java b/src/test/java/com/und/server/scenario/service/ScenarioNotificationServiceTest.java new file mode 100644 index 00000000..6d06999d --- /dev/null +++ b/src/test/java/com/und/server/scenario/service/ScenarioNotificationServiceTest.java @@ -0,0 +1,209 @@ +package com.und.server.scenario.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.notification.constants.NotificationMethodType; +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.response.ScenarioNotificationResponse; +import com.und.server.notification.dto.response.TimeNotificationResponse; +import com.und.server.scenario.repository.ScenarioRepository; + + +@ExtendWith(MockitoExtension.class) +class ScenarioNotificationServiceTest { + + @InjectMocks + private ScenarioNotificationService scenarioNotificationService; + + @Mock + private ScenarioRepository scenarioRepository; + + private final Long memberId = 1L; + + + @Test + void Given_ValidMemberId_When_GetScenarioNotifications_Then_ReturnTimeNotifications() { + // given + TimeNotificationResponse timeNotificationResponse = TimeNotificationResponse.builder() + .notificationType(NotificationType.TIME) + .startHour(9) + .startMinute(30) + .build(); + + ScenarioNotificationResponse expectedResponse = ScenarioNotificationResponse.builder() + .scenarioId(1L) + .scenarioName("아침 루틴") + .memo("아침에 할 일들") + .notificationId(1L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeekOrdinal(List.of(1, 2, 3, 4, 5)) + .notificationCondition(timeNotificationResponse) + .build(); + + given(scenarioRepository.findTimeScenarioNotifications(memberId)) + .willReturn(List.of(expectedResponse)); + + // when + List result = scenarioNotificationService.getScenarioNotifications(memberId); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).scenarioId()).isEqualTo(1L); + assertThat(result.get(0).scenarioName()).isEqualTo("아침 루틴"); + assertThat(result.get(0).notificationType()).isEqualTo(NotificationType.TIME); + assertThat(result.get(0).notificationCondition()).isEqualTo(timeNotificationResponse); + } + + + @Test + void Given_ValidMemberId_When_GetScenarioNotifications_Then_ReturnMultipleTimeNotifications() { + // given + TimeNotificationResponse morningNotification = TimeNotificationResponse.builder() + .notificationType(NotificationType.TIME) + .startHour(9) + .startMinute(30) + .build(); + + TimeNotificationResponse eveningNotification = TimeNotificationResponse.builder() + .notificationType(NotificationType.TIME) + .startHour(18) + .startMinute(0) + .build(); + + ScenarioNotificationResponse morningResponse = ScenarioNotificationResponse.builder() + .scenarioId(1L) + .scenarioName("아침 루틴") + .memo("아침에 할 일들") + .notificationId(1L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeekOrdinal(List.of(1, 2, 3, 4, 5)) + .notificationCondition(morningNotification) + .build(); + + ScenarioNotificationResponse eveningResponse = ScenarioNotificationResponse.builder() + .scenarioId(2L) + .scenarioName("저녁 루틴") + .memo("저녁에 할 일들") + .notificationId(2L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeekOrdinal(List.of(1, 2, 3, 4, 5, 6, 7)) + .notificationCondition(eveningNotification) + .build(); + + given(scenarioRepository.findTimeScenarioNotifications(memberId)) + .willReturn(List.of(morningResponse, eveningResponse)); + + // when + List result = scenarioNotificationService.getScenarioNotifications(memberId); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).scenarioId()).isEqualTo(1L); + assertThat(result.get(0).scenarioName()).isEqualTo("아침 루틴"); + assertThat(result.get(1).scenarioId()).isEqualTo(2L); + assertThat(result.get(1).scenarioName()).isEqualTo("저녁 루틴"); + } + + + @Test + void Given_ValidMemberId_When_GetScenarioNotifications_Then_ReturnEmptyList() { + // given + given(scenarioRepository.findTimeScenarioNotifications(memberId)) + .willReturn(List.of()); + + // when + List result = scenarioNotificationService.getScenarioNotifications(memberId); + + // then + assertThat(result).isEmpty(); + } + + + @Test + void Given_ValidMemberId_When_GetScenarioNotifications_Then_ReturnNotificationsWithNullDaysOfWeek() { + // given + TimeNotificationResponse timeNotificationResponse = TimeNotificationResponse.builder() + .notificationType(NotificationType.TIME) + .startHour(12) + .startMinute(0) + .build(); + + ScenarioNotificationResponse expectedResponse = ScenarioNotificationResponse.builder() + .scenarioId(1L) + .scenarioName("점심 루틴") + .memo("점심에 할 일들") + .notificationId(1L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeekOrdinal(List.of()) + .notificationCondition(timeNotificationResponse) + .build(); + + given(scenarioRepository.findTimeScenarioNotifications(memberId)) + .willReturn(List.of(expectedResponse)); + + // when + List result = scenarioNotificationService.getScenarioNotifications(memberId); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).daysOfWeekOrdinal()).isEmpty(); + } + + + @Test + void Given_ValidMemberId_When_GetScenarioNotifications_Then_ReturnNotificationsWithDifferentNotificationMethods() { + // given + TimeNotificationResponse timeNotificationResponse = TimeNotificationResponse.builder() + .notificationType(NotificationType.TIME) + .startHour(10) + .startMinute(0) + .build(); + + ScenarioNotificationResponse pushResponse = ScenarioNotificationResponse.builder() + .scenarioId(1L) + .scenarioName("푸시 알림 루틴") + .memo("푸시 알림으로 받는 루틴") + .notificationId(1L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .daysOfWeekOrdinal(List.of(1, 2, 3, 4, 5)) + .notificationCondition(timeNotificationResponse) + .build(); + + ScenarioNotificationResponse alarmResponse = ScenarioNotificationResponse.builder() + .scenarioId(2L) + .scenarioName("알람 루틴") + .memo("알람으로 받는 루틴") + .notificationId(2L) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.ALARM) + .daysOfWeekOrdinal(List.of(1, 2, 3, 4, 5)) + .notificationCondition(timeNotificationResponse) + .build(); + + given(scenarioRepository.findTimeScenarioNotifications(memberId)) + .willReturn(List.of(pushResponse, alarmResponse)); + + // when + List result = scenarioNotificationService.getScenarioNotifications(memberId); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).notificationMethodType()).isEqualTo(NotificationMethodType.PUSH); + assertThat(result.get(1).notificationMethodType()).isEqualTo(NotificationMethodType.ALARM); + } + +} diff --git a/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java b/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java new file mode 100644 index 00000000..82542384 --- /dev/null +++ b/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java @@ -0,0 +1,1157 @@ +package com.und.server.scenario.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.und.server.common.exception.ServerException; +import com.und.server.member.entity.Member; +import com.und.server.notification.constants.NotificationMethodType; +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.NotificationInfoDto; +import com.und.server.notification.dto.request.NotificationRequest; +import com.und.server.notification.dto.request.TimeNotificationRequest; +import com.und.server.notification.dto.response.TimeNotificationResponse; +import com.und.server.notification.entity.Notification; +import com.und.server.notification.event.NotificationEventPublisher; +import com.und.server.notification.service.NotificationService; +import com.und.server.scenario.constants.MissionType; +import com.und.server.scenario.dto.request.BasicMissionRequest; +import com.und.server.scenario.dto.request.ScenarioDetailRequest; +import com.und.server.scenario.dto.request.ScenarioOrderUpdateRequest; +import com.und.server.scenario.dto.request.TodayMissionRequest; +import com.und.server.scenario.dto.response.OrderUpdateResponse; +import com.und.server.scenario.dto.response.ScenarioDetailResponse; +import com.und.server.scenario.dto.response.ScenarioResponse; +import com.und.server.scenario.entity.Mission; +import com.und.server.scenario.entity.Scenario; +import com.und.server.scenario.exception.ReorderRequiredException; +import com.und.server.scenario.exception.ScenarioErrorResult; +import com.und.server.scenario.repository.ScenarioRepository; +import com.und.server.scenario.util.MissionTypeGroupSorter; +import com.und.server.scenario.util.OrderCalculator; + +import jakarta.persistence.EntityManager; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ScenarioServiceTest { + + @InjectMocks + private ScenarioService scenarioService; + + @Mock + private MissionService missionService; + + @Mock + private NotificationService notificationService; + + @Mock + private ScenarioRepository scenarioRepository; + + @Mock + private MissionTypeGroupSorter missionTypeGroupSorter; + + @Mock + private OrderCalculator orderCalculator; + + @Mock + private EntityManager em; + + @Mock + private com.und.server.scenario.util.ScenarioValidator scenarioValidator; + + @Mock + private NotificationEventPublisher notificationEventPublisher; + + @Mock + private Clock clock; + + @BeforeEach + void setUp() { + // Clock 설정 + when(clock.withZone(ZoneId.of("Asia/Seoul"))).thenReturn(Clock.fixed( + LocalDate.of(2024, 1, 15).atStartOfDay(ZoneId.of("Asia/Seoul")).toInstant(), + ZoneId.of("Asia/Seoul") + )); + } + + @Test + void Given_memberId_When_FindScenarios_Then_ReturnScenarios() { + //given + final Long memberId = 1L; + + final Member member = Member.builder() + .id(memberId) + .build(); + + final Notification notification1 = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + final Notification notification2 = Notification.builder() + .id(2L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + + final Scenario scenarioA = Scenario.builder() + .id(1L) + .member(member) + .scenarioName("시나리오A") + .memo("메모A") + .scenarioOrder(1) + .notification(notification1) + .build(); + final Scenario scenarioB = Scenario.builder() + .id(1L) + .member(member) + .scenarioName("시나리오B") + .memo("메모B") + .scenarioOrder(2) + .notification(notification2) + .build(); + + final List scenarioList = List.of(scenarioA, scenarioB); + + //when + Mockito + .when(scenarioRepository.findByMemberIdAndNotificationType(memberId, NotificationType.TIME)) + .thenReturn(scenarioList); + + List result = scenarioService.findScenariosByMemberId(memberId, NotificationType.TIME); + + //then + assertNotNull(result); + assertThat(result) + .hasSize(2) + .satisfies(r -> assertThat(r.get(0).scenarioName()).isEqualTo("시나리오A")) + .satisfies(r -> assertThat(r.get(1).scenarioName()).isEqualTo("시나리오B")); + } + + + @Test + void Given_validScenario_When_findScenarioByScenarioId_Then_returnResponse() { + // given + final Long memberId = 1L; + final Long scenarioId = 10L; + + final Member member = Member.builder() + .id(memberId) + .build(); + + final Notification notification = Notification.builder() + .id(100L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + + final Scenario scenario = Scenario.builder() + .id(scenarioId) + .member(member) + .scenarioName("아침 루틴") + .memo("메모") + .scenarioOrder(1) + .notification(notification) + .missions(List.of()) + .build(); + + final TimeNotificationResponse notifDetail = TimeNotificationResponse.builder() + .startHour(8) + .startMinute(30) + .build(); + + final NotificationInfoDto notifInfoDto = new NotificationInfoDto( + true, + List.of(1), + notifDetail + ); + + // mock + Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) + .thenReturn(Optional.of(scenario)); + Mockito.when(notificationService.findNotificationDetails(notification)).thenReturn(notifDetail); + Mockito.when(missionTypeGroupSorter.groupAndSortByType(scenario.getMissions(), MissionType.BASIC)) + .thenReturn(List.of()); + + // when + ScenarioDetailResponse response = scenarioService.findScenarioDetailByScenarioId(memberId, scenarioId); + + // then + assertNotNull(response); + assertThat(response) + .satisfies(r -> assertThat(r.scenarioId()).isEqualTo(scenarioId)) + .satisfies(r -> assertThat(r.notificationCondition()).isInstanceOf(TimeNotificationResponse.class)) + .satisfies(r -> { + TimeNotificationResponse detail = (TimeNotificationResponse) r.notificationCondition(); + assertThat(detail.startHour()).isEqualTo(8); + assertThat(detail.startMinute()).isEqualTo(30); + }); + } + + + @Test + void Given_notExistScenario_When_findScenarioByScenarioId_Then_throwNotFoundException() { + // given + final Long memberId = 1L; + final Long scenarioId = 99L; + + Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> scenarioService.findScenarioDetailByScenarioId(memberId, scenarioId)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.NOT_FOUND_SCENARIO.getMessage()); + } + + + @Test + void Given_otherUserScenario_When_findScenarioByScenarioId_Then_throwNotFoundException() { + // given + final Long memberId = 1L; + final Long scenarioId = 10L; + + // 다른 사용자의 시나리오는 존재하지 않음 (권한 검증으로 인해) + Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> scenarioService.findScenarioDetailByScenarioId(memberId, scenarioId)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.NOT_FOUND_SCENARIO.getMessage()); + } + + + @Test + void Given_ValidMemberAndScenario_When_AddTodayMissionToScenario_Then_InvokeMissionService() { + Long memberId = 1L; + Long scenarioId = 10L; + LocalDate date = LocalDate.now(); + + Member member = Member.builder().id(memberId).build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .member(member) + .missions(new java.util.ArrayList<>()) + .build(); + + TodayMissionRequest request = new TodayMissionRequest("Stretch"); + + Mockito.when(scenarioRepository.findTodayScenarioFetchByIdAndMemberId(memberId, scenarioId, date)) + .thenReturn(Optional.of(scenario)); + + scenarioService.addTodayMissionToScenario(memberId, scenarioId, request, date); + + verify(missionService).addTodayMission(scenario, request, date); + } + + + @Test + void Given_OtherUserScenario_When_AddTodayMissionToScenario_Then_ThrowNotFoundException() { + Long requestMemberId = 1L; + Long scenarioId = 10L; + LocalDate date = LocalDate.now(); + + TodayMissionRequest request = new TodayMissionRequest("Stretch"); + + Mockito.when(scenarioRepository.findTodayScenarioFetchByIdAndMemberId(requestMemberId, scenarioId, date)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> + scenarioService.addTodayMissionToScenario(requestMemberId, scenarioId, request, date) + ).isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.NOT_FOUND_SCENARIO.getMessage()); + } + + + @Test + void Given_ValidRequest_When_AddScenario_Then_SaveScenarioAndAddMissions() { + //given + Long memberId = 1L; + int calculatedOrder = 100000; + + Member member = Member.builder().id(memberId).build(); + given(em.getReference(Member.class, memberId)).willReturn(member); + + BasicMissionRequest mission1 = BasicMissionRequest.builder() + .content("Run") + .build(); + + BasicMissionRequest mission2 = BasicMissionRequest.builder() + .content("Read") + .build(); + + List missionList = List.of(mission1, mission2); + + NotificationRequest notifRequest = NotificationRequest.builder() + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.ALARM) + .daysOfWeekOrdinal(List.of(1, 2)) + .build(); + + TimeNotificationRequest condition = TimeNotificationRequest.builder() + .startHour(9) + .startMinute(0) + .build(); + + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("Morning") + .memo("Routine") + .basicMissions(missionList) + .notification(notifRequest) + .notificationCondition(condition) + .build(); + + Notification savedNotification = Notification.builder() + .id(10L) + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.ALARM) + .build(); + + given(notificationService.addNotification(notifRequest, condition)).willReturn(savedNotification); + + given(scenarioRepository.findOrdersByMemberIdAndNotificationType(memberId, NotificationType.TIME)) + .willReturn(List.of()); + + ArgumentCaptor scenarioCaptor = ArgumentCaptor.forClass(Scenario.class); + + Mission savedMission1 = Mission.builder() + .id(1L) + .content("Run") + .missionType(MissionType.BASIC) + .build(); + + Mission savedMission2 = Mission.builder() + .id(2L) + .content("Read") + .missionType(MissionType.BASIC) + .build(); + + List savedMissions = List.of(savedMission1, savedMission2); + List groupedBasicMissions = List.of(savedMission1, savedMission2); + + given(missionService.addBasicMission(any(Scenario.class), eq(missionList))) + .willReturn(savedMissions); + + // Mock findScenariosByMemberId to return the created scenario + Scenario createdScenario = Scenario.builder() + .id(1L) + .scenarioName("Morning") + .memo("Routine") + .scenarioOrder(calculatedOrder) + .notification(savedNotification) + .member(member) + .build(); + List expectedResponse = List.of(ScenarioResponse.from(createdScenario)); + given(scenarioRepository.findByMemberIdAndNotificationType(memberId, NotificationType.TIME)) + .willReturn(List.of(createdScenario)); + + // when + List result = scenarioService.addScenario(memberId, scenarioRequest); + + // then + verify(notificationService).addNotification(notifRequest, condition); + verify(missionService).addBasicMission(any(Scenario.class), eq(missionList)); + verify(scenarioRepository).save(scenarioCaptor.capture()); + verify(notificationEventPublisher).publishCreateEvent(eq(memberId), any(Scenario.class)); + + Scenario saved = scenarioCaptor.getValue(); + + assertThat(saved) + .satisfies(s -> assertThat(s.getScenarioName()).isEqualTo("Morning")) + .satisfies(s -> assertThat(s.getMemo()).isEqualTo("Routine")) + .satisfies(s -> assertThat(s.getScenarioOrder()).isEqualTo(calculatedOrder)) + .satisfies(s -> assertThat(s.getNotification()).isEqualTo(savedNotification)) + .satisfies(s -> assertThat(s.getMember().getId()).isEqualTo(member.getId())); + + assertThat(result) + .isNotNull() + .hasSize(1) + .satisfies(r -> assertThat(r.get(0).scenarioId()).isEqualTo(1L)) + .satisfies(r -> assertThat(r.get(0).scenarioName()).isEqualTo("Morning")) + .satisfies(r -> assertThat(r.get(0).memo()).isEqualTo("Routine")); + } + + + @Test + void Given_ReorderRequired_When_AddScenario_Then_ReorderAndRetry() { + // given + Long memberId = 1L; + int reorderedOrder = 5000; + + Member member = Member.builder().id(memberId).build(); + given(em.getReference(Member.class, memberId)).willReturn(member); + + NotificationRequest notifRequest = NotificationRequest.builder() + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.ALARM) + .daysOfWeekOrdinal(List.of(1, 2)) + .build(); + + TimeNotificationRequest condition = TimeNotificationRequest.builder() + .startHour(7) + .startMinute(0) + .build(); + + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("Evening") + .memo("Routine") + .basicMissions(List.of()) + .notification(notifRequest) + .notificationCondition(condition) + .build(); + + Notification savedNotification = Notification.builder() + .id(11L) + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.ALARM) + .build(); + + + given(notificationService.addNotification(notifRequest, condition)) + .willReturn(savedNotification); + given(scenarioRepository.findOrdersByMemberIdAndNotificationType(memberId, NotificationType.TIME)) + .willReturn(List.of(10_000_000)); + given(orderCalculator.getOrder(anyInt(), isNull())) + .willThrow(new ReorderRequiredException(10_000_000)) + .willReturn(reorderedOrder); + + Scenario s1 = Scenario.builder().id(1L).scenarioOrder(10_000_000).build(); + given(scenarioRepository.findByMemberIdAndNotificationType(memberId, NotificationType.TIME)) + .willReturn(List.of(s1)); + given(orderCalculator.getMinOrderAfterReorder(List.of(s1))) + .willReturn(reorderedOrder); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Scenario.class); + + given(missionService.addBasicMission(any(Scenario.class), eq(List.of()))) + .willReturn(List.of()); + + // Mock findScenariosByMemberId to return the created scenario + Scenario createdScenario = Scenario.builder() + .id(1L) + .scenarioName("Morning") + .memo("Routine") + .scenarioOrder(reorderedOrder) + .notification(savedNotification) + .member(member) + .build(); + given(scenarioRepository.findByMemberIdAndNotificationType(memberId, NotificationType.TIME)) + .willReturn(List.of(createdScenario)); + + // when + List result = scenarioService.addScenario(memberId, scenarioRequest); + + // then + verify(scenarioRepository).save(captor.capture()); + verify(notificationEventPublisher).publishCreateEvent(eq(memberId), any(Scenario.class)); + + Scenario saved = captor.getValue(); + assertThat(result) + .isNotNull() + .hasSize(1) + .satisfies(r -> assertThat(r.get(0).scenarioId()).isEqualTo(1L)) + .satisfies(r -> assertThat(r.get(0).scenarioName()).isEqualTo("Morning")) + .satisfies(r -> assertThat(r.get(0).memo()).isEqualTo("Routine")); + } + + @Test + void Given_PastDate_When_AddTodayMissionToScenario_Then_ThrowException() { + // given + Long memberId = 1L; + Long scenarioId = 10L; + LocalDate pastDate = LocalDate.now().minusDays(1); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .build(); + + TodayMissionRequest request = new TodayMissionRequest("Past Mission"); + + given(scenarioRepository.findTodayScenarioFetchByIdAndMemberId(memberId, scenarioId, pastDate)) + .willReturn(Optional.of(scenario)); + doThrow(new ServerException(ScenarioErrorResult.INVALID_TODAY_MISSION_DATE)) + .when(missionService).addTodayMission(scenario, request, pastDate); + + // when & then + assertThatThrownBy(() -> + scenarioService.addTodayMissionToScenario(memberId, scenarioId, request, pastDate) + ).isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.INVALID_TODAY_MISSION_DATE.getMessage()); + } + + + @Test + void Given_ValidRequest_When_UpdateScenario_Then_UpdateScenarioAndNotification() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + + Member member = Member.builder().id(memberId).build(); + Notification oldNotification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + // removed unused newNotification + + Scenario oldScenario = Scenario.builder() + .id(scenarioId) + .member(member) + .scenarioName("기존 시나리오") + .memo("기존 메모") + .notification(oldNotification) + .missions(new java.util.ArrayList<>()) + .build(); + + NotificationRequest notifRequest = NotificationRequest.builder() + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.ALARM) + .daysOfWeekOrdinal(List.of(1, 2)) + .build(); + + TimeNotificationRequest condition = TimeNotificationRequest.builder() + .startHour(9) + .startMinute(0) + .build(); + + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("수정된 시나리오") + .memo("수정된 메모") + .basicMissions(List.of()) + .notification(notifRequest) + .notificationCondition(condition) + .build(); + + Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) + .thenReturn(Optional.of(oldScenario)); + Mockito.doAnswer(invocation -> { + Notification target = invocation.getArgument(0); + target.activate(notifRequest.notificationType(), + notifRequest.notificationMethodType(), + notifRequest.daysOfWeekOrdinal()); + return null; + }).when(notificationService).updateNotification(oldNotification, notifRequest, condition); + + // Mock findScenariosByMemberId to return the updated scenario + Scenario updatedScenario = Scenario.builder() + .id(scenarioId) + .scenarioName("수정할 시나리오") + .memo("수정할 메모") + .notification(oldNotification) + .member(member) + .build(); + given(scenarioRepository.findByMemberIdAndNotificationType(memberId, NotificationType.TIME)) + .willReturn(List.of(updatedScenario)); + + // when + List result = scenarioService.updateScenario(memberId, scenarioId, scenarioRequest); + + // then + assertThat(oldScenario) + .satisfies(s -> assertThat(s.getScenarioName()).isEqualTo("수정된 시나리오")) + .satisfies(s -> assertThat(s.getMemo()).isEqualTo("수정된 메모")) + .satisfies( + s -> assertThat(s.getNotification().getNotificationType()).isEqualTo(notifRequest.notificationType())) + .satisfies(s -> assertThat(s.getNotification().getNotificationMethodType()) + .isEqualTo(notifRequest.notificationMethodType())) + .satisfies(s -> assertThat(s.getNotification().isActive()).isTrue()); + verify(notificationService).updateNotification(oldNotification, notifRequest, condition); + verify(missionService).updateBasicMission(oldScenario, List.of()); + verify(notificationEventPublisher).publishUpdateEvent(eq(memberId), eq(oldScenario), eq(true)); + + assertThat(result) + .isNotNull() + .hasSize(1) + .satisfies(r -> assertThat(r.get(0).scenarioId()).isEqualTo(scenarioId)) + .satisfies(r -> assertThat(r.get(0).scenarioName()).isEqualTo("수정할 시나리오")) + .satisfies(r -> assertThat(r.get(0).memo()).isEqualTo("수정할 메모")); + } + + + @Test + void Given_ValidRequest_When_UpdateScenarioOrder_Then_UpdateOrder() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + int newOrder = 1500; + + Member member = Member.builder().id(memberId).build(); + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .member(member) + .notification(notification) + .scenarioOrder(1000) + .build(); + + ScenarioOrderUpdateRequest orderRequest = ScenarioOrderUpdateRequest.builder() + .prevOrder(1000) + .nextOrder(2000) + .build(); + + Mockito.when(scenarioRepository.findByIdAndMemberId(scenarioId, memberId)) + .thenReturn(Optional.of(scenario)); + Mockito.when(orderCalculator.getOrder(1000, 2000)) + .thenReturn(newOrder); + + // when + OrderUpdateResponse response = scenarioService.updateScenarioOrder(memberId, scenarioId, orderRequest); + + // then + assertThat(scenario.getScenarioOrder()).isEqualTo(newOrder); + assertThat(response) + .satisfies(r -> assertThat(r.isReorder()).isFalse()) + .satisfies(r -> assertThat(r.orderUpdates()).hasSize(1)) + .satisfies(r -> assertThat(r.orderUpdates().get(0).id()).isEqualTo(scenarioId)) + .satisfies(r -> assertThat(r.orderUpdates().get(0).newOrder()).isEqualTo(newOrder)); + verify(orderCalculator).getOrder(1000, 2000); + } + + + @Test + void Given_ReorderRequired_When_UpdateScenarioOrder_Then_ReorderScenarios() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + int errorOrder = 1500; + + Member member = Member.builder().id(memberId).build(); + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .member(member) + .notification(notification) + .scenarioOrder(1000) + .build(); + + ScenarioOrderUpdateRequest orderRequest = ScenarioOrderUpdateRequest.builder() + .prevOrder(1000) + .nextOrder(1001) // 너무 가까워서 ReorderRequiredException 발생 + .build(); + + Scenario scenario1 = Scenario.builder().id(1L).scenarioOrder(1000).build(); + Scenario scenario2 = Scenario.builder().id(2L).scenarioOrder(2000).build(); + List reorderedScenarios = List.of(scenario1, scenario2); + + Mockito.when(scenarioRepository.findByIdAndMemberId(scenarioId, memberId)) + .thenReturn(Optional.of(scenario)); + Mockito.when(orderCalculator.getOrder(1000, 1001)) + .thenThrow(new ReorderRequiredException(errorOrder)); + Mockito.when(scenarioRepository.findByMemberIdAndNotificationType(memberId, NotificationType.TIME)) + .thenReturn(List.of(scenario1, scenario2)); + Mockito.when(orderCalculator.reorder(anyList(), eq(scenarioId), eq(errorOrder))) + .thenReturn(reorderedScenarios); + + // when + OrderUpdateResponse response = scenarioService.updateScenarioOrder(memberId, scenarioId, orderRequest); + + // then + assertThat(response) + .satisfies(r -> assertThat(r.isReorder()).isTrue()) + .satisfies(r -> assertThat(r.orderUpdates()).hasSize(2)); + verify(orderCalculator).reorder(anyList(), eq(scenarioId), eq(errorOrder)); + } + + @Test + void Given_ValidRequest_When_AddScenarioWithoutNotification_Then_CreateInactiveNotificationAndSave() { + // given + Long memberId = 1L; + + NotificationRequest notificationRequest = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .build(); + + ScenarioDetailRequest request = ScenarioDetailRequest.builder() + .scenarioName("시나리오") + .memo("메모") + .basicMissions(List.of()) + .notification(notificationRequest) + .notificationCondition(null) + .build(); + + given(scenarioRepository.findOrdersByMemberIdAndNotificationType(memberId, NotificationType.TIME)) + .willReturn(List.of()); + Notification saved = + Notification.builder().id(1L).isActive(true).notificationType(NotificationType.TIME).build(); + given(notificationService.addNotification(notificationRequest, null)).willReturn(saved); + + // when + scenarioService.addScenario(memberId, request); + + // then + verify(notificationService).addNotification(notificationRequest, null); + verify(scenarioRepository).save(any(Scenario.class)); + verify(missionService).addBasicMission(any(Scenario.class), eq(List.of())); + verify(notificationEventPublisher).publishCreateEvent(eq(memberId), any(Scenario.class)); + } + + + @Test + void Given_ValidRequest_When_DeleteScenarioWithAllMissions_Then_DeleteScenarioAndNotification() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + + Member member = Member.builder().id(memberId).build(); + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .member(member) + .notification(notification) + .build(); + + Mockito.when(scenarioRepository.findNotificationFetchByIdAndMemberId(memberId, scenarioId)) + .thenReturn(Optional.of(scenario)); + + // when + scenarioService.deleteScenarioWithAllMissions(memberId, scenarioId); + + // then + verify(missionService).deleteMissions(scenarioId); + verify(notificationService).deleteNotification(notification); + verify(scenarioRepository).delete(scenario); + verify(notificationEventPublisher).publishDeleteEvent(eq(memberId), eq(scenarioId), eq(true)); + } + + + @Test + void Given_NotExistScenario_When_UpdateScenario_Then_ThrowNotFoundException() { + // given + Long memberId = 1L; + Long scenarioId = 99L; + + NotificationRequest notification = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .build(); + + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("수정할 시나리오") + .memo("수정할 메모") + .basicMissions(List.of()) + .notification(notification) + .notificationCondition(null) + .build(); + + Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> scenarioService.updateScenario(memberId, scenarioId, scenarioRequest)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.NOT_FOUND_SCENARIO.getMessage()); + } + + + @Test + void Given_NotExistScenario_When_UpdateScenarioOrder_Then_ThrowNotFoundException() { + // given + Long memberId = 1L; + Long scenarioId = 99L; + + ScenarioOrderUpdateRequest orderRequest = ScenarioOrderUpdateRequest.builder() + .prevOrder(1000) + .nextOrder(2000) + .build(); + + Mockito.when(scenarioRepository.findByIdAndMemberId(scenarioId, memberId)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> scenarioService.updateScenarioOrder(memberId, scenarioId, orderRequest)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.NOT_FOUND_SCENARIO.getMessage()); + } + + + @Test + void Given_NotExistScenario_When_DeleteScenarioWithAllMissions_Then_ThrowNotFoundException() { + // given + Long memberId = 1L; + Long scenarioId = 99L; + + Mockito.when(scenarioRepository.findNotificationFetchByIdAndMemberId(memberId, scenarioId)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> scenarioService.deleteScenarioWithAllMissions(memberId, scenarioId)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.NOT_FOUND_SCENARIO.getMessage()); + } + + + @Test + void Given_ValidRequest_When_UpdateScenarioWithoutNotification_Then_UpdateScenarioAndNotification() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + + Member member = Member.builder().id(memberId).build(); + Notification oldNotification = Notification.builder() + .id(1L) + .isActive(false) + .notificationType(NotificationType.TIME) + .build(); + + Scenario oldScenario = Scenario.builder() + .id(scenarioId) + .member(member) + .scenarioName("기존 시나리오") + .memo("기존 메모") + .notification(oldNotification) + .missions(new java.util.ArrayList<>()) + .build(); + + NotificationRequest notificationRequest = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .build(); + + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("수정된 시나리오") + .memo("수정된 메모") + .basicMissions(List.of()) + .notification(notificationRequest) + .notificationCondition(null) + .build(); + + Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) + .thenReturn(Optional.of(oldScenario)); + + // Mock findScenariosByMemberId to return the updated scenario + Scenario updatedScenario = Scenario.builder() + .id(scenarioId) + .scenarioName("수정할 시나리오") + .memo("수정할 메모") + .notification(oldNotification) + .member(member) + .build(); + given(scenarioRepository.findByMemberIdAndNotificationType(memberId, NotificationType.TIME)) + .willReturn(List.of(updatedScenario)); + + // when + List result = scenarioService.updateScenario(memberId, scenarioId, scenarioRequest); + + // then + assertThat(oldScenario) + .satisfies(s -> assertThat(s.getScenarioName()).isEqualTo("수정된 시나리오")) + .satisfies(s -> assertThat(s.getMemo()).isEqualTo("수정된 메모")); + verify(notificationService).updateNotification(oldNotification, notificationRequest, null); + verify(missionService).updateBasicMission(oldScenario, List.of()); + verify(notificationEventPublisher).publishUpdateEvent(eq(memberId), eq(oldScenario), eq(false)); + + assertThat(result) + .isNotNull() + .hasSize(1) + .satisfies(r -> assertThat(r.get(0).scenarioId()).isEqualTo(scenarioId)) + .satisfies(r -> assertThat(r.get(0).scenarioName()).isEqualTo("수정할 시나리오")) + .satisfies(r -> assertThat(r.get(0).memo()).isEqualTo("수정할 메모")); + } + + + @Test + void Given_NotExistScenario_When_UpdateScenarioWithoutNotification_Then_ThrowNotFoundException() { + // given + Long memberId = 1L; + Long scenarioId = 99L; + + NotificationRequest notificationRequest = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .build(); + + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("수정할 시나리오") + .memo("수정할 메모") + .basicMissions(List.of()) + .notification(notificationRequest) + .notificationCondition(null) + .build(); + + Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> + scenarioService.updateScenario(memberId, scenarioId, scenarioRequest)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.NOT_FOUND_SCENARIO.getMessage()); + } + + + @Test + void Given_MaxScenarioCountExceeded_When_AddScenario_Then_ThrowMaxCountExceededException() { + // given + Long memberId = 1L; + + NotificationRequest notifRequest = NotificationRequest.builder() + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.ALARM) + .daysOfWeekOrdinal(List.of(1, 2)) + .build(); + + TimeNotificationRequest condition = TimeNotificationRequest.builder() + .startHour(9) + .startMinute(0) + .build(); + + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("New Scenario") + .memo("New Memo") + .basicMissions(List.of()) + .notification(notifRequest) + .notificationCondition(condition) + .build(); + + // 20개의 시나리오가 이미 존재 (최대 개수) + List orderList = new java.util.ArrayList<>(); + for (int i = 0; i < 20; i++) { + orderList.add(1000 + i * 1000); + } + + given(scenarioRepository.findOrdersByMemberIdAndNotificationType(memberId, NotificationType.TIME)) + .willReturn(orderList); + doThrow(new ServerException(ScenarioErrorResult.MAX_SCENARIO_COUNT_EXCEEDED)) + .when(scenarioValidator).validateMaxScenarioCount(orderList); + + // when & then + assertThatThrownBy(() -> scenarioService.addScenario(memberId, scenarioRequest)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.MAX_SCENARIO_COUNT_EXCEEDED.getMessage()); + } + + + @Test + void Given_MaxScenarioCountExceeded_When_AddScenarioWithoutNotification_Then_ThrowMaxCountExceededException() { + // given + Long memberId = 1L; + + NotificationRequest notificationRequest = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .build(); + + ScenarioDetailRequest request = ScenarioDetailRequest.builder() + .scenarioName("시나리오") + .memo("메모") + .basicMissions(List.of()) + .notification(notificationRequest) + .notificationCondition(null) + .build(); + + // 20개의 시나리오가 이미 존재 (최대 개수) + List orderList = new java.util.ArrayList<>(); + for (int i = 0; i < 20; i++) { + orderList.add(1000 + i * 1000); + } + + given(scenarioRepository.findOrdersByMemberIdAndNotificationType(memberId, NotificationType.TIME)) + .willReturn(orderList); + doThrow(new ServerException(ScenarioErrorResult.MAX_SCENARIO_COUNT_EXCEEDED)) + .when(scenarioValidator).validateMaxScenarioCount(orderList); + + // when & then + assertThatThrownBy(() -> scenarioService.addScenario(memberId, request)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.MAX_SCENARIO_COUNT_EXCEEDED.getMessage()); + } + + + @Test + void Given_notificationInfoIsNull_When_findScenarioByScenarioId_Then_returnResponseWithNullCondition() { + // given + final Long memberId = 1L; + final Long scenarioId = 10L; + + final Member member = Member.builder() + .id(memberId) + .build(); + + final Notification notification = Notification.builder() + .id(100L) + .isActive(false) + .notificationType(NotificationType.TIME) + .build(); + + final Scenario scenario = Scenario.builder() + .id(scenarioId) + .member(member) + .scenarioName("알림 없는 루틴") + .memo("메모") + .scenarioOrder(1) + .notification(notification) + .missions(List.of()) + .build(); + + // mock - notificationInfo가 null인 경우 + Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) + .thenReturn(Optional.of(scenario)); + Mockito.when(notificationService.findNotificationDetails(notification)).thenReturn(null); + Mockito.when(missionTypeGroupSorter.groupAndSortByType(scenario.getMissions(), MissionType.BASIC)) + .thenReturn(List.of()); + + // when + ScenarioDetailResponse response = scenarioService.findScenarioDetailByScenarioId(memberId, scenarioId); + + // then + assertNotNull(response); + assertThat(response) + .satisfies(r -> assertThat(r.scenarioId()).isEqualTo(scenarioId)) + .satisfies(r -> assertThat(r.notification().isEveryDay()).isNull()) + .satisfies(r -> assertThat(r.notification().daysOfWeekOrdinal()).isNull()) + .satisfies(r -> assertThat(r.notificationCondition()).isNull()); + } + + + @Test + void Given_EmptyOrderList_When_AddScenario_Then_CreateScenarioWithStartOrder() { + // given + Long memberId = 1L; + + Member member = Member.builder().id(memberId).build(); + given(em.getReference(Member.class, memberId)).willReturn(member); + + NotificationRequest notifRequest = NotificationRequest.builder() + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.ALARM) + .daysOfWeekOrdinal(List.of(1, 2)) + .build(); + + TimeNotificationRequest condition = TimeNotificationRequest.builder() + .startHour(9) + .startMinute(0) + .build(); + + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("First Scenario") + .memo("First Memo") + .basicMissions(List.of()) + .notification(notifRequest) + .notificationCondition(condition) + .build(); + + Notification savedNotification = Notification.builder() + .id(10L) + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.ALARM) + .build(); + + given(notificationService.addNotification(notifRequest, condition)).willReturn(savedNotification); + // 빈 리스트 반환 - 첫 번째 시나리오 + given(scenarioRepository.findOrdersByMemberIdAndNotificationType(memberId, NotificationType.TIME)) + .willReturn(List.of()); + + ArgumentCaptor scenarioCaptor = ArgumentCaptor.forClass(Scenario.class); + + // when + scenarioService.addScenario(memberId, scenarioRequest); + + // then + verify(scenarioRepository).save(scenarioCaptor.capture()); + verify(notificationEventPublisher).publishCreateEvent(eq(memberId), any(Scenario.class)); + + Scenario saved = scenarioCaptor.getValue(); + assertThat(saved.getScenarioOrder()).isEqualTo(OrderCalculator.START_ORDER); + } + + + @Test + void Given_FutureDate_When_AddTodayMissionToScenario_Then_InvokeMissionService() { + // given + Long memberId = 1L; + Long scenarioId = 10L; + LocalDate futureDate = LocalDate.now().plusDays(1); + + Member member = Member.builder().id(memberId).build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .member(member) + .missions(new java.util.ArrayList<>()) + .build(); + + TodayMissionRequest request = new TodayMissionRequest("Future Mission"); + + Mockito.when(scenarioRepository.findTodayScenarioFetchByIdAndMemberId(memberId, scenarioId, futureDate)) + .thenReturn(Optional.of(scenario)); + + // when + scenarioService.addTodayMissionToScenario(memberId, scenarioId, request, futureDate); + + // then + verify(missionService).addTodayMission(scenario, request, futureDate); + } + + + @Test + void Given_EmptyScenarioList_When_FindScenariosByMemberId_Then_ReturnEmptyList() { + // given + final Long memberId = 1L; + + Mockito + .when(scenarioRepository.findByMemberIdAndNotificationType(memberId, NotificationType.TIME)) + .thenReturn(List.of()); + + // when + List result = scenarioService.findScenariosByMemberId(memberId, NotificationType.TIME); + + // then + assertNotNull(result); + assertThat(result).isEmpty(); + } + +} diff --git a/src/test/java/com/und/server/scenario/util/MissionTypeGrouperTest.java b/src/test/java/com/und/server/scenario/util/MissionTypeGrouperTest.java new file mode 100644 index 00000000..834ed1d1 --- /dev/null +++ b/src/test/java/com/und/server/scenario/util/MissionTypeGrouperTest.java @@ -0,0 +1,98 @@ +package com.und.server.scenario.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import com.und.server.common.exception.ServerException; +import com.und.server.scenario.constants.MissionType; +import com.und.server.scenario.entity.Mission; +import com.und.server.scenario.exception.ScenarioErrorResult; + +@ExtendWith(MockitoExtension.class) +class MissionTypeGrouperTest { + + @InjectMocks + private MissionTypeGroupSorter grouper; + + + @BeforeEach + void setUp() { + grouper = new MissionTypeGroupSorter(); + } + + @Test + void Given_BasicMissions_When_GroupAndSort_Then_ReturnSortedList() { + // given + Mission m1 = Mission.builder().missionOrder(2).missionType(MissionType.BASIC).build(); + Mission m2 = Mission.builder().missionOrder(1).missionType(MissionType.BASIC).build(); + Mission m3 = Mission.builder().missionOrder(null).missionType(MissionType.TODAY).build(); + List input = List.of(m1, m2, m3); + + // when + List result = grouper.groupAndSortByType(input, MissionType.BASIC); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).getMissionOrder()).isEqualTo(1); + assertThat(result.get(1).getMissionOrder()).isEqualTo(2); + } + + + @Test + void Given_TodayMissions_When_GroupAndSort_Then_ReturnReverseSortedList() { + // given + LocalDateTime now = LocalDateTime.of(2024, 1, 15, 12, 0); + + Mission m1 = Mission.builder() + .missionOrder(null) + .missionType(MissionType.TODAY) + .build(); + ReflectionTestUtils.setField(m1, "createdAt", now.minusDays(1)); + + Mission m2 = Mission.builder() + .missionOrder(null) + .missionType(MissionType.TODAY) + .build(); + ReflectionTestUtils.setField(m2, "createdAt", now); + + Mission m3 = Mission.builder() + .missionOrder(2) + .missionType(MissionType.BASIC) + .build(); + + List input = List.of(m1, m2, m3); + + // when + List result = grouper.groupAndSortByType(input, MissionType.TODAY); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).getCreatedAt()).isEqualTo(now); + assertThat(result.get(1).getCreatedAt()).isEqualTo(now.minusDays(1)); + } + + + @Test + void Given_UnsupportedType_When_GroupAndSort_Then_ThrowException() { + // given + Mission invalidMission = Mission.builder().missionOrder(1).missionType(null).build(); + List input = List.of(invalidMission); + + // then + assertThatThrownBy(() -> + grouper.groupAndSortByType(input, null) + ).isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.UNSUPPORTED_MISSION_TYPE.getMessage()); + } + +} diff --git a/src/test/java/com/und/server/scenario/util/MissionValidatorTest.java b/src/test/java/com/und/server/scenario/util/MissionValidatorTest.java new file mode 100644 index 00000000..42ac84f9 --- /dev/null +++ b/src/test/java/com/und/server/scenario/util/MissionValidatorTest.java @@ -0,0 +1,129 @@ +package com.und.server.scenario.util; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.common.exception.ServerException; +import com.und.server.scenario.entity.Mission; +import com.und.server.scenario.exception.ScenarioErrorResult; + +@ExtendWith(MockitoExtension.class) +class MissionValidatorTest { + + @InjectMocks + private MissionValidator missionValidator; + + @Test + void Given_TodayDate_When_ValidateTodayMissionDateRange_Then_NoException() { + // given + LocalDate today = LocalDate.of(2024, 1, 15); + LocalDate requestDate = LocalDate.of(2024, 1, 15); + + // when & then + assertDoesNotThrow(() -> missionValidator.validateTodayMissionDateRange(today, requestDate)); + } + + @Test + void Given_FutureDate_When_ValidateTodayMissionDateRange_Then_NoException() { + // given + LocalDate today = LocalDate.of(2024, 1, 15); + LocalDate futureDate = LocalDate.of(2024, 1, 16); + + // when & then + assertDoesNotThrow(() -> missionValidator.validateTodayMissionDateRange(today, futureDate)); + } + + @Test + void Given_PastDate_When_ValidateTodayMissionDateRange_Then_ThrowException() { + // given + LocalDate today = LocalDate.of(2024, 1, 15); + LocalDate pastDate = LocalDate.of(2024, 1, 14); + + // when & then + assertThatThrownBy(() -> missionValidator.validateTodayMissionDateRange(today, pastDate)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.INVALID_TODAY_MISSION_DATE.getMessage()); + } + + + @Test + void Given_BasicMissionListBelowMaxCount_When_ValidateMaxBasicMissionCount_Then_NoException() { + // given + List missionList = List.of( + Mission.builder().build(), + Mission.builder().build(), + Mission.builder().build() + ); // 3개 (20개 미만) + + // when & then + assertDoesNotThrow(() -> missionValidator.validateMaxBasicMissionCount(missionList)); + } + + @Test + void Given_BasicMissionListAtMaxCount_When_ValidateMaxBasicMissionCount_Then_ThrowException() { + // given + List missionList = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + missionList.add(Mission.builder().build()); + } // 20개 (최대값) + + // when & then + assertThatThrownBy(() -> missionValidator.validateMaxBasicMissionCount(missionList)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.MAX_MISSION_COUNT_EXCEEDED.getMessage()); + } + + @Test + void Given_TodayMissionListBelowMaxCount_When_ValidateMaxTodayMissionCount_Then_NoException() { + // given + List missionList = List.of( + Mission.builder().build(), + Mission.builder().build() + ); // 2개 (20개 미만) + + // when & then + assertDoesNotThrow(() -> missionValidator.validateMaxTodayMissionCount(missionList)); + } + + @Test + void Given_TodayMissionListAtMaxCount_When_ValidateMaxTodayMissionCount_Then_ThrowException() { + // given + List missionList = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + missionList.add(Mission.builder().build()); + } // 20개 (최대값) + + // when & then + assertThatThrownBy(() -> missionValidator.validateMaxTodayMissionCount(missionList)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.MAX_MISSION_COUNT_EXCEEDED.getMessage()); + } + + @Test + void Given_EmptyMissionList_When_ValidateMaxBasicMissionCount_Then_NoException() { + // given + List missionList = List.of(); + + // when & then + assertDoesNotThrow(() -> missionValidator.validateMaxBasicMissionCount(missionList)); + } + + @Test + void Given_EmptyMissionList_When_ValidateMaxTodayMissionCount_Then_NoException() { + // given + List missionList = List.of(); + + // when & then + assertDoesNotThrow(() -> missionValidator.validateMaxTodayMissionCount(missionList)); + } + +} diff --git a/src/test/java/com/und/server/scenario/util/OrderCalculatorTest.java b/src/test/java/com/und/server/scenario/util/OrderCalculatorTest.java new file mode 100644 index 00000000..32bb4461 --- /dev/null +++ b/src/test/java/com/und/server/scenario/util/OrderCalculatorTest.java @@ -0,0 +1,128 @@ +package com.und.server.scenario.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.scenario.entity.Scenario; +import com.und.server.scenario.exception.ReorderRequiredException; + +@ExtendWith(MockitoExtension.class) +class OrderCalculatorTest { + + @InjectMocks + private OrderCalculator orderCalculator; + + + @Test + void Given_NullPrevAndNextOrder_When_GetOrder_Then_ReturnStartOrder() { + int result = orderCalculator.getOrder(null, null); + assertThat(result).isEqualTo(OrderCalculator.START_ORDER); + } + + @Test + void Given_NullPrevOrder_When_GetOrder_Then_ReturnStartOrderBeforeNext() { + int result = orderCalculator.getOrder(null, 3000); + assertThat(result).isEqualTo(2000); + } + + @Test + void Given_NullNextOrder_When_GetOrder_Then_ReturnLastOrderAfterPrev() { + int result = orderCalculator.getOrder(3000, null); + assertThat(result).isEqualTo(4000); + } + + @Test + void Given_ValidPrevAndNextOrder_When_GetOrder_Then_ReturnMiddleOrder() { + int result = orderCalculator.getOrder(2000, 4000); + assertThat(result).isEqualTo(3000); + } + + @Test + void Given_SmallGapPrevAndNextOrder_When_GetOrder_Then_ThrowReorderRequiredException() { + assertThatThrownBy(() -> orderCalculator.getOrder(1000, 1050)) + .isInstanceOf(ReorderRequiredException.class); + } + + @Test + void Given_ResultOutOfRangeOrder_When_GetOrder_Then_ThrowReorderRequiredException() { + assertThatThrownBy(() -> orderCalculator.getOrder(10_000_000, null)) + .isInstanceOf(ReorderRequiredException.class); + } + + @Test + void Given_ScenariosAndTargetId_When_Reorder_Then_ReturnReorderedScenarios() { + // given + Scenario scenario1 = Scenario.builder() + .id(1L) + .scenarioOrder(1000) + .missions(new java.util.ArrayList<>()) + .build(); + Scenario scenario2 = Scenario.builder() + .id(2L) + .scenarioOrder(2000) + .missions(new java.util.ArrayList<>()) + .build(); + Scenario scenario3 = Scenario.builder() + .id(3L) + .scenarioOrder(3000) + .missions(new java.util.ArrayList<>()) + .build(); + List scenarios = new java.util.ArrayList<>(List.of(scenario1, scenario2, scenario3)); + + Long targetScenarioId = 2L; + int errorOrder = 1500; + + // when + List result = orderCalculator.reorder(scenarios, targetScenarioId, errorOrder); + + // then + assertThat(result).hasSize(3); + assertThat(result.get(0).getScenarioOrder()).isEqualTo(OrderCalculator.START_ORDER); + assertThat(result.get(1).getScenarioOrder()).isEqualTo( + OrderCalculator.START_ORDER + OrderCalculator.DEFAULT_ORDER); + assertThat(result.get(2).getScenarioOrder()).isEqualTo( + OrderCalculator.START_ORDER + 2 * OrderCalculator.DEFAULT_ORDER); + } + + @Test + void Given_EmptyScenarioList_When_GetMinOrderAfterReorder_Then_ReturnStartOrder() { + // given + List emptyScenarios = List.of(); + + // when + Integer result = orderCalculator.getMinOrderAfterReorder(emptyScenarios); + + // then + assertThat(result).isEqualTo(OrderCalculator.START_ORDER); + } + + @Test + void Given_ScenarioList_When_GetMinOrderAfterReorder_Then_ReturnMinOrderMinusDefault() { + // given + Scenario scenario1 = Scenario.builder() + .id(1L) + .scenarioOrder(1000) + .missions(new java.util.ArrayList<>()) + .build(); + Scenario scenario2 = Scenario.builder() + .id(2L) + .scenarioOrder(3000) + .missions(new java.util.ArrayList<>()) + .build(); + List scenarios = new java.util.ArrayList<>(List.of(scenario1, scenario2)); + + // when + Integer result = orderCalculator.getMinOrderAfterReorder(scenarios); + + // then + assertThat(result).isEqualTo(OrderCalculator.START_ORDER - OrderCalculator.DEFAULT_ORDER); + } + +} diff --git a/src/test/java/com/und/server/scenario/util/ScenarioValidatorTest.java b/src/test/java/com/und/server/scenario/util/ScenarioValidatorTest.java new file mode 100644 index 00000000..ba0f39ed --- /dev/null +++ b/src/test/java/com/und/server/scenario/util/ScenarioValidatorTest.java @@ -0,0 +1,82 @@ +package com.und.server.scenario.util; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.BDDMockito.given; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.common.exception.ServerException; +import com.und.server.scenario.exception.ScenarioErrorResult; +import com.und.server.scenario.repository.ScenarioRepository; + +@ExtendWith(MockitoExtension.class) +class ScenarioValidatorTest { + + @Mock + private ScenarioRepository scenarioRepository; + + @InjectMocks + private ScenarioValidator scenarioValidator; + + @Test + void Given_ExistingScenarioId_When_ValidateScenarioExists_Then_NoException() { + // given + Long scenarioId = 1L; + given(scenarioRepository.existsById(scenarioId)).willReturn(true); + + // when & then + assertDoesNotThrow(() -> scenarioValidator.validateScenarioExists(scenarioId)); + } + + @Test + void Given_NonExistingScenarioId_When_ValidateScenarioExists_Then_ThrowException() { + // given + Long scenarioId = 99L; + given(scenarioRepository.existsById(scenarioId)).willReturn(false); + + // when & then + assertThatThrownBy(() -> scenarioValidator.validateScenarioExists(scenarioId)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.NOT_FOUND_SCENARIO.getMessage()); + } + + @Test + void Given_OrderListBelowMaxCount_When_ValidateMaxScenarioCount_Then_NoException() { + // given + List orderList = List.of(1000, 2000, 3000); // 3개 (20개 미만) + + // when & then + assertDoesNotThrow(() -> scenarioValidator.validateMaxScenarioCount(orderList)); + } + + @Test + void Given_OrderListAtMaxCount_When_ValidateMaxScenarioCount_Then_ThrowException() { + // given + List orderList = List.of( + 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000, + 11000, 12000, 13000, 14000, 15000, 16000, 17000, 18000, 19000, 20000 + ); // 20개 (최대값) + + // when & then + assertThatThrownBy(() -> scenarioValidator.validateMaxScenarioCount(orderList)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.MAX_SCENARIO_COUNT_EXCEEDED.getMessage()); + } + + @Test + void Given_EmptyOrderList_When_ValidateMaxScenarioCount_Then_NoException() { + // given + List orderList = List.of(); + + // when & then + assertDoesNotThrow(() -> scenarioValidator.validateMaxScenarioCount(orderList)); + } + +} diff --git a/src/test/java/com/und/server/service/AuthServiceTest.java b/src/test/java/com/und/server/service/AuthServiceTest.java deleted file mode 100644 index 1abbb2e9..00000000 --- a/src/test/java/com/und/server/service/AuthServiceTest.java +++ /dev/null @@ -1,294 +0,0 @@ -package com.und.server.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; - -import java.util.Optional; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.und.server.dto.AuthRequest; -import com.und.server.dto.AuthResponse; -import com.und.server.dto.HandshakeRequest; -import com.und.server.dto.HandshakeResponse; -import com.und.server.dto.OidcPublicKeys; -import com.und.server.dto.RefreshTokenRequest; -import com.und.server.dto.TestAuthRequest; -import com.und.server.entity.Member; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; -import com.und.server.jwt.JwtProperties; -import com.und.server.jwt.JwtProvider; -import com.und.server.oauth.IdTokenPayload; -import com.und.server.oauth.OidcClient; -import com.und.server.oauth.OidcClientFactory; -import com.und.server.oauth.OidcProviderFactory; -import com.und.server.oauth.Provider; -import com.und.server.repository.MemberRepository; - -@ExtendWith(MockitoExtension.class) -class AuthServiceTest { - - @InjectMocks - private AuthService authService; - - @Mock - private MemberRepository memberRepository; - - @Mock - private OidcClientFactory oidcClientFactory; - - @Mock - private OidcProviderFactory oidcProviderFactory; - - @Mock - private JwtProvider jwtProvider; - - @Mock - private JwtProperties jwtProperties; - - @Mock - private RefreshTokenService refreshTokenService; - - @Mock - private NonceService nonceService; - - private final String providerId = "dummyId"; - private final String nickname = "dummyNickname"; - private final Long memberId = 1L; - private final String idToken = "dummy.id.token"; - private final String accessToken = "dummy.access.token"; - private final String refreshToken = "dummy.refresh.token"; - private final Integer accessTokenExpireTime = 3600; - private final Integer refreshTokenExpireTime = 7200; - - // FIXME: Remove this test method when deleting TestController - @Test - @DisplayName("Issues tokens for an existing member for testing purposes") - void Given_ExistingMemberForTest_When_IssueTokensForTest_Then_Succeeds() { - // given - final TestAuthRequest request = new TestAuthRequest("kakao", providerId, nickname); - final Member existingMember = Member.builder().id(memberId).kakaoId(providerId).nickname(nickname).build(); - - doReturn(Optional.of(existingMember)).when(memberRepository).findByKakaoId(providerId); - setupTokenIssuance(accessToken, refreshToken); - - // when - final AuthResponse response = authService.issueTokensForTest(request); - - // then - verify(memberRepository).findByKakaoId(providerId); - verify(memberRepository, never()).save(any(Member.class)); - verify(refreshTokenService).saveRefreshToken(memberId, refreshToken); - assertThat(response.accessToken()).isEqualTo(accessToken); - assertThat(response.refreshToken()).isEqualTo(refreshToken); - } - - // FIXME: Remove this test method when deleting TestController - @Test - @DisplayName("Creates a new member and issues tokens for testing purposes") - void Given_NewMemberForTest_When_IssueTokensForTest_Then_CreatesMemberAndSucceeds() { - // given - final TestAuthRequest request = new TestAuthRequest("kakao", providerId, nickname); - final Member newMember = Member.builder().id(memberId).kakaoId(providerId).nickname(nickname).build(); - - doReturn(Optional.empty()).when(memberRepository).findByKakaoId(providerId); - doReturn(newMember).when(memberRepository).save(any(Member.class)); - setupTokenIssuance(accessToken, refreshToken); - - // when - final AuthResponse response = authService.issueTokensForTest(request); - - // then - verify(memberRepository).findByKakaoId(providerId); - verify(memberRepository).save(any(Member.class)); - verify(refreshTokenService).saveRefreshToken(memberId, refreshToken); - assertThat(response.accessToken()).isEqualTo(accessToken); - assertThat(response.refreshToken()).isEqualTo(refreshToken); - } - - @Test - @DisplayName("Throws an exception on handshake with an invalid provider") - void Given_InvalidProvider_When_Handshake_Then_ThrowsException() { - // given - final HandshakeRequest handshakeRequest = new HandshakeRequest("facebook"); - - // when & then - final ServerException exception = assertThrows(ServerException.class, - () -> authService.handshake(handshakeRequest)); - - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_PROVIDER); - } - - @Test - @DisplayName("Returns a nonce on a successful handshake") - void Given_ValidProvider_When_Handshake_Then_ReturnsNonce() { - // given - final String nonce = "generated-nonce"; - final String providerName = "kakao"; - final HandshakeRequest handshakeRequest = new HandshakeRequest(providerName); - - doReturn(nonce).when(nonceService).generateNonceValue(); - doNothing().when(nonceService).saveNonce(nonce, Provider.KAKAO); - - // when - final HandshakeResponse response = authService.handshake(handshakeRequest); - - // then - verify(nonceService).generateNonceValue(); - verify(nonceService).saveNonce(nonce, Provider.KAKAO); - assertThat(response.nonce()).isEqualTo(nonce); - } - - @Test - @DisplayName("Throws an exception on login with an invalid provider") - void Given_InvalidProvider_When_Login_Then_ThrowsException() { - // given - final AuthRequest authRequest = new AuthRequest("facebook", idToken); - - // when & then - final ServerException exception = assertThrows(ServerException.class, - () -> authService.login(authRequest)); - - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_PROVIDER); - } - - @Test - @DisplayName("Issues tokens successfully when a registered member logs in") - void Given_RegisteredMember_When_Login_Then_IssuesTokensSuccessfully() { - // given - final AuthRequest authRequest = new AuthRequest("kakao", idToken); - final OidcClient oidcClient = mock(OidcClient.class); - final OidcPublicKeys keys = mock(OidcPublicKeys.class); - final IdTokenPayload payload = new IdTokenPayload(providerId, nickname); - final Member member = Member.builder().id(memberId).kakaoId(providerId).build(); - - doReturn("nonce").when(jwtProvider).extractNonce(idToken); - doNothing().when(nonceService).validateNonce("nonce", Provider.KAKAO); - doReturn(oidcClient).when(oidcClientFactory).getOidcClient(Provider.KAKAO); - doReturn(keys).when(oidcClient).getOidcPublicKeys(); - doReturn(payload).when(oidcProviderFactory).getIdTokenPayload(Provider.KAKAO, idToken, keys); - doReturn(Optional.of(member)).when(memberRepository).findByKakaoId(providerId); - setupTokenIssuance(accessToken, refreshToken); - - // when - final AuthResponse response = authService.login(authRequest); - - // then - verify(nonceService).validateNonce("nonce", Provider.KAKAO); - verify(memberRepository, never()).save(any(Member.class)); - verify(refreshTokenService).saveRefreshToken(memberId, refreshToken); - assertThat(response.tokenType()).isEqualTo("Bearer"); - assertThat(response.accessToken()).isEqualTo(accessToken); - assertThat(response.refreshToken()).isEqualTo(refreshToken); - assertThat(response.accessTokenExpiresIn()).isEqualTo(accessTokenExpireTime); - assertThat(response.refreshTokenExpiresIn()).isEqualTo(refreshTokenExpireTime); - } - - @Test - @DisplayName("Creates a new member and issues tokens on the first login") - void Given_NewMember_When_Login_Then_CreatesMemberAndIssuesTokens() { - // given - final AuthRequest authRequest = new AuthRequest("kakao", idToken); - final OidcClient oidcClient = mock(OidcClient.class); - final OidcPublicKeys keys = mock(OidcPublicKeys.class); - final IdTokenPayload payload = new IdTokenPayload(providerId, nickname); - final Member newMember = Member.builder().id(memberId).kakaoId(providerId).build(); - - doReturn("nonce").when(jwtProvider).extractNonce(idToken); - doReturn(oidcClient).when(oidcClientFactory).getOidcClient(Provider.KAKAO); - doReturn(keys).when(oidcClient).getOidcPublicKeys(); - doReturn(payload).when(oidcProviderFactory).getIdTokenPayload(Provider.KAKAO, idToken, keys); - doReturn(Optional.empty()).when(memberRepository).findByKakaoId(providerId); - doReturn(newMember).when(memberRepository).save(any(Member.class)); - setupTokenIssuance(accessToken, refreshToken); - - // when - final AuthResponse response = authService.login(authRequest); - - // then - verify(nonceService).validateNonce("nonce", Provider.KAKAO); - verify(memberRepository).save(any(Member.class)); - verify(refreshTokenService).saveRefreshToken(memberId, refreshToken); - assertThat(response.accessToken()).isEqualTo(accessToken); - assertThat(response.refreshToken()).isEqualTo(refreshToken); - } - - @Test - @DisplayName("Throws an exception when reissuing tokens with a mismatched refresh token") - void Given_MismatchedRefreshToken_When_ReissueTokens_Then_ThrowsException() { - // given - final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, "wrong.refresh.token"); - - doReturn(memberId).when(jwtProvider).getMemberIdFromExpiredAccessToken(accessToken); - doReturn(refreshToken).when(refreshTokenService).getRefreshToken(memberId); - - // when & then - final ServerException exception = assertThrows(ServerException.class, - () -> authService.reissueTokens(request)); - - verify(refreshTokenService).deleteRefreshToken(memberId); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); - } - - @Test - @DisplayName("Throws an exception on token reissue if no refresh token is stored") - void Given_NoStoredRefreshToken_When_ReissueTokens_Then_ThrowsException() { - // given - final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); - - doReturn(memberId).when(jwtProvider).getMemberIdFromExpiredAccessToken(accessToken); - doReturn(null).when(refreshTokenService).getRefreshToken(memberId); - - // when & then - final ServerException exception = assertThrows(ServerException.class, - () -> authService.reissueTokens(request)); - - verify(refreshTokenService).deleteRefreshToken(memberId); - verify(jwtProvider, never()).generateAccessToken(any()); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); - } - - @Test - @DisplayName("Reissues tokens successfully with a valid refresh token") - void Given_ValidRefreshToken_When_ReissueTokens_Then_Succeeds() { - // given - final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); - final String newAccessToken = "new-access-token"; - final String newRefreshToken = "new-refresh-token"; - - doReturn(memberId).when(jwtProvider).getMemberIdFromExpiredAccessToken(accessToken); - doReturn(refreshToken).when(refreshTokenService).getRefreshToken(memberId); - setupTokenIssuance(newAccessToken, newRefreshToken); - - // when - final AuthResponse response = authService.reissueTokens(request); - - // then - verify(refreshTokenService).saveRefreshToken(memberId, newRefreshToken); - assertThat(response.accessToken()).isEqualTo(newAccessToken); - assertThat(response.refreshToken()).isEqualTo(newRefreshToken); - assertThat(response.accessTokenExpiresIn()).isEqualTo(accessTokenExpireTime); - assertThat(response.refreshTokenExpiresIn()).isEqualTo(refreshTokenExpireTime); - } - - private void setupTokenIssuance(final String newAccessToken, final String newRefreshToken) { - doReturn(newAccessToken).when(jwtProvider).generateAccessToken(memberId); - doReturn(newRefreshToken).when(refreshTokenService).generateRefreshToken(); - doReturn("Bearer").when(jwtProperties).type(); - doReturn(accessTokenExpireTime).when(jwtProperties).accessTokenExpireTime(); - doReturn(refreshTokenExpireTime).when(jwtProperties).refreshTokenExpireTime(); - } - -} diff --git a/src/test/java/com/und/server/service/NonceServiceTest.java b/src/test/java/com/und/server/service/NonceServiceTest.java deleted file mode 100644 index 86e87049..00000000 --- a/src/test/java/com/und/server/service/NonceServiceTest.java +++ /dev/null @@ -1,126 +0,0 @@ -package com.und.server.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.*; - -import java.util.Optional; -import java.util.UUID; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.und.server.entity.Nonce; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; -import com.und.server.oauth.Provider; -import com.und.server.repository.NonceRepository; - -@ExtendWith(MockitoExtension.class) -class NonceServiceTest { - - @Mock - private NonceRepository nonceRepository; - - @InjectMocks - private NonceService nonceService; - - @Test - @DisplayName("Generates a UUID-formatted nonce value") - void Given_Nothing_When_GenerateNonceValue_Then_ReturnsUuidString() { - // when - final String nonce = nonceService.generateNonceValue(); - - // then - assertThat(nonce).isNotNull(); - assertThat(nonce).hasSize(36); // UUID format - } - - @Test - @DisplayName("Saves a nonce successfully") - void Given_NonceValueAndProvider_When_SaveNonce_Then_RepositorySaveIsCalled() { - // given - final String nonceValue = UUID.randomUUID().toString(); - final Provider provider = Provider.KAKAO; - - // when - nonceService.saveNonce(nonceValue, provider); - - // then - verify(nonceRepository).save(any(Nonce.class)); - } - - @Test - @DisplayName("Throws an exception when validating a non-existent nonce") - void Given_NonceNotInRepository_When_ValidateNonce_Then_ThrowsInvalidNonceException() { - // given - final String nonceValue = UUID.randomUUID().toString(); - final Provider provider = Provider.KAKAO; - - doReturn(Optional.empty()).when(nonceRepository).findById(nonceValue); - - // when & then - assertThatThrownBy(() -> nonceService.validateNonce(nonceValue, provider)) - .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_NONCE); - verify(nonceRepository, never()).deleteById(anyString()); - } - - @Test - @DisplayName("Throws an exception when the provider does not match") - void Given_NonceWithMismatchedProvider_When_ValidateNonce_Then_ThrowsInvalidNonceException() { - // given - final String nonceValue = UUID.randomUUID().toString(); - final Provider requestProvider = Provider.KAKAO; - final Nonce storedNonce = Nonce.builder() - .value(nonceValue) - .provider(null) // Stored nonce has a different (null) provider - .build(); - - doReturn(Optional.of(storedNonce)).when(nonceRepository).findById(nonceValue); - - // when & then - assertThatThrownBy(() -> nonceService.validateNonce(nonceValue, requestProvider)) - .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_NONCE); - verify(nonceRepository, never()).deleteById(anyString()); - } - - @Test - @DisplayName("Validates a nonce successfully and deletes it") - void Given_ValidNonceInRepository_When_ValidateNonce_Then_DeletesNonce() { - // given - final String nonceValue = UUID.randomUUID().toString(); - final Provider provider = Provider.KAKAO; - final Nonce nonce = Nonce.builder() - .value(nonceValue) - .provider(provider) - .build(); - - doReturn(Optional.of(nonce)).when(nonceRepository).findById(nonceValue); - - // when - nonceService.validateNonce(nonceValue, provider); - - // then - verify(nonceRepository).deleteById(nonceValue); - } - - @Test - @DisplayName("Deletes a nonce successfully") - void Given_NonceValue_When_DeleteNonce_Then_RepositoryDeleteIsCalled() { - // given - final String nonceValue = UUID.randomUUID().toString(); - - // when - nonceService.deleteNonce(nonceValue); - - // then - verify(nonceRepository).deleteById(nonceValue); - } - -} diff --git a/src/test/java/com/und/server/service/RefreshTokenServiceTest.java b/src/test/java/com/und/server/service/RefreshTokenServiceTest.java deleted file mode 100644 index 0eb77f3e..00000000 --- a/src/test/java/com/und/server/service/RefreshTokenServiceTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.und.server.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -import java.util.Optional; -import java.util.UUID; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.und.server.entity.RefreshToken; -import com.und.server.repository.RefreshTokenRepository; - -@ExtendWith(MockitoExtension.class) -class RefreshTokenServiceTest { - - @Mock - private RefreshTokenRepository refreshTokenRepository; - - @InjectMocks - private RefreshTokenService refreshTokenService; - - private final Long memberId = 1L; - private final String refreshToken = UUID.randomUUID().toString(); - - @Test - @DisplayName("Generates a new UUID-formatted refresh token") - void Given_Nothing_When_GenerateRefreshToken_Then_ReturnsUuidString() { - // when - final String token = refreshTokenService.generateRefreshToken(); - - // then - assertThat(token).isNotNull(); - assertThat(token).hasSize(36); // UUID format - } - - @Test - @DisplayName("Saves a refresh token for a member") - void Given_MemberIdAndToken_When_SaveRefreshToken_Then_RepositorySaveIsCalled() { - // when - refreshTokenService.saveRefreshToken(memberId, refreshToken); - - // then - verify(refreshTokenRepository).save(any(RefreshToken.class)); - } - - @Test - @DisplayName("Retrieves an existing refresh token for a member") - void Given_ExistingTokenInRepository_When_GetRefreshToken_Then_ReturnsCorrectToken() { - // given - RefreshToken token = RefreshToken.builder() - .memberId(memberId) - .refreshToken(refreshToken) - .build(); - - doReturn(Optional.of(token)).when(refreshTokenRepository).findById(memberId); - - // when - final String result = refreshTokenService.getRefreshToken(memberId); - - // then - assertThat(result).isEqualTo(refreshToken); - } - - @Test - @DisplayName("Returns null when refresh token is not found") - void Given_TokenNotInRepository_When_GetRefreshToken_Then_ReturnsNull() { - // given - doReturn(Optional.empty()).when(refreshTokenRepository).findById(memberId); - - // when - final String result = refreshTokenService.getRefreshToken(memberId); - - // then - assertThat(result).isNull(); - } - - @Test - @DisplayName("Deletes a refresh token for a member") - void Given_MemberId_When_DeleteRefreshToken_Then_RepositoryDeleteIsCalled() { - // when - refreshTokenService.deleteRefreshToken(memberId); - - // then - verify(refreshTokenRepository).deleteById(memberId); - } - -} diff --git a/src/test/java/com/und/server/terms/controller/TermsControllerTest.java b/src/test/java/com/und/server/terms/controller/TermsControllerTest.java new file mode 100644 index 00000000..2c5f84b9 --- /dev/null +++ b/src/test/java/com/und/server/terms/controller/TermsControllerTest.java @@ -0,0 +1,166 @@ +package com.und.server.terms.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.und.server.auth.filter.AuthMemberArgumentResolver; +import com.und.server.common.exception.GlobalExceptionHandler; +import com.und.server.common.exception.ServerException; +import com.und.server.terms.dto.request.EventPushAgreementRequest; +import com.und.server.terms.dto.request.TermsAgreementRequest; +import com.und.server.terms.dto.response.TermsAgreementResponse; +import com.und.server.terms.exception.TermsErrorResult; +import com.und.server.terms.service.TermsService; + +@ExtendWith(MockitoExtension.class) +class TermsControllerTest { + + @InjectMocks + private TermsController termsController; + + @Mock + private TermsService termsService; + + @Mock + private AuthMemberArgumentResolver authMemberArgumentResolver; + + private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final Long memberId = 1L; + + @BeforeEach + void init() { + mockMvc = MockMvcBuilders.standaloneSetup(termsController) + .setCustomArgumentResolvers(authMemberArgumentResolver) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + @DisplayName("Fails to get terms agreement and returns not found when no agreement exists") + void Given_NoAgreement_When_GetTermsAgreement_Then_ReturnsNotFound() throws Exception { + // given + final String url = "/v1/terms"; + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + doThrow(new ServerException(TermsErrorResult.TERMS_NOT_FOUND)).when(termsService).getTermsAgreement(memberId); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.get(url) + ); + + // then + resultActions.andExpect(status().isNotFound()); + } + + @Test + @DisplayName("Successfully gets terms agreement") + void Given_ExistingAgreement_When_GetTermsAgreement_Then_ReturnsOkWithResponse() throws Exception { + // given + final String url = "/v1/terms"; + final TermsAgreementResponse response = new TermsAgreementResponse(1L, memberId, true, true, true, true); + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + doReturn(response).when(termsService).getTermsAgreement(memberId); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.get(url) + ); + + // then + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.memberId").value(memberId)) + .andExpect(jsonPath("$.eventPushAgreed").value(true)); + } + + @Test + @DisplayName("Fails to add terms agreement when it already exists") + void Given_ExistingAgreement_When_AddTermsAgreement_Then_ReturnsConflict() throws Exception { + // given + final String url = "/v1/terms"; + final TermsAgreementRequest request = new TermsAgreementRequest(true, true, true, false); + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + doThrow(new ServerException(TermsErrorResult.TERMS_ALREADY_EXISTS)) + .when(termsService).addTermsAgreement(memberId, request); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.post(url) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions.andExpect(status().isConflict()); + } + + @Test + @DisplayName("Successfully adds a new terms agreement") + void Given_NoAgreement_When_AddTermsAgreement_Then_ReturnsCreatedWithResponse() throws Exception { + // given + final String url = "/v1/terms"; + final TermsAgreementRequest request = new TermsAgreementRequest(true, true, true, true); + final TermsAgreementResponse response = new TermsAgreementResponse(1L, memberId, true, true, true, true); + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + doReturn(response).when(termsService).addTermsAgreement(memberId, request); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.post(url) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions.andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.eventPushAgreed").value(true)); + } + + @Test + @DisplayName("Successfully updates event push agreement") + void Given_ExistingAgreement_When_UpdateEventPushAgreement_Then_ReturnsOkWithResponse() throws Exception { + // given + final String url = "/v1/terms"; + final EventPushAgreementRequest request = new EventPushAgreementRequest(false); + final TermsAgreementResponse response = new TermsAgreementResponse(1L, memberId, true, true, true, false); + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + doReturn(response).when(termsService).updateEventPushAgreement(memberId, request); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.patch(url) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.eventPushAgreed").value(false)); + } + +} diff --git a/src/test/java/com/und/server/terms/repository/TermsRepositoryTest.java b/src/test/java/com/und/server/terms/repository/TermsRepositoryTest.java new file mode 100644 index 00000000..e2a70472 --- /dev/null +++ b/src/test/java/com/und/server/terms/repository/TermsRepositoryTest.java @@ -0,0 +1,109 @@ +package com.und.server.terms.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import com.und.server.member.entity.Member; +import com.und.server.member.repository.MemberRepository; +import com.und.server.terms.entity.Terms; + +@DataJpaTest +class TermsRepositoryTest { + + @Autowired + private TermsRepository termsRepository; + + @Autowired + private MemberRepository memberRepository; + + private Member member; + + @BeforeEach + void setUp() { + member = memberRepository.save(Member.builder().nickname("test-user").build()); + } + + @Test + @DisplayName("Finds terms by member ID when they exist") + void Given_ExistingTerms_When_FindByMemberId_Then_ReturnsOptionalOfTerms() { + // given + final Terms terms = Terms.builder() + .member(member) + .termsOfServiceAgreed(true) + .privacyPolicyAgreed(true) + .isOver14(true) + .eventPushAgreed(false) + .build(); + termsRepository.save(terms); + + // when + final Optional foundTerms = termsRepository.findByMemberId(member.getId()); + + // then + assertThat(foundTerms).isPresent(); + assertThat(foundTerms.get().getMember().getId()).isEqualTo(member.getId()); + assertThat(foundTerms.get().getEventPushAgreed()).isFalse(); + } + + @Test + @DisplayName("Returns empty optional when finding terms for a member without them") + void Given_MemberWithoutTerms_When_FindByMemberId_Then_ReturnsEmptyOptional() { + // when + final Optional foundTerms = termsRepository.findByMemberId(member.getId()); + + // then + assertThat(foundTerms).isNotPresent(); + } + + @Test + @DisplayName("Returns true when checking existence for a member with terms") + void Given_ExistingTerms_When_ExistsByMemberId_Then_ReturnsTrue() { + // given + final Terms terms = Terms.builder().member(member).build(); + termsRepository.save(terms); + + // when + final boolean exists = termsRepository.existsByMemberId(member.getId()); + + // then + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("Returns false when checking existence for a member without terms") + void Given_MemberWithoutTerms_When_ExistsByMemberId_Then_ReturnsFalse() { + // when + final boolean exists = termsRepository.existsByMemberId(member.getId()); + + // then + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("Finds all terms with members using EntityGraph to avoid N+1 problem") + void Given_MultipleTerms_When_FindAll_Then_ReturnsTermsWithFetchedMembers() { + // given + final Member member2 = memberRepository.save(Member.builder().nickname("test-user-2").build()); + + final Terms terms1 = Terms.builder().member(member).build(); + final Terms terms2 = Terms.builder().member(member2).build(); + termsRepository.saveAll(List.of(terms1, terms2)); + + // when + final List allTerms = termsRepository.findAll(); + + // then + assertThat(allTerms).hasSize(2); + assertThat(allTerms.stream().map(t -> t.getMember().getNickname())) + .containsExactlyInAnyOrder("test-user", "test-user-2"); + } + +} diff --git a/src/test/java/com/und/server/terms/service/TermsServiceTest.java b/src/test/java/com/und/server/terms/service/TermsServiceTest.java new file mode 100644 index 00000000..141148ad --- /dev/null +++ b/src/test/java/com/und/server/terms/service/TermsServiceTest.java @@ -0,0 +1,212 @@ +package com.und.server.terms.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.common.exception.ServerException; +import com.und.server.member.entity.Member; +import com.und.server.member.service.MemberService; +import com.und.server.terms.dto.request.EventPushAgreementRequest; +import com.und.server.terms.dto.request.TermsAgreementRequest; +import com.und.server.terms.dto.response.TermsAgreementResponse; +import com.und.server.terms.entity.Terms; +import com.und.server.terms.exception.TermsErrorResult; +import com.und.server.terms.repository.TermsRepository; + +@ExtendWith(MockitoExtension.class) +class TermsServiceTest { + + @InjectMocks + private TermsService termsService; + + @Mock + private TermsRepository termsRepository; + + @Mock + private MemberService memberService; + + private final Long memberId = 1L; + private final Member member = Member.builder().id(memberId).build(); + + @Test + @DisplayName("Retrieves a list of all term agreements") + void Given_ExistingTermAgreements_When_GetTermsList_Then_ReturnsListOfResponses() { + // given + final Terms terms1 = Terms.builder() + .id(1L) + .member(member) + .termsOfServiceAgreed(true) + .privacyPolicyAgreed(true) + .isOver14(true) + .eventPushAgreed(false) + .build(); + final Terms terms2 = Terms.builder() + .id(2L) + .member(Member.builder().id(2L).build()) + .termsOfServiceAgreed(true) + .privacyPolicyAgreed(true) + .isOver14(true) + .eventPushAgreed(true) + .build(); + final List termsList = List.of(terms1, terms2); + doReturn(termsList).when(termsRepository).findAll(); + + // when + final List responses = termsService.getTermsList(); + + // then + assertThat(responses).hasSize(2); + assertThat(responses.get(0).id()).isEqualTo(1L); + assertThat(responses.get(1).id()).isEqualTo(2L); + verify(termsRepository).findAll(); + } + + @Test + @DisplayName("Fails to get terms agreement for a non-existent member") + void Given_NonExistentMember_When_GetTermsAgreement_Then_ThrowsException() { + // given + doThrow(new ServerException(TermsErrorResult.TERMS_NOT_FOUND)).when(memberService).checkMemberExists(memberId); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> termsService.getTermsAgreement(memberId)); + + assertThat(exception.getErrorResult()).isEqualTo(TermsErrorResult.TERMS_NOT_FOUND); + } + + @Test + @DisplayName("Fails to get terms agreement when none exists for the member") + void Given_MemberWithoutTerms_When_GetTermsAgreement_Then_ThrowsException() { + // given + doNothing().when(memberService).checkMemberExists(memberId); + doReturn(Optional.empty()).when(termsRepository).findByMemberId(memberId); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> termsService.getTermsAgreement(memberId)); + + assertThat(exception.getErrorResult()).isEqualTo(TermsErrorResult.TERMS_NOT_FOUND); + } + + @Test + @DisplayName("Retrieves terms agreement for an existing member") + void Given_ExistingMemberWithTerms_When_GetTermsAgreement_Then_ReturnsResponse() { + // given + final Terms terms = Terms.builder() + .id(1L) + .member(member) + .termsOfServiceAgreed(true) + .privacyPolicyAgreed(true) + .isOver14(true) + .eventPushAgreed(true) + .build(); + doNothing().when(memberService).checkMemberExists(memberId); + doReturn(Optional.of(terms)).when(termsRepository).findByMemberId(memberId); + + // when + final TermsAgreementResponse response = termsService.getTermsAgreement(memberId); + + // then + assertThat(response.id()).isEqualTo(1L); + assertThat(response.memberId()).isEqualTo(memberId); + assertThat(response.eventPushAgreed()).isTrue(); + } + + @Test + @DisplayName("Fails to add terms agreement if it already exists") + void Given_ExistingTerms_When_AddTermsAgreement_Then_ThrowsException() { + // given + final TermsAgreementRequest request = new TermsAgreementRequest(true, true, true, false); + doReturn(true).when(termsRepository).existsByMemberId(memberId); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> termsService.addTermsAgreement(memberId, request)); + + assertThat(exception.getErrorResult()).isEqualTo(TermsErrorResult.TERMS_ALREADY_EXISTS); + } + + @Test + @DisplayName("Successfully adds a new terms agreement") + void Given_NewMember_When_AddTermsAgreement_Then_SavesAndReturnsResponse() { + // given + final TermsAgreementRequest request = new TermsAgreementRequest(true, true, true, true); + final Terms savedTerms = Terms.builder() + .id(1L) + .member(member) + .termsOfServiceAgreed(true) + .privacyPolicyAgreed(true) + .isOver14(true) + .eventPushAgreed(true) + .build(); + + doReturn(member).when(memberService).findMemberById(memberId); + doReturn(false).when(termsRepository).existsByMemberId(memberId); + doReturn(savedTerms).when(termsRepository).save(any(Terms.class)); + + // when + final TermsAgreementResponse response = termsService.addTermsAgreement(memberId, request); + + // then + assertThat(response.id()).isEqualTo(1L); + assertThat(response.memberId()).isEqualTo(memberId); + assertThat(response.eventPushAgreed()).isTrue(); + verify(termsRepository).save(any(Terms.class)); + } + + @Test + @DisplayName("Fails to update event push agreement for a non-existent member") + void Given_NonExistentMember_When_UpdateEventPushAgreement_Then_ThrowsException() { + // given + final EventPushAgreementRequest request = new EventPushAgreementRequest(true); + doThrow(new ServerException(TermsErrorResult.TERMS_NOT_FOUND)).when(memberService).checkMemberExists(memberId); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> termsService.updateEventPushAgreement(memberId, request)); + + assertThat(exception.getErrorResult()).isEqualTo(TermsErrorResult.TERMS_NOT_FOUND); + } + + @Test + @DisplayName("Successfully updates event push agreement") + void Given_ExistingTerms_When_UpdateEventPushAgreement_Then_UpdatesAndReturnsResponse() { + // given + final EventPushAgreementRequest request = new EventPushAgreementRequest(false); + final Terms existingTerms = Terms.builder() + .id(1L) + .member(member) + .termsOfServiceAgreed(true) + .privacyPolicyAgreed(true) + .isOver14(true) + .eventPushAgreed(true) + .build(); + + doNothing().when(memberService).checkMemberExists(memberId); + doReturn(Optional.of(existingTerms)).when(termsRepository).findByMemberId(memberId); + + // when + final TermsAgreementResponse response = termsService.updateEventPushAgreement(memberId, request); + + // then + assertThat(response.id()).isEqualTo(1L); + assertThat(response.eventPushAgreed()).isFalse(); + assertThat(existingTerms.getEventPushAgreed()).isFalse(); + } + +} diff --git a/src/test/java/com/und/server/weather/config/WeatherPropertiesTest.java b/src/test/java/com/und/server/weather/config/WeatherPropertiesTest.java new file mode 100644 index 00000000..abf3b117 --- /dev/null +++ b/src/test/java/com/und/server/weather/config/WeatherPropertiesTest.java @@ -0,0 +1,116 @@ +package com.und.server.weather.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("WeatherProperties 테스트") +class WeatherPropertiesTest { + + @Test + @DisplayName("WeatherProperties가 올바르게 로드된다") + void Given_ValidProperties_When_LoadWeatherProperties_Then_PropertiesAreLoaded() { + // given & when + WeatherProperties.Kma kma = new WeatherProperties.Kma("https://test-kma-api.com", "test-service-key"); + WeatherProperties.OpenMeteo openMeteo = new WeatherProperties.OpenMeteo("https://test-open-meteo-api.com"); + WeatherProperties.OpenMeteoKma openMeteoKma = new WeatherProperties.OpenMeteoKma("https://test-open-meteo-kma-api.com"); + WeatherProperties weatherProperties = new WeatherProperties(kma, openMeteo, openMeteoKma); + + // then + assertThat(weatherProperties).isNotNull(); + assertThat(kma).isNotNull(); + assertThat(openMeteo).isNotNull(); + assertThat(openMeteoKma).isNotNull(); + } + + @Test + @DisplayName("Kma 설정이 올바르게 생성된다") + void Given_KmaProperties_When_CreateProperties_Then_PropertiesAreCreated() { + // given + String serviceKey = "test-service-key"; + String baseUrl = "https://test-kma-api.com"; + + // when + WeatherProperties.Kma kma = new WeatherProperties.Kma(baseUrl, serviceKey); + + // then + assertThat(kma.serviceKey()).isEqualTo(serviceKey); + assertThat(kma.baseUrl()).isEqualTo(baseUrl); + } + + @Test + @DisplayName("OpenMeteo 설정이 올바르게 생성된다") + void Given_OpenMeteoProperties_When_CreateProperties_Then_PropertiesAreCreated() { + // given + String baseUrl = "https://test-open-meteo-api.com"; + + // when + WeatherProperties.OpenMeteo openMeteo = new WeatherProperties.OpenMeteo(baseUrl); + + // then + assertThat(openMeteo.baseUrl()).isEqualTo(baseUrl); + } + + @Test + @DisplayName("OpenMeteoKma 설정이 올바르게 생성된다") + void Given_OpenMeteoKmaProperties_When_CreateProperties_Then_PropertiesAreCreated() { + // given + String baseUrl = "https://test-open-meteo-kma-api.com"; + + // when + WeatherProperties.OpenMeteoKma openMeteoKma = new WeatherProperties.OpenMeteoKma(baseUrl); + + // then + assertThat(openMeteoKma.baseUrl()).isEqualTo(baseUrl); + } + + @Test + @DisplayName("WeatherProperties의 전체 설정이 올바르게 생성된다") + void Given_WeatherProperties_When_CreateAllProperties_Then_AllPropertiesAreCreated() { + // given + WeatherProperties.Kma kma = new WeatherProperties.Kma("https://test-kma-api.com", "test-kma-service-key"); + WeatherProperties.OpenMeteo openMeteo = new WeatherProperties.OpenMeteo("https://test-open-meteo-api.com"); + WeatherProperties.OpenMeteoKma openMeteoKma = new WeatherProperties.OpenMeteoKma("https://test-open-meteo-kma-api.com"); + + // when + WeatherProperties weatherProperties = new WeatherProperties(kma, openMeteo, openMeteoKma); + + // then + assertThat(weatherProperties.kma()).isEqualTo(kma); + assertThat(weatherProperties.openMeteo()).isEqualTo(openMeteo); + assertThat(weatherProperties.openMeteoKma()).isEqualTo(openMeteoKma); + } + + @Test + @DisplayName("Kma 설정의 null 값이 올바르게 처리된다") + void Given_KmaProperties_When_NullValues_Then_NullValuesAreHandled() { + // given & when + WeatherProperties.Kma kma = new WeatherProperties.Kma(null, null); + + // then + assertThat(kma.serviceKey()).isNull(); + assertThat(kma.baseUrl()).isNull(); + } + + @Test + @DisplayName("OpenMeteo 설정의 null 값이 올바르게 처리된다") + void Given_OpenMeteoProperties_When_NullValues_Then_NullValuesAreHandled() { + // given & when + WeatherProperties.OpenMeteo openMeteo = new WeatherProperties.OpenMeteo(null); + + // then + assertThat(openMeteo.baseUrl()).isNull(); + } + + @Test + @DisplayName("OpenMeteoKma 설정의 null 값이 올바르게 처리된다") + void Given_OpenMeteoKmaProperties_When_NullValues_Then_NullValuesAreHandled() { + // given & when + WeatherProperties.OpenMeteoKma openMeteoKma = new WeatherProperties.OpenMeteoKma(null); + + // then + assertThat(openMeteoKma.baseUrl()).isNull(); + } + +} diff --git a/src/test/java/com/und/server/weather/constants/FineDustTypeTest.java b/src/test/java/com/und/server/weather/constants/FineDustTypeTest.java new file mode 100644 index 00000000..61cde511 --- /dev/null +++ b/src/test/java/com/und/server/weather/constants/FineDustTypeTest.java @@ -0,0 +1,208 @@ +package com.und.server.weather.constants; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("FineDustType 테스트") +class FineDustTypeTest { + + @Test + @DisplayName("PM10 농도로 FineDustType을 가져올 수 있다") + void Given_Pm10Concentration_When_FromPm10Concentration_Then_ReturnsFineDustType() { + // given + double pm10Value = 25.0; // 좋음 범위 + + // when + FineDustType result = FineDustType.fromPm10Concentration(pm10Value); + + // then + assertThat(result).isEqualTo(FineDustType.GOOD); + } + + + @Test + @DisplayName("PM2.5 농도로 FineDustType을 가져올 수 있다") + void Given_Pm25Concentration_When_FromPm25Concentration_Then_ReturnsFineDustType() { + // given + double pm25Value = 20.0; // 보통 범위 + + // when + FineDustType result = FineDustType.fromPm25Concentration(pm25Value); + + // then + assertThat(result).isEqualTo(FineDustType.NORMAL); + } + + + @Test + @DisplayName("PM10 농도 경계값을 올바르게 처리한다") + void Given_Pm10BoundaryValues_When_FromPm10Concentration_Then_ReturnsCorrectFineDustType() { + // given & when & then + assertThat(FineDustType.fromPm10Concentration(0.0)).isEqualTo(FineDustType.GOOD); + assertThat(FineDustType.fromPm10Concentration(30.0)).isEqualTo(FineDustType.GOOD); + assertThat(FineDustType.fromPm10Concentration(31.0)).isEqualTo(FineDustType.NORMAL); + assertThat(FineDustType.fromPm10Concentration(80.0)).isEqualTo(FineDustType.NORMAL); + assertThat(FineDustType.fromPm10Concentration(81.0)).isEqualTo(FineDustType.BAD); + assertThat(FineDustType.fromPm10Concentration(150.0)).isEqualTo(FineDustType.BAD); + assertThat(FineDustType.fromPm10Concentration(151.0)).isEqualTo(FineDustType.VERY_BAD); + } + + + @Test + @DisplayName("PM2.5 농도 경계값을 올바르게 처리한다") + void Given_Pm25BoundaryValues_When_FromPm25Concentration_Then_ReturnsCorrectFineDustType() { + // given & when & then + assertThat(FineDustType.fromPm25Concentration(0.0)).isEqualTo(FineDustType.GOOD); + assertThat(FineDustType.fromPm25Concentration(15.0)).isEqualTo(FineDustType.GOOD); + assertThat(FineDustType.fromPm25Concentration(16.0)).isEqualTo(FineDustType.NORMAL); + assertThat(FineDustType.fromPm25Concentration(35.0)).isEqualTo(FineDustType.NORMAL); + assertThat(FineDustType.fromPm25Concentration(36.0)).isEqualTo(FineDustType.BAD); + assertThat(FineDustType.fromPm25Concentration(75.0)).isEqualTo(FineDustType.BAD); + assertThat(FineDustType.fromPm25Concentration(76.0)).isEqualTo(FineDustType.VERY_BAD); + } + + + @Test + @DisplayName("음수 PM10 농도에 대해 DEFAULT를 반환한다") + void Given_NegativePm10Concentration_When_FromPm10Concentration_Then_ReturnsDefault() { + // given + double negativePm10 = -10.0; + + // when + FineDustType result = FineDustType.fromPm10Concentration(negativePm10); + + // then + assertThat(result).isEqualTo(FineDustType.DEFAULT); + } + + + @Test + @DisplayName("음수 PM2.5 농도에 대해 DEFAULT를 반환한다") + void Given_NegativePm25Concentration_When_FromPm25Concentration_Then_ReturnsDefault() { + // given + double negativePm25 = -5.0; + + // when + FineDustType result = FineDustType.fromPm25Concentration(negativePm25); + + // then + assertThat(result).isEqualTo(FineDustType.DEFAULT); + } + + + @Test + @DisplayName("가장 심각한 미세먼지 타입을 가져올 수 있다") + void Given_FineDustTypesList_When_GetWorst_Then_ReturnsWorstFineDustType() { + // given + List fineDustTypes = List.of( + FineDustType.GOOD, // severity: 1 + FineDustType.NORMAL, // severity: 2 + FineDustType.BAD, // severity: 3 + FineDustType.VERY_BAD // severity: 4 + ); + + // when + FineDustType worst = FineDustType.getWorst(fineDustTypes); + + // then + assertThat(worst).isEqualTo(FineDustType.VERY_BAD); + } + + + @Test + @DisplayName("빈 리스트에서 DEFAULT를 반환한다") + void Given_EmptyFineDustTypesList_When_GetWorst_Then_ReturnsDefault() { + // given + List fineDustTypes = List.of(); + + // when + FineDustType worst = FineDustType.getWorst(fineDustTypes); + + // then + assertThat(worst).isEqualTo(FineDustType.DEFAULT); + } + + @Test + @DisplayName("FineDustType의 심각도가 올바르다") + void Given_FineDustType_When_GetSeverity_Then_ReturnsCorrectSeverity() { + // given & when & then + assertThat(FineDustType.UNKNOWN.getSeverity()).isZero(); + assertThat(FineDustType.GOOD.getSeverity()).isEqualTo(1); + assertThat(FineDustType.NORMAL.getSeverity()).isEqualTo(2); + assertThat(FineDustType.BAD.getSeverity()).isEqualTo(3); + assertThat(FineDustType.VERY_BAD.getSeverity()).isEqualTo(4); + } + + @Test + @DisplayName("FineDustType의 설명이 올바르다") + void Given_FineDustType_When_GetDescription_Then_ReturnsCorrectDescription() { + // given & when & then + assertThat(FineDustType.UNKNOWN.getDescription()).isEqualTo("없음"); + assertThat(FineDustType.GOOD.getDescription()).isEqualTo("좋음"); + assertThat(FineDustType.NORMAL.getDescription()).isEqualTo("보통"); + assertThat(FineDustType.BAD.getDescription()).isEqualTo("나쁨"); + assertThat(FineDustType.VERY_BAD.getDescription()).isEqualTo("매우나쁨"); + } + + @Test + @DisplayName("PM10 범위가 올바르다") + void Given_FineDustType_When_GetPm10Range_Then_ReturnsCorrectRange() { + // given & when & then + assertThat(FineDustType.GOOD.getMinPm10()).isZero(); + assertThat(FineDustType.GOOD.getMaxPm10()).isEqualTo(30); + assertThat(FineDustType.NORMAL.getMinPm10()).isEqualTo(31); + assertThat(FineDustType.NORMAL.getMaxPm10()).isEqualTo(80); + assertThat(FineDustType.BAD.getMinPm10()).isEqualTo(81); + assertThat(FineDustType.BAD.getMaxPm10()).isEqualTo(150); + assertThat(FineDustType.VERY_BAD.getMinPm10()).isEqualTo(151); + assertThat(FineDustType.VERY_BAD.getMaxPm10()).isEqualTo(Integer.MAX_VALUE); + } + + @Test + @DisplayName("PM2.5 범위가 올바르다") + void Given_FineDustType_When_GetPm25Range_Then_ReturnsCorrectRange() { + // given & when & then + assertThat(FineDustType.GOOD.getMinPm25()).isZero(); + assertThat(FineDustType.GOOD.getMaxPm25()).isEqualTo(15); + assertThat(FineDustType.NORMAL.getMinPm25()).isEqualTo(16); + assertThat(FineDustType.NORMAL.getMaxPm25()).isEqualTo(35); + assertThat(FineDustType.BAD.getMinPm25()).isEqualTo(36); + assertThat(FineDustType.BAD.getMaxPm25()).isEqualTo(75); + assertThat(FineDustType.VERY_BAD.getMinPm25()).isEqualTo(76); + assertThat(FineDustType.VERY_BAD.getMaxPm25()).isEqualTo(Integer.MAX_VALUE); + } + + @Test + @DisplayName("OpenMeteo 변수명이 올바르다") + void Given_FineDustType_When_GetOpenMeteoVariables_Then_ReturnsCorrectVariables() { + // given & when & then + assertThat(FineDustType.OPEN_METEO_VARIABLES).isEqualTo("pm2_5,pm10"); + } + + @Test + @DisplayName("UNKNOWN 타입의 범위가 올바르다") + void Given_UnknownFineDustType_When_GetRange_Then_ReturnsCorrectRange() { + // given & when & then + assertThat(FineDustType.UNKNOWN.getMinPm10()).isEqualTo(-1); + assertThat(FineDustType.UNKNOWN.getMaxPm10()).isEqualTo(-1); + assertThat(FineDustType.UNKNOWN.getMinPm25()).isEqualTo(-1); + assertThat(FineDustType.UNKNOWN.getMaxPm25()).isEqualTo(-1); + } + + @Test + @DisplayName("반올림된 값으로 올바른 타입을 반환한다") + void Given_RoundedValues_When_FromConcentration_Then_ReturnsCorrectFineDustType() { + // given & when & then + assertThat(FineDustType.fromPm10Concentration(30.4)).isEqualTo(FineDustType.GOOD); + assertThat(FineDustType.fromPm10Concentration(30.5)).isEqualTo(FineDustType.NORMAL); + assertThat(FineDustType.fromPm25Concentration(15.4)).isEqualTo(FineDustType.GOOD); + assertThat(FineDustType.fromPm25Concentration(15.5)).isEqualTo(FineDustType.NORMAL); + assertThat(FineDustType.fromPm25Concentration(15.6)).isEqualTo(FineDustType.NORMAL); + assertThat(FineDustType.fromPm25Concentration(15.7)).isEqualTo(FineDustType.NORMAL); + } + +} diff --git a/src/test/java/com/und/server/weather/constants/TimeSlotTest.java b/src/test/java/com/und/server/weather/constants/TimeSlotTest.java new file mode 100644 index 00000000..a4c927a2 --- /dev/null +++ b/src/test/java/com/und/server/weather/constants/TimeSlotTest.java @@ -0,0 +1,219 @@ +package com.und.server.weather.constants; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("TimeSlot 테스트") +class TimeSlotTest { + + @Test + @DisplayName("현재 시간대를 가져올 수 있다") + void Given_DateTime_When_GetCurrentSlot_Then_ReturnsTimeSlot() { + // given + LocalDateTime dateTime = LocalDateTime.of(2024, 1, 15, 14, 30); // 14:30 + + // when + TimeSlot result = TimeSlot.getCurrentSlot(dateTime); + + // then + assertThat(result).isEqualTo(TimeSlot.SLOT_12_15); + } + + + @Test + @DisplayName("LocalTime으로 시간대를 가져올 수 있다") + void Given_LocalTime_When_From_Then_ReturnsTimeSlot() { + // given + LocalTime time = LocalTime.of(14, 30); // 14:30 + + // when + TimeSlot result = TimeSlot.from(time); + + // then + assertThat(result).isEqualTo(TimeSlot.SLOT_12_15); + } + + + @Test + @DisplayName("자정 시간대를 올바르게 처리한다") + void Given_MidnightTime_When_From_Then_ReturnsSlot00_03() { + // given + LocalTime midnight = LocalTime.of(0, 0); // 00:00 + LocalTime earlyMorning = LocalTime.of(2, 30); // 02:30 + + // when + TimeSlot midnightSlot = TimeSlot.from(midnight); + TimeSlot earlyMorningSlot = TimeSlot.from(earlyMorning); + + // then + assertThat(midnightSlot).isEqualTo(TimeSlot.SLOT_00_03); + assertThat(earlyMorningSlot).isEqualTo(TimeSlot.SLOT_00_03); + } + + + @Test + @DisplayName("시간대 경계값을 올바르게 처리한다") + void Given_BoundaryTimes_When_From_Then_ReturnsCorrectTimeSlot() { + // given + LocalTime startTime = LocalTime.of(12, 0); // 12:00 + LocalTime endTime = LocalTime.of(14, 59); // 14:59 + + // when + TimeSlot startSlot = TimeSlot.from(startTime); + TimeSlot endSlot = TimeSlot.from(endTime); + + // then + assertThat(startSlot).isEqualTo(TimeSlot.SLOT_12_15); + assertThat(endSlot).isEqualTo(TimeSlot.SLOT_12_15); + } + + + @Test + @DisplayName("시간대 경계를 벗어나면 다음 시간대로 처리한다") + void Given_BoundaryOverflowTime_When_From_Then_ReturnsNextTimeSlot() { + // given + LocalTime boundaryTime = LocalTime.of(15, 0); // 15:00 + + // when + TimeSlot result = TimeSlot.from(boundaryTime); + + // then + assertThat(result).isEqualTo(TimeSlot.SLOT_15_18); + } + + + @Test + @DisplayName("자정 직전 시간대를 올바르게 처리한다") + void Given_BeforeMidnightTime_When_From_Then_ReturnsSlot21_24() { + // given + LocalTime beforeMidnight = LocalTime.of(23, 30); // 23:30 + + // when + TimeSlot result = TimeSlot.from(beforeMidnight); + + // then + assertThat(result).isEqualTo(TimeSlot.SLOT_21_24); + } + + + @Test + @DisplayName("예보 시간 목록을 가져올 수 있다") + void Given_TimeSlot_When_GetForecastHours_Then_ReturnsHourList() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_12_15; // 12:00-15:00 + + // when + List forecastHours = timeSlot.getForecastHours(); + + // then + assertThat(forecastHours).containsExactly(12, 13, 14); + } + + + @Test + @DisplayName("00-03 시간대의 예보 시간을 가져올 수 있다") + void Given_Slot00_03_When_GetForecastHours_Then_ReturnsHourList() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_00_03; // 00:00-03:00 + + // when + List forecastHours = timeSlot.getForecastHours(); + + // then + assertThat(forecastHours).containsExactly(0, 1, 2); + } + + + @Test + @DisplayName("21-24 시간대의 예보 시간을 가져올 수 있다") + void Given_Slot21_24_When_GetForecastHours_Then_ReturnsHourList() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_21_24; // 21:00-24:00 + + // when + List forecastHours = timeSlot.getForecastHours(); + + // then + assertThat(forecastHours).containsExactly(21, 22, 23); + } + + + @Test + @DisplayName("전체 하루 시간 목록을 가져올 수 있다") + void Given_Request_When_GetAllDayHours_Then_Returns24HourList() { + // when + List allDayHours = TimeSlot.getAllDayHours(); + + // then + assertThat(allDayHours).hasSize(24); + assertThat(allDayHours.get(0)).isZero(); + assertThat(allDayHours.get(23)).isEqualTo(23); + } + + + @Test + @DisplayName("모든 시간대에 대해 현재 시간대를 올바르게 반환한다") + void Given_AllTimeSlots_When_GetCurrentSlot_Then_ReturnsCorrectTimeSlot() { + // given + TimeSlot[] timeSlots = TimeSlot.values(); + + // when & then + for (TimeSlot timeSlot : timeSlots) { + LocalTime middleTime = LocalTime.of(timeSlot.getStartHour() + 1, 30); + TimeSlot result = TimeSlot.from(middleTime); + assertThat(result).isEqualTo(timeSlot); + } + } + + + @Test + @DisplayName("시간대의 시작 시간과 종료 시간이 올바르다") + void Given_TimeSlots_When_GetBoundaries_Then_ReturnsCorrectHours() { + // given & when & then + assertThat(TimeSlot.SLOT_00_03.getStartHour()).isZero(); + assertThat(TimeSlot.SLOT_00_03.getEndHour()).isEqualTo(3); + + assertThat(TimeSlot.SLOT_03_06.getStartHour()).isEqualTo(3); + assertThat(TimeSlot.SLOT_03_06.getEndHour()).isEqualTo(6); + + assertThat(TimeSlot.SLOT_06_09.getStartHour()).isEqualTo(6); + assertThat(TimeSlot.SLOT_06_09.getEndHour()).isEqualTo(9); + + assertThat(TimeSlot.SLOT_09_12.getStartHour()).isEqualTo(9); + assertThat(TimeSlot.SLOT_09_12.getEndHour()).isEqualTo(12); + + assertThat(TimeSlot.SLOT_12_15.getStartHour()).isEqualTo(12); + assertThat(TimeSlot.SLOT_12_15.getEndHour()).isEqualTo(15); + + assertThat(TimeSlot.SLOT_15_18.getStartHour()).isEqualTo(15); + assertThat(TimeSlot.SLOT_15_18.getEndHour()).isEqualTo(18); + + assertThat(TimeSlot.SLOT_18_21.getStartHour()).isEqualTo(18); + assertThat(TimeSlot.SLOT_18_21.getEndHour()).isEqualTo(21); + + assertThat(TimeSlot.SLOT_21_24.getStartHour()).isEqualTo(21); + assertThat(TimeSlot.SLOT_21_24.getEndHour()).isEqualTo(24); + } + + + @Test + @DisplayName("시간대 경계에서 정확한 시간대를 반환한다") + void Given_ExactBoundaryTimes_When_From_Then_ReturnsCorrectTimeSlots() { + // given & when & then + assertThat(TimeSlot.from(LocalTime.of(0, 0))).isEqualTo(TimeSlot.SLOT_00_03); + assertThat(TimeSlot.from(LocalTime.of(3, 0))).isEqualTo(TimeSlot.SLOT_03_06); + assertThat(TimeSlot.from(LocalTime.of(6, 0))).isEqualTo(TimeSlot.SLOT_06_09); + assertThat(TimeSlot.from(LocalTime.of(9, 0))).isEqualTo(TimeSlot.SLOT_09_12); + assertThat(TimeSlot.from(LocalTime.of(12, 0))).isEqualTo(TimeSlot.SLOT_12_15); + assertThat(TimeSlot.from(LocalTime.of(15, 0))).isEqualTo(TimeSlot.SLOT_15_18); + assertThat(TimeSlot.from(LocalTime.of(18, 0))).isEqualTo(TimeSlot.SLOT_18_21); + assertThat(TimeSlot.from(LocalTime.of(21, 0))).isEqualTo(TimeSlot.SLOT_21_24); + } + +} diff --git a/src/test/java/com/und/server/weather/constants/UvTypeTest.java b/src/test/java/com/und/server/weather/constants/UvTypeTest.java new file mode 100644 index 00000000..7461fd57 --- /dev/null +++ b/src/test/java/com/und/server/weather/constants/UvTypeTest.java @@ -0,0 +1,176 @@ +package com.und.server.weather.constants; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("UvType 테스트") +class UvTypeTest { + + @Test + @DisplayName("UV 지수로 UvType을 가져올 수 있다") + void Given_UvIndex_When_FromUvIndex_Then_ReturnsUvType() { + // given + double uvIndexValue = 3.0; // 낮음 범위 + + // when + UvType result = UvType.fromUvIndex(uvIndexValue); + + // then + assertThat(result).isEqualTo(UvType.LOW); + } + + @Test + @DisplayName("UV 지수 경계값을 올바르게 처리한다") + void Given_UvIndexBoundaryValues_When_FromUvIndex_Then_ReturnsCorrectUvType() { + // given & when & then + assertThat(UvType.fromUvIndex(0.0)).isEqualTo(UvType.VERY_LOW); + assertThat(UvType.fromUvIndex(2.0)).isEqualTo(UvType.VERY_LOW); + assertThat(UvType.fromUvIndex(3.0)).isEqualTo(UvType.LOW); + assertThat(UvType.fromUvIndex(4.0)).isEqualTo(UvType.LOW); + assertThat(UvType.fromUvIndex(5.0)).isEqualTo(UvType.NORMAL); + assertThat(UvType.fromUvIndex(6.0)).isEqualTo(UvType.NORMAL); + assertThat(UvType.fromUvIndex(7.0)).isEqualTo(UvType.HIGH); + assertThat(UvType.fromUvIndex(9.0)).isEqualTo(UvType.HIGH); + assertThat(UvType.fromUvIndex(10.0)).isEqualTo(UvType.VERY_HIGH); + assertThat(UvType.fromUvIndex(15.0)).isEqualTo(UvType.VERY_HIGH); + } + + @Test + @DisplayName("음수 UV 지수에 대해 DEFAULT를 반환한다") + void Given_NegativeUvIndex_When_FromUvIndex_Then_ReturnsDefault() { + // given + double negativeUvIndex = -5.0; + + // when + UvType result = UvType.fromUvIndex(negativeUvIndex); + + // then + assertThat(result).isEqualTo(UvType.DEFAULT); + } + + @Test + @DisplayName("가장 심각한 자외선 타입을 가져올 수 있다") + void Given_UvTypesList_When_GetWorst_Then_ReturnsWorstUvType() { + // given + List uvTypes = List.of( + UvType.VERY_LOW, // severity: 1 + UvType.LOW, // severity: 2 + UvType.NORMAL, // severity: 3 + UvType.HIGH, // severity: 4 + UvType.VERY_HIGH // severity: 5 + ); + + // when + UvType worst = UvType.getWorst(uvTypes); + + // then + assertThat(worst).isEqualTo(UvType.VERY_HIGH); + } + + @Test + @DisplayName("빈 리스트에서 DEFAULT를 반환한다") + void Given_EmptyUvTypesList_When_GetWorst_Then_ReturnsDefault() { + // given + List uvTypes = List.of(); + + // when + UvType worst = UvType.getWorst(uvTypes); + + // then + assertThat(worst).isEqualTo(UvType.DEFAULT); + } + + @Test + @DisplayName("UvType의 심각도가 올바르다") + void Given_UvType_When_GetSeverity_Then_ReturnsCorrectSeverity() { + // given & when & then + assertThat(UvType.UNKNOWN.getSeverity()).isZero(); + assertThat(UvType.VERY_LOW.getSeverity()).isEqualTo(1); + assertThat(UvType.LOW.getSeverity()).isEqualTo(2); + assertThat(UvType.NORMAL.getSeverity()).isEqualTo(3); + assertThat(UvType.HIGH.getSeverity()).isEqualTo(4); + assertThat(UvType.VERY_HIGH.getSeverity()).isEqualTo(5); + } + + @Test + @DisplayName("UvType의 설명이 올바르다") + void Given_UvType_When_GetDescription_Then_ReturnsCorrectDescription() { + // given & when & then + assertThat(UvType.UNKNOWN.getDescription()).isEqualTo("없음"); + assertThat(UvType.VERY_LOW.getDescription()).isEqualTo("매우낮음"); + assertThat(UvType.LOW.getDescription()).isEqualTo("낮음"); + assertThat(UvType.NORMAL.getDescription()).isEqualTo("보통"); + assertThat(UvType.HIGH.getDescription()).isEqualTo("높음"); + assertThat(UvType.VERY_HIGH.getDescription()).isEqualTo("매우높음"); + } + + @Test + @DisplayName("UV 지수 범위가 올바르다") + void Given_UvType_When_GetUvIndexRange_Then_ReturnsCorrectRange() { + // given & when & then + assertThat(UvType.VERY_LOW.getMinUvIndex()).isZero(); + assertThat(UvType.VERY_LOW.getMaxUvIndex()).isEqualTo(2); + assertThat(UvType.LOW.getMinUvIndex()).isEqualTo(3); + assertThat(UvType.LOW.getMaxUvIndex()).isEqualTo(4); + assertThat(UvType.NORMAL.getMinUvIndex()).isEqualTo(5); + assertThat(UvType.NORMAL.getMaxUvIndex()).isEqualTo(6); + assertThat(UvType.HIGH.getMinUvIndex()).isEqualTo(7); + assertThat(UvType.HIGH.getMaxUvIndex()).isEqualTo(9); + assertThat(UvType.VERY_HIGH.getMinUvIndex()).isEqualTo(10); + assertThat(UvType.VERY_HIGH.getMaxUvIndex()).isEqualTo(Integer.MAX_VALUE); + } + + @Test + @DisplayName("OpenMeteo 변수명이 올바르다") + void Given_UvType_When_GetOpenMeteoVariables_Then_ReturnsCorrectVariables() { + // given & when & then + assertThat(UvType.OPEN_METEO_VARIABLES).isEqualTo("uv_index"); + } + + @Test + @DisplayName("UNKNOWN 타입의 범위가 올바르다") + void Given_UnknownUvType_When_GetRange_Then_ReturnsCorrectRange() { + // given & when & then + assertThat(UvType.UNKNOWN.getMinUvIndex()).isEqualTo(-1); + assertThat(UvType.UNKNOWN.getMaxUvIndex()).isEqualTo(-1); + } + + @Test + @DisplayName("반올림된 값으로 올바른 타입을 반환한다") + void Given_RoundedValues_When_FromUvIndex_Then_ReturnsCorrectUvType() { + // given & when & then + assertThat(UvType.fromUvIndex(2.4)).isEqualTo(UvType.VERY_LOW); + assertThat(UvType.fromUvIndex(2.5)).isEqualTo(UvType.LOW); + assertThat(UvType.fromUvIndex(4.4)).isEqualTo(UvType.LOW); + assertThat(UvType.fromUvIndex(4.5)).isEqualTo(UvType.NORMAL); + assertThat(UvType.fromUvIndex(6.4)).isEqualTo(UvType.NORMAL); + assertThat(UvType.fromUvIndex(6.5)).isEqualTo(UvType.HIGH); + assertThat(UvType.fromUvIndex(9.4)).isEqualTo(UvType.HIGH); + assertThat(UvType.fromUvIndex(9.5)).isEqualTo(UvType.VERY_HIGH); + } + + @Test + @DisplayName("극한 UV 지수값을 처리할 수 있다") + void Given_ExtremeUvIndexValues_When_FromUvIndex_Then_ReturnsCorrectUvType() { + // given & when & then + assertThat(UvType.fromUvIndex(0.0)).isEqualTo(UvType.VERY_LOW); + assertThat(UvType.fromUvIndex(100.0)).isEqualTo(UvType.VERY_HIGH); + assertThat(UvType.fromUvIndex(Double.MAX_VALUE)).isEqualTo(UvType.UNKNOWN); + } + + @Test + @DisplayName("소수점 UV 지수값을 올바르게 처리한다") + void Given_DecimalUvIndexValues_When_FromUvIndex_Then_ReturnsCorrectUvType() { + // given & when & then + assertThat(UvType.fromUvIndex(1.5)).isEqualTo(UvType.VERY_LOW); + assertThat(UvType.fromUvIndex(3.7)).isEqualTo(UvType.LOW); + assertThat(UvType.fromUvIndex(5.2)).isEqualTo(UvType.NORMAL); + assertThat(UvType.fromUvIndex(7.8)).isEqualTo(UvType.HIGH); + assertThat(UvType.fromUvIndex(12.3)).isEqualTo(UvType.VERY_HIGH); + } + +} diff --git a/src/test/java/com/und/server/weather/constants/WeatherTypeTest.java b/src/test/java/com/und/server/weather/constants/WeatherTypeTest.java new file mode 100644 index 00000000..8473198c --- /dev/null +++ b/src/test/java/com/und/server/weather/constants/WeatherTypeTest.java @@ -0,0 +1,269 @@ +package com.und.server.weather.constants; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("WeatherType 테스트") +class WeatherTypeTest { + + @Test + @DisplayName("PTY 값으로 WeatherType을 가져올 수 있다") + void Given_PtyValue_When_FromPtyValue_Then_ReturnsWeatherType() { + // given + int ptyValue = 1; // 비 + + // when + WeatherType result = WeatherType.fromPtyValue(ptyValue); + + // then + assertThat(result).isEqualTo(WeatherType.RAIN); + } + + @Test + @DisplayName("SKY 값으로 WeatherType을 가져올 수 있다") + void Given_SkyValue_When_FromSkyValue_Then_ReturnsWeatherType() { + // given + int skyValue = 1; // 맑음 + + // when + WeatherType result = WeatherType.fromSkyValue(skyValue); + + // then + assertThat(result).isEqualTo(WeatherType.SUNNY); + } + + @Test + @DisplayName("존재하지 않는 PTY 값에 대해 DEFAULT를 반환한다") + void Given_InvalidPtyValue_When_FromPtyValue_Then_ReturnsDefault() { + // given + int invalidPtyValue = 999; + + // when + WeatherType result = WeatherType.fromPtyValue(invalidPtyValue); + + // then + assertThat(result).isEqualTo(WeatherType.DEFAULT); + } + + @Test + @DisplayName("존재하지 않는 SKY 값에 대해 DEFAULT를 반환한다") + void Given_InvalidSkyValue_When_FromSkyValue_Then_ReturnsDefault() { + // given + int invalidSkyValue = 999; + + // when + WeatherType result = WeatherType.fromSkyValue(invalidSkyValue); + + // then + assertThat(result).isEqualTo(WeatherType.DEFAULT); + } + + @Test + @DisplayName("OpenMeteo 코드로 WeatherType을 가져올 수 있다") + void Given_OpenMeteoCode_When_FromOpenMeteoCode_Then_ReturnsWeatherType() { + // given + int sunnyCode = 0; + int cloudyCode = 1; + int overcastCode = 45; + int rainCode = 61; + int snowCode = 71; + int showerCode = 80; + int sleetCode = 85; + + // when & then + assertThat(WeatherType.fromOpenMeteoCode(sunnyCode)).isEqualTo(WeatherType.SUNNY); + assertThat(WeatherType.fromOpenMeteoCode(cloudyCode)).isEqualTo(WeatherType.CLOUDY); + assertThat(WeatherType.fromOpenMeteoCode(overcastCode)).isEqualTo(WeatherType.OVERCAST); + assertThat(WeatherType.fromOpenMeteoCode(rainCode)).isEqualTo(WeatherType.RAIN); + assertThat(WeatherType.fromOpenMeteoCode(snowCode)).isEqualTo(WeatherType.SNOW); + assertThat(WeatherType.fromOpenMeteoCode(showerCode)).isEqualTo(WeatherType.SHOWER); + assertThat(WeatherType.fromOpenMeteoCode(sleetCode)).isEqualTo(WeatherType.SLEET); + } + + @Test + @DisplayName("존재하지 않는 OpenMeteo 코드에 대해 DEFAULT를 반환한다") + void Given_InvalidOpenMeteoCode_When_FromOpenMeteoCode_Then_ReturnsDefault() { + // given + int invalidCode = 999; + + // when + WeatherType result = WeatherType.fromOpenMeteoCode(invalidCode); + + // then + assertThat(result).isEqualTo(WeatherType.DEFAULT); + } + + @Test + @DisplayName("시간대별 기준 시간을 가져올 수 있다") + void Given_TimeSlot_When_GetBaseTime_Then_ReturnsBaseTime() { + // given & when & then + assertThat(WeatherType.getBaseTime(TimeSlot.SLOT_00_03)).isEqualTo("2300"); + assertThat(WeatherType.getBaseTime(TimeSlot.SLOT_03_06)).isEqualTo("0200"); + assertThat(WeatherType.getBaseTime(TimeSlot.SLOT_06_09)).isEqualTo("0500"); + assertThat(WeatherType.getBaseTime(TimeSlot.SLOT_09_12)).isEqualTo("0800"); + assertThat(WeatherType.getBaseTime(TimeSlot.SLOT_12_15)).isEqualTo("1100"); + assertThat(WeatherType.getBaseTime(TimeSlot.SLOT_15_18)).isEqualTo("1400"); + assertThat(WeatherType.getBaseTime(TimeSlot.SLOT_18_21)).isEqualTo("1700"); + assertThat(WeatherType.getBaseTime(TimeSlot.SLOT_21_24)).isEqualTo("2000"); + } + + @Test + @DisplayName("시간대별 기준 날짜를 가져올 수 있다") + void Given_TimeSlotAndDate_When_GetBaseDate_Then_ReturnsBaseDate() { + // given + LocalDate date = LocalDate.of(2024, 1, 15); + + // when & then + // 00-03 시간대는 전날 기준 + assertThat(WeatherType.getBaseDate(TimeSlot.SLOT_00_03, date)) + .isEqualTo(LocalDate.of(2024, 1, 14)); + + // 다른 시간대는 당일 기준 + assertThat(WeatherType.getBaseDate(TimeSlot.SLOT_12_15, date)) + .isEqualTo(LocalDate.of(2024, 1, 15)); + } + + @Test + @DisplayName("가장 심각한 날씨 타입을 가져올 수 있다") + void Given_WeatherTypesList_When_GetWorst_Then_ReturnsWorstWeatherType() { + // given + List weatherTypes = List.of( + WeatherType.SUNNY, // severity: 1 + WeatherType.CLOUDY, // severity: 2 + WeatherType.RAIN, // severity: 5 + WeatherType.SHOWER // severity: 6 + ); + + // when + WeatherType worst = WeatherType.getWorst(weatherTypes); + + // then + assertThat(worst).isEqualTo(WeatherType.SHOWER); + } + + @Test + @DisplayName("null이 포함된 리스트에서도 가장 심각한 날씨 타입을 가져올 수 있다") + void Given_WeatherTypesListWithNull_When_GetWorst_Then_ReturnsWorstWeatherType() { + // given + List weatherTypes = Arrays.asList( + WeatherType.SUNNY, // severity: 1 + null, + WeatherType.RAIN, // severity: 5 + null + ); + + // when + WeatherType worst = WeatherType.getWorst(weatherTypes); + + // then + assertThat(worst).isEqualTo(WeatherType.RAIN); + } + + @Test + @DisplayName("빈 리스트에서 DEFAULT를 반환한다") + void Given_EmptyWeatherTypesList_When_GetWorst_Then_ReturnsDefault() { + // given + List weatherTypes = List.of(); + + // when + WeatherType worst = WeatherType.getWorst(weatherTypes); + + // then + assertThat(worst).isEqualTo(WeatherType.DEFAULT); + } + + @Test + @DisplayName("모든 null 리스트에서 DEFAULT를 반환한다") + void Given_AllNullWeatherTypesList_When_GetWorst_Then_ReturnsDefault() { + // given + List weatherTypes = Arrays.asList(null, null, null); + + // when + WeatherType worst = WeatherType.getWorst(weatherTypes); + + // then + assertThat(worst).isEqualTo(WeatherType.DEFAULT); + } + + @Test + @DisplayName("WeatherType의 심각도가 올바르다") + void Given_WeatherType_When_GetSeverity_Then_ReturnsCorrectSeverity() { + // given & when & then + assertThat(WeatherType.UNKNOWN.getSeverity()).isZero(); + assertThat(WeatherType.SUNNY.getSeverity()).isEqualTo(1); + assertThat(WeatherType.CLOUDY.getSeverity()).isEqualTo(2); + assertThat(WeatherType.OVERCAST.getSeverity()).isEqualTo(2); + assertThat(WeatherType.SLEET.getSeverity()).isEqualTo(3); + assertThat(WeatherType.SNOW.getSeverity()).isEqualTo(4); + assertThat(WeatherType.RAIN.getSeverity()).isEqualTo(5); + assertThat(WeatherType.SHOWER.getSeverity()).isEqualTo(6); + } + + @Test + @DisplayName("WeatherType의 설명이 올바르다") + void Given_WeatherType_When_GetDescription_Then_ReturnsCorrectDescription() { + // given & when & then + assertThat(WeatherType.UNKNOWN.getDescription()).isEqualTo("없음"); + assertThat(WeatherType.SUNNY.getDescription()).isEqualTo("맑음"); + assertThat(WeatherType.CLOUDY.getDescription()).isEqualTo("구름많음"); + assertThat(WeatherType.OVERCAST.getDescription()).isEqualTo("흐림"); + assertThat(WeatherType.RAIN.getDescription()).isEqualTo("비"); + assertThat(WeatherType.SLEET.getDescription()).isEqualTo("진눈깨비"); + assertThat(WeatherType.SNOW.getDescription()).isEqualTo("눈"); + assertThat(WeatherType.SHOWER.getDescription()).isEqualTo("소나기"); + } + + @Test + @DisplayName("PTY 값이 올바르다") + void Given_WeatherType_When_GetPtyValue_Then_ReturnsCorrectPtyValue() { + // given & when & then + assertThat(WeatherType.RAIN.getPtyValue()).isEqualTo(1); + assertThat(WeatherType.SLEET.getPtyValue()).isEqualTo(2); + assertThat(WeatherType.SNOW.getPtyValue()).isEqualTo(3); + assertThat(WeatherType.SHOWER.getPtyValue()).isEqualTo(4); + + // PTY 값이 없는 타입들 + assertThat(WeatherType.UNKNOWN.getPtyValue()).isNull(); + assertThat(WeatherType.SUNNY.getPtyValue()).isNull(); + assertThat(WeatherType.CLOUDY.getPtyValue()).isNull(); + assertThat(WeatherType.OVERCAST.getPtyValue()).isNull(); + } + + @Test + @DisplayName("SKY 값이 올바르다") + void Given_WeatherType_When_GetSkyValue_Then_ReturnsCorrectSkyValue() { + // given & when & then + assertThat(WeatherType.SUNNY.getSkyValue()).isEqualTo(1); + assertThat(WeatherType.CLOUDY.getSkyValue()).isEqualTo(3); + assertThat(WeatherType.OVERCAST.getSkyValue()).isEqualTo(4); + + // SKY 값이 없는 타입들 + assertThat(WeatherType.UNKNOWN.getSkyValue()).isNull(); + assertThat(WeatherType.RAIN.getSkyValue()).isNull(); + assertThat(WeatherType.SLEET.getSkyValue()).isNull(); + assertThat(WeatherType.SNOW.getSkyValue()).isNull(); + assertThat(WeatherType.SHOWER.getSkyValue()).isNull(); + } + + @Test + @DisplayName("OpenMeteo 변수명이 올바르다") + void Given_WeatherType_When_GetOpenMeteoVariables_Then_ReturnsCorrectVariables() { + // given & when & then + assertThat(WeatherType.OPEN_METEO_VARIABLES).isEqualTo("weathercode"); + } + + @Test + @DisplayName("KMA 날짜 포맷터가 올바르다") + void Given_WeatherType_When_GetKmaDateFormatter_Then_ReturnsCorrectFormatter() { + // given & when & then + assertThat(WeatherType.KMA_DATE_FORMATTER).isNotNull(); + assertThat(WeatherType.KMA_DATE_FORMATTER.toString()).contains("Value(DayOfMonth,2)"); + } + +} diff --git a/src/test/java/com/und/server/weather/controller/WeatherControllerTest.java b/src/test/java/com/und/server/weather/controller/WeatherControllerTest.java new file mode 100644 index 00000000..5fbede2c --- /dev/null +++ b/src/test/java/com/und/server/weather/controller/WeatherControllerTest.java @@ -0,0 +1,223 @@ +package com.und.server.weather.controller; + +import static org.mockito.BDDMockito.given; +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; + +import java.time.LocalDate; +import java.time.ZoneId; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.und.server.weather.constants.FineDustType; +import com.und.server.weather.constants.UvType; +import com.und.server.weather.constants.WeatherType; +import com.und.server.weather.dto.request.WeatherRequest; +import com.und.server.weather.dto.response.WeatherResponse; +import com.und.server.weather.service.WeatherService; + +@ExtendWith(MockitoExtension.class) +@DisplayName("WeatherController 테스트") +class WeatherControllerTest { + + @Mock + private WeatherService weatherService; + + @InjectMocks + private WeatherController weatherController; + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(weatherController).build(); + objectMapper = new ObjectMapper(); + } + + + @Test + @DisplayName("날씨 정보를 성공적으로 조회한다") + void Given_ValidRequest_When_GetWeather_Then_ReturnsWeatherResponse() throws Exception { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate date = LocalDate.of(2024, 1, 15); + WeatherResponse expectedResponse = WeatherResponse.from( + WeatherType.SUNNY, FineDustType.GOOD, UvType.LOW + ); + + given(weatherService.getWeatherInfo((request), (date), ZoneId.of("Asia/Seoul"))) + .willReturn(expectedResponse); + + // when & then + mockMvc.perform(post("/v1/weather") + .param("date", "2024-01-15") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.weather").value("SUNNY")) + .andExpect(jsonPath("$.fineDust").value("GOOD")) + .andExpect(jsonPath("$.uv").value("LOW")); + } + + + @Test + @DisplayName("비 오는 날씨 정보를 조회한다") + void Given_RainyWeather_When_GetWeather_Then_ReturnsRainyWeatherResponse() throws Exception { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate date = LocalDate.of(2024, 1, 15); + WeatherResponse expectedResponse = WeatherResponse.from( + WeatherType.RAIN, FineDustType.NORMAL, UvType.NORMAL + ); + + given(weatherService.getWeatherInfo((request), (date), ZoneId.of("Asia/Seoul"))) + .willReturn(expectedResponse); + + // when & then + mockMvc.perform(post("/v1/weather") + .param("date", "2024-01-15") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.weather").value("RAIN")) + .andExpect(jsonPath("$.fineDust").value("NORMAL")) + .andExpect(jsonPath("$.uv").value("NORMAL")); + } + + + @Test + @DisplayName("미세먼지 나쁨 상태의 날씨 정보를 조회한다") + void Given_BadFineDust_When_GetWeather_Then_ReturnsBadFineDustResponse() throws Exception { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate date = LocalDate.of(2024, 1, 15); + WeatherResponse expectedResponse = WeatherResponse.from( + WeatherType.CLOUDY, FineDustType.BAD, UvType.HIGH + ); + + given(weatherService.getWeatherInfo((request), (date), ZoneId.of("Asia/Seoul"))) + .willReturn(expectedResponse); + + // when & then + mockMvc.perform(post("/v1/weather") + .param("date", "2024-01-15") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.weather").value("CLOUDY")) + .andExpect(jsonPath("$.fineDust").value("BAD")) + .andExpect(jsonPath("$.uv").value("HIGH")); + } + + @Test + @DisplayName("눈 오는 날씨 정보를 조회한다") + void Given_SnowyWeather_When_GetWeather_Then_ReturnsSnowyWeatherResponse() throws Exception { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate date = LocalDate.of(2024, 1, 15); + WeatherResponse expectedResponse = WeatherResponse.from( + WeatherType.SNOW, FineDustType.GOOD, UvType.VERY_LOW + ); + + given(weatherService.getWeatherInfo((request), (date), ZoneId.of("Asia/Seoul"))) + .willReturn(expectedResponse); + + // when & then + mockMvc.perform(post("/v1/weather") + .param("date", "2024-01-15") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.weather").value("SNOW")) + .andExpect(jsonPath("$.fineDust").value("GOOD")) + .andExpect(jsonPath("$.uv").value("VERY_LOW")); + } + + + @Test + @DisplayName("다른 좌표로 날씨 정보를 조회한다") + void Given_DifferentCoordinates_When_GetWeather_Then_ReturnsWeatherResponse() throws Exception { + // given + WeatherRequest request = new WeatherRequest(35.1796, 129.0756); // 부산 + LocalDate date = LocalDate.of(2024, 1, 15); + WeatherResponse expectedResponse = WeatherResponse.from( + WeatherType.SUNNY, FineDustType.GOOD, UvType.VERY_HIGH + ); + + given(weatherService.getWeatherInfo((request), (date), ZoneId.of("Asia/Seoul"))) + .willReturn(expectedResponse); + + // when & then + mockMvc.perform(post("/v1/weather") + .param("date", "2024-01-15") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.weather").value("SUNNY")) + .andExpect(jsonPath("$.fineDust").value("GOOD")) + .andExpect(jsonPath("$.uv").value("VERY_HIGH")); + } + + + @Test + @DisplayName("다른 날짜로 날씨 정보를 조회한다") + void Given_DifferentDate_When_GetWeather_Then_ReturnsWeatherResponse() throws Exception { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate date = LocalDate.of(2024, 12, 25); + WeatherResponse expectedResponse = WeatherResponse.from( + WeatherType.CLOUDY, FineDustType.NORMAL, UvType.LOW + ); + + given(weatherService.getWeatherInfo((request), (date), ZoneId.of("Asia/Seoul"))) + .willReturn(expectedResponse); + + // when & then + mockMvc.perform(post("/v1/weather") + .param("date", "2024-12-25") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.weather").value("CLOUDY")) + .andExpect(jsonPath("$.fineDust").value("NORMAL")) + .andExpect(jsonPath("$.uv").value("LOW")); + } + + + @Test + @DisplayName("모든 날씨 타입이 최악인 상태를 조회한다") + void Given_WorstWeatherConditions_When_GetWeather_Then_ReturnsWorstWeatherResponse() throws Exception { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate date = LocalDate.of(2024, 1, 15); + WeatherResponse expectedResponse = WeatherResponse.from( + WeatherType.SNOW, FineDustType.VERY_BAD, UvType.VERY_HIGH + ); + + given(weatherService.getWeatherInfo((request), (date), ZoneId.of("Asia/Seoul"))) + .willReturn(expectedResponse); + + // when & then + mockMvc.perform(post("/v1/weather") + .param("date", "2024-01-15") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.weather").value("SNOW")) + .andExpect(jsonPath("$.fineDust").value("VERY_BAD")) + .andExpect(jsonPath("$.uv").value("VERY_HIGH")); + } + +} diff --git a/src/test/java/com/und/server/weather/dto/response/WeatherResponseTest.java b/src/test/java/com/und/server/weather/dto/response/WeatherResponseTest.java new file mode 100644 index 00000000..5e93ad5e --- /dev/null +++ b/src/test/java/com/und/server/weather/dto/response/WeatherResponseTest.java @@ -0,0 +1,298 @@ +package com.und.server.weather.dto.response; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.und.server.weather.constants.FineDustType; +import com.und.server.weather.constants.UvType; +import com.und.server.weather.constants.WeatherType; +import com.und.server.weather.dto.cache.WeatherCacheData; + +@DisplayName("WeatherResponse 테스트") +class WeatherResponseTest { + + @Test + @DisplayName("WeatherType, FineDustType, UvType으로 WeatherResponse를 생성한다") + void Given_WeatherTypes_When_From_Then_ReturnsWeatherResponse() { + // given + WeatherType weather = WeatherType.SUNNY; + FineDustType fineDust = FineDustType.GOOD; + UvType uv = UvType.LOW; + + // when + WeatherResponse result = WeatherResponse.from(weather, fineDust, uv); + + // then + assertThat(result.weather()).isEqualTo(weather); + assertThat(result.fineDust()).isEqualTo(fineDust); + assertThat(result.uv()).isEqualTo(uv); + } + + + @Test + @DisplayName("비 오는 날씨로 WeatherResponse를 생성한다") + void Given_RainyWeather_When_From_Then_ReturnsRainyWeatherResponse() { + // given + WeatherType weather = WeatherType.RAIN; + FineDustType fineDust = FineDustType.NORMAL; + UvType uv = UvType.NORMAL; + + // when + WeatherResponse result = WeatherResponse.from(weather, fineDust, uv); + + // then + assertThat(result.weather()).isEqualTo(WeatherType.RAIN); + assertThat(result.fineDust()).isEqualTo(FineDustType.NORMAL); + assertThat(result.uv()).isEqualTo(UvType.NORMAL); + } + + + @Test + @DisplayName("눈 오는 날씨로 WeatherResponse를 생성한다") + void Given_SnowyWeather_When_From_Then_ReturnsSnowyWeatherResponse() { + // given + WeatherType weather = WeatherType.SNOW; + FineDustType fineDust = FineDustType.GOOD; + UvType uv = UvType.VERY_LOW; + + // when + WeatherResponse result = WeatherResponse.from(weather, fineDust, uv); + + // then + assertThat(result.weather()).isEqualTo(WeatherType.SNOW); + assertThat(result.fineDust()).isEqualTo(FineDustType.GOOD); + assertThat(result.uv()).isEqualTo(UvType.VERY_LOW); + } + + + @Test + @DisplayName("흐린 날씨로 WeatherResponse를 생성한다") + void Given_CloudyWeather_When_From_Then_ReturnsCloudyWeatherResponse() { + // given + WeatherType weather = WeatherType.CLOUDY; + FineDustType fineDust = FineDustType.BAD; + UvType uv = UvType.HIGH; + + // when + WeatherResponse result = WeatherResponse.from(weather, fineDust, uv); + + // then + assertThat(result.weather()).isEqualTo(WeatherType.CLOUDY); + assertThat(result.fineDust()).isEqualTo(FineDustType.BAD); + assertThat(result.uv()).isEqualTo(UvType.HIGH); + } + + + @Test + @DisplayName("미세먼지가 매우 나쁜 날씨로 WeatherResponse를 생성한다") + void Given_VeryBadFineDust_When_From_Then_ReturnsVeryBadFineDustResponse() { + // given + WeatherType weather = WeatherType.SUNNY; + FineDustType fineDust = FineDustType.VERY_BAD; + UvType uv = UvType.VERY_HIGH; + + // when + WeatherResponse result = WeatherResponse.from(weather, fineDust, uv); + + // then + assertThat(result.weather()).isEqualTo(WeatherType.SUNNY); + assertThat(result.fineDust()).isEqualTo(FineDustType.VERY_BAD); + assertThat(result.uv()).isEqualTo(UvType.VERY_HIGH); + } + + + @Test + @DisplayName("UV 지수가 높은 날씨로 WeatherResponse를 생성한다") + void Given_HighUvIndex_When_From_Then_ReturnsHighUvResponse() { + // given + WeatherType weather = WeatherType.SUNNY; + FineDustType fineDust = FineDustType.GOOD; + UvType uv = UvType.HIGH; + + // when + WeatherResponse result = WeatherResponse.from(weather, fineDust, uv); + + // then + assertThat(result.weather()).isEqualTo(WeatherType.SUNNY); + assertThat(result.fineDust()).isEqualTo(FineDustType.GOOD); + assertThat(result.uv()).isEqualTo(UvType.HIGH); + } + + @Test + @DisplayName("WeatherCacheData로 WeatherResponse를 생성한다") + void Given_WeatherCacheData_When_From_Then_ReturnsWeatherResponse() { + // given + WeatherCacheData cacheData = WeatherCacheData.from( + WeatherType.SUNNY, FineDustType.GOOD, UvType.LOW + ); + + // when + WeatherResponse result = WeatherResponse.from(cacheData); + + // then + assertThat(result.weather()).isEqualTo(WeatherType.SUNNY); + assertThat(result.fineDust()).isEqualTo(FineDustType.GOOD); + assertThat(result.uv()).isEqualTo(UvType.LOW); + } + + + @Test + @DisplayName("비 오는 날씨의 WeatherCacheData로 WeatherResponse를 생성한다") + void Given_RainyWeatherCacheData_When_From_Then_ReturnsRainyWeatherResponse() { + // given + WeatherCacheData cacheData = WeatherCacheData.from( + WeatherType.RAIN, FineDustType.NORMAL, UvType.NORMAL + ); + + // when + WeatherResponse result = WeatherResponse.from(cacheData); + + // then + assertThat(result.weather()).isEqualTo(WeatherType.RAIN); + assertThat(result.fineDust()).isEqualTo(FineDustType.NORMAL); + assertThat(result.uv()).isEqualTo(UvType.NORMAL); + } + + + @Test + @DisplayName("눈 오는 날씨의 WeatherCacheData로 WeatherResponse를 생성한다") + void Given_SnowyWeatherCacheData_When_From_Then_ReturnsSnowyWeatherResponse() { + // given + WeatherCacheData cacheData = WeatherCacheData.from( + WeatherType.SNOW, FineDustType.GOOD, UvType.VERY_LOW + ); + + // when + WeatherResponse result = WeatherResponse.from(cacheData); + + // then + assertThat(result.weather()).isEqualTo(WeatherType.SNOW); + assertThat(result.fineDust()).isEqualTo(FineDustType.GOOD); + assertThat(result.uv()).isEqualTo(UvType.VERY_LOW); + } + + + @Test + @DisplayName("흐린 날씨의 WeatherCacheData로 WeatherResponse를 생성한다") + void Given_CloudyWeatherCacheData_When_From_Then_ReturnsCloudyWeatherResponse() { + // given + WeatherCacheData cacheData = WeatherCacheData.from( + WeatherType.CLOUDY, FineDustType.BAD, UvType.HIGH + ); + + // when + WeatherResponse result = WeatherResponse.from(cacheData); + + // then + assertThat(result.weather()).isEqualTo(WeatherType.CLOUDY); + assertThat(result.fineDust()).isEqualTo(FineDustType.BAD); + assertThat(result.uv()).isEqualTo(UvType.HIGH); + } + + + @Test + @DisplayName("미세먼지가 매우 나쁜 WeatherCacheData로 WeatherResponse를 생성한다") + void Given_VeryBadFineDustCacheData_When_From_Then_ReturnsVeryBadFineDustResponse() { + // given + WeatherCacheData cacheData = WeatherCacheData.from( + WeatherType.SUNNY, FineDustType.VERY_BAD, UvType.VERY_HIGH + ); + + // when + WeatherResponse result = WeatherResponse.from(cacheData); + + // then + assertThat(result.weather()).isEqualTo(WeatherType.SUNNY); + assertThat(result.fineDust()).isEqualTo(FineDustType.VERY_BAD); + assertThat(result.uv()).isEqualTo(UvType.VERY_HIGH); + } + + + @Test + @DisplayName("UV 지수가 높은 WeatherCacheData로 WeatherResponse를 생성한다") + void Given_HighUvCacheData_When_From_Then_ReturnsHighUvResponse() { + // given + WeatherCacheData cacheData = WeatherCacheData.from( + WeatherType.SUNNY, FineDustType.GOOD, UvType.HIGH + ); + + // when + WeatherResponse result = WeatherResponse.from(cacheData); + + // then + assertThat(result.weather()).isEqualTo(WeatherType.SUNNY); + assertThat(result.fineDust()).isEqualTo(FineDustType.GOOD); + assertThat(result.uv()).isEqualTo(UvType.HIGH); + } + + + @Test + @DisplayName("모든 날씨 조건이 최악인 WeatherCacheData로 WeatherResponse를 생성한다") + void Given_WorstWeatherCacheData_When_From_Then_ReturnsWorstWeatherResponse() { + // given + WeatherCacheData cacheData = WeatherCacheData.from( + WeatherType.SNOW, FineDustType.VERY_BAD, UvType.VERY_HIGH + ); + + // when + WeatherResponse result = WeatherResponse.from(cacheData); + + // then + assertThat(result.weather()).isEqualTo(WeatherType.SNOW); + assertThat(result.fineDust()).isEqualTo(FineDustType.VERY_BAD); + assertThat(result.uv()).isEqualTo(UvType.VERY_HIGH); + } + + + @Test + @DisplayName("모든 날씨 조건이 좋은 WeatherCacheData로 WeatherResponse를 생성한다") + void Given_BestWeatherCacheData_When_From_Then_ReturnsBestWeatherResponse() { + // given + WeatherCacheData cacheData = WeatherCacheData.from( + WeatherType.SUNNY, FineDustType.GOOD, UvType.LOW + ); + + // when + WeatherResponse result = WeatherResponse.from(cacheData); + + // then + assertThat(result.weather()).isEqualTo(WeatherType.SUNNY); + assertThat(result.fineDust()).isEqualTo(FineDustType.GOOD); + assertThat(result.uv()).isEqualTo(UvType.LOW); + } + + @Test + @DisplayName("WeatherResponse의 빌더 패턴으로 생성한다") + void Given_Builder_When_Build_Then_ReturnsWeatherResponse() { + // when + WeatherResponse result = WeatherResponse.builder() + .weather(WeatherType.SUNNY) + .fineDust(FineDustType.GOOD) + .uv(UvType.LOW) + .build(); + + // then + assertThat(result.weather()).isEqualTo(WeatherType.SUNNY); + assertThat(result.fineDust()).isEqualTo(FineDustType.GOOD); + assertThat(result.uv()).isEqualTo(UvType.LOW); + } + + @Test + @DisplayName("WeatherResponse의 빌더 패턴으로 비 오는 날씨를 생성한다") + void Given_Builder_When_BuildRainyWeather_Then_ReturnsRainyWeatherResponse() { + // when + WeatherResponse result = WeatherResponse.builder() + .weather(WeatherType.RAIN) + .fineDust(FineDustType.NORMAL) + .uv(UvType.NORMAL) + .build(); + + // then + assertThat(result.weather()).isEqualTo(WeatherType.RAIN); + assertThat(result.fineDust()).isEqualTo(FineDustType.NORMAL); + assertThat(result.uv()).isEqualTo(UvType.NORMAL); + } + +} diff --git a/src/test/java/com/und/server/weather/exception/KmaApiExceptionTest.java b/src/test/java/com/und/server/weather/exception/KmaApiExceptionTest.java new file mode 100644 index 00000000..872847dc --- /dev/null +++ b/src/test/java/com/und/server/weather/exception/KmaApiExceptionTest.java @@ -0,0 +1,102 @@ +package com.und.server.weather.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("KmaApiException 테스트") +class KmaApiExceptionTest { + + @Test + @DisplayName("WeatherErrorResult로 KmaApiException을 생성한다") + void Given_WeatherErrorResult_When_CreateKmaApiException_Then_ExceptionIsCreated() { + // given + WeatherErrorResult errorResult = WeatherErrorResult.KMA_API_ERROR; + + // when + KmaApiException exception = new KmaApiException(errorResult); + + // then + assertThat(exception).isNotNull(); + assertThat(exception.getErrorResult()).isEqualTo(errorResult); + assertThat(exception.getMessage()).isEqualTo(errorResult.getMessage()); + } + + @Test + @DisplayName("WeatherErrorResult와 원인으로 KmaApiException을 생성한다") + void Given_WeatherErrorResultAndCause_When_CreateKmaApiException_Then_ExceptionIsCreated() { + // given + WeatherErrorResult errorResult = WeatherErrorResult.KMA_BAD_REQUEST; + Throwable cause = new RuntimeException("Test cause"); + + // when + KmaApiException exception = new KmaApiException(errorResult, cause); + + // then + assertThat(exception).isNotNull(); + assertThat(exception.getErrorResult()).isEqualTo(errorResult); + assertThat(exception.getMessage()).isEqualTo(errorResult.getMessage()); + assertThat(exception.getCause()).isEqualTo(cause); + } + + @Test + @DisplayName("다양한 WeatherErrorResult로 KmaApiException을 생성한다") + void Given_DifferentWeatherErrorResults_When_CreateKmaApiException_Then_ExceptionsAreCreated() { + // given & when & then + KmaApiException badRequestException = new KmaApiException(WeatherErrorResult.KMA_BAD_REQUEST); + assertThat(badRequestException.getErrorResult()).isEqualTo(WeatherErrorResult.KMA_BAD_REQUEST); + + KmaApiException serverErrorException = new KmaApiException(WeatherErrorResult.KMA_SERVER_ERROR); + assertThat(serverErrorException.getErrorResult()).isEqualTo(WeatherErrorResult.KMA_SERVER_ERROR); + + KmaApiException rateLimitException = new KmaApiException(WeatherErrorResult.KMA_RATE_LIMIT); + assertThat(rateLimitException.getErrorResult()).isEqualTo(WeatherErrorResult.KMA_RATE_LIMIT); + + KmaApiException apiErrorException = new KmaApiException(WeatherErrorResult.KMA_API_ERROR); + assertThat(apiErrorException.getErrorResult()).isEqualTo(WeatherErrorResult.KMA_API_ERROR); + } + + @Test + @DisplayName("KmaApiException의 메시지가 올바르게 설정된다") + void Given_KmaApiException_When_GetMessage_Then_MessageIsCorrect() { + // given + WeatherErrorResult errorResult = WeatherErrorResult.KMA_TIMEOUT; + + // when + KmaApiException exception = new KmaApiException(errorResult); + + // then + assertThat(exception.getMessage()).isEqualTo(errorResult.getMessage()); + } + + @Test + @DisplayName("KmaApiException의 원인이 올바르게 설정된다") + void Given_KmaApiExceptionWithCause_When_GetCause_Then_CauseIsCorrect() { + // given + WeatherErrorResult errorResult = WeatherErrorResult.KMA_API_ERROR; + Throwable cause = new IllegalArgumentException("Invalid argument"); + + // when + KmaApiException exception = new KmaApiException(errorResult, cause); + + // then + assertThat(exception.getCause()).isEqualTo(cause); + assertThat(exception.getCause().getMessage()).isEqualTo("Invalid argument"); + } + + @Test + @DisplayName("KmaApiException의 errorResult 필드가 올바르게 접근된다") + void Given_KmaApiException_When_GetErrorResult_Then_ErrorResultIsCorrect() { + // given + WeatherErrorResult errorResult = WeatherErrorResult.KMA_BAD_REQUEST; + + // when + KmaApiException exception = new KmaApiException(errorResult); + + // then + assertThat(exception.getErrorResult()).isEqualTo(errorResult); + assertThat(exception.getErrorResult().getMessage()).isEqualTo(errorResult.getMessage()); + } + +} diff --git a/src/test/java/com/und/server/weather/infrastructure/KmaApiFacadeTest.java b/src/test/java/com/und/server/weather/infrastructure/KmaApiFacadeTest.java new file mode 100644 index 00000000..d1b47edf --- /dev/null +++ b/src/test/java/com/und/server/weather/infrastructure/KmaApiFacadeTest.java @@ -0,0 +1,162 @@ +package com.und.server.weather.infrastructure; + +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.ArgumentMatchers.anyInt; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDate; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientResponseException; + +import com.und.server.weather.config.WeatherProperties; +import com.und.server.weather.constants.TimeSlot; +import com.und.server.weather.dto.GridPoint; +import com.und.server.weather.exception.KmaApiException; +import com.und.server.weather.exception.WeatherErrorResult; +import com.und.server.weather.infrastructure.client.KmaWeatherClient; +import com.und.server.weather.infrastructure.dto.KmaWeatherResponse; + +@ExtendWith(MockitoExtension.class) +class KmaApiFacadeTest { + + @Mock + private KmaWeatherClient kmaWeatherClient; + + @Mock + private WeatherProperties weatherProperties; + + @InjectMocks + private KmaApiFacade kmaApiFacade; + + private GridPoint gridPoint; + private TimeSlot timeSlot; + private LocalDate date; + + @BeforeEach + void setUp() { + gridPoint = new GridPoint(60, 127); + timeSlot = TimeSlot.SLOT_09_12; + date = LocalDate.of(2024, 1, 1); + + WeatherProperties.Kma props = org.mockito.Mockito.mock(WeatherProperties.Kma.class); + given(props.serviceKey()).willReturn("test-key"); + given(weatherProperties.kma()).willReturn(props); + } + + @Test + @DisplayName("정상 호출 시 KmaWeatherResponse 반환") + void Given_ValidRequest_When_CallWeatherApi_Then_ReturnResponse() { + KmaWeatherResponse mockResponse = org.mockito.Mockito.mock(KmaWeatherResponse.class); + given(kmaWeatherClient.getVilageForecast(any(), anyInt(), anyInt(), any(), any(), any(), anyInt(), anyInt())) + .willReturn(mockResponse); + + KmaWeatherResponse result = kmaApiFacade.callWeatherApi(gridPoint, timeSlot, date); + + assertThat(result).isEqualTo(mockResponse); + } + + @Test + @DisplayName("네트워크 타임아웃 발생 시 KMA_TIMEOUT 반환") + void Given_Timeout_When_CallWeatherApi_Then_ThrowKmaTimeout() { + given(kmaWeatherClient.getVilageForecast(any(), anyInt(), anyInt(), any(), any(), any(), anyInt(), anyInt())) + .willThrow(new ResourceAccessException("timeout")); + + assertThatThrownBy(() -> kmaApiFacade.callWeatherApi(gridPoint, timeSlot, date)) + .isInstanceOf(KmaApiException.class) + .satisfies(e -> { + assertThat(((KmaApiException) e).getErrorResult()) + .isEqualTo(WeatherErrorResult.KMA_TIMEOUT); + }); + } + + @Test + @DisplayName("4xx 발생 시 KMA_BAD_REQUEST 반환") + void Given_4xx_When_CallWeatherApi_Then_ThrowKmaBadRequest() { + given(kmaWeatherClient.getVilageForecast(any(), anyInt(), anyInt(), any(), any(), any(), anyInt(), anyInt())) + .willThrow(HttpClientErrorException.create(HttpStatus.BAD_REQUEST, "Bad request", null, null, null)); + + assertThatThrownBy(() -> kmaApiFacade.callWeatherApi(gridPoint, timeSlot, date)) + .isInstanceOf(KmaApiException.class) + .satisfies(e -> { + assertThat(((KmaApiException) e).getErrorResult()) + .isEqualTo(WeatherErrorResult.KMA_BAD_REQUEST); + }); + } + + @Test + @DisplayName("5xx 발생 시 KMA_SERVER_ERROR 반환") + void Given_5xx_When_CallWeatherApi_Then_ThrowKmaServerError() { + given(kmaWeatherClient.getVilageForecast(any(), anyInt(), anyInt(), any(), any(), any(), anyInt(), anyInt())) + .willThrow( + HttpServerErrorException.create(HttpStatus.INTERNAL_SERVER_ERROR, "Server error", null, null, null)); + + assertThatThrownBy(() -> kmaApiFacade.callWeatherApi(gridPoint, timeSlot, date)) + .isInstanceOf(KmaApiException.class) + .satisfies(e -> { + assertThat(((KmaApiException) e).getErrorResult()) + .isEqualTo(WeatherErrorResult.KMA_SERVER_ERROR); + }); + } + + @Test + @DisplayName("429 발생 시 KMA_RATE_LIMIT 반환") + void Given_429_When_CallWeatherApi_Then_ThrowKmaRateLimit() { + RestClientResponseException rateLimitEx = + new RestClientResponseException("Too many requests", 429, "Too many requests", null, null, null); + + given(kmaWeatherClient.getVilageForecast(any(), anyInt(), anyInt(), any(), any(), any(), anyInt(), anyInt())) + .willThrow(rateLimitEx); + + assertThatThrownBy(() -> kmaApiFacade.callWeatherApi(gridPoint, timeSlot, date)) + .isInstanceOf(KmaApiException.class) + .satisfies(e -> { + assertThat(((KmaApiException) e).getErrorResult()) + .isEqualTo(WeatherErrorResult.KMA_RATE_LIMIT); + }); + } + + @Test + @DisplayName("기타 Exception 발생 시 KMA_API_ERROR 반환") + void Given_OtherError_When_CallWeatherApi_Then_ThrowKmaApiError() { + given(kmaWeatherClient.getVilageForecast(any(), anyInt(), anyInt(), any(), any(), any(), anyInt(), anyInt())) + .willThrow(new RuntimeException("Unexpected error")); + + assertThatThrownBy(() -> kmaApiFacade.callWeatherApi(gridPoint, timeSlot, date)) + .isInstanceOf(KmaApiException.class) + .satisfies(e -> { + assertThat(((KmaApiException) e).getErrorResult()) + .isEqualTo(WeatherErrorResult.KMA_API_ERROR); + }); + } + + @Test + @DisplayName("RestClientResponseException (429 아님) 발생 시 KMA_API_ERROR 반환") + void Given_RestClientResponseExceptionNon429_When_CallWeatherApi_Then_ThrowKmaApiError() { + RestClientResponseException otherError = + new RestClientResponseException("Other error", 418, "I'm a teapot", null, null, null); + + given(kmaWeatherClient.getVilageForecast(any(), anyInt(), anyInt(), any(), any(), any(), anyInt(), anyInt())) + .willThrow(otherError); + + assertThatThrownBy(() -> kmaApiFacade.callWeatherApi(gridPoint, timeSlot, date)) + .isInstanceOf(KmaApiException.class) + .satisfies(e -> { + assertThat(((KmaApiException) e).getErrorResult()) + .isEqualTo(WeatherErrorResult.KMA_API_ERROR); + }); + } + +} diff --git a/src/test/java/com/und/server/weather/infrastructure/OpenMeteoApiFacadeTest.java b/src/test/java/com/und/server/weather/infrastructure/OpenMeteoApiFacadeTest.java new file mode 100644 index 00000000..fb2bcb76 --- /dev/null +++ b/src/test/java/com/und/server/weather/infrastructure/OpenMeteoApiFacadeTest.java @@ -0,0 +1,184 @@ +package com.und.server.weather.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable; +import static org.mockito.BDDMockito.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; + +import java.time.LocalDate; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientResponseException; + +import com.und.server.weather.exception.WeatherErrorResult; +import com.und.server.weather.exception.WeatherException; +import com.und.server.weather.infrastructure.client.OpenMeteoClient; +import com.und.server.weather.infrastructure.client.OpenMeteoKmaClient; +import com.und.server.weather.infrastructure.dto.OpenMeteoResponse; +import com.und.server.weather.infrastructure.dto.OpenMeteoWeatherResponse; + +@ExtendWith(MockitoExtension.class) +class OpenMeteoApiFacadeTest { + + @Mock + private OpenMeteoClient openMeteoClient; + + @Mock + private OpenMeteoKmaClient openMeteoKmaClient; + + @InjectMocks + private OpenMeteoApiFacade facade; + + private final Double latitude = 37.5; + private final Double longitude = 127.0; + private final LocalDate date = LocalDate.of(2024, 1, 1); + + @Test + @DisplayName("callDustUvApi - 정상 응답 반환") + void Given_ValidRequest_When_CallDustUvApi_Then_ReturnResponse() { + // given + OpenMeteoResponse mockResponse = mock(OpenMeteoResponse.class); + given(openMeteoClient.getForecast(any(), any(), any(), any(), any(), any())) + .willReturn(mockResponse); + + // when + OpenMeteoResponse result = facade.callDustUvApi(latitude, longitude, date); + + // then + assertThat(result).isEqualTo(mockResponse); + } + + @Test + @DisplayName("callDustUvApi - 네트워크 타임아웃 시 WeatherException 발생") + void Given_Timeout_When_CallDustUvApi_Then_ThrowWeatherException() { + // given + given(openMeteoClient.getForecast(any(), any(), any(), any(), any(), any())) + .willThrow(new ResourceAccessException("timeout")); + + // when + Throwable thrown = catchThrowable(() -> + facade.callDustUvApi(37.5, 127.0, LocalDate.of(2024, 1, 1))); + + // then + assertThat(thrown).isInstanceOf(WeatherException.class); + WeatherException ex = (WeatherException) thrown; + assertThat(ex.getErrorResult()).isEqualTo(WeatherErrorResult.OPEN_METEO_TIMEOUT); + } + + + @Test + @DisplayName("callWeatherApi - 정상 응답 반환") + void Given_ValidRequest_When_CallWeatherApi_Then_ReturnResponse() { + // given + OpenMeteoWeatherResponse mockResponse = mock(OpenMeteoWeatherResponse.class); + given(openMeteoKmaClient.getWeatherForecast(any(), any(), any(), any(), any(), any())) + .willReturn(mockResponse); + + // when + OpenMeteoWeatherResponse result = facade.callWeatherApi(latitude, longitude, date); + + // then + assertThat(result).isEqualTo(mockResponse); + } + + @Test + @DisplayName("callWeatherApi - 예외 발생 시 WeatherException 발생") + void Given_Error_When_CallWeatherApi_Then_ThrowWeatherException() { + // given + given(openMeteoKmaClient.getWeatherForecast(any(), any(), any(), any(), any(), any())) + .willThrow(new RuntimeException("API error")); + + // when + Throwable thrown = catchThrowable(() -> + facade.callWeatherApi(37.5, 127.0, LocalDate.of(2024, 1, 1))); + + // then + assertThat(thrown).isInstanceOf(WeatherException.class); + WeatherException ex = (WeatherException) thrown; + assertThat(ex.getErrorResult()).isEqualTo(WeatherErrorResult.OPEN_METEO_API_ERROR); + } + + + @Test + @DisplayName("callDustUvApi - 4xx 발생 시 WeatherException 발생") + void Given_HttpClientError_When_CallDustUvApi_Then_ThrowWeatherException() { + // given + given(openMeteoClient.getForecast(any(), any(), any(), any(), any(), any())) + .willThrow(new HttpClientErrorException(HttpStatus.BAD_REQUEST)); + + // when + Throwable thrown = catchThrowable(() -> + facade.callDustUvApi(latitude, longitude, date)); + + // then + assertThat(thrown).isInstanceOf(WeatherException.class); + WeatherException ex = (WeatherException) thrown; + assertThat(ex.getErrorResult()).isEqualTo(WeatherErrorResult.OPEN_METEO_BAD_REQUEST); + } + + @Test + @DisplayName("callDustUvApi - 5xx 발생 시 WeatherException 발생") + void Given_HttpServerError_When_CallDustUvApi_Then_ThrowWeatherException() { + // given + given(openMeteoClient.getForecast(any(), any(), any(), any(), any(), any())) + .willThrow(new HttpServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR)); + + // when + Throwable thrown = catchThrowable(() -> + facade.callDustUvApi(latitude, longitude, date)); + + // then + assertThat(thrown).isInstanceOf(WeatherException.class); + WeatherException ex = (WeatherException) thrown; + assertThat(ex.getErrorResult()).isEqualTo(WeatherErrorResult.OPEN_METEO_SERVER_ERROR); + } + + @Test + @DisplayName("callDustUvApi - 429 발생 시 WeatherException 발생") + void Given_TooManyRequests_When_CallDustUvApi_Then_ThrowWeatherException() { + // given + RestClientResponseException tooManyRequests = + new RestClientResponseException("Rate limit", 429, "Too Many Requests", null, null, null); + given(openMeteoClient.getForecast(any(), any(), any(), any(), any(), any())) + .willThrow(tooManyRequests); + + // when + Throwable thrown = catchThrowable(() -> + facade.callDustUvApi(latitude, longitude, date)); + + // then + assertThat(thrown).isInstanceOf(WeatherException.class); + WeatherException ex = (WeatherException) thrown; + assertThat(ex.getErrorResult()).isEqualTo(WeatherErrorResult.OPEN_METEO_RATE_LIMIT); + } + + @Test + @DisplayName("callDustUvApi - RestClientResponseException(기타) 발생 시 WeatherException 발생") + void Given_RestClientResponseException_When_CallDustUvApi_Then_ThrowWeatherException() { + // given + RestClientResponseException otherError = + new RestClientResponseException("Other error", 418, "I'm a teapot", null, null, null); + given(openMeteoClient.getForecast(any(), any(), any(), any(), any(), any())) + .willThrow(otherError); + + // when + Throwable thrown = catchThrowable(() -> + facade.callDustUvApi(latitude, longitude, date)); + + // then + assertThat(thrown).isInstanceOf(WeatherException.class); + WeatherException ex = (WeatherException) thrown; + assertThat(ex.getErrorResult()).isEqualTo(WeatherErrorResult.OPEN_METEO_API_ERROR); + } + +} diff --git a/src/test/java/com/und/server/weather/infrastructure/dto/KmaWeatherResponseTest.java b/src/test/java/com/und/server/weather/infrastructure/dto/KmaWeatherResponseTest.java new file mode 100644 index 00000000..50a56aff --- /dev/null +++ b/src/test/java/com/und/server/weather/infrastructure/dto/KmaWeatherResponseTest.java @@ -0,0 +1,55 @@ +package com.und.server.weather.infrastructure.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class KmaWeatherResponseTest { + + @Test + @DisplayName("KmaWeatherResponse.Header 생성 및 필드 검증") + void testHeader() { + KmaWeatherResponse.Header header = + new KmaWeatherResponse.Header("00", "NORMAL SERVICE"); + + assertThat(header.resultCode()).isEqualTo("00"); + assertThat(header.resultMsg()).isEqualTo("NORMAL SERVICE"); + } + + @Test + @DisplayName("KmaWeatherResponse.WeatherItem 생성 및 필드 검증") + void testWeatherItem() { + KmaWeatherResponse.WeatherItem item = new KmaWeatherResponse.WeatherItem( + "20240101", "0200", "TMP", + "20240101", "0300", "5", + 60, 127 + ); + + assertThat(item.baseDate()).isEqualTo("20240101"); + assertThat(item.fcstValue()).isEqualTo("5"); + assertThat(item.nx()).isEqualTo(60); + assertThat(item.ny()).isEqualTo(127); + } + + @Test + @DisplayName("KmaWeatherResponse 전체 구조 생성 및 필드 검증") + void testResponse() { + KmaWeatherResponse.WeatherItem item = new KmaWeatherResponse.WeatherItem( + "20240101", "0200", "TMP", "20240101", "0300", "5", 60, 127 + ); + KmaWeatherResponse.Items items = new KmaWeatherResponse.Items(List.of(item)); + KmaWeatherResponse.Body body = new KmaWeatherResponse.Body("JSON", items, 1); + KmaWeatherResponse.Header header = new KmaWeatherResponse.Header("00", "NORMAL SERVICE"); + KmaWeatherResponse.Response response = new KmaWeatherResponse.Response(header, body); + + KmaWeatherResponse weatherResponse = new KmaWeatherResponse(response); + + assertThat(weatherResponse.response()).isEqualTo(response); + assertThat(weatherResponse.response().header().resultCode()).isEqualTo("00"); + assertThat(weatherResponse.response().body().items().item()).contains(item); + } + +} diff --git a/src/test/java/com/und/server/weather/infrastructure/dto/OpenMeteoResponseTest.java b/src/test/java/com/und/server/weather/infrastructure/dto/OpenMeteoResponseTest.java new file mode 100644 index 00000000..f431617b --- /dev/null +++ b/src/test/java/com/und/server/weather/infrastructure/dto/OpenMeteoResponseTest.java @@ -0,0 +1,60 @@ +package com.und.server.weather.infrastructure.dto; + + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class OpenMeteoResponseTest { + + @Test + @DisplayName("OpenMeteoResponse.HourlyUnits 생성 및 필드 검증") + void testHourlyUnits() { + OpenMeteoResponse.HourlyUnits units = + new OpenMeteoResponse.HourlyUnits("time-unit", "µg/m3", "µg/m3", "index"); + + assertThat(units.time()).isEqualTo("time-unit"); + assertThat(units.pm25()).isEqualTo("µg/m3"); + assertThat(units.pm10()).isEqualTo("µg/m3"); + assertThat(units.uvIndex()).isEqualTo("index"); + } + + @Test + @DisplayName("OpenMeteoResponse.Hourly 생성 및 필드 검증") + void testHourly() { + OpenMeteoResponse.Hourly hourly = new OpenMeteoResponse.Hourly( + List.of("2024-01-01T00:00"), + List.of(10.0), + List.of(20.0), + List.of(5.0) + ); + + assertThat(hourly.time()).contains("2024-01-01T00:00"); + assertThat(hourly.pm25()).contains(10.0); + assertThat(hourly.pm10()).contains(20.0); + assertThat(hourly.uvIndex()).contains(5.0); + } + + @Test + @DisplayName("OpenMeteoResponse 생성 및 필드 검증") + void testResponse() { + OpenMeteoResponse.HourlyUnits units = + new OpenMeteoResponse.HourlyUnits("time-unit", "µg/m3", "µg/m3", "index"); + OpenMeteoResponse.Hourly hourly = new OpenMeteoResponse.Hourly( + List.of("2024-01-01T00:00"), List.of(10.0), List.of(20.0), List.of(5.0) + ); + + OpenMeteoResponse response = + new OpenMeteoResponse(37.5, 127.0, "Asia/Seoul", units, hourly); + + assertThat(response.latitude()).isEqualTo(37.5); + assertThat(response.longitude()).isEqualTo(127.0); + assertThat(response.timezone()).isEqualTo("Asia/Seoul"); + assertThat(response.hourlyUnits()).isEqualTo(units); + assertThat(response.hourly()).isEqualTo(hourly); + } + +} diff --git a/src/test/java/com/und/server/weather/infrastructure/dto/OpenMeteoWeatherResponseTest.java b/src/test/java/com/und/server/weather/infrastructure/dto/OpenMeteoWeatherResponseTest.java new file mode 100644 index 00000000..92d257a3 --- /dev/null +++ b/src/test/java/com/und/server/weather/infrastructure/dto/OpenMeteoWeatherResponseTest.java @@ -0,0 +1,51 @@ +package com.und.server.weather.infrastructure.dto; + + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class OpenMeteoWeatherResponseTest { + + @Test + @DisplayName("OpenMeteoWeatherResponse.HourlyUnits 생성 및 필드 검증") + void testHourlyUnits() { + OpenMeteoWeatherResponse.HourlyUnits units = + new OpenMeteoWeatherResponse.HourlyUnits("time-unit", "weather-code"); + + assertThat(units.time()).isEqualTo("time-unit"); + assertThat(units.weathercode()).isEqualTo("weather-code"); + } + + @Test + @DisplayName("OpenMeteoWeatherResponse.Hourly 생성 및 필드 검증") + void testHourly() { + OpenMeteoWeatherResponse.Hourly hourly = + new OpenMeteoWeatherResponse.Hourly(List.of("2024-01-01T00:00"), List.of(80)); + + assertThat(hourly.time()).contains("2024-01-01T00:00"); + assertThat(hourly.weathercode()).contains(80); + } + + @Test + @DisplayName("OpenMeteoWeatherResponse 생성 및 필드 검증") + void testResponse() { + OpenMeteoWeatherResponse.HourlyUnits units = + new OpenMeteoWeatherResponse.HourlyUnits("time-unit", "weather-code"); + OpenMeteoWeatherResponse.Hourly hourly = + new OpenMeteoWeatherResponse.Hourly(List.of("2024-01-01T00:00"), List.of(80)); + + OpenMeteoWeatherResponse response = + new OpenMeteoWeatherResponse(37.5, 127.0, "Asia/Seoul", units, hourly); + + assertThat(response.latitude()).isEqualTo(37.5); + assertThat(response.longitude()).isEqualTo(127.0); + assertThat(response.timezone()).isEqualTo("Asia/Seoul"); + assertThat(response.hourlyUnits()).isEqualTo(units); + assertThat(response.hourly()).isEqualTo(hourly); + } + +} diff --git a/src/test/java/com/und/server/weather/service/FineDustExtractorTest.java b/src/test/java/com/und/server/weather/service/FineDustExtractorTest.java new file mode 100644 index 00000000..c9d29160 --- /dev/null +++ b/src/test/java/com/und/server/weather/service/FineDustExtractorTest.java @@ -0,0 +1,156 @@ +package com.und.server.weather.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.und.server.weather.constants.FineDustType; +import com.und.server.weather.infrastructure.dto.OpenMeteoResponse; + +class FineDustExtractorTest { + + private final FineDustExtractor extractor = new FineDustExtractor(); + + @Test + @DisplayName("정상 응답에서 미세먼지 정보를 추출한다 (GOOD, BAD, VERY_BAD)") + void Given_ValidOpenMeteoResponse_When_ExtractDustForHours_Then_ReturnsFineDustMap() { + // given + List times = Arrays.asList( + "2024-01-01T09:00", + "2024-01-01T10:00", + "2024-01-01T11:00" + ); + List pm25 = Arrays.asList(10.0, 50.0, 80.0); // GOOD, BAD, VERY_BAD + List pm10 = Arrays.asList(20.0, 120.0, 200.0); // GOOD, BAD, VERY_BAD + List uv = Arrays.asList(1.0, 2.0, 3.0); + + OpenMeteoResponse.Hourly hourly = new OpenMeteoResponse.Hourly(times, pm25, pm10, uv); + OpenMeteoResponse response = + new OpenMeteoResponse(37.5, 127.0, "Asia/Seoul", null, hourly); + + List targetHours = Arrays.asList(9, 10, 11); + LocalDate date = LocalDate.of(2024, 1, 1); + + // when + Map result = extractor.extractDustForHours(response, targetHours, date); + + // then + assertThat(result).hasSize(3); + assertThat(result).containsEntry(9, FineDustType.GOOD); + assertThat(result).containsEntry(10, FineDustType.BAD); + assertThat(result).containsEntry(11, FineDustType.VERY_BAD); + } + + @Test + @DisplayName("응답이 null이면 빈 맵을 반환한다") + void Given_NullResponse_When_ExtractDustForHours_Then_ReturnsEmptyMap() { + Map result = + extractor.extractDustForHours( + null, List.of(9), LocalDate.of(2024, 1, 1)); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("hourly가 null이면 빈 맵을 반환한다") + void Given_NullHourly_When_ExtractDustForHours_Then_ReturnsEmptyMap() { + OpenMeteoResponse response = + new OpenMeteoResponse(37.5, 127.0, "Asia/Seoul", null, null); + + Map result = + extractor.extractDustForHours(response, List.of(9), LocalDate.of(2024, 1, 1)); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("times/pm10/pm25가 null이면 빈 맵을 반환한다") + void Given_InvalidData_When_ExtractDustForHours_Then_ReturnsEmptyMap() { + OpenMeteoResponse.Hourly hourly = + new OpenMeteoResponse.Hourly(null, null, null, null); + OpenMeteoResponse response = + new OpenMeteoResponse(37.5, 127.0, "Asia/Seoul", null, hourly); + + Map result = + extractor.extractDustForHours(response, List.of(9), LocalDate.of(2024, 1, 1)); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("날짜가 다르면 결과에 포함되지 않는다") + void Given_DifferentDate_When_ExtractDustForHours_Then_Ignore() { + List times = List.of("2024-01-02T09:00"); + List pm25 = List.of(10.0); + List pm10 = List.of(20.0); + List uv = List.of(1.0); + + OpenMeteoResponse.Hourly hourly = + new OpenMeteoResponse.Hourly(times, pm25, pm10, uv); + OpenMeteoResponse response = + new OpenMeteoResponse(37.5, 127.0, "Asia/Seoul", null, hourly); + + Map result = + extractor.extractDustForHours(response, List.of(9), LocalDate.of(2024, 1, 1)); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("hour 파싱이 불가능하면 무시한다") + void Given_InvalidHourString_When_ExtractDustForHours_Then_Ignore() { + List times = List.of("2024-01-01Tabc"); + List pm25 = List.of(10.0); + List pm10 = List.of(20.0); + List uv = List.of(1.0); + + OpenMeteoResponse.Hourly hourly = + new OpenMeteoResponse.Hourly(times, pm25, pm10, uv); + OpenMeteoResponse response = + new OpenMeteoResponse(37.5, 127.0, "Asia/Seoul", null, hourly); + + Map result = + extractor.extractDustForHours(response, List.of(9), LocalDate.of(2024, 1, 1)); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("pm10/pm25 값이 부족하거나 null이면 무시된다") + void Given_IndexOutOfBoundsOrNull_When_ExtractDustForHours_Then_Ignore() { + List times = Arrays.asList("2024-01-01T09:00", "2024-01-01T10:00"); + List pm25 = Arrays.asList(null, 50.0); // 첫 번째 null + List pm10 = Collections.singletonList(20.0); // index 1 없음 + List uv = Arrays.asList(1.0, 2.0); + + OpenMeteoResponse.Hourly hourly = + new OpenMeteoResponse.Hourly(times, pm25, pm10, uv); + OpenMeteoResponse response = + new OpenMeteoResponse(37.5, 127.0, "Asia/Seoul", null, hourly); + + Map result = + extractor.extractDustForHours(response, Arrays.asList(9, 10), LocalDate.of(2024, 1, 1)); + assertThat(result).isEmpty(); + } + + // --- FineDustType enum 전용 검증 --- + + @Test + @DisplayName("FineDustType 구간별 매핑 동작 확인") + void Given_Values_When_FromPm10AndPm25_Then_CorrectLevel() { + assertThat(FineDustType.fromPm10Concentration(20)).isEqualTo(FineDustType.GOOD); + assertThat(FineDustType.fromPm10Concentration(100)).isEqualTo(FineDustType.BAD); + assertThat(FineDustType.fromPm25Concentration(10)).isEqualTo(FineDustType.GOOD); + assertThat(FineDustType.fromPm25Concentration(40)).isEqualTo(FineDustType.BAD); + } + + @Test + @DisplayName("FineDustType getWorst는 더 나쁜 수준을 반환한다") + void Given_TwoLevels_When_GetWorst_Then_ReturnWorst() { + FineDustType result = FineDustType.getWorst(List.of(FineDustType.GOOD, FineDustType.BAD)); + assertThat(result).isEqualTo(FineDustType.BAD); + } + +} diff --git a/src/test/java/com/und/server/weather/service/FutureWeatherDecisionSelectorTest.java b/src/test/java/com/und/server/weather/service/FutureWeatherDecisionSelectorTest.java new file mode 100644 index 00000000..07324103 --- /dev/null +++ b/src/test/java/com/und/server/weather/service/FutureWeatherDecisionSelectorTest.java @@ -0,0 +1,167 @@ +package com.und.server.weather.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.weather.constants.FineDustType; +import com.und.server.weather.constants.UvType; +import com.und.server.weather.constants.WeatherType; + +@ExtendWith(MockitoExtension.class) +@DisplayName("FutureWeatherDecisionSelector 테스트") +class FutureWeatherDecisionSelectorTest { + + @InjectMocks + private FutureWeatherDecisionSelector futureWeatherDecisionSelector; + + + @Test + @DisplayName("최악의 날씨를 정상적으로 선택한다") + void Given_WeatherTypes_When_CalculateWorstWeather_Then_ReturnsWorstWeather() { + // given + List weatherTypes = Arrays.asList( + WeatherType.SUNNY, + WeatherType.CLOUDY, + WeatherType.RAIN, + WeatherType.SHOWER + ); + + // when + WeatherType result = futureWeatherDecisionSelector.calculateWorstWeather(weatherTypes); + + // then + assertThat(result).isEqualTo(WeatherType.SHOWER); + } + + + @Test + @DisplayName("날씨 리스트가 null일 때 기본값을 반환한다") + void Given_NullWeatherTypes_When_CalculateWorstWeather_Then_ReturnsDefault() { + // given + List weatherTypes = null; + + // when + WeatherType result = futureWeatherDecisionSelector.calculateWorstWeather(weatherTypes); + + // then + assertThat(result).isEqualTo(WeatherType.DEFAULT); + } + + + @Test + @DisplayName("날씨 리스트가 비어있을 때 기본값을 반환한다") + void Given_EmptyWeatherTypes_When_CalculateWorstWeather_Then_ReturnsDefault() { + // given + List weatherTypes = Collections.emptyList(); + + // when + WeatherType result = futureWeatherDecisionSelector.calculateWorstWeather(weatherTypes); + + // then + assertThat(result).isEqualTo(WeatherType.DEFAULT); + } + + + @Test + @DisplayName("최악의 미세먼지를 정상적으로 선택한다") + void Given_FineDustTypes_When_CalculateWorstFineDust_Then_ReturnsWorstFineDust() { + // given + List fineDustTypes = Arrays.asList( + FineDustType.GOOD, + FineDustType.NORMAL, + FineDustType.BAD, + FineDustType.VERY_BAD + ); + + // when + FineDustType result = futureWeatherDecisionSelector.calculateWorstFineDust(fineDustTypes); + + // then + assertThat(result).isEqualTo(FineDustType.VERY_BAD); + } + + + @Test + @DisplayName("미세먼지 리스트가 null일 때 기본값을 반환한다") + void Given_NullFineDustTypes_When_CalculateWorstFineDust_Then_ReturnsDefault() { + // given + List fineDustTypes = null; + + // when + FineDustType result = futureWeatherDecisionSelector.calculateWorstFineDust(fineDustTypes); + + // then + assertThat(result).isEqualTo(FineDustType.DEFAULT); + } + + + @Test + @DisplayName("미세먼지 리스트가 비어있을 때 기본값을 반환한다") + void Given_EmptyFineDustTypes_When_CalculateWorstFineDust_Then_ReturnsDefault() { + // given + List fineDustTypes = Collections.emptyList(); + + // when + FineDustType result = futureWeatherDecisionSelector.calculateWorstFineDust(fineDustTypes); + + // then + assertThat(result).isEqualTo(FineDustType.DEFAULT); + } + + + @Test + @DisplayName("최악의 자외선을 정상적으로 선택한다") + void Given_UvTypes_When_CalculateWorstUv_Then_ReturnsWorstUv() { + // given + List uvTypes = Arrays.asList( + UvType.LOW, + UvType.NORMAL, + UvType.HIGH, + UvType.VERY_HIGH + ); + + // when + UvType result = futureWeatherDecisionSelector.calculateWorstUv(uvTypes); + + // then + assertThat(result).isEqualTo(UvType.VERY_HIGH); + } + + + @Test + @DisplayName("자외선 리스트가 null일 때 기본값을 반환한다") + void Given_NullUvTypes_When_CalculateWorstUv_Then_ReturnsDefault() { + // given + List uvTypes = null; + + // when + UvType result = futureWeatherDecisionSelector.calculateWorstUv(uvTypes); + + // then + assertThat(result).isEqualTo(UvType.DEFAULT); + } + + + @Test + @DisplayName("자외선 리스트가 비어있을 때 기본값을 반환한다") + void Given_EmptyUvTypes_When_CalculateWorstUv_Then_ReturnsDefault() { + // given + List uvTypes = Collections.emptyList(); + + // when + UvType result = futureWeatherDecisionSelector.calculateWorstUv(uvTypes); + + // then + assertThat(result).isEqualTo(UvType.DEFAULT); + } + +} diff --git a/src/test/java/com/und/server/weather/service/KmaWeatherExtractorTest.java b/src/test/java/com/und/server/weather/service/KmaWeatherExtractorTest.java new file mode 100644 index 00000000..bc85657d --- /dev/null +++ b/src/test/java/com/und/server/weather/service/KmaWeatherExtractorTest.java @@ -0,0 +1,277 @@ +package com.und.server.weather.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.weather.constants.WeatherType; +import com.und.server.weather.infrastructure.dto.KmaWeatherResponse; + +@ExtendWith(MockitoExtension.class) +@DisplayName("KmaWeatherExtractor 테스트") +class KmaWeatherExtractorTest { + + @InjectMocks + private KmaWeatherExtractor kmaWeatherExtractor; + + + @Test + @DisplayName("KMA 응답에서 날씨 정보를 정상적으로 추출한다") + void Given_ValidKmaResponse_When_ExtractWeatherForHours_Then_ReturnsWeatherMap() { + // given + KmaWeatherResponse.WeatherItem ptyItem = + new KmaWeatherResponse.WeatherItem( + "20240101", "0800", "PTY", + "20240101", "0900", "0", 55, 127); + KmaWeatherResponse.WeatherItem skyItem = + new KmaWeatherResponse.WeatherItem( + "20240101", "0800", "SKY", + "20240101", "0900", "1", 55, 127); + KmaWeatherResponse.WeatherItem ptyItem2 = + new KmaWeatherResponse.WeatherItem( + "20240101", "0800", "PTY", + "20240101", "1000", "1", 55, 127); + KmaWeatherResponse.WeatherItem skyItem2 = + new KmaWeatherResponse.WeatherItem( + "20240101", "0800", "SKY", + "20240101", "1000", "3", 55, 127); + + KmaWeatherResponse.Items items = + new KmaWeatherResponse.Items(Arrays.asList(ptyItem, skyItem, ptyItem2, skyItem2)); + KmaWeatherResponse.Body body = new KmaWeatherResponse.Body("JSON", items, 4); + KmaWeatherResponse.Response response = new KmaWeatherResponse.Response(null, body); + KmaWeatherResponse weatherResponse = new KmaWeatherResponse(response); + + List targetHours = Arrays.asList(9, 10); + LocalDate date = LocalDate.of(2024, 1, 1); + + // when + Map result = + kmaWeatherExtractor.extractWeatherForHours(weatherResponse, targetHours, date); + + // then + assertThat(result).hasSize(2); + assertThat(result).containsEntry(9, WeatherType.SUNNY); + assertThat(result).containsEntry(10, WeatherType.RAIN); + } + + + @Test + @DisplayName("KMA 응답이 null일 때 빈 맵을 반환한다") + void Given_NullKmaResponse_When_ExtractWeatherForHours_Then_ReturnsEmptyMap() { + // given + KmaWeatherResponse weatherResponse = null; + List targetHours = Arrays.asList(9, 10); + LocalDate date = LocalDate.of(2024, 1, 1); + + // when + Map result = + kmaWeatherExtractor.extractWeatherForHours(weatherResponse, targetHours, date); + + // then + assertThat(result).isEmpty(); + } + + + @Test + @DisplayName("KMA 응답 구조가 불완전할 때 빈 맵을 반환한다") + void Given_IncompleteKmaResponse_When_ExtractWeatherForHours_Then_ReturnsEmptyMap() { + // given + KmaWeatherResponse weatherResponse = new KmaWeatherResponse(null); + List targetHours = Arrays.asList(9, 10); + LocalDate date = LocalDate.of(2024, 1, 1); + + // when + Map result = + kmaWeatherExtractor.extractWeatherForHours(weatherResponse, targetHours, date); + + // then + assertThat(result).isEmpty(); + } + + + @Test + @DisplayName("타겟 시간이 null일 때 빈 맵을 반환한다") + void Given_NullTargetHours_When_ExtractWeatherForHours_Then_ReturnsEmptyMap() { + // given + KmaWeatherResponse.WeatherItem item = + new KmaWeatherResponse.WeatherItem("20240101", "0800", "PTY", + "20240101", "0900", "0", 55, 127); + KmaWeatherResponse.Items items = new KmaWeatherResponse.Items(Arrays.asList(item)); + KmaWeatherResponse.Body body = new KmaWeatherResponse.Body("JSON", items, 1); + KmaWeatherResponse.Response response = new KmaWeatherResponse.Response(null, body); + KmaWeatherResponse weatherResponse = new KmaWeatherResponse(response); + + List targetHours = null; + LocalDate date = LocalDate.of(2024, 1, 1); + + // when + Map result = + kmaWeatherExtractor.extractWeatherForHours(weatherResponse, targetHours, date); + + // then + assertThat(result).isEmpty(); + } + + + @Test + @DisplayName("타겟 시간이 비어있을 때 빈 맵을 반환한다") + void Given_EmptyTargetHours_When_ExtractWeatherForHours_Then_ReturnsEmptyMap() { + // given + KmaWeatherResponse.WeatherItem item = + new KmaWeatherResponse.WeatherItem("20240101", "0800", "PTY", + "20240101", "0900", "0", 55, 127); + KmaWeatherResponse.Items items = new KmaWeatherResponse.Items(Arrays.asList(item)); + KmaWeatherResponse.Body body = new KmaWeatherResponse.Body("JSON", items, 1); + KmaWeatherResponse.Response response = new KmaWeatherResponse.Response(null, body); + KmaWeatherResponse weatherResponse = new KmaWeatherResponse(response); + + List targetHours = Arrays.asList(); + LocalDate date = LocalDate.of(2024, 1, 1); + + // when + Map result = + kmaWeatherExtractor.extractWeatherForHours(weatherResponse, targetHours, date); + + // then + assertThat(result).isEmpty(); + } + + + @Test + @DisplayName("다른 날짜의 데이터는 무시한다") + void Given_DifferentDateData_When_ExtractWeatherForHours_Then_IgnoresDifferentDate() { + // given + KmaWeatherResponse.WeatherItem item1 = + new KmaWeatherResponse.WeatherItem("20240101", "0800", "PTY", + "20240101", "0900", "0", 55, 127); + KmaWeatherResponse.Items items = new KmaWeatherResponse.Items(Arrays.asList(item1)); + KmaWeatherResponse.Body body = new KmaWeatherResponse.Body("JSON", items, 1); + KmaWeatherResponse.Response response = new KmaWeatherResponse.Response(null, body); + KmaWeatherResponse weatherResponse = new KmaWeatherResponse(response); + + List targetHours = Arrays.asList(9); + LocalDate date = LocalDate.of(2024, 1, 1); + + // when + Map result = + kmaWeatherExtractor.extractWeatherForHours(weatherResponse, targetHours, date); + + // then + assertThat(result).isNotNull(); + } + + + @Test + @DisplayName("PTY와 SKY가 모두 있을 때 PTY를 우선한다") + void Given_PtyAndSkyData_When_ExtractWeatherForHours_Then_PrioritizesPty() { + // given + KmaWeatherResponse.WeatherItem ptyItem = + new KmaWeatherResponse.WeatherItem("20240101", "0800", "PTY", + "20240101", "0900", "1", 55, 127); + KmaWeatherResponse.WeatherItem skyItem = + new KmaWeatherResponse.WeatherItem("20240101", "0800", "SKY", + "20240101", "0900", "1", 55, 127); + KmaWeatherResponse.Items items = new KmaWeatherResponse.Items(Arrays.asList(ptyItem, skyItem)); + KmaWeatherResponse.Body body = new KmaWeatherResponse.Body("JSON", items, 2); + KmaWeatherResponse.Response response = new KmaWeatherResponse.Response(null, body); + KmaWeatherResponse weatherResponse = new KmaWeatherResponse(response); + + List targetHours = Arrays.asList(9); + LocalDate date = LocalDate.of(2024, 1, 1); + + // when + Map result = + kmaWeatherExtractor.extractWeatherForHours(weatherResponse, targetHours, date); + + // then + assertThat(result).hasSize(1); + assertThat(result).containsEntry(9, WeatherType.RAIN); + } + + + @Test + @DisplayName("PTY가 0일 때 SKY 값을 사용한다") + void Given_PtyZeroAndSkyData_When_ExtractWeatherForHours_Then_UsesSkyValue() { + // given + KmaWeatherResponse.WeatherItem ptyItem = + new KmaWeatherResponse.WeatherItem("20240101", "0800", "PTY", + "20240101", "0900", "0", 55, 127); + KmaWeatherResponse.WeatherItem skyItem = + new KmaWeatherResponse.WeatherItem("20240101", "0800", "SKY", + "20240101", "0900", "3", 55, 127); + KmaWeatherResponse.Items items = new KmaWeatherResponse.Items(Arrays.asList(ptyItem, skyItem)); + KmaWeatherResponse.Body body = new KmaWeatherResponse.Body("JSON", items, 2); + KmaWeatherResponse.Response response = new KmaWeatherResponse.Response(null, body); + KmaWeatherResponse weatherResponse = new KmaWeatherResponse(response); + + List targetHours = Arrays.asList(9); + LocalDate date = LocalDate.of(2024, 1, 1); + + // when + Map result = + kmaWeatherExtractor.extractWeatherForHours(weatherResponse, targetHours, date); + + // then + assertThat(result).hasSize(1); + assertThat(result).containsEntry(9, WeatherType.CLOUDY); + } + + + @Test + @DisplayName("잘못된 시간 형식은 무시한다") + void Given_InvalidTimeFormat_When_ExtractWeatherForHours_Then_IgnoresInvalidTime() { + // given + KmaWeatherResponse.WeatherItem item = + new KmaWeatherResponse.WeatherItem("20240101", "0800", "PTY", + "20240101", "invalid", "0", 55, 127); + KmaWeatherResponse.Items items = new KmaWeatherResponse.Items(Arrays.asList(item)); + KmaWeatherResponse.Body body = new KmaWeatherResponse.Body("JSON", items, 1); + KmaWeatherResponse.Response response = new KmaWeatherResponse.Response(null, body); + KmaWeatherResponse weatherResponse = new KmaWeatherResponse(response); + + List targetHours = Arrays.asList(9); + LocalDate date = LocalDate.of(2024, 1, 1); + + // when + Map result = + kmaWeatherExtractor.extractWeatherForHours(weatherResponse, targetHours, date); + + // then + assertThat(result).isEmpty(); + } + + + @Test + @DisplayName("잘못된 값 형식은 기본값을 사용한다") + void Given_InvalidValueFormat_When_ExtractWeatherForHours_Then_UsesDefaultValue() { + // given + KmaWeatherResponse.WeatherItem item = + new KmaWeatherResponse.WeatherItem("20240101", "0800", "PTY", + "20240101", "0900", "invalid", 55, 127); + KmaWeatherResponse.Items items = new KmaWeatherResponse.Items(Arrays.asList(item)); + KmaWeatherResponse.Body body = new KmaWeatherResponse.Body("JSON", items, 1); + KmaWeatherResponse.Response response = new KmaWeatherResponse.Response(null, body); + KmaWeatherResponse weatherResponse = new KmaWeatherResponse(response); + + List targetHours = Arrays.asList(9); + LocalDate date = LocalDate.of(2024, 1, 1); + + // when + Map result = + kmaWeatherExtractor.extractWeatherForHours(weatherResponse, targetHours, date); + + // then + assertThat(result).isEmpty(); + } + +} diff --git a/src/test/java/com/und/server/weather/service/OpenMeteoWeatherExtractorTest.java b/src/test/java/com/und/server/weather/service/OpenMeteoWeatherExtractorTest.java new file mode 100644 index 00000000..54784b09 --- /dev/null +++ b/src/test/java/com/und/server/weather/service/OpenMeteoWeatherExtractorTest.java @@ -0,0 +1,152 @@ +package com.und.server.weather.service; + + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.und.server.weather.constants.WeatherType; +import com.und.server.weather.infrastructure.dto.OpenMeteoWeatherResponse; + +class OpenMeteoWeatherExtractorTest { + + private final OpenMeteoWeatherExtractor extractor = new OpenMeteoWeatherExtractor(); + + + @Test + @DisplayName("OpenMeteo 응답에서 날씨 정보를 정상적으로 추출한다") + void Given_ValidOpenMeteoResponse_When_ExtractWeatherForHours_Then_ReturnsWeatherMap() { + // given + List times = Arrays.asList("2024-01-01T09:00", "2024-01-01T10:00"); + List codes = Arrays.asList(0, 61); // SUNNY, RAIN + OpenMeteoWeatherResponse.Hourly hourly = new OpenMeteoWeatherResponse.Hourly(times, codes); + OpenMeteoWeatherResponse response = + new OpenMeteoWeatherResponse(37.5, 127.0, "Asia/Seoul", null, hourly); + + List targetHours = Arrays.asList(9, 10); + LocalDate date = LocalDate.of(2024, 1, 1); + + // when + Map result = extractor.extractWeatherForHours(response, targetHours, date); + + // then + assertThat(result).hasSize(2); + assertThat(result).containsEntry(9, WeatherType.SUNNY); + assertThat(result).containsEntry(10, WeatherType.RAIN); + } + + + @Test + @DisplayName("응답이 null이면 빈 맵을 반환한다") + void Given_NullResponse_When_ExtractWeatherForHours_Then_ReturnsEmptyMap() { + // when + Map result = + extractor.extractWeatherForHours(null, List.of(9), LocalDate.of(2024, 1, 1)); + + // then + assertThat(result).isEmpty(); + } + + + @Test + @DisplayName("hourly가 null이면 빈 맵을 반환한다") + void Given_NullHourly_When_ExtractWeatherForHours_Then_ReturnsEmptyMap() { + // given + OpenMeteoWeatherResponse response = + new OpenMeteoWeatherResponse(37.5, 127.0, "Asia/Seoul", null, null); + + // when + Map result = + extractor.extractWeatherForHours(response, List.of(9), LocalDate.of(2024, 1, 1)); + + // then + assertThat(result).isEmpty(); + } + + + @Test + @DisplayName("times나 weatherCodes가 null이면 빈 맵을 반환한다") + void Given_InvalidData_When_ExtractWeatherForHours_Then_ReturnsEmptyMap() { + // given + OpenMeteoWeatherResponse.Hourly hourly = + new OpenMeteoWeatherResponse.Hourly(null, null); + OpenMeteoWeatherResponse response = + new OpenMeteoWeatherResponse(37.5, 127.0, "Asia/Seoul", null, hourly); + + // when + Map result = + extractor.extractWeatherForHours(response, List.of(9), LocalDate.of(2024, 1, 1)); + + // then + assertThat(result).isEmpty(); + } + + + @Test + @DisplayName("날짜가 일치하지 않으면 결과에 포함되지 않는다") + void Given_DifferentDate_When_ExtractWeatherForHours_Then_Ignore() { + // given + List times = List.of("2024-01-02T09:00"); // 날짜 불일치 + List codes = List.of(0); + OpenMeteoWeatherResponse.Hourly hourly = + new OpenMeteoWeatherResponse.Hourly(times, codes); + OpenMeteoWeatherResponse response = + new OpenMeteoWeatherResponse(37.5, 127.0, "Asia/Seoul", null, hourly); + + // when + Map result = + extractor.extractWeatherForHours(response, List.of(9), LocalDate.of(2024, 1, 1)); + + // then + assertThat(result).isEmpty(); + } + + + @Test + @DisplayName("hour 파싱이 불가능하면 무시한다") + void Given_InvalidHourString_When_ExtractWeatherForHours_Then_Ignore() { + // given + List times = List.of("2024-01-01Tabc"); // 시간 파싱 불가 + List codes = List.of(0); + OpenMeteoWeatherResponse.Hourly hourly = + new OpenMeteoWeatherResponse.Hourly(times, codes); + OpenMeteoWeatherResponse response = + new OpenMeteoWeatherResponse(37.5, 127.0, "Asia/Seoul", null, hourly); + + // when + Map result = + extractor.extractWeatherForHours(response, List.of(9), LocalDate.of(2024, 1, 1)); + + // then + assertThat(result).isEmpty(); + } + + + @Test + @DisplayName("weatherCodes가 부족하거나 null이면 무시된다") + void Given_IndexOutOfBoundsOrNull_When_ExtractWeatherForHours_Then_Ignore() { + // given + List times = + Arrays.asList("2024-01-01T09:00", "2024-01-01T10:00"); + List codes = Collections.singletonList(null); // index 0 null, index 1 없음 + OpenMeteoWeatherResponse.Hourly hourly = + new OpenMeteoWeatherResponse.Hourly(times, codes); + OpenMeteoWeatherResponse response = + new OpenMeteoWeatherResponse(37.5, 127.0, "Asia/Seoul", null, hourly); + + // when + Map result = + extractor.extractWeatherForHours(response, Arrays.asList(9, 10), LocalDate.of(2024, 1, 1)); + + // then + assertThat(result).isEmpty(); + } + +} diff --git a/src/test/java/com/und/server/weather/service/UvIndexExtractorTest.java b/src/test/java/com/und/server/weather/service/UvIndexExtractorTest.java new file mode 100644 index 00000000..0b1cd2f8 --- /dev/null +++ b/src/test/java/com/und/server/weather/service/UvIndexExtractorTest.java @@ -0,0 +1,160 @@ +package com.und.server.weather.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.und.server.weather.constants.UvType; +import com.und.server.weather.infrastructure.dto.OpenMeteoResponse; + +class UvIndexExtractorTest { + + private final UvIndexExtractor extractor = new UvIndexExtractor(); + + @Test + @DisplayName("정상 응답에서 UV 정보를 추출한다") + void Given_ValidOpenMeteoResponse_When_ExtractUvForHours_Then_ReturnsUvMap() { + // given + List times = Arrays.asList( + "2024-01-01T09:00", + "2024-01-01T10:00", + "2024-01-01T11:00", + "2024-01-01T12:00", + "2024-01-01T13:00" + ); + List uvIndex = Arrays.asList(1.0, 3.0, 5.0, 8.0, 12.0); // VERY_LOW, LOW, NORMAL, HIGH, VERY_HIGH + List pm25 = Collections.nCopies(5, 0.0); // dummy + List pm10 = Collections.nCopies(5, 0.0); // dummy + + OpenMeteoResponse.Hourly hourly = new OpenMeteoResponse.Hourly(times, pm25, pm10, uvIndex); + OpenMeteoResponse response = + new OpenMeteoResponse(37.5, 127.0, "Asia/Seoul", null, hourly); + + List targetHours = Arrays.asList(9, 10, 11, 12, 13); + LocalDate date = LocalDate.of(2024, 1, 1); + + // when + Map result = extractor.extractUvForHours(response, targetHours, date); + + // then + assertThat(result).hasSize(5); + assertThat(result).containsEntry(9, UvType.VERY_LOW); + assertThat(result).containsEntry(10, UvType.LOW); + assertThat(result).containsEntry(11, UvType.NORMAL); + assertThat(result).containsEntry(12, UvType.HIGH); + assertThat(result).containsEntry(13, UvType.VERY_HIGH); + } + + @Test + @DisplayName("응답이 null이면 빈 맵을 반환한다") + void Given_NullResponse_When_ExtractUvForHours_Then_ReturnsEmptyMap() { + Map result = + extractor.extractUvForHours(null, List.of(9), LocalDate.of(2024, 1, 1)); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("hourly가 null이면 빈 맵을 반환한다") + void Given_NullHourly_When_ExtractUvForHours_Then_ReturnsEmptyMap() { + OpenMeteoResponse response = + new OpenMeteoResponse(37.5, 127.0, "Asia/Seoul", null, null); + + Map result = + extractor.extractUvForHours(response, List.of(9), LocalDate.of(2024, 1, 1)); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("times/uvIndex가 null이면 빈 맵을 반환한다") + void Given_InvalidData_When_ExtractUvForHours_Then_ReturnsEmptyMap() { + OpenMeteoResponse.Hourly hourly = + new OpenMeteoResponse.Hourly(null, null, null, null); + OpenMeteoResponse response = + new OpenMeteoResponse(37.5, 127.0, "Asia/Seoul", null, hourly); + + Map result = + extractor.extractUvForHours(response, List.of(9), LocalDate.of(2024, 1, 1)); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("날짜가 다르면 결과에 포함되지 않는다") + void Given_DifferentDate_When_ExtractUvForHours_Then_Ignore() { + List times = List.of("2024-01-02T09:00"); + List uvIndex = List.of(5.0); + List pm25 = List.of(0.0); + List pm10 = List.of(0.0); + + OpenMeteoResponse.Hourly hourly = + new OpenMeteoResponse.Hourly(times, pm25, pm10, uvIndex); + OpenMeteoResponse response = + new OpenMeteoResponse(37.5, 127.0, "Asia/Seoul", null, hourly); + + Map result = + extractor.extractUvForHours(response, List.of(9), LocalDate.of(2024, 1, 1)); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("hour 파싱이 불가능하면 무시한다") + void Given_InvalidHourString_When_ExtractUvForHours_Then_Ignore() { + List times = List.of("2024-01-01Tabc"); + List uvIndex = List.of(5.0); + List pm25 = List.of(0.0); + List pm10 = List.of(0.0); + + OpenMeteoResponse.Hourly hourly = + new OpenMeteoResponse.Hourly(times, pm25, pm10, uvIndex); + OpenMeteoResponse response = + new OpenMeteoResponse(37.5, 127.0, "Asia/Seoul", null, hourly); + + Map result = + extractor.extractUvForHours(response, List.of(9), LocalDate.of(2024, 1, 1)); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("uvIndex 값이 부족하거나 null이면 무시된다") + void Given_IndexOutOfBoundsOrNull_When_ExtractUvForHours_Then_Ignore() { + List times = Arrays.asList("2024-01-01T09:00", "2024-01-01T10:00"); + List uvIndex = Arrays.asList(null, null); + List pm25 = Arrays.asList(0.0, 0.0); + List pm10 = Arrays.asList(0.0, 0.0); + + OpenMeteoResponse.Hourly hourly = + new OpenMeteoResponse.Hourly(times, pm25, pm10, uvIndex); + OpenMeteoResponse response = + new OpenMeteoResponse(37.5, 127.0, "Asia/Seoul", null, hourly); + + Map result = + extractor.extractUvForHours(response, Arrays.asList(9, 10), LocalDate.of(2024, 1, 1)); + assertThat(result).isEmpty(); + } + + // --- UvType enum 전용 검증 --- + + @Test + @DisplayName("UvType fromUvIndex 구간별 매핑 확인") + void Given_Value_When_FromUvIndex_Then_CorrectLevel() { + assertThat(UvType.fromUvIndex(1)).isEqualTo(UvType.VERY_LOW); + assertThat(UvType.fromUvIndex(3)).isEqualTo(UvType.LOW); + assertThat(UvType.fromUvIndex(5)).isEqualTo(UvType.NORMAL); + assertThat(UvType.fromUvIndex(8)).isEqualTo(UvType.HIGH); + assertThat(UvType.fromUvIndex(12)).isEqualTo(UvType.VERY_HIGH); + } + + @Test + @DisplayName("UvType getWorst는 더 나쁜 수준을 반환한다") + void Given_TwoLevels_When_GetWorst_Then_ReturnWorst() { + UvType result = UvType.getWorst(List.of(UvType.LOW, UvType.HIGH)); + assertThat(result).isEqualTo(UvType.HIGH); + } + +} diff --git a/src/test/java/com/und/server/weather/service/WeatherApiServiceTest.java b/src/test/java/com/und/server/weather/service/WeatherApiServiceTest.java new file mode 100644 index 00000000..5442a331 --- /dev/null +++ b/src/test/java/com/und/server/weather/service/WeatherApiServiceTest.java @@ -0,0 +1,337 @@ +package com.und.server.weather.service; + +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.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeoutException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.weather.constants.TimeSlot; +import com.und.server.weather.dto.GridPoint; +import com.und.server.weather.dto.OpenMeteoWeatherApiResultDto; +import com.und.server.weather.dto.WeatherApiResultDto; +import com.und.server.weather.dto.request.WeatherRequest; +import com.und.server.weather.exception.KmaApiException; +import com.und.server.weather.exception.WeatherErrorResult; +import com.und.server.weather.exception.WeatherException; +import com.und.server.weather.infrastructure.KmaApiFacade; +import com.und.server.weather.infrastructure.OpenMeteoApiFacade; +import com.und.server.weather.infrastructure.dto.KmaWeatherResponse; +import com.und.server.weather.infrastructure.dto.OpenMeteoResponse; +import com.und.server.weather.infrastructure.dto.OpenMeteoWeatherResponse; + +@ExtendWith(MockitoExtension.class) +@DisplayName("WeatherApiService 테스트") +class WeatherApiServiceTest { + + @Mock + private KmaApiFacade kmaApiFacade; + + @Mock + private OpenMeteoApiFacade openMeteoApiFacade; + + @Mock + private Executor weatherExecutor; + + @InjectMocks + private WeatherApiService weatherApiService; + + + @BeforeEach + void setUp() { + // CompletableFuture.supplyAsync를 동기적으로 실행하도록 설정 + doAnswer(invocation -> { + Runnable runnable = invocation.getArgument(0); + runnable.run(); + return null; + }).when(weatherExecutor).execute(any(Runnable.class)); + } + + + @Test + @DisplayName("오늘 날씨 API를 정상적으로 호출한다") + void Given_TodayWeatherRequest_When_CallTodayWeather_Then_ReturnsWeatherApiResult() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + TimeSlot timeSlot = TimeSlot.SLOT_09_12; + LocalDate today = LocalDate.now(); + + KmaWeatherResponse mockKmaResponse = new KmaWeatherResponse(null); + OpenMeteoResponse mockOpenMeteoResponse = new OpenMeteoResponse( + 37.5665, 126.9780, "Asia/Seoul", null, null); + + when(kmaApiFacade.callWeatherApi(any(GridPoint.class), eq(timeSlot), eq(today))) + .thenReturn(mockKmaResponse); + when(openMeteoApiFacade.callDustUvApi((37.5665), (126.9780), (today))) + .thenReturn(mockOpenMeteoResponse); + + // when + WeatherApiResultDto result = weatherApiService.callTodayWeather(request, timeSlot, today); + + // then + assertThat(result).isNotNull(); + } + + + @Test + @DisplayName("미래 날씨 API를 정상적으로 호출한다") + void Given_FutureWeatherRequest_When_CallFutureWeather_Then_ReturnsWeatherApiResult() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + TimeSlot timeSlot = TimeSlot.SLOT_15_18; + LocalDate today = LocalDate.now(); + LocalDate targetDate = LocalDate.now().plusDays(1); + + KmaWeatherResponse mockKmaResponse = new KmaWeatherResponse(null); + OpenMeteoResponse mockOpenMeteoResponse = new OpenMeteoResponse( + 37.5665, 126.9780, "Asia/Seoul", null, null); + + when(kmaApiFacade.callWeatherApi(any(GridPoint.class), eq(timeSlot), eq(today))) + .thenReturn(mockKmaResponse); + when(openMeteoApiFacade.callDustUvApi((37.5665), (126.9780), (targetDate))) + .thenReturn(mockOpenMeteoResponse); + + // when + WeatherApiResultDto result = weatherApiService.callFutureWeather(request, timeSlot, today, targetDate); + + // then + assertThat(result).isNotNull(); + } + + + @Test + @DisplayName("OpenMeteo 폴백 날씨 API를 정상적으로 호출한다") + void Given_OpenMeteoFallbackRequest_When_CallOpenMeteoFallBackWeather_Then_ReturnsOpenMeteoWeatherApiResult() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate targetDate = LocalDate.now().plusDays(2); + + OpenMeteoWeatherResponse mockWeatherResponse = + new OpenMeteoWeatherResponse( + 37.5665, 126.9780, "Asia/Seoul", null, null); + OpenMeteoResponse mockDustUvResponse = new OpenMeteoResponse( + 37.5665, 126.9780, "Asia/Seoul", null, null); + + when(openMeteoApiFacade.callWeatherApi((37.5665), (126.9780), (targetDate))) + .thenReturn(mockWeatherResponse); + when(openMeteoApiFacade.callDustUvApi((37.5665), (126.9780), (targetDate))) + .thenReturn(mockDustUvResponse); + + // when + OpenMeteoWeatherApiResultDto result = weatherApiService.callOpenMeteoFallBackWeather(request, targetDate); + + // then + assertThat(result).isNotNull(); + } + + + @Test + @DisplayName("KMA API 타임아웃 시 KmaApiException을 발생시킨다") + void Given_KmaApiTimeout_When_CallTodayWeather_Then_ThrowsKmaApiException() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + TimeSlot timeSlot = TimeSlot.SLOT_09_12; + LocalDate today = LocalDate.now(); + + when(kmaApiFacade.callWeatherApi(any(GridPoint.class), eq(timeSlot), eq(today))) + .thenThrow(new CompletionException(new TimeoutException("API timeout"))); + when(openMeteoApiFacade.callDustUvApi((37.5665), (126.9780), (today))) + .thenReturn(new OpenMeteoResponse( + 37.5665, 126.9780, "Asia/Seoul", null, null)); + + // when & then + assertThatThrownBy(() -> weatherApiService.callTodayWeather(request, timeSlot, today)) + .isInstanceOf(KmaApiException.class) + .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.KMA_TIMEOUT); + } + + + @Test + @DisplayName("WeatherException이 발생하면 그대로 전파한다") + void Given_WeatherException_When_CallTodayWeather_Then_ThrowsWeatherException() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + TimeSlot timeSlot = TimeSlot.SLOT_09_12; + LocalDate today = LocalDate.now(); + + WeatherException expectedException = new WeatherException(WeatherErrorResult.INVALID_COORDINATES); + when(kmaApiFacade.callWeatherApi(any(GridPoint.class), eq(timeSlot), eq(today))) + .thenThrow(new CompletionException(expectedException)); + when(openMeteoApiFacade.callDustUvApi((37.5665), (126.9780), (today))) + .thenReturn(new OpenMeteoResponse( + 37.5665, 126.9780, "Asia/Seoul", null, null)); + + // when & then + assertThatThrownBy(() -> weatherApiService.callTodayWeather(request, timeSlot, today)) + .isInstanceOf(WeatherException.class) + .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.INVALID_COORDINATES); + } + + + @Test + @DisplayName("예상치 못한 예외 발생 시 WeatherException을 발생시킨다") + void Given_UnexpectedException_When_CallTodayWeather_Then_ThrowsWeatherException() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + TimeSlot timeSlot = TimeSlot.SLOT_09_12; + LocalDate today = LocalDate.now(); + + RuntimeException unexpectedException = new RuntimeException("Unexpected error"); + when(kmaApiFacade.callWeatherApi(any(GridPoint.class), eq(timeSlot), eq(today))) + .thenThrow(new CompletionException(unexpectedException)); + when(openMeteoApiFacade.callDustUvApi((37.5665), (126.9780), (today))) + .thenReturn(new OpenMeteoResponse( + 37.5665, 126.9780, "Asia/Seoul", null, null)); + + // when & then + assertThatThrownBy(() -> weatherApiService.callTodayWeather(request, timeSlot, today)) + .isInstanceOf(WeatherException.class) + .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.WEATHER_SERVICE_ERROR); + } + + + @Test + @DisplayName("일반 예외 발생 시 WeatherException을 발생시킨다") + void Given_GeneralException_When_CallTodayWeather_Then_ThrowsWeatherException() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + TimeSlot timeSlot = TimeSlot.SLOT_09_12; + LocalDate today = LocalDate.now(); + + Exception generalException = new Exception("General error"); + when(kmaApiFacade.callWeatherApi(any(GridPoint.class), eq(timeSlot), eq(today))) + .thenThrow(new CompletionException(generalException)); + + // when & then + assertThatThrownBy(() -> weatherApiService.callTodayWeather(request, timeSlot, today)) + .isInstanceOf(WeatherException.class) + .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.WEATHER_SERVICE_ERROR); + } + + + @Test + @DisplayName("미래 날씨 API에서 타임아웃 시 KmaApiException을 발생시킨다") + void Given_KmaApiTimeout_When_CallFutureWeather_Then_ThrowsKmaApiException() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + TimeSlot timeSlot = TimeSlot.SLOT_15_18; + LocalDate today = LocalDate.now(); + LocalDate targetDate = LocalDate.now().plusDays(1); + + when(kmaApiFacade.callWeatherApi(any(GridPoint.class), eq(timeSlot), eq(today))) + .thenThrow(new CompletionException(new TimeoutException("API timeout"))); + when(openMeteoApiFacade.callDustUvApi((37.5665), (126.9780), (targetDate))) + .thenReturn(new OpenMeteoResponse( + 37.5665, 126.9780, "Asia/Seoul", null, null)); + + // when & then + assertThatThrownBy(() -> weatherApiService.callFutureWeather(request, timeSlot, today, targetDate)) + .isInstanceOf(KmaApiException.class) + .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.KMA_TIMEOUT); + } + + + @Test + @DisplayName("OpenMeteo 폴백에서 WeatherException이 발생하면 그대로 전파한다") + void Given_WeatherException_When_CallOpenMeteoFallBackWeather_Then_ThrowsWeatherException() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate targetDate = LocalDate.now().plusDays(2); + + WeatherException expectedException = new WeatherException(WeatherErrorResult.INVALID_COORDINATES); + when(openMeteoApiFacade.callWeatherApi((37.5665), (126.9780), (targetDate))) + .thenThrow(new CompletionException(expectedException)); + when(openMeteoApiFacade.callDustUvApi((37.5665), (126.9780), (targetDate))) + .thenReturn(new OpenMeteoResponse(37.5665, 126.9780, "Asia/Seoul", null, null)); + + // when & then + assertThatThrownBy(() -> weatherApiService.callOpenMeteoFallBackWeather(request, targetDate)) + .isInstanceOf(WeatherException.class) + .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.INVALID_COORDINATES); + } + + + @Test + @DisplayName("OpenMeteo 폴백에서 예상치 못한 예외 발생 시 WeatherException을 발생시킨다") + void Given_UnexpectedException_When_CallOpenMeteoFallBackWeather_Then_ThrowsWeatherException() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate targetDate = LocalDate.now().plusDays(2); + + RuntimeException unexpectedException = new RuntimeException("Unexpected error"); + when(openMeteoApiFacade.callWeatherApi((37.5665), (126.9780), (targetDate))) + .thenThrow(new CompletionException(unexpectedException)); + when(openMeteoApiFacade.callDustUvApi((37.5665), (126.9780), (targetDate))) + .thenReturn(new OpenMeteoResponse( + 37.5665, 126.9780, "Asia/Seoul", null, null)); + + // when & then + assertThatThrownBy(() -> weatherApiService.callOpenMeteoFallBackWeather(request, targetDate)) + .isInstanceOf(WeatherException.class) + .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.WEATHER_SERVICE_ERROR); + } + + + @Test + @DisplayName("다른 시간대에서도 정상적으로 API를 호출한다") + void Given_DifferentTimeSlot_When_CallTodayWeather_Then_ReturnsWeatherApiResult() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + TimeSlot timeSlot = TimeSlot.SLOT_21_24; + LocalDate today = LocalDate.now(); + + KmaWeatherResponse mockKmaResponse = new KmaWeatherResponse(null); + OpenMeteoResponse mockOpenMeteoResponse = new OpenMeteoResponse(37.5665, 126.9780, "Asia/Seoul", null, null); + + when(kmaApiFacade.callWeatherApi(any(GridPoint.class), eq(timeSlot), eq(today))) + .thenReturn(mockKmaResponse); + when(openMeteoApiFacade.callDustUvApi((37.5665), (126.9780), (today))) + .thenReturn(mockOpenMeteoResponse); + + // when + WeatherApiResultDto result = weatherApiService.callTodayWeather(request, timeSlot, today); + + // then + assertThat(result).isNotNull(); + } + + + @Test + @DisplayName("다른 좌표에서도 정상적으로 API를 호출한다") + void Given_DifferentCoordinates_When_CallTodayWeather_Then_ReturnsWeatherApiResult() { + // given + WeatherRequest request = new WeatherRequest(35.1796, 129.0756); // 부산 + TimeSlot timeSlot = TimeSlot.SLOT_03_06; + LocalDate today = LocalDate.now(); + + KmaWeatherResponse mockKmaResponse = new KmaWeatherResponse(null); + OpenMeteoResponse mockOpenMeteoResponse = new OpenMeteoResponse( + 35.1796, 129.0756, "Asia/Seoul", null, null); + + when(kmaApiFacade.callWeatherApi(any(GridPoint.class), eq(timeSlot), eq(today))) + .thenReturn(mockKmaResponse); + when(openMeteoApiFacade.callDustUvApi((35.1796), (129.0756), (today))) + .thenReturn(mockOpenMeteoResponse); + + // when + WeatherApiResultDto result = weatherApiService.callTodayWeather(request, timeSlot, today); + + // then + assertThat(result).isNotNull(); + } + +} diff --git a/src/test/java/com/und/server/weather/service/WeatherCacheServiceTest.java b/src/test/java/com/und/server/weather/service/WeatherCacheServiceTest.java new file mode 100644 index 00000000..3d4bb31f --- /dev/null +++ b/src/test/java/com/und/server/weather/service/WeatherCacheServiceTest.java @@ -0,0 +1,325 @@ +package com.und.server.weather.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import com.und.server.weather.constants.FineDustType; +import com.und.server.weather.constants.TimeSlot; +import com.und.server.weather.constants.UvType; +import com.und.server.weather.constants.WeatherType; +import com.und.server.weather.dto.OpenMeteoWeatherApiResultDto; +import com.und.server.weather.dto.WeatherApiResultDto; +import com.und.server.weather.dto.cache.WeatherCacheData; +import com.und.server.weather.dto.request.WeatherRequest; +import com.und.server.weather.exception.KmaApiException; +import com.und.server.weather.exception.WeatherErrorResult; +import com.und.server.weather.util.CacheSerializer; +import com.und.server.weather.util.WeatherKeyGenerator; +import com.und.server.weather.util.WeatherTtlCalculator; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("WeatherCacheService 테스트") +@SuppressWarnings("unchecked") +class WeatherCacheServiceTest { + + @Mock + private RedisTemplate redisTemplate; + @Mock + private WeatherApiService weatherApiService; + @Mock + private WeatherDecisionService weatherDecisionService; + @Mock + private WeatherKeyGenerator keyGenerator; + @Mock + private WeatherTtlCalculator ttlCalculator; + @Mock + private CacheSerializer cacheSerializer; + + @InjectMocks + private WeatherCacheService weatherCacheService; + + private final WeatherRequest request = new WeatherRequest(37.5, 127.0); + + @Mock + private HashOperations hashOperations; + @Mock + private ValueOperations valueOperations; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() { + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + } + + @Test + @DisplayName("오늘 날씨 캐시 키를 생성한다") + void Given_TodayWeatherRequest_When_GenerateTodayKey_Then_ReturnsCacheKey() { + // given + LocalDateTime nowDateTime = LocalDateTime.of(2024, 1, 1, 10, 30); + LocalDate nowDate = nowDateTime.toLocalDate(); + TimeSlot currentSlot = TimeSlot.SLOT_09_12; + String expectedCacheKey = "today:weather:37.5665:126.9780:2024-01-01:SLOT_09_12"; + + when(keyGenerator.generateTodayKey((37.5665), (126.9780), (nowDate), (currentSlot))) + .thenReturn(expectedCacheKey); + + // when + String cacheKey = keyGenerator.generateTodayKey(37.5665, 126.9780, nowDate, currentSlot); + + // then + assertThat(cacheKey).isEqualTo(expectedCacheKey); + } + + @Test + @DisplayName("미래 날씨 캐시 키를 생성한다") + void Given_FutureWeatherRequest_When_GenerateFutureKey_Then_ReturnsCacheKey() { + // given + LocalDate targetDate = LocalDate.of(2024, 1, 2); + TimeSlot currentSlot = TimeSlot.SLOT_09_12; + String expectedCacheKey = "future:weather:37.5665:126.9780:2024-01-02:SLOT_09_12"; + + when(keyGenerator.generateFutureKey((37.5665), (126.9780), (targetDate), (currentSlot))) + .thenReturn(expectedCacheKey); + + // when + String cacheKey = keyGenerator.generateFutureKey(37.5665, 126.9780, targetDate, currentSlot); + + // then + assertThat(cacheKey).isEqualTo(expectedCacheKey); + } + + @Test + @DisplayName("WeatherCacheData가 유효한지 확인한다") + void Given_ValidWeatherCacheData_When_IsValid_Then_ReturnsTrue() { + WeatherCacheData validData = WeatherCacheData.from( + WeatherType.SUNNY, FineDustType.GOOD, UvType.LOW + ); + assertThat(validData.isValid()).isTrue(); + } + + @Test + @DisplayName("WeatherCacheData가 유효하지 않은지 확인한다") + void Given_InvalidWeatherCacheData_When_IsValid_Then_ReturnsFalse() { + WeatherCacheData invalidData = WeatherCacheData.from( + null, FineDustType.GOOD, UvType.LOW + ); + assertThat(invalidData.isValid()).isFalse(); + } + + @Test + @DisplayName("TTL을 계산한다") + void Given_TimeSlotAndDateTime_When_CalculateTtl_Then_ReturnsDuration() { + TimeSlot timeSlot = TimeSlot.SLOT_09_12; + LocalDateTime nowDateTime = LocalDateTime.of(2024, 1, 1, 10, 30); + + when(ttlCalculator.calculateTtl(timeSlot, nowDateTime)) + .thenReturn(Duration.ofHours(2)); + + Duration ttl = ttlCalculator.calculateTtl(timeSlot, nowDateTime); + + assertThat(ttl).isEqualTo(Duration.ofHours(2)); + } + + @Test + @DisplayName("캐시에 유효한 today 데이터가 있으면 그대로 반환한다") + void Given_CacheExists_When_GetTodayWeatherCache_Then_ReturnCachedData() { + LocalDateTime now = LocalDateTime.of(2024, 1, 1, 9, 0); + String cacheKey = "todayKey"; + String hourKey = "09"; + + WeatherCacheData cachedData = WeatherCacheData.from( + WeatherType.SUNNY, FineDustType.GOOD, UvType.LOW + ); + + given(keyGenerator.generateTodayKey(any(), any(), any(), any())).willReturn(cacheKey); + given(keyGenerator.generateTodayHourFieldKey(any())).willReturn(hourKey); + given(hashOperations.get(cacheKey, hourKey)).willReturn("json"); + given(cacheSerializer.deserializeWeatherCacheDataFromHash("json")).willReturn(cachedData); + + WeatherCacheData result = weatherCacheService.getTodayWeatherCache(request, now); + + assertThat(result).isEqualTo(cachedData); + } + + @Test + @DisplayName("캐시에 데이터 없으면 API 호출 후 저장한다") + void Given_NoCache_When_GetTodayWeatherCache_Then_CallApiAndSave() { + LocalDateTime now = LocalDateTime.of(2024, 1, 1, 9, 0); + String cacheKey = "todayKey"; + String hourKey = "09"; + + WeatherCacheData newData = WeatherCacheData.getDefault(); + Map newMap = Map.of(hourKey, newData); + + given(keyGenerator.generateTodayKey(any(), any(), any(), any())).willReturn(cacheKey); + given(keyGenerator.generateTodayHourFieldKey(any())).willReturn(hourKey); + given(hashOperations.get(cacheKey, hourKey)).willReturn(null); + + given(weatherApiService.callTodayWeather(any(), any(), any())) + .willReturn(mock(WeatherApiResultDto.class)); + given(weatherDecisionService.getTodayWeatherCacheData(any(), any(), any())).willReturn(newMap); + given(ttlCalculator.calculateTtl(any(), any())).willReturn(Duration.ofMinutes(10)); + given(cacheSerializer.serializeWeatherCacheDataToHash(any())).willReturn(Map.of(hourKey, "{}")); + + WeatherCacheData result = weatherCacheService.getTodayWeatherCache(request, now); + + assertThat(result).isEqualTo(newData); + verify(redisTemplate).expire(eq(cacheKey), any()); + } + + @Test + @DisplayName("KMA API 실패시 OpenMeteo fallback 사용한다") + void Given_KmaFails_When_GetTodayWeatherCache_Then_UseFallback() { + LocalDateTime now = LocalDateTime.of(2024, 1, 1, 9, 0); + String cacheKey = "todayKey"; + String hourKey = "09"; + WeatherCacheData fallbackData = WeatherCacheData.getDefault(); + Map map = Map.of(hourKey, fallbackData); + + given(keyGenerator.generateTodayKey(any(), any(), any(), any())).willReturn(cacheKey); + given(keyGenerator.generateTodayHourFieldKey(any())).willReturn(hourKey); + given(hashOperations.get(cacheKey, hourKey)).willReturn(null); + + given(weatherApiService.callTodayWeather(any(), any(), any())) + .willThrow(new KmaApiException(WeatherErrorResult.KMA_TIMEOUT, new RuntimeException())); + + given(weatherApiService.callOpenMeteoFallBackWeather(any(), any())) + .willReturn(mock(OpenMeteoWeatherApiResultDto.class)); + + given(weatherDecisionService.getTodayWeatherCacheDataFallback(any(), any(), any())).willReturn(map); + given(ttlCalculator.calculateTtl(any(), any())).willReturn(Duration.ofMinutes(5)); + given(cacheSerializer.serializeWeatherCacheDataToHash(any())).willReturn(Map.of(hourKey, "{}")); + + // when + WeatherCacheData result = weatherCacheService.getTodayWeatherCache(request, now); + + // then + assertThat(result).isEqualTo(fallbackData); + } + + + @Test + @DisplayName("Future 캐시 조회 시 캐시에 있으면 그대로 반환한다") + void Given_CacheExists_When_GetFutureWeatherCache_Then_ReturnCachedData() { + LocalDateTime now = LocalDateTime.of(2024, 1, 1, 9, 0); + LocalDate targetDate = LocalDate.of(2024, 1, 2); + String cacheKey = "futureKey"; + + WeatherCacheData cachedData = WeatherCacheData.from( + WeatherType.CLOUDY, FineDustType.NORMAL, UvType.HIGH + ); + + given(keyGenerator.generateFutureKey(any(), any(), any(), any())).willReturn(cacheKey); + given(valueOperations.get(cacheKey)).willReturn("json"); + given(cacheSerializer.deserializeWeatherCacheData("json")).willReturn(cachedData); + + WeatherCacheData result = weatherCacheService.getFutureWeatherCache(request, now, targetDate); + + assertThat(result).isEqualTo(cachedData); + } + + @Test + @DisplayName("캐시에 유효하지 않은 today 데이터면 API 호출로 대체한다") + void Given_InvalidCache_When_GetTodayWeatherCache_Then_CallApi() { + LocalDateTime now = LocalDateTime.of(2024, 1, 1, 9, 0); + String cacheKey = "todayKey"; + String hourKey = "09"; + + WeatherCacheData invalidData = WeatherCacheData.from(null, FineDustType.GOOD, UvType.LOW); + + given(keyGenerator.generateTodayKey(any(), any(), any(), any())).willReturn(cacheKey); + given(keyGenerator.generateTodayHourFieldKey(any())).willReturn(hourKey); + given(hashOperations.get(cacheKey, hourKey)).willReturn("json"); + given(cacheSerializer.deserializeWeatherCacheDataFromHash("json")).willReturn(invalidData); + + // API 대체 호출 + WeatherCacheData newData = WeatherCacheData.getDefault(); + Map map = Map.of(hourKey, newData); + given(weatherApiService.callTodayWeather(any(), any(), any())).willReturn(mock(WeatherApiResultDto.class)); + given(weatherDecisionService.getTodayWeatherCacheData(any(), any(), any())).willReturn(map); + given(ttlCalculator.calculateTtl(any(), any())).willReturn(Duration.ofMinutes(10)); + given(cacheSerializer.serializeWeatherCacheDataToHash(any())).willReturn(Map.of(hourKey, "{}")); + + WeatherCacheData result = weatherCacheService.getTodayWeatherCache(request, now); + + assertThat(result).isEqualTo(newData); + } + + @Test + @DisplayName("Future 캐시 없으면 API 호출 후 저장한다") + void Given_NoCache_When_GetFutureWeatherCache_Then_CallApiAndSave() { + LocalDateTime now = LocalDateTime.of(2024, 1, 1, 9, 0); + LocalDate targetDate = LocalDate.of(2024, 1, 2); + String cacheKey = "futureKey"; + + given(keyGenerator.generateFutureKey(any(), any(), any(), any())).willReturn(cacheKey); + given(valueOperations.get(cacheKey)).willReturn(null); + + WeatherCacheData newData = WeatherCacheData.getDefault(); + given(weatherApiService.callFutureWeather(any(), any(), any(), any())).willReturn( + mock(WeatherApiResultDto.class)); + given(weatherDecisionService.getFutureWeatherCacheData(any(), any())).willReturn(newData); + given(ttlCalculator.calculateTtl(any(), any())).willReturn(Duration.ofMinutes(5)); + given(cacheSerializer.serializeWeatherCacheData(any())).willReturn("{}"); + + WeatherCacheData result = weatherCacheService.getFutureWeatherCache(request, now, targetDate); + + assertThat(result).isEqualTo(newData); + + verify(redisTemplate, times(2)).opsForValue(); + verify(valueOperations).set(eq(cacheKey), any(), eq(Duration.ofMinutes(5))); + } + + + @Test + @DisplayName("Future 캐시 조회시 KMA 실패하면 fallback 호출한다") + void Given_KmaFails_When_GetFutureWeatherCache_Then_UseFallback() { + LocalDateTime now = LocalDateTime.of(2024, 1, 1, 9, 0); + LocalDate targetDate = LocalDate.of(2024, 1, 2); + String cacheKey = "futureKey"; + + given(keyGenerator.generateFutureKey(any(), any(), any(), any())).willReturn(cacheKey); + given(valueOperations.get(cacheKey)).willReturn(null); + + given(weatherApiService.callFutureWeather(any(), any(), any(), any())) + .willThrow(new KmaApiException(WeatherErrorResult.KMA_TIMEOUT, new RuntimeException())); + + WeatherCacheData fallbackData = WeatherCacheData.getDefault(); + given(weatherApiService.callOpenMeteoFallBackWeather(any(), any())).willReturn( + mock(OpenMeteoWeatherApiResultDto.class)); + given(weatherDecisionService.getFutureWeatherCacheDataFallback(any(), any())).willReturn(fallbackData); + given(ttlCalculator.calculateTtl(any(), any())).willReturn(Duration.ofMinutes(5)); + given(cacheSerializer.serializeWeatherCacheData(any())).willReturn("{}"); + + WeatherCacheData result = weatherCacheService.getFutureWeatherCache(request, now, targetDate); + + assertThat(result).isEqualTo(fallbackData); + } + +} diff --git a/src/test/java/com/und/server/weather/service/WeatherDecisionServiceTest.java b/src/test/java/com/und/server/weather/service/WeatherDecisionServiceTest.java new file mode 100644 index 00000000..31f6b8b7 --- /dev/null +++ b/src/test/java/com/und/server/weather/service/WeatherDecisionServiceTest.java @@ -0,0 +1,365 @@ +package com.und.server.weather.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.weather.constants.FineDustType; +import com.und.server.weather.constants.TimeSlot; +import com.und.server.weather.constants.UvType; +import com.und.server.weather.constants.WeatherType; +import com.und.server.weather.dto.OpenMeteoWeatherApiResultDto; +import com.und.server.weather.dto.WeatherApiResultDto; +import com.und.server.weather.dto.cache.WeatherCacheData; +import com.und.server.weather.infrastructure.dto.KmaWeatherResponse; +import com.und.server.weather.infrastructure.dto.OpenMeteoResponse; +import com.und.server.weather.infrastructure.dto.OpenMeteoWeatherResponse; + +@ExtendWith(MockitoExtension.class) +@DisplayName("WeatherDecisionService 테스트") +class WeatherDecisionServiceTest { + + @Mock + private KmaWeatherExtractor kmaWeatherExtractor; + + @Mock + private OpenMeteoWeatherExtractor openMeteoWeatherExtractor; + + @Mock + private FineDustExtractor fineDustExtractor; + + @Mock + private UvIndexExtractor uvIndexExtractor; + + @Mock + private FutureWeatherDecisionSelector futureWeatherDecisionSelector; + + @InjectMocks + private WeatherDecisionService weatherDecisionService; + + + @Test + @DisplayName("오늘 날씨 캐시 데이터를 정상적으로 생성한다") + void Given_WeatherApiResult_When_GetTodayWeatherCacheData_Then_ReturnsHourlyData() { + // given + WeatherApiResultDto weatherApiResult = WeatherApiResultDto.builder() + .kmaWeatherResponse(new KmaWeatherResponse(null)) + .openMeteoResponse(new OpenMeteoResponse(37.5665, 126.9780, "Asia/Seoul", null, null)) + .build(); + TimeSlot currentSlot = TimeSlot.SLOT_09_12; + LocalDate today = LocalDate.of(2024, 1, 1); + + Map weathersByHour = Map.of( + 9, WeatherType.SUNNY, + 10, WeatherType.CLOUDY, + 11, WeatherType.RAIN + ); + Map dustByHour = Map.of( + 9, FineDustType.GOOD, + 10, FineDustType.NORMAL, + 11, FineDustType.BAD + ); + Map uvByHour = Map.of( + 9, UvType.LOW, + 10, UvType.NORMAL, + 11, UvType.HIGH + ); + + when(kmaWeatherExtractor.extractWeatherForHours(any(KmaWeatherResponse.class), any(), eq(today))) + .thenReturn(weathersByHour); + when(fineDustExtractor.extractDustForHours(any(OpenMeteoResponse.class), any(), eq(today))) + .thenReturn(dustByHour); + when(uvIndexExtractor.extractUvForHours(any(OpenMeteoResponse.class), any(), eq(today))) + .thenReturn(uvByHour); + + // when + Map result = weatherDecisionService.getTodayWeatherCacheData( + weatherApiResult, currentSlot, today); + + // then + assertThat(result).isNotNull().hasSize(3); + + assertThat(result.get("09")) + .isNotNull() + .extracting(WeatherCacheData::weather, WeatherCacheData::fineDust, WeatherCacheData::uv) + .containsExactly(WeatherType.SUNNY, FineDustType.GOOD, UvType.LOW); + + assertThat(result.get("11")) + .isNotNull() + .extracting(WeatherCacheData::weather, WeatherCacheData::fineDust, WeatherCacheData::uv) + .containsExactly(WeatherType.RAIN, FineDustType.BAD, UvType.HIGH); + } + + + @Test + @DisplayName("미래 날씨 캐시 데이터를 정상적으로 생성한다") + void Given_WeatherApiResult_When_GetFutureWeatherCacheData_Then_ReturnsWorstWeatherData() { + // given + WeatherApiResultDto weatherApiResult = WeatherApiResultDto.builder() + .kmaWeatherResponse(new KmaWeatherResponse(null)) + .openMeteoResponse(new OpenMeteoResponse(37.5665, 126.9780, "Asia/Seoul", null, null)) + .build(); + LocalDate targetDate = LocalDate.of(2024, 1, 2); + + Map weatherMap = Map.of( + 9, WeatherType.SUNNY, + 10, WeatherType.CLOUDY, + 11, WeatherType.RAIN, + 12, WeatherType.SNOW + ); + Map dustMap = Map.of( + 9, FineDustType.GOOD, + 10, FineDustType.NORMAL, + 11, FineDustType.BAD, + 12, FineDustType.VERY_BAD + ); + Map uvMap = Map.of( + 9, UvType.LOW, + 10, UvType.NORMAL, + 11, UvType.HIGH, + 12, UvType.VERY_HIGH + ); + + when(kmaWeatherExtractor.extractWeatherForHours(any(KmaWeatherResponse.class), any(), eq(targetDate))) + .thenReturn(weatherMap); + when(fineDustExtractor.extractDustForHours(any(OpenMeteoResponse.class), any(), eq(targetDate))) + .thenReturn(dustMap); + when(uvIndexExtractor.extractUvForHours(any(OpenMeteoResponse.class), any(), eq(targetDate))) + .thenReturn(uvMap); + when(futureWeatherDecisionSelector.calculateWorstWeather(any())) + .thenReturn(WeatherType.SNOW); + when(futureWeatherDecisionSelector.calculateWorstFineDust(any())) + .thenReturn(FineDustType.VERY_BAD); + when(futureWeatherDecisionSelector.calculateWorstUv(any())) + .thenReturn(UvType.VERY_HIGH); + + // when + WeatherCacheData result = weatherDecisionService.getFutureWeatherCacheData(weatherApiResult, targetDate); + + // then + assertThat(result).isNotNull(); + assertThat(result.weather()).isEqualTo(WeatherType.SNOW); + assertThat(result.fineDust()).isEqualTo(FineDustType.VERY_BAD); + assertThat(result.uv()).isEqualTo(UvType.VERY_HIGH); + } + + + @Test + @DisplayName("오늘 날씨 폴백 캐시 데이터를 정상적으로 생성한다") + void Given_OpenMeteoWeatherApiResult_When_GetTodayWeatherCacheDataFallback_Then_ReturnsHourlyData() { + // given + OpenMeteoWeatherApiResultDto weatherApiResult = OpenMeteoWeatherApiResultDto.builder() + .openMeteoWeatherResponse(new OpenMeteoWeatherResponse(37.5665, 126.9780, "Asia/Seoul", null, null)) + .openMeteoResponse(new OpenMeteoResponse(37.5665, 126.9780, "Asia/Seoul", null, null)) + .build(); + TimeSlot currentSlot = TimeSlot.SLOT_15_18; + LocalDate today = LocalDate.of(2024, 1, 1); + + Map weathersByHour = Map.of( + 15, WeatherType.CLOUDY, + 16, WeatherType.OVERCAST, + 17, WeatherType.RAIN + ); + Map dustByHour = Map.of( + 15, FineDustType.NORMAL, + 16, FineDustType.BAD, + 17, FineDustType.VERY_BAD + ); + Map uvByHour = Map.of( + 15, UvType.NORMAL, + 16, UvType.HIGH, + 17, UvType.VERY_HIGH + ); + + when(openMeteoWeatherExtractor.extractWeatherForHours(any(OpenMeteoWeatherResponse.class), any(), eq(today))) + .thenReturn(weathersByHour); + when(fineDustExtractor.extractDustForHours(any(OpenMeteoResponse.class), any(), eq(today))) + .thenReturn(dustByHour); + when(uvIndexExtractor.extractUvForHours(any(OpenMeteoResponse.class), any(), eq(today))) + .thenReturn(uvByHour); + + // when + Map result = weatherDecisionService.getTodayWeatherCacheDataFallback( + weatherApiResult, currentSlot, today); + + // then + assertThat(result) + .isNotNull() + .hasSize(3); + + assertThat(result.get("15")) + .isNotNull() + .extracting(WeatherCacheData::weather, WeatherCacheData::fineDust, WeatherCacheData::uv) + .containsExactly(WeatherType.CLOUDY, FineDustType.NORMAL, UvType.NORMAL); + + assertThat(result.get("17")) + .isNotNull() + .extracting(WeatherCacheData::weather, WeatherCacheData::fineDust, WeatherCacheData::uv) + .containsExactly(WeatherType.RAIN, FineDustType.VERY_BAD, UvType.VERY_HIGH); + } + + + @Test + @DisplayName("미래 날씨 폴백 캐시 데이터를 정상적으로 생성한다") + void Given_OpenMeteoWeatherApiResult_When_GetFutureWeatherCacheDataFallback_Then_ReturnsWorstWeatherData() { + // given + OpenMeteoWeatherApiResultDto weatherApiResult = OpenMeteoWeatherApiResultDto.builder() + .openMeteoWeatherResponse(new OpenMeteoWeatherResponse(37.5665, 126.9780, "Asia/Seoul", null, null)) + .openMeteoResponse(new OpenMeteoResponse(37.5665, 126.9780, "Asia/Seoul", null, null)) + .build(); + LocalDate targetDate = LocalDate.of(2024, 1, 3); + + Map weatherMap = Map.of( + 9, WeatherType.SUNNY, + 10, WeatherType.CLOUDY, + 11, WeatherType.RAIN, + 12, WeatherType.SNOW + ); + Map dustMap = Map.of( + 9, FineDustType.GOOD, + 10, FineDustType.NORMAL, + 11, FineDustType.BAD, + 12, FineDustType.VERY_BAD + ); + Map uvMap = Map.of( + 9, UvType.LOW, + 10, UvType.NORMAL, + 11, UvType.HIGH, + 12, UvType.VERY_HIGH + ); + + when(openMeteoWeatherExtractor.extractWeatherForHours(any(OpenMeteoWeatherResponse.class), any(), + eq(targetDate))) + .thenReturn(weatherMap); + when(fineDustExtractor.extractDustForHours(any(OpenMeteoResponse.class), any(), eq(targetDate))) + .thenReturn(dustMap); + when(uvIndexExtractor.extractUvForHours(any(OpenMeteoResponse.class), any(), eq(targetDate))) + .thenReturn(uvMap); + when(futureWeatherDecisionSelector.calculateWorstWeather(any())) + .thenReturn(WeatherType.SNOW); + when(futureWeatherDecisionSelector.calculateWorstFineDust(any())) + .thenReturn(FineDustType.VERY_BAD); + when(futureWeatherDecisionSelector.calculateWorstUv(any())) + .thenReturn(UvType.VERY_HIGH); + + // when + WeatherCacheData result = + weatherDecisionService.getFutureWeatherCacheDataFallback(weatherApiResult, targetDate); + + // then + assertThat(result).isNotNull(); + assertThat(result.weather()).isEqualTo(WeatherType.SNOW); + assertThat(result.fineDust()).isEqualTo(FineDustType.VERY_BAD); + assertThat(result.uv()).isEqualTo(UvType.VERY_HIGH); + } + + + @Test + @DisplayName("시간별 데이터가 없을 때 기본값을 사용한다") + void Given_EmptyHourlyData_When_GetTodayWeatherCacheData_Then_UsesDefaultValues() { + // given + WeatherApiResultDto weatherApiResult = WeatherApiResultDto.builder() + .kmaWeatherResponse(new KmaWeatherResponse(null)) + .openMeteoResponse(new OpenMeteoResponse(37.5665, 126.9780, "Asia/Seoul", null, null)) + .build(); + TimeSlot currentSlot = TimeSlot.SLOT_21_24; + LocalDate today = LocalDate.of(2024, 1, 1); + + Map weathersByHour = Map.of(); + Map dustByHour = Map.of(); + Map uvByHour = Map.of(); + + when(kmaWeatherExtractor.extractWeatherForHours(any(KmaWeatherResponse.class), any(), eq(today))) + .thenReturn(weathersByHour); + when(fineDustExtractor.extractDustForHours(any(OpenMeteoResponse.class), any(), eq(today))) + .thenReturn(dustByHour); + when(uvIndexExtractor.extractUvForHours(any(OpenMeteoResponse.class), any(), eq(today))) + .thenReturn(uvByHour); + + // when + Map result = weatherDecisionService.getTodayWeatherCacheData( + weatherApiResult, currentSlot, today); + + // then + assertThat(result) + .isNotNull() + .hasSize(3); + + assertThat(result.get("21")) + .isNotNull() + .extracting(WeatherCacheData::weather, WeatherCacheData::fineDust, WeatherCacheData::uv) + .containsExactly(WeatherType.DEFAULT, FineDustType.DEFAULT, UvType.DEFAULT); + + assertThat(result.get("23")) + .isNotNull() + .extracting(WeatherCacheData::weather, WeatherCacheData::fineDust, WeatherCacheData::uv) + .containsExactly(WeatherType.DEFAULT, FineDustType.DEFAULT, UvType.DEFAULT); + } + + + @Test + @DisplayName("다른 시간대에서도 정상적으로 데이터를 생성한다") + void Given_DifferentTimeSlot_When_GetTodayWeatherCacheData_Then_ReturnsCorrectData() { + // given + WeatherApiResultDto weatherApiResult = WeatherApiResultDto.builder() + .kmaWeatherResponse(new KmaWeatherResponse(null)) + .openMeteoResponse(new OpenMeteoResponse(37.5665, 126.9780, "Asia/Seoul", null, null)) + .build(); + TimeSlot currentSlot = TimeSlot.SLOT_03_06; + LocalDate today = LocalDate.of(2024, 1, 1); + + Map weathersByHour = Map.of( + 3, WeatherType.SUNNY, + 4, WeatherType.SUNNY, + 5, WeatherType.SUNNY + ); + Map dustByHour = Map.of( + 3, FineDustType.GOOD, + 4, FineDustType.GOOD, + 5, FineDustType.GOOD + ); + Map uvByHour = Map.of( + 3, UvType.UNKNOWN, + 4, UvType.UNKNOWN, + 5, UvType.UNKNOWN + ); + + when(kmaWeatherExtractor.extractWeatherForHours(any(KmaWeatherResponse.class), any(), eq(today))) + .thenReturn(weathersByHour); + when(fineDustExtractor.extractDustForHours(any(OpenMeteoResponse.class), any(), eq(today))) + .thenReturn(dustByHour); + when(uvIndexExtractor.extractUvForHours(any(OpenMeteoResponse.class), any(), eq(today))) + .thenReturn(uvByHour); + + // when + Map result = weatherDecisionService.getTodayWeatherCacheData( + weatherApiResult, currentSlot, today); + + // then + assertThat(result) + .isNotNull() + .hasSize(3); + + assertThat(result.get("03")) + .isNotNull() + .extracting(WeatherCacheData::weather, WeatherCacheData::fineDust, WeatherCacheData::uv) + .containsExactly(WeatherType.SUNNY, FineDustType.GOOD, UvType.UNKNOWN); + + assertThat(result.get("05")) + .isNotNull() + .extracting(WeatherCacheData::weather, WeatherCacheData::fineDust, WeatherCacheData::uv) + .containsExactly(WeatherType.SUNNY, FineDustType.GOOD, UvType.UNKNOWN); + } + +} diff --git a/src/test/java/com/und/server/weather/service/WeatherServiceTest.java b/src/test/java/com/und/server/weather/service/WeatherServiceTest.java new file mode 100644 index 00000000..d602cb48 --- /dev/null +++ b/src/test/java/com/und/server/weather/service/WeatherServiceTest.java @@ -0,0 +1,330 @@ +package com.und.server.weather.service; + +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.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.und.server.weather.constants.FineDustType; +import com.und.server.weather.constants.UvType; +import com.und.server.weather.constants.WeatherType; +import com.und.server.weather.dto.cache.WeatherCacheData; +import com.und.server.weather.dto.request.WeatherRequest; +import com.und.server.weather.dto.response.WeatherResponse; +import com.und.server.weather.exception.WeatherErrorResult; +import com.und.server.weather.exception.WeatherException; + +@ExtendWith(MockitoExtension.class) +@DisplayName("WeatherService 테스트") +class WeatherServiceTest { + + @Mock + private WeatherCacheService weatherCacheService; + + @Mock + private Clock clock; + + @InjectMocks + private WeatherService weatherService; + + + @BeforeEach + void setUp() { + // Clock 설정 + when(clock.withZone(ZoneId.of("Asia/Seoul"))).thenReturn(Clock.fixed( + LocalDate.of(2024, 1, 15).atStartOfDay(ZoneId.of("Asia/Seoul")).toInstant(), + ZoneId.of("Asia/Seoul") + )); + } + + + @Test + @DisplayName("오늘 날씨 정보를 정상적으로 조회한다") + void Given_TodayWeatherRequest_When_GetWeatherInfo_Then_ReturnsTodayWeather() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate today = LocalDate.of(2024, 1, 15); + LocalDateTime nowDateTime = LocalDateTime.of(2024, 1, 15, 12, 0); + WeatherCacheData mockCacheData = WeatherCacheData.builder() + .weather(WeatherType.SUNNY) + .fineDust(FineDustType.GOOD) + .uv(UvType.LOW) + .build(); + + when(weatherCacheService.getTodayWeatherCache(eq(request), any(LocalDateTime.class))) + .thenReturn(mockCacheData); + + // when + WeatherResponse response = weatherService.getWeatherInfo(request, today, ZoneId.of("Asia/Seoul")); + + // then + assertThat(response).isNotNull(); + assertThat(response.weather()).isEqualTo(WeatherType.SUNNY); + assertThat(response.fineDust()).isEqualTo(FineDustType.GOOD); + } + + + @Test + @DisplayName("미래 날씨 정보를 정상적으로 조회한다") + void Given_FutureWeatherRequest_When_GetWeatherInfo_Then_ReturnsFutureWeather() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate futureDate = LocalDate.of(2024, 1, 16); + LocalDateTime nowDateTime = LocalDateTime.of(2024, 1, 15, 12, 0); + WeatherCacheData mockCacheData = WeatherCacheData.builder() + .weather(WeatherType.CLOUDY) + .fineDust(FineDustType.NORMAL) + .uv(UvType.NORMAL) + .build(); + + when(weatherCacheService.getFutureWeatherCache(eq(request), any(LocalDateTime.class), eq(futureDate))) + .thenReturn(mockCacheData); + + // when + WeatherResponse response = weatherService.getWeatherInfo(request, futureDate, ZoneId.of("Asia/Seoul")); + + // then + assertThat(response).isNotNull(); + assertThat(response.weather()).isEqualTo(WeatherType.CLOUDY); + assertThat(response.fineDust()).isEqualTo(FineDustType.NORMAL); + } + + + @Test + @DisplayName("오늘 날씨 캐시가 null일 때 기본값을 반환한다") + void Given_TodayWeatherCacheIsNull_When_GetWeatherInfo_Then_ReturnsDefault() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate today = LocalDate.of(2024, 1, 15); + + when(weatherCacheService.getTodayWeatherCache(eq(request), any(LocalDateTime.class))) + .thenReturn(null); + + // when + WeatherResponse response = weatherService.getWeatherInfo(request, today, ZoneId.of("Asia/Seoul")); + + // then + assertThat(response).isNotNull(); + assertThat(response.weather()).isEqualTo(WeatherCacheData.getDefault().weather()); + assertThat(response.fineDust()).isEqualTo(WeatherCacheData.getDefault().fineDust()); + } + + + @Test + @DisplayName("오늘 날씨 캐시가 유효하지 않을 때 유효한 기본값을 반환한다") + void Given_TodayWeatherCacheIsInvalid_When_GetWeatherInfo_Then_ReturnsValidDefault() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate today = LocalDate.of(2024, 1, 15); + WeatherCacheData invalidCacheData = WeatherCacheData.builder() + .weather(null) + .fineDust(FineDustType.GOOD) + .uv(UvType.LOW) + .build(); + + when(weatherCacheService.getTodayWeatherCache(eq(request), any(LocalDateTime.class))) + .thenReturn(invalidCacheData); + + // when + WeatherResponse response = weatherService.getWeatherInfo(request, today, ZoneId.of("Asia/Seoul")); + + // then + assertThat(response).isNotNull(); + assertThat(response.weather()).isEqualTo(invalidCacheData.getValidDefault().weather()); + assertThat(response.fineDust()).isEqualTo(invalidCacheData.getValidDefault().fineDust()); + } + + + @Test + @DisplayName("미래 날씨 캐시가 null일 때 기본값을 반환한다") + void Given_FutureWeatherCacheIsNull_When_GetWeatherInfo_Then_ReturnsDefault() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate futureDate = LocalDate.of(2024, 1, 16); + + when(weatherCacheService.getFutureWeatherCache(eq(request), any(LocalDateTime.class), eq(futureDate))) + .thenReturn(null); + + // when + WeatherResponse response = weatherService.getWeatherInfo(request, futureDate, ZoneId.of("Asia/Seoul")); + + // then + assertThat(response).isNotNull(); + assertThat(response.weather()).isEqualTo(WeatherCacheData.getDefault().weather()); + assertThat(response.fineDust()).isEqualTo(WeatherCacheData.getDefault().fineDust()); + } + + + @Test + @DisplayName("미래 날씨 캐시가 유효하지 않을 때 유효한 기본값을 반환한다") + void Given_FutureWeatherCacheIsInvalid_When_GetWeatherInfo_Then_ReturnsValidDefault() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate futureDate = LocalDate.of(2024, 1, 16); + WeatherCacheData invalidCacheData = WeatherCacheData.builder() + .weather(WeatherType.CLOUDY) + .fineDust(null) + .uv(UvType.NORMAL) + .build(); + + when(weatherCacheService.getFutureWeatherCache(eq(request), any(LocalDateTime.class), eq(futureDate))) + .thenReturn(invalidCacheData); + + // when + WeatherResponse response = weatherService.getWeatherInfo(request, futureDate, ZoneId.of("Asia/Seoul")); + + // then + assertThat(response).isNotNull(); + assertThat(response.weather()).isEqualTo(invalidCacheData.getValidDefault().weather()); + assertThat(response.fineDust()).isEqualTo(invalidCacheData.getValidDefault().fineDust()); + } + + + @Test + @DisplayName("위도가 -90보다 작을 때 예외를 발생시킨다") + void Given_LatitudeLessThanMinus90_When_GetWeatherInfo_Then_ThrowsException() { + // given + WeatherRequest request = new WeatherRequest(-91.0, 126.9780); + LocalDate today = LocalDate.of(2024, 1, 15); + + // when & then + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today, ZoneId.of("Asia/Seoul"))) + .isInstanceOf(WeatherException.class) + .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.INVALID_COORDINATES); + } + + + @Test + @DisplayName("위도가 90보다 클 때 예외를 발생시킨다") + void Given_LatitudeGreaterThan90_When_GetWeatherInfo_Then_ThrowsException() { + // given + WeatherRequest request = new WeatherRequest(91.0, 126.9780); + LocalDate today = LocalDate.of(2024, 1, 15); + + // when & then + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today, ZoneId.of("Asia/Seoul"))) + .isInstanceOf(WeatherException.class) + .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.INVALID_COORDINATES); + } + + + @Test + @DisplayName("경도가 -180보다 작을 때 예외를 발생시킨다") + void Given_LongitudeLessThanMinus180_When_GetWeatherInfo_Then_ThrowsException() { + // given + WeatherRequest request = new WeatherRequest(37.5665, -181.0); + LocalDate today = LocalDate.of(2024, 1, 15); + + // when & then + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today, ZoneId.of("Asia/Seoul"))) + .isInstanceOf(WeatherException.class) + .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.INVALID_COORDINATES); + } + + + @Test + @DisplayName("경도가 180보다 클 때 예외를 발생시킨다") + void Given_LongitudeGreaterThan180_When_GetWeatherInfo_Then_ThrowsException() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 181.0); + LocalDate today = LocalDate.of(2024, 1, 15); + + // when & then + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today, ZoneId.of("Asia/Seoul"))) + .isInstanceOf(WeatherException.class) + .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.INVALID_COORDINATES); + } + + + @Test + @DisplayName("요청 날짜가 오늘보다 이전일 때 예외를 발생시킨다") + void Given_DateBeforeToday_When_GetWeatherInfo_Then_ThrowsException() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate yesterday = LocalDate.of(2024, 1, 14); + + // when & then + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, yesterday, ZoneId.of("Asia/Seoul"))) + .isInstanceOf(WeatherException.class) + .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.DATE_OUT_OF_RANGE); + } + + + @Test + @DisplayName("요청 날짜가 최대 허용 날짜보다 이후일 때 예외를 발생시킨다") + void Given_DateAfterMaxDate_When_GetWeatherInfo_Then_ThrowsException() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate maxDatePlusOne = LocalDate.of(2024, 1, 19); // MAX_FUTURE_DATE + 1 + + // when & then + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, maxDatePlusOne, ZoneId.of("Asia/Seoul"))) + .isInstanceOf(WeatherException.class) + .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.DATE_OUT_OF_RANGE); + } + + + @Test + @DisplayName("유효한 좌표와 날짜로 날씨 정보를 조회한다") + void Given_ValidCoordinatesAndDate_When_GetWeatherInfo_Then_ReturnsWeatherInfo() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate today = LocalDate.of(2024, 1, 15); + WeatherCacheData mockCacheData = WeatherCacheData.builder() + .weather(WeatherType.RAIN) + .fineDust(FineDustType.BAD) + .uv(UvType.HIGH) + .build(); + + when(weatherCacheService.getTodayWeatherCache(eq(request), any(LocalDateTime.class))) + .thenReturn(mockCacheData); + + // when + WeatherResponse response = weatherService.getWeatherInfo(request, today, ZoneId.of("Asia/Seoul")); + + // then + assertThat(response).isNotNull(); + assertThat(response.weather()).isEqualTo(WeatherType.RAIN); + assertThat(response.fineDust()).isEqualTo(FineDustType.BAD); + } + + + @Test + @DisplayName("최대 허용 날짜로 날씨 정보를 조회한다") + void Given_MaxAllowedDate_When_GetWeatherInfo_Then_ReturnsWeatherInfo() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate maxDate = LocalDate.of(2024, 1, 18); // MAX_FUTURE_DATE + WeatherCacheData mockCacheData = WeatherCacheData.builder() + .weather(WeatherType.SNOW) + .fineDust(FineDustType.VERY_BAD) + .uv(UvType.VERY_LOW) + .build(); + + when(weatherCacheService.getFutureWeatherCache(eq(request), any(LocalDateTime.class), eq(maxDate))) + .thenReturn(mockCacheData); + + // when + WeatherResponse response = weatherService.getWeatherInfo(request, maxDate, ZoneId.of("Asia/Seoul")); + + // then + assertThat(response).isNotNull(); + assertThat(response.weather()).isEqualTo(WeatherType.SNOW); + assertThat(response.fineDust()).isEqualTo(FineDustType.VERY_BAD); + } + +} diff --git a/src/test/java/com/und/server/weather/util/CacheSerializerTest.java b/src/test/java/com/und/server/weather/util/CacheSerializerTest.java new file mode 100644 index 00000000..77e712cc --- /dev/null +++ b/src/test/java/com/und/server/weather/util/CacheSerializerTest.java @@ -0,0 +1,386 @@ +package com.und.server.weather.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.und.server.weather.constants.FineDustType; +import com.und.server.weather.constants.UvType; +import com.und.server.weather.constants.WeatherType; +import com.und.server.weather.dto.cache.WeatherCacheData; + +@DisplayName("CacheSerializer 테스트") +class CacheSerializerTest { + + private CacheSerializer cacheSerializer; + + @BeforeEach + void setUp() { + cacheSerializer = new CacheSerializer(); + } + + @Test + @DisplayName("WeatherCacheData를 JSON으로 직렬화할 수 있다") + void Given_WeatherCacheData_When_Serialize_Then_ReturnsJsonString() { + // given + WeatherCacheData data = WeatherCacheData.builder() + .weather(WeatherType.SUNNY) + .fineDust(FineDustType.GOOD) + .uv(UvType.LOW) + .build(); + + // when + String result = cacheSerializer.serializeWeatherCacheData(data); + + // then + assertThat(result) + .isNotNull() + .contains("SUNNY") + .contains("GOOD") + .contains("LOW"); + } + + + @Test + @DisplayName("JSON을 WeatherCacheData로 역직렬화할 수 있다") + void Given_JsonString_When_Deserialize_Then_ReturnsWeatherCacheData() { + // given + String json = """ + { + "weather": "SUNNY", + "fineDust": "GOOD", + "uv": "LOW" + } + """; + + // when + WeatherCacheData result = cacheSerializer.deserializeWeatherCacheData(json); + + // then + assertThat(result) + .isNotNull() + .satisfies(data -> { + assertThat(data.weather()).isEqualTo(WeatherType.SUNNY); + assertThat(data.fineDust()).isEqualTo(FineDustType.GOOD); + assertThat(data.uv()).isEqualTo(UvType.LOW); + }); + } + + + @Test + @DisplayName("직렬화 후 역직렬화하면 원본 데이터와 같다") + void Given_WeatherCacheData_When_SerializeAndDeserialize_Then_ReturnsOriginalData() { + // given + WeatherCacheData originalData = WeatherCacheData.builder() + .weather(WeatherType.RAIN) + .fineDust(FineDustType.BAD) + .uv(UvType.HIGH) + .build(); + + // when + String serialized = cacheSerializer.serializeWeatherCacheData(originalData); + WeatherCacheData deserialized = cacheSerializer.deserializeWeatherCacheData(serialized); + + // then + assertThat(deserialized) + .isNotNull() + .satisfies(data -> { + assertThat(data.weather()).isEqualTo(originalData.weather()); + assertThat(data.fineDust()).isEqualTo(originalData.fineDust()); + assertThat(data.uv()).isEqualTo(originalData.uv()); + }); + } + + + @Test + @DisplayName("WeatherCacheData 맵을 해시로 직렬화할 수 있다") + void Given_WeatherCacheDataMap_When_SerializeToHash_Then_ReturnsStringMap() { + // given + Map hourlyData = new HashMap<>(); + hourlyData.put("12", WeatherCacheData.builder() + .weather(WeatherType.SUNNY) + .fineDust(FineDustType.GOOD) + .uv(UvType.LOW) + .build()); + hourlyData.put("13", WeatherCacheData.builder() + .weather(WeatherType.CLOUDY) + .fineDust(FineDustType.NORMAL) + .uv(UvType.NORMAL) + .build()); + + // when + Map result = cacheSerializer.serializeWeatherCacheDataToHash(hourlyData); + + // then + assertThat(result) + .hasSize(2) + .satisfies(map -> { + assertThat(map.get("12")).contains("SUNNY"); + assertThat(map.get("13")).contains("CLOUDY"); + }); + } + + + @Test + @DisplayName("해시에서 WeatherCacheData를 역직렬화할 수 있다") + void Given_JsonString_When_DeserializeFromHash_Then_ReturnsWeatherCacheData() { + // given + String json = """ + { + "weather": "SNOW", + "fineDust": "VERY_BAD", + "uv": "VERY_HIGH" + } + """; + + // when + WeatherCacheData result = cacheSerializer.deserializeWeatherCacheDataFromHash(json); + + // then + assertThat(result) + .isNotNull() + .satisfies(data -> { + assertThat(data.weather()).isEqualTo(WeatherType.SNOW); + assertThat(data.fineDust()).isEqualTo(FineDustType.VERY_BAD); + assertThat(data.uv()).isEqualTo(UvType.VERY_HIGH); + }); + } + + + @Test + @DisplayName("빈 WeatherCacheData를 직렬화할 수 있다") + void Given_EmptyWeatherCacheData_When_Serialize_Then_ReturnsEmptyJson() { + // given + WeatherCacheData data = WeatherCacheData.builder().build(); + + // when + String result = cacheSerializer.serializeWeatherCacheData(data); + + // then + assertThat(result) + .isNotNull() + .contains("{}"); + } + + + @Test + @DisplayName("빈 JSON을 역직렬화하면 null을 반환한다") + void Given_EmptyJson_When_Deserialize_Then_ReturnsNullValues() { + // given + String json = "{}"; + + // when + WeatherCacheData result = cacheSerializer.deserializeWeatherCacheData(json); + + // then + assertThat(result) + .isNotNull() + .satisfies(data -> { + assertThat(data.weather()).isNull(); + assertThat(data.fineDust()).isNull(); + assertThat(data.uv()).isNull(); + }); + } + + + @Test + @DisplayName("잘못된 JSON을 역직렬화하면 null을 반환한다") + void Given_InvalidJson_When_DeserializeWeatherCacheData_Then_ReturnsNull() { + // given + String invalidJson = "{ invalid json }"; + + // when + WeatherCacheData result = cacheSerializer.deserializeWeatherCacheData(invalidJson); + + // then + assertThat(result).isNull(); + } + + + @Test + @DisplayName("null JSON을 역직렬화하면 예외가 발생한다") + void Given_NullJson_When_DeserializeWeatherCacheData_Then_ThrowsException() { + // given + String nullJson = null; + + // when & then + assertThatThrownBy(() -> cacheSerializer.deserializeWeatherCacheData(nullJson)) + .isInstanceOf(IllegalArgumentException.class); + } + + + @Test + @DisplayName("null WeatherCacheData를 직렬화하면 null 문자열을 반환한다") + void Given_NullWeatherCacheData_When_SerializeWeatherCacheData_Then_ReturnsNullString() { + // given + WeatherCacheData nullData = null; + + // when + String result = cacheSerializer.serializeWeatherCacheData(nullData); + + // then + assertThat(result).isEqualTo("null"); + } + + + @Test + @DisplayName("모든 WeatherType에 대해 직렬화/역직렬화가 가능하다") + void Given_AllWeatherTypes_When_SerializeAndDeserializeWeatherCacheData_Then_ReturnsCorrectData() { + // given + for (WeatherType weatherType : WeatherType.values()) { + WeatherCacheData data = WeatherCacheData.builder() + .weather(weatherType) + .fineDust(FineDustType.GOOD) + .uv(UvType.LOW) + .build(); + + // when + String serialized = cacheSerializer.serializeWeatherCacheData(data); + WeatherCacheData deserialized = cacheSerializer.deserializeWeatherCacheData(serialized); + + // then + assertThat(deserialized) + .isNotNull() + .satisfies(result -> assertThat(result.weather()).isEqualTo(weatherType)); + } + } + + + @Test + @DisplayName("모든 FineDustType에 대해 직렬화/역직렬화가 가능하다") + void Given_AllFineDustTypes_When_SerializeAndDeserializeWeatherCacheData_Then_ReturnsCorrectData() { + // given + for (FineDustType fineDustType : FineDustType.values()) { + WeatherCacheData data = WeatherCacheData.builder() + .weather(WeatherType.SUNNY) + .fineDust(fineDustType) + .uv(UvType.LOW) + .build(); + + // when + String serialized = cacheSerializer.serializeWeatherCacheData(data); + WeatherCacheData deserialized = cacheSerializer.deserializeWeatherCacheData(serialized); + + // then + assertThat(deserialized) + .isNotNull() + .satisfies(result -> assertThat(result.fineDust()).isEqualTo(fineDustType)); + } + } + + + @Test + @DisplayName("모든 UvType에 대해 직렬화/역직렬화가 가능하다") + void Given_AllUvTypes_When_SerializeAndDeserializeWeatherCacheData_Then_ReturnsCorrectData() { + // given + for (UvType uvType : UvType.values()) { + WeatherCacheData data = WeatherCacheData.builder() + .weather(WeatherType.SUNNY) + .fineDust(FineDustType.GOOD) + .uv(uvType) + .build(); + + // when + String serialized = cacheSerializer.serializeWeatherCacheData(data); + WeatherCacheData deserialized = cacheSerializer.deserializeWeatherCacheData(serialized); + + // then + assertThat(deserialized) + .isNotNull() + .satisfies(result -> assertThat(result.uv()).isEqualTo(uvType)); + } + } + + + @Test + @DisplayName("해시 직렬화에서 null 데이터도 처리한다") + void Given_MapWithNullData_When_SerializeToHash_Then_ProcessesAllData() { + // given + Map hourlyData = new HashMap<>(); + hourlyData.put("12", WeatherCacheData.builder() + .weather(WeatherType.SUNNY) + .fineDust(FineDustType.GOOD) + .uv(UvType.LOW) + .build()); + // null 데이터는 "null" 문자열로 직렬화됨 + hourlyData.put("13", null); + + // when + Map result = cacheSerializer.serializeWeatherCacheDataToHash(hourlyData); + + // then + assertThat(result) + .hasSize(2) + .satisfies(map -> { + assertThat(map.get("12")).contains("SUNNY"); + assertThat(map.get("13")).isEqualTo("null"); + }); + } + + + @Test + @DisplayName("해시에서 잘못된 JSON을 역직렬화하면 null을 반환한다") + void Given_InvalidJson_When_DeserializeFromHash_Then_ReturnsNull() { + // given + String invalidJson = "{ invalid json }"; + + // when + WeatherCacheData result = cacheSerializer.deserializeWeatherCacheDataFromHash(invalidJson); + + // then + assertThat(result).isNull(); + } + + + @Test + @DisplayName("해시에서 null JSON을 역직렬화하면 예외가 발생한다") + void Given_NullJson_When_DeserializeFromHash_Then_ThrowsException() { + // given + String nullJson = null; + + // when & then + assertThatThrownBy(() -> cacheSerializer.deserializeWeatherCacheDataFromHash(nullJson)) + .isInstanceOf(IllegalArgumentException.class); + } + + + @Test + @DisplayName("빈 맵을 해시로 직렬화하면 빈 맵을 반환한다") + void Given_EmptyMap_When_SerializeToHash_Then_ReturnsEmptyMap() { + // given + Map emptyMap = new HashMap<>(); + + // when + Map result = cacheSerializer.serializeWeatherCacheDataToHash(emptyMap); + + // then + assertThat(result).isEmpty(); + } + + + @Test + @DisplayName("해시에서 빈 JSON을 역직렬화하면 null 값을 가진 객체를 반환한다") + void Given_EmptyJson_When_DeserializeFromHash_Then_ReturnsNullValues() { + // given + String json = "{}"; + + // when + WeatherCacheData result = cacheSerializer.deserializeWeatherCacheDataFromHash(json); + + // then + assertThat(result) + .isNotNull() + .satisfies(data -> { + assertThat(data.weather()).isNull(); + assertThat(data.fineDust()).isNull(); + assertThat(data.uv()).isNull(); + }); + } + +} diff --git a/src/test/java/com/und/server/weather/util/GridConverterTest.java b/src/test/java/com/und/server/weather/util/GridConverterTest.java new file mode 100644 index 00000000..c956b7cc --- /dev/null +++ b/src/test/java/com/und/server/weather/util/GridConverterTest.java @@ -0,0 +1,207 @@ +package com.und.server.weather.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.und.server.weather.dto.GridPoint; + +@DisplayName("GridConverter 테스트") +class GridConverterTest { + + @Test + @DisplayName("위도/경도를 API 그리드로 변환할 수 있다") + void Given_LatitudeAndLongitude_When_ConvertToApiGrid_Then_ReturnsGridPoint() { + // given + double latitude = 37.5665; // 서울 위도 + double longitude = 126.9780; // 서울 경도 + + // when + GridPoint result = GridConverter.convertToApiGrid(latitude, longitude); + + // then + assertThat(result).isNotNull(); + assertThat(result.gridX()).isPositive(); + assertThat(result.gridY()).isPositive(); + } + + + @Test + @DisplayName("위도/경도를 캐시 그리드로 변환할 수 있다") + void Given_LatitudeAndLongitudeAndGrid_When_ConvertToCacheGrid_Then_ReturnsGridPoint() { + // given + double latitude = 37.5665; // 서울 위도 + double longitude = 126.9780; // 서울 경도 + double grid = 1.0; // 1km 그리드 + + // when + GridPoint result = GridConverter.convertToCacheGrid(latitude, longitude, grid); + + // then + assertThat(result).isNotNull(); + assertThat(result.gridX()).isPositive(); + assertThat(result.gridY()).isPositive(); + } + + + @Test + @DisplayName("부산 좌표를 API 그리드로 변환할 수 있다") + void Given_BusanCoordinates_When_ConvertToApiGrid_Then_ReturnsGridPoint() { + // given + double latitude = 35.1796; // 부산 위도 + double longitude = 129.0756; // 부산 경도 + + // when + GridPoint result = GridConverter.convertToApiGrid(latitude, longitude); + + // then + assertThat(result).isNotNull(); + assertThat(result.gridX()).isPositive(); + assertThat(result.gridY()).isPositive(); + } + + + @Test + @DisplayName("제주도 좌표를 API 그리드로 변환할 수 있다") + void Given_JejuCoordinates_When_ConvertToApiGrid_Then_ReturnsGridPoint() { + // given + double latitude = 33.4996; // 제주도 위도 + double longitude = 126.5312; // 제주도 경도 + + // when + GridPoint result = GridConverter.convertToApiGrid(latitude, longitude); + + // then + assertThat(result).isNotNull(); + assertThat(result.gridX()).isPositive(); + assertThat(result.gridY()).isPositive(); + } + + @Test + @DisplayName("다양한 그리드 크기로 변환할 수 있다") + void Given_DifferentGridSizes_When_ConvertToCacheGrid_Then_ReturnsDifferentGridPoints() { + // given + double latitude = 37.5665; + double longitude = 126.9780; + double grid1 = 1.0; + double grid5 = 5.0; + double grid10 = 10.0; + + // when + GridPoint result1 = GridConverter.convertToCacheGrid(latitude, longitude, grid1); + GridPoint result5 = GridConverter.convertToCacheGrid(latitude, longitude, grid5); + GridPoint result10 = GridConverter.convertToCacheGrid(latitude, longitude, grid10); + + // then + assertThat(result1).isNotNull(); + assertThat(result5).isNotNull(); + assertThat(result10).isNotNull(); + + // 그리드 크기가 클수록 좌표값이 작아지는 경향이 있음 + assertThat(result1.gridX()).isGreaterThanOrEqualTo(result5.gridX()); + assertThat(result5.gridX()).isGreaterThanOrEqualTo(result10.gridX()); + } + + + @Test + @DisplayName("같은 좌표는 같은 그리드로 변환된다") + void Given_SameCoordinates_When_ConvertToApiGrid_Then_ReturnsSameGridPoint() { + // given + double latitude = 37.5665; + double longitude = 126.9780; + + // when + GridPoint result1 = GridConverter.convertToApiGrid(latitude, longitude); + GridPoint result2 = GridConverter.convertToApiGrid(latitude, longitude); + + // then + assertThat(result1).isEqualTo(result2); + } + + + @Test + @DisplayName("극한 좌표값도 처리할 수 있다") + void Given_ExtremeCoordinates_When_ConvertToApiGrid_Then_ReturnsValidGridPoint() { + // given + double minLatitude = 33.0; // 한국 최남단 + double maxLatitude = 38.6; // 한국 최북단 + double minLongitude = 124.5; // 한국 최서단 + double maxLongitude = 132.0; // 한국 최동단 + + // when & then + assertThat(GridConverter.convertToApiGrid(minLatitude, minLongitude)).isNotNull(); + assertThat(GridConverter.convertToApiGrid(maxLatitude, maxLongitude)).isNotNull(); + assertThat(GridConverter.convertToApiGrid(minLatitude, maxLongitude)).isNotNull(); + assertThat(GridConverter.convertToApiGrid(maxLatitude, minLongitude)).isNotNull(); + } + + + @Test + @DisplayName("경도가 180도를 넘는 경우를 처리할 수 있다") + void Given_LongitudeOver180_When_ConvertToApiGrid_Then_ReturnsValidGridPoint() { + // given + double latitude = 37.5665; + double longitude = 181.0; // 180도 초과 + + // when + GridPoint result = GridConverter.convertToApiGrid(latitude, longitude); + + // then + assertThat(result).isNotNull(); + assertThat(result.gridX()).isPositive(); + assertThat(result.gridY()).isPositive(); + } + + + @Test + @DisplayName("경도가 -180도 미만인 경우를 처리할 수 있다") + void Given_LongitudeUnderMinus180_When_ConvertToApiGrid_Then_ReturnsValidGridPoint() { + // given + double latitude = 37.5665; + double longitude = -181.0; // -180도 미만 + + // when + GridPoint result = GridConverter.convertToApiGrid(latitude, longitude); + + // then + assertThat(result).isNotNull(); + assertThat(result.gridX()).isPositive(); + assertThat(result.gridY()).isPositive(); + } + + + @Test + @DisplayName("경도가 정확히 180도인 경우를 처리할 수 있다") + void Given_LongitudeExactly180_When_ConvertToApiGrid_Then_ReturnsValidGridPoint() { + // given + double latitude = 37.5665; + double longitude = 180.0; // 정확히 180도 + + // when + GridPoint result = GridConverter.convertToApiGrid(latitude, longitude); + + // then + assertThat(result).isNotNull(); + assertThat(result.gridX()).isPositive(); + assertThat(result.gridY()).isPositive(); + } + + + @Test + @DisplayName("경도가 정확히 -180도인 경우를 처리할 수 있다") + void Given_LongitudeExactlyMinus180_When_ConvertToApiGrid_Then_ReturnsValidGridPoint() { + // given + double latitude = 37.5665; + double longitude = -180.0; // 정확히 -180도 + + // when + GridPoint result = GridConverter.convertToApiGrid(latitude, longitude); + + // then + assertThat(result).isNotNull(); + assertThat(result.gridX()).isPositive(); + assertThat(result.gridY()).isPositive(); + } + +} diff --git a/src/test/java/com/und/server/weather/util/WeatherKeyGeneratorTest.java b/src/test/java/com/und/server/weather/util/WeatherKeyGeneratorTest.java new file mode 100644 index 00000000..f86d1d03 --- /dev/null +++ b/src/test/java/com/und/server/weather/util/WeatherKeyGeneratorTest.java @@ -0,0 +1,190 @@ +package com.und.server.weather.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.und.server.weather.constants.TimeSlot; + +@DisplayName("WeatherKeyGenerator 테스트") +class WeatherKeyGeneratorTest { + + private WeatherKeyGenerator weatherKeyGenerator; + + @BeforeEach + void setUp() { + weatherKeyGenerator = new WeatherKeyGenerator(); + } + + @Test + @DisplayName("오늘 날씨 캐시 키를 생성할 수 있다") + void Given_LatitudeAndLongitudeAndTodayAndSlot_When_GenerateTodayKey_Then_ReturnsCacheKey() { + // given + Double latitude = 37.5665; + Double longitude = 126.9780; + LocalDate today = LocalDate.of(2024, 1, 15); + TimeSlot slot = TimeSlot.SLOT_12_15; + + // when + String result = weatherKeyGenerator.generateTodayKey(latitude, longitude, today, slot); + + // then + assertThat(result).isNotNull(); + assertThat(result).contains("wx"); + assertThat(result).contains("today"); + assertThat(result).contains("2024-01-15"); + assertThat(result).contains("SLOT_12_15"); + } + + + @Test + @DisplayName("미래 날씨 캐시 키를 생성할 수 있다") + void Given_LatitudeAndLongitudeAndFutureDateAndSlot_When_GenerateFutureKey_Then_ReturnsCacheKey() { + // given + Double latitude = 37.5665; + Double longitude = 126.9780; + LocalDate futureDate = LocalDate.of(2024, 1, 20); + TimeSlot slot = TimeSlot.SLOT_06_09; + + // when + String result = weatherKeyGenerator.generateFutureKey(latitude, longitude, futureDate, slot); + + // then + assertThat(result).isNotNull(); + assertThat(result).contains("wx"); + assertThat(result).contains("future"); + assertThat(result).contains("2024-01-20"); + assertThat(result).contains("SLOT_06_09"); + } + + + @Test + @DisplayName("시간대별 필드 키를 생성할 수 있다") + void Given_DateTime_When_GenerateTodayHourFieldKey_Then_ReturnsHourString() { + // given + LocalDateTime dateTime = LocalDateTime.of(2024, 1, 15, 14, 30); + + // when + String result = weatherKeyGenerator.generateTodayHourFieldKey(dateTime); + + // then + assertThat(result).isEqualTo("14"); + } + + + @Test + @DisplayName("자정 시간대 필드 키를 생성할 수 있다") + void Given_MidnightDateTime_When_GenerateTodayHourFieldKey_Then_ReturnsZeroHourString() { + // given + LocalDateTime dateTime = LocalDateTime.of(2024, 1, 15, 0, 0); + + // when + String result = weatherKeyGenerator.generateTodayHourFieldKey(dateTime); + + // then + assertThat(result).isEqualTo("00"); + } + + + @Test + @DisplayName("자정 직전 시간대 필드 키를 생성할 수 있다") + void Given_BeforeMidnightDateTime_When_GenerateTodayHourFieldKey_Then_ReturnsTwentyThreeHourString() { + // given + LocalDateTime dateTime = LocalDateTime.of(2024, 1, 15, 23, 59); + + // when + String result = weatherKeyGenerator.generateTodayHourFieldKey(dateTime); + + // then + assertThat(result).isEqualTo("23"); + } + + + @Test + @DisplayName("다양한 시간대에 대해 캐시 키를 생성할 수 있다") + void Given_DifferentTimeSlots_When_GenerateKeys_Then_ReturnsValidCacheKeys() { + // given + Double latitude = 37.5665; + Double longitude = 126.9780; + LocalDate date = LocalDate.of(2024, 1, 15); + + // when & then + for (TimeSlot slot : TimeSlot.values()) { + String todayKey = weatherKeyGenerator.generateTodayKey(latitude, longitude, date, slot); + String futureKey = weatherKeyGenerator.generateFutureKey(latitude, longitude, date, slot); + + assertThat(todayKey).isNotNull(); + assertThat(futureKey).isNotNull(); + assertThat(todayKey).contains(slot.name()); + assertThat(futureKey).contains(slot.name()); + } + } + + + @Test + @DisplayName("다양한 지역에 대해 캐시 키를 생성할 수 있다") + void Given_DifferentLocations_When_GenerateTodayKey_Then_ReturnsDifferentCacheKeys() { + // given + LocalDate date = LocalDate.of(2024, 1, 15); + TimeSlot slot = TimeSlot.SLOT_12_15; + + // 서울 + String seoulKey = weatherKeyGenerator.generateTodayKey(37.5665, 126.9780, date, slot); + // 부산 + String busanKey = weatherKeyGenerator.generateTodayKey(35.1796, 129.0756, date, slot); + // 제주도 + String jejuKey = weatherKeyGenerator.generateTodayKey(33.4996, 126.5312, date, slot); + + // then + assertThat(seoulKey).isNotNull(); + assertThat(busanKey).isNotNull(); + assertThat(jejuKey).isNotNull(); + assertThat(seoulKey).isNotEqualTo(busanKey); + assertThat(busanKey).isNotEqualTo(jejuKey); + assertThat(seoulKey).isNotEqualTo(jejuKey); + } + + + @Test + @DisplayName("같은 좌표와 시간대는 같은 캐시 키를 생성한다") + void Given_SameCoordinatesAndTimeSlot_When_GenerateTodayKey_Then_ReturnsSameCacheKey() { + // given + Double latitude = 37.5665; + Double longitude = 126.9780; + LocalDate date = LocalDate.of(2024, 1, 15); + TimeSlot slot = TimeSlot.SLOT_12_15; + + // when + String key1 = weatherKeyGenerator.generateTodayKey(latitude, longitude, date, slot); + String key2 = weatherKeyGenerator.generateTodayKey(latitude, longitude, date, slot); + + // then + assertThat(key1).isEqualTo(key2); + } + + + @Test + @DisplayName("캐시 키 형식이 올바르다") + void Given_ValidInputs_When_GenerateCacheKeys_Then_ReturnsCorrectFormat() { + // given + Double latitude = 37.5665; + Double longitude = 126.9780; + LocalDate date = LocalDate.of(2024, 1, 15); + TimeSlot slot = TimeSlot.SLOT_12_15; + + // when + String todayKey = weatherKeyGenerator.generateTodayKey(latitude, longitude, date, slot); + String futureKey = weatherKeyGenerator.generateFutureKey(latitude, longitude, date, slot); + + // then + // 형식: wx:today:gridX:gridY:date:slot 또는 wx:future:gridX:gridY:date:slot + assertThat(todayKey).matches("wx:today:\\d+:\\d+:\\d{4}-\\d{2}-\\d{2}:SLOT_\\d{2}_\\d{2}"); + assertThat(futureKey).matches("wx:future:\\d+:\\d+:\\d{4}-\\d{2}-\\d{2}:SLOT_\\d{2}_\\d{2}"); + } + +} diff --git a/src/test/java/com/und/server/weather/util/WeatherTtlCalculatorTest.java b/src/test/java/com/und/server/weather/util/WeatherTtlCalculatorTest.java new file mode 100644 index 00000000..eb222c34 --- /dev/null +++ b/src/test/java/com/und/server/weather/util/WeatherTtlCalculatorTest.java @@ -0,0 +1,452 @@ +package com.und.server.weather.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.LocalTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.und.server.weather.constants.TimeSlot; + +@DisplayName("WeatherTtlCalculator 테스트") +class WeatherTtlCalculatorTest { + + private WeatherTtlCalculator weatherTtlCalculator; + + @BeforeEach + void setUp() { + weatherTtlCalculator = new WeatherTtlCalculator(); + } + + @Test + @DisplayName("21-24 시간대에서 21시 이후일 때 다음날 자정까지 TTL을 계산한다") + void Given_Slot21_24After21Hour_When_CalculateTtl_Then_ReturnsNextDayMidnightDuration() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_21_24; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 22, 30); // 22:30 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + LocalDateTime expectedNextDayMidnight = LocalDateTime.of(2024, 1, 2, 0, 0); + Duration expectedDuration = Duration.between(currentTime, expectedNextDayMidnight); + assertThat(ttl).isEqualTo(expectedDuration); + } + + @Test + @DisplayName("21-24 시간대에서 정확히 21시일 때 다음날 자정까지 TTL을 계산한다") + void Given_Slot21_24Exactly21Hour_When_CalculateTtl_Then_ReturnsNextDayMidnightDuration() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_21_24; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 21, 0); // 21:00 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + LocalDateTime expectedNextDayMidnight = LocalDateTime.of(2024, 1, 2, 0, 0); + Duration expectedDuration = Duration.between(currentTime, expectedNextDayMidnight); + assertThat(ttl).isEqualTo(expectedDuration); + } + + @Test + @DisplayName("21-24 시간대에서 23시일 때 다음날 자정까지 TTL을 계산한다") + void Given_Slot21_24At23Hour_When_CalculateTtl_Then_ReturnsNextDayMidnightDuration() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_21_24; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 23, 45); // 23:45 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + LocalDateTime expectedNextDayMidnight = LocalDateTime.of(2024, 1, 2, 0, 0); + Duration expectedDuration = Duration.between(currentTime, expectedNextDayMidnight); + assertThat(ttl).isEqualTo(expectedDuration); + } + + @Test + @DisplayName("21-24 시간대에서 21시 이전일 때 일반적인 로직을 적용한다") + void Given_Slot21_24Before21Hour_When_CalculateTtl_Then_ReturnsNormalDuration() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_21_24; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 20, 30); // 20:30 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + assertThat(ttl).isEqualTo(Duration.ZERO); + } + + @Test + @DisplayName("21-24 시간대에서 21시 이전일 때 일반적인 로직을 적용한다 - 15시") + void Given_Slot21_24At15Hour_When_CalculateTtl_Then_ReturnsNormalDuration() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_21_24; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 15, 0); // 15:00 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + assertThat(ttl).isEqualTo(Duration.ZERO); + } + + @Test + @DisplayName("다른 시간대에서 21시 이후일 때 일반적인 로직을 적용한다") + void Given_OtherTimeSlotAfter21Hour_When_CalculateTtl_Then_ReturnsNormalDuration() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_12_15; // 21-24가 아닌 시간대 + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 22, 0); // 22:00 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + assertThat(ttl).isEqualTo(Duration.ZERO); + } + + @Test + @DisplayName("12-15 시간대에서 12시일 때 양수 TTL을 반환한다") + void Given_Slot12_15At12Hour_When_CalculateTtl_Then_ReturnsPositiveDuration() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_12_15; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 12, 0); // 12:00 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + LocalTime deleteTime = LocalTime.of(15, 0); // endHour가 15이므로 15:00 + Duration expectedDuration = Duration.between(currentTime.toLocalTime(), deleteTime); + assertThat(ttl).isEqualTo(expectedDuration); + } + + @Test + @DisplayName("12-15 시간대에서 14시일 때 양수 TTL을 반환한다") + void Given_Slot12_15At14Hour_When_CalculateTtl_Then_ReturnsPositiveDuration() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_12_15; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 14, 30); // 14:30 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + LocalTime deleteTime = LocalTime.of(15, 0); // endHour가 15이므로 15:00 + Duration expectedDuration = Duration.between(currentTime.toLocalTime(), deleteTime); + assertThat(ttl).isEqualTo(expectedDuration); + } + + @Test + @DisplayName("12-15 시간대에서 15시일 때 0을 반환한다") + void Given_Slot12_15At15Hour_When_CalculateTtl_Then_ReturnsZero() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_12_15; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 15, 0); // 15:00 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + assertThat(ttl).isEqualTo(Duration.ZERO); + } + + @Test + @DisplayName("12-15 시간대에서 16시일 때 0을 반환한다") + void Given_Slot12_15At16Hour_When_CalculateTtl_Then_ReturnsZero() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_12_15; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 16, 0); // 16:00 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + assertThat(ttl).isEqualTo(Duration.ZERO); + } + + @Test + @DisplayName("00-03 시간대에서 01시일 때 양수 TTL을 반환한다") + void Given_Slot00_03At01Hour_When_CalculateTtl_Then_ReturnsPositiveDuration() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_00_03; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 1, 30); // 01:30 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + LocalTime deleteTime = LocalTime.of(3, 0); // endHour가 3이므로 03:00 + Duration expectedDuration = Duration.between(currentTime.toLocalTime(), deleteTime); + assertThat(ttl).isEqualTo(expectedDuration); + } + + @Test + @DisplayName("00-03 시간대에서 03시일 때 0을 반환한다") + void Given_Slot00_03At03Hour_When_CalculateTtl_Then_ReturnsZero() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_00_03; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 3, 0); // 03:00 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + assertThat(ttl).isEqualTo(Duration.ZERO); + } + + @Test + @DisplayName("00-03 시간대에서 04시일 때 0을 반환한다") + void Given_Slot00_03At04Hour_When_CalculateTtl_Then_ReturnsZero() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_00_03; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 4, 0); // 04:00 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + assertThat(ttl).isEqualTo(Duration.ZERO); + } + + @Test + @DisplayName("06-09 시간대에서 07시일 때 양수 TTL을 반환한다") + void Given_Slot06_09At07Hour_When_CalculateTtl_Then_ReturnsPositiveDuration() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_06_09; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 7, 15); // 07:15 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + LocalTime deleteTime = LocalTime.of(9, 0); // endHour가 9이므로 09:00 + Duration expectedDuration = Duration.between(currentTime.toLocalTime(), deleteTime); + assertThat(ttl).isEqualTo(expectedDuration); + } + + @Test + @DisplayName("06-09 시간대에서 09시일 때 0을 반환한다") + void Given_Slot06_09At09Hour_When_CalculateTtl_Then_ReturnsZero() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_06_09; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 9, 0); // 09:00 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + assertThat(ttl).isEqualTo(Duration.ZERO); + } + + @Test + @DisplayName("09-12 시간대에서 10시일 때 양수 TTL을 반환한다") + void Given_Slot09_12At10Hour_When_CalculateTtl_Then_ReturnsPositiveDuration() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_09_12; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 10, 45); // 10:45 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + LocalTime deleteTime = LocalTime.of(12, 0); // endHour가 12이므로 12:00 + Duration expectedDuration = Duration.between(currentTime.toLocalTime(), deleteTime); + assertThat(ttl).isEqualTo(expectedDuration); + } + + @Test + @DisplayName("15-18 시간대에서 16시일 때 양수 TTL을 반환한다") + void Given_Slot15_18At16Hour_When_CalculateTtl_Then_ReturnsPositiveDuration() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_15_18; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 16, 20); // 16:20 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + LocalTime deleteTime = LocalTime.of(18, 0); // endHour가 18이므로 18:00 + Duration expectedDuration = Duration.between(currentTime.toLocalTime(), deleteTime); + assertThat(ttl).isEqualTo(expectedDuration); + } + + @Test + @DisplayName("18-21 시간대에서 19시일 때 양수 TTL을 반환한다") + void Given_Slot18_21At19Hour_When_CalculateTtl_Then_ReturnsPositiveDuration() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_18_21; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 19, 10); // 19:10 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + LocalTime deleteTime = LocalTime.of(21, 0); // endHour가 21이므로 21:00 + Duration expectedDuration = Duration.between(currentTime.toLocalTime(), deleteTime); + assertThat(ttl).isEqualTo(expectedDuration); + } + + @Test + @DisplayName("18-21 시간대에서 21시일 때 0을 반환한다") + void Given_Slot18_21At21Hour_When_CalculateTtl_Then_ReturnsZero() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_18_21; + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 21, 0); // 21:00 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + assertThat(ttl).isEqualTo(Duration.ZERO); + } + + @Test + @DisplayName("모든 시간대에서 TTL 계산이 정상적으로 동작한다") + void Given_AllTimeSlots_When_CalculateTtl_Then_AllReturnValidDurations() { + // given + TimeSlot[] timeSlots = { + TimeSlot.SLOT_00_03, // endHour: 3 + TimeSlot.SLOT_03_06, // endHour: 6 + TimeSlot.SLOT_06_09, // endHour: 9 + TimeSlot.SLOT_09_12, // endHour: 12 + TimeSlot.SLOT_12_15, // endHour: 15 + TimeSlot.SLOT_15_18, // endHour: 18 + TimeSlot.SLOT_18_21, // endHour: 21 + TimeSlot.SLOT_21_24 // endHour: 24 + }; + + // when & then + for (TimeSlot timeSlot : timeSlots) { + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 12, 0); // 12:00 + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + assertThat(ttl).isGreaterThanOrEqualTo(Duration.ZERO); + } + } + + @Test + @DisplayName("endHour가 24인 시간대에서 deleteTime이 00:00으로 설정되는지 확인한다") + void Given_TimeSlotWithEndHour24_When_CalculateTtl_Then_DeleteTimeIsMidnight() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_21_24; // endHour가 24 + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 20, 0); // 20:00 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + assertThat(ttl).isEqualTo(Duration.ZERO); + } + + @Test + @DisplayName("endHour가 24가 아닌 시간대에서 deleteTime이 endHour로 설정되는지 확인한다") + void Given_TimeSlotWithEndHourNot24_When_CalculateTtl_Then_DeleteTimeIsEndHour() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_12_15; // endHour가 15 + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 13, 0); // 13:00 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + LocalTime deleteTime = LocalTime.of(15, 0); // endHour가 15이므로 15:00 + Duration expectedDuration = Duration.between(currentTime.toLocalTime(), deleteTime); + assertThat(ttl).isEqualTo(expectedDuration); + } + + + @Test + @DisplayName("현재 시간이 deleteTime보다 이전일 때 양수 TTL을 반환하는지 확인한다") + void Given_CurrentTimeBeforeDeleteTime_When_CalculateTtl_Then_ReturnsPositiveDuration() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_12_15; // deleteTime은 15:00 + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 14, 0); // 14:00 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + LocalTime deleteTime = LocalTime.of(15, 0); // endHour가 15이므로 15:00 + Duration expectedDuration = Duration.between(currentTime.toLocalTime(), deleteTime); + assertThat(ttl).isEqualTo(expectedDuration); + } + + @Test + @DisplayName("현재 시간이 deleteTime보다 이후일 때 0을 반환하는지 확인한다") + void Given_CurrentTimeAfterDeleteTime_When_CalculateTtl_Then_ReturnsZero() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_00_03; // deleteTime은 03:00 + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 4, 0); // 04:00 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + // 04시는 deleteTime(03:00)보다 이후이므로 0을 반환 + assertThat(ttl).isEqualTo(Duration.ZERO); + } + + @Test + @DisplayName("현재 시간이 deleteTime과 같을 때 0을 반환하는지 확인한다") + void Given_CurrentTimeEqualToDeleteTime_When_CalculateTtl_Then_ReturnsZero() { + // given + TimeSlot timeSlot = TimeSlot.SLOT_12_15; // deleteTime은 15:00 + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 15, 0); // 15:00 + + // when + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + // then + assertThat(ttl).isEqualTo(Duration.ZERO); + } + + @Test + @DisplayName("다양한 시간대에서 현재 시간에 따른 TTL 계산을 테스트한다") + void Given_VariousCurrentTimes_When_CalculateTtl_Then_ReturnsValidDurations() { + // given + TimeSlot[] timeSlots = TimeSlot.values(); + + // when & then + for (TimeSlot timeSlot : timeSlots) { + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 12, 0); // 12:00 + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + assertThat(ttl).isGreaterThanOrEqualTo(Duration.ZERO); + + if (timeSlot == TimeSlot.SLOT_21_24) { + assertThat(ttl).isGreaterThanOrEqualTo(Duration.ZERO); + } else { + assertThat(ttl).isGreaterThanOrEqualTo(Duration.ZERO); + } + } + } + + @Test + @DisplayName("모든 시간대에서 TTL 계산의 경계값을 테스트한다") + void Given_AllTimeSlots_When_CalculateTtlAtBoundary_Then_ReturnsValidDurations() { + // given + TimeSlot[] timeSlots = TimeSlot.values(); + + // when & then + for (TimeSlot timeSlot : timeSlots) { + LocalDateTime currentTime = LocalDateTime.of(2024, 1, 1, 12, 0); // 12:00 + Duration ttl = weatherTtlCalculator.calculateTtl(timeSlot, currentTime); + + assertThat(ttl).isGreaterThanOrEqualTo(Duration.ZERO); + + if (timeSlot == TimeSlot.SLOT_21_24) { + assertThat(ttl).isGreaterThanOrEqualTo(Duration.ZERO); + } else { + assertThat(ttl).isGreaterThanOrEqualTo(Duration.ZERO); + } + } + } + +}