From 515ef8e7619684da55d8fac5981abdd2d71f1b84 Mon Sep 17 00:00:00 2001 From: choridev Date: Tue, 29 Jul 2025 02:00:22 +0900 Subject: [PATCH 01/26] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20codes=20b?= =?UTF-8?q?y=20adding=20final=20keyword?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/config/ObservabilityUserConfig.java | 15 ++++++++++----- .../java/com/und/server/config/RedisConfig.java | 2 +- .../com/und/server/config/SecurityConfig.java | 4 ++-- .../com/und/server/exception/ServerException.java | 4 ++-- .../java/com/und/server/oauth/KakaoProvider.java | 10 +++++----- .../com/und/server/oauth/OidcClientFactory.java | 4 ++-- .../java/com/und/server/oauth/OidcProvider.java | 2 +- .../com/und/server/oauth/OidcProviderFactory.java | 8 ++++---- .../com/und/server/oauth/PublicKeyProvider.java | 8 ++++---- .../security/CustomAuthenticationEntryPoint.java | 6 +++--- .../server/security/JwtAuthenticationFilter.java | 14 ++++++++------ .../security/SecurityErrorResponseWriter.java | 5 ++++- .../java/com/und/server/service/AuthService.java | 2 +- .../java/com/und/server/service/NonceService.java | 4 ++-- .../und/server/service/RefreshTokenService.java | 2 +- 15 files changed, 50 insertions(+), 40 deletions(-) diff --git a/src/main/java/com/und/server/config/ObservabilityUserConfig.java b/src/main/java/com/und/server/config/ObservabilityUserConfig.java index 2a9ffa44..07a05064 100644 --- a/src/main/java/com/und/server/config/ObservabilityUserConfig.java +++ b/src/main/java/com/und/server/config/ObservabilityUserConfig.java @@ -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/RedisConfig.java b/src/main/java/com/und/server/config/RedisConfig.java index 58f15971..6a43a66e 100644 --- a/src/main/java/com/und/server/config/RedisConfig.java +++ b/src/main/java/com/und/server/config/RedisConfig.java @@ -24,7 +24,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()) diff --git a/src/main/java/com/und/server/config/SecurityConfig.java b/src/main/java/com/und/server/config/SecurityConfig.java index b5fac196..330c0528 100644 --- a/src/main/java/com/und/server/config/SecurityConfig.java +++ b/src/main/java/com/und/server/config/SecurityConfig.java @@ -35,7 +35,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,7 +51,7 @@ 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 .csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) diff --git a/src/main/java/com/und/server/exception/ServerException.java b/src/main/java/com/und/server/exception/ServerException.java index d976540b..3bc411d0 100644 --- a/src/main/java/com/und/server/exception/ServerException.java +++ b/src/main/java/com/und/server/exception/ServerException.java @@ -7,12 +7,12 @@ public class ServerException extends RuntimeException { private final ServerErrorResult errorResult; - public ServerException(ServerErrorResult errorResult) { + public ServerException(final ServerErrorResult errorResult) { super(errorResult.getMessage()); this.errorResult = errorResult; } - public ServerException(ServerErrorResult errorResult, Throwable cause) { + public ServerException(final ServerErrorResult errorResult, final Throwable cause) { super(errorResult.getMessage(), cause); this.errorResult = errorResult; } diff --git a/src/main/java/com/und/server/oauth/KakaoProvider.java b/src/main/java/com/und/server/oauth/KakaoProvider.java index 03aa673d..2237c71e 100644 --- a/src/main/java/com/und/server/oauth/KakaoProvider.java +++ b/src/main/java/com/und/server/oauth/KakaoProvider.java @@ -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 IdTokenPayload getIdTokenPayload(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/oauth/OidcClientFactory.java b/src/main/java/com/und/server/oauth/OidcClientFactory.java index 5d70ece7..51545cb0 100644 --- a/src/main/java/com/und/server/oauth/OidcClientFactory.java +++ b/src/main/java/com/und/server/oauth/OidcClientFactory.java @@ -14,12 +14,12 @@ public class OidcClientFactory { private final Map oidcClients; - public OidcClientFactory(KakaoClient kakaoClient) { + public OidcClientFactory(final KakaoClient kakaoClient) { oidcClients = new EnumMap<>(Provider.class); oidcClients.put(Provider.KAKAO, kakaoClient); } - public OidcClient getOidcClient(Provider provider) { + public OidcClient getOidcClient(final 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 index d574bcd0..b4d0782a 100644 --- a/src/main/java/com/und/server/oauth/OidcProvider.java +++ b/src/main/java/com/und/server/oauth/OidcProvider.java @@ -4,6 +4,6 @@ public interface OidcProvider { - IdTokenPayload getIdTokenPayload(String token, OidcPublicKeys oidcPublicKeys); + IdTokenPayload getIdTokenPayload(final String token, final OidcPublicKeys oidcPublicKeys); } diff --git a/src/main/java/com/und/server/oauth/OidcProviderFactory.java b/src/main/java/com/und/server/oauth/OidcProviderFactory.java index cbb01deb..dc9a14f4 100644 --- a/src/main/java/com/und/server/oauth/OidcProviderFactory.java +++ b/src/main/java/com/und/server/oauth/OidcProviderFactory.java @@ -15,15 +15,15 @@ public class OidcProviderFactory { private final Map oidcProviders; - public OidcProviderFactory(KakaoProvider kakaoProvider) { + public OidcProviderFactory(final KakaoProvider kakaoProvider) { this.oidcProviders = new EnumMap<>(Provider.class); oidcProviders.put(Provider.KAKAO, kakaoProvider); } public IdTokenPayload getIdTokenPayload( - Provider provider, - String token, - OidcPublicKeys oidcPublicKeys + final Provider provider, + final String token, + final OidcPublicKeys oidcPublicKeys ) { return getOidcProvider(provider).getIdTokenPayload(token, oidcPublicKeys); } diff --git a/src/main/java/com/und/server/oauth/PublicKeyProvider.java b/src/main/java/com/und/server/oauth/PublicKeyProvider.java index 256e9c46..887af17a 100644 --- a/src/main/java/com/und/server/oauth/PublicKeyProvider.java +++ b/src/main/java/com/und/server/oauth/PublicKeyProvider.java @@ -19,7 +19,7 @@ @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,15 +28,15 @@ 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) { + } catch (final IllegalArgumentException | NoSuchAlgorithmException | InvalidKeySpecException e) { throw new ServerException(ServerErrorResult.INVALID_PUBLIC_KEY, e); } } diff --git a/src/main/java/com/und/server/security/CustomAuthenticationEntryPoint.java b/src/main/java/com/und/server/security/CustomAuthenticationEntryPoint.java index 41ed9ae5..4312d1ca 100644 --- a/src/main/java/com/und/server/security/CustomAuthenticationEntryPoint.java +++ b/src/main/java/com/und/server/security/CustomAuthenticationEntryPoint.java @@ -20,9 +20,9 @@ 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); } diff --git a/src/main/java/com/und/server/security/JwtAuthenticationFilter.java b/src/main/java/com/und/server/security/JwtAuthenticationFilter.java index 8102e7b9..892f9ff9 100644 --- a/src/main/java/com/und/server/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/und/server/security/JwtAuthenticationFilter.java @@ -29,16 +29,17 @@ 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())); @@ -52,11 +53,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/security/SecurityErrorResponseWriter.java index c0af1d3d..0977bd42 100644 --- a/src/main/java/com/und/server/security/SecurityErrorResponseWriter.java +++ b/src/main/java/com/und/server/security/SecurityErrorResponseWriter.java @@ -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 ServerErrorResult 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/service/AuthService.java b/src/main/java/com/und/server/service/AuthService.java index b16e6ce3..58b5cad3 100644 --- a/src/main/java/com/und/server/service/AuthService.java +++ b/src/main/java/com/und/server/service/AuthService.java @@ -39,7 +39,7 @@ public class AuthService { // FIXME: Remove this method when deleting TestController @Transactional - public AuthResponse issueTokensForTest(TestAuthRequest request) { + public AuthResponse issueTokensForTest(final TestAuthRequest request) { final Provider provider = convertToProvider(request.provider()); final IdTokenPayload payload = new IdTokenPayload(request.providerId(), request.nickname()); final Member member = findOrCreateMember(provider, payload); diff --git a/src/main/java/com/und/server/service/NonceService.java b/src/main/java/com/und/server/service/NonceService.java index f994ec27..94185400 100644 --- a/src/main/java/com/und/server/service/NonceService.java +++ b/src/main/java/com/und/server/service/NonceService.java @@ -32,12 +32,12 @@ public void validateNonce(final String nonceValue, final Provider provider) { public void saveNonce(final String value, final Provider provider) { - Nonce token = Nonce.builder() + final Nonce nonce = Nonce.builder() .value(value) .provider(provider) .build(); - nonceRepository.save(token); + nonceRepository.save(nonce); } public void deleteNonce(final String value) { diff --git a/src/main/java/com/und/server/service/RefreshTokenService.java b/src/main/java/com/und/server/service/RefreshTokenService.java index b1abbd49..876405f6 100644 --- a/src/main/java/com/und/server/service/RefreshTokenService.java +++ b/src/main/java/com/und/server/service/RefreshTokenService.java @@ -26,7 +26,7 @@ public String getRefreshToken(final Long memberId) { } public void saveRefreshToken(final Long memberId, final String refreshToken) { - RefreshToken token = RefreshToken.builder() + final RefreshToken token = RefreshToken.builder() .memberId(memberId) .refreshToken(refreshToken) .build(); From f495900289a1969e15247119c73ad0aded4a1bc8 Mon Sep 17 00:00:00 2001 From: choridev Date: Tue, 29 Jul 2025 02:18:37 +0900 Subject: [PATCH 02/26] =?UTF-8?q?=F0=9F=A7=91=E2=80=8D=F0=9F=92=BB=20Chang?= =?UTF-8?q?e=20the=20examples=20of=20ErrorResponse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/und/server/dto/ErrorResponse.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/und/server/dto/ErrorResponse.java b/src/main/java/com/und/server/dto/ErrorResponse.java index 401a67b5..6f74fffb 100644 --- a/src/main/java/com/und/server/dto/ErrorResponse.java +++ b/src/main/java/com/und/server/dto/ErrorResponse.java @@ -6,11 +6,11 @@ @Schema(description = "API Error Response") public record ErrorResponse( - @Schema(description = "Error Code", example = "INVALID_TOKEN") + @Schema(description = "Error Code", example = "UNAUTHORIZED_ACCESS") @JsonProperty("code") String code, - @Schema(description = "Error Message", example = "Invalid Token") + @Schema(description = "Error Message", example = "Unauthorized Access") @JsonProperty("message") Object message ) { } From 1c455b61bfb14742e2347b9a75adbbc7892fac10 Mon Sep 17 00:00:00 2001 From: choridev Date: Tue, 29 Jul 2025 18:33:51 +0900 Subject: [PATCH 03/26] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20Provide=20JWT=20e?= =?UTF-8?q?xceptions=20according=20to=20the=20env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/exception/ServerErrorResult.java | 3 + .../java/com/und/server/jwt/JwtProvider.java | 71 ++++++++++---- .../com/und/server/jwt/JwtProviderTest.java | 94 ++++++++++++++++++- 3 files changed, 146 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/und/server/exception/ServerErrorResult.java b/src/main/java/com/und/server/exception/ServerErrorResult.java index 943a9cc7..a807fce1 100644 --- a/src/main/java/com/und/server/exception/ServerErrorResult.java +++ b/src/main/java/com/und/server/exception/ServerErrorResult.java @@ -15,8 +15,11 @@ public enum ServerErrorResult { 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 diff --git a/src/main/java/com/und/server/jwt/JwtProvider.java b/src/main/java/com/und/server/jwt/JwtProvider.java index 3b920379..673f634d 100644 --- a/src/main/java/com/und/server/jwt/JwtProvider.java +++ b/src/main/java/com/und/server/jwt/JwtProvider.java @@ -4,11 +4,14 @@ import java.security.PublicKey; import java.time.LocalDateTime; import java.time.ZoneId; +import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; +import java.util.Set; +import org.springframework.core.env.Environment; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -26,31 +29,34 @@ 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.SecurityException; +import io.jsonwebtoken.security.SignatureException; +import io.jsonwebtoken.security.WeakKeyException; import lombok.RequiredArgsConstructor; @Component @RequiredArgsConstructor public class JwtProvider { + private final Environment environment; private final JwtProperties jwtProperties; public Map getDecodedHeader(final String token) { try { - String decodedHeader = decodeBase64UrlPart(token.split("\\.")[0]); + final String decodedHeader = decodeBase64UrlPart(token.split("\\.")[0]); return new ObjectMapper().readValue(decodedHeader, new TypeReference<>() { }); - } catch (Exception e) { + } catch (final Exception e) { throw new ServerException(ServerErrorResult.INVALID_TOKEN, e); } } - public String extractNonce(String idToken) { + 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 (Exception e) { + } catch (final Exception e) { throw new ServerException(ServerErrorResult.INVALID_TOKEN, e); } } @@ -61,7 +67,7 @@ public IdTokenPayload parseOidcIdToken( final String aud, final PublicKey publicKey ) { - JwtParserBuilder builder = Jwts.parser() + final JwtParserBuilder builder = Jwts.parser() .verifyWith(publicKey) .requireIssuer(iss) .requireAudience(aud); @@ -97,7 +103,7 @@ public Long getMemberIdFromToken(final String token) { } private Claims parseAccessTokenClaims(final String token) { - JwtParserBuilder builder = Jwts.parser() + final JwtParserBuilder builder = Jwts.parser() .verifyWith(jwtProperties.secretKey()); return parseClaims(token, builder); } @@ -105,7 +111,7 @@ private Claims parseAccessTokenClaims(final String token) { private Claims parseClaims(final String token, final JwtParserBuilder builder) { try { return parseToken(token, builder); - } catch (ExpiredJwtException e) { + } catch (final ExpiredJwtException e) { throw new ServerException(ServerErrorResult.EXPIRED_TOKEN, e); } } @@ -114,8 +120,14 @@ 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) { + // If the token is not expired, we throw an exception. + if (isProdOrStgProfile()) { + throw new ServerException(ServerErrorResult.INVALID_TOKEN); + } else { + throw new ServerException(ServerErrorResult.NOT_EXPIRED_TOKEN); + } + } catch (final ExpiredJwtException e) { + // If the token is expired, we can still extract the member ID. return Long.valueOf(e.getClaims().getSubject()); } } @@ -123,20 +135,41 @@ public Long getMemberIdFromExpiredAccessToken(final String token) { 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) { + .parseSignedClaims(token) + .getPayload(); + } catch (final ExpiredJwtException e) { + // This must be re-thrown for getMemberIdFromExpiredAccessToken to work correctly. throw e; - } catch (JwtException e) { + } catch (final JwtException e) { + // For prod or stg environments, return a generic error to avoid leaking details. + if (isProdOrStgProfile()) { + throw new ServerException(ServerErrorResult.UNAUTHORIZED_ACCESS, e); + } + + // For non-production environments, provide detailed error messages. + if (e instanceof MalformedJwtException) { + throw new ServerException(ServerErrorResult.MALFORMED_TOKEN, e); + } + if (e instanceof UnsupportedJwtException) { + throw new ServerException(ServerErrorResult.UNSUPPORTED_TOKEN, e); + } + if (e instanceof WeakKeyException) { + throw new ServerException(ServerErrorResult.WEAK_TOKEN_KEY, e); + } + if (e instanceof SignatureException) { + throw new ServerException(ServerErrorResult.INVALID_TOKEN_SIGNATURE, e); + } + // Fallback for any other JWT-related exceptions. throw new ServerException(ServerErrorResult.INVALID_TOKEN, e); } } - private String decodeBase64UrlPart(String encodedPart) { + private boolean isProdOrStgProfile() { + return Arrays.stream(environment.getActiveProfiles()) + .anyMatch(Set.of("prod", "stg")::contains); + } + + private String decodeBase64UrlPart(final String encodedPart) { return new String(Decoders.BASE64URL.decode(encodedPart), StandardCharsets.UTF_8); } diff --git a/src/test/java/com/und/server/jwt/JwtProviderTest.java b/src/test/java/com/und/server/jwt/JwtProviderTest.java index 85a22b62..4392916b 100644 --- a/src/test/java/com/und/server/jwt/JwtProviderTest.java +++ b/src/test/java/com/und/server/jwt/JwtProviderTest.java @@ -4,6 +4,7 @@ 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; @@ -20,6 +21,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.env.Environment; import org.springframework.security.core.Authentication; import com.und.server.exception.ServerErrorResult; @@ -28,6 +30,10 @@ 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 +41,9 @@ class JwtProviderTest { @Mock private JwtProperties jwtProperties; + @Mock + private Environment environment; + private JwtProvider jwtProvider; private final SecretKey secretKey = Jwts.SIG.HS256.key().build(); @@ -45,7 +54,7 @@ class JwtProviderTest { @BeforeEach void init() { - jwtProvider = new JwtProvider(jwtProperties); + jwtProvider = new JwtProvider(environment, jwtProperties); } @Test @@ -106,6 +115,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(new String[]{"local", "dev"}).when(environment).getActiveProfiles(); final String wrongAudience = "wrong-client"; final KeyPair keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); final PublicKey publicKey = keyPair.getPublic(); @@ -283,6 +293,7 @@ void Given_ExpiredToken_When_GetMemberIdFromToken_Then_ThrowsServerException() { @DisplayName("Throws ServerException when token structure is invalid") void Given_MalformedToken_When_GetMemberIdFromToken_Then_ThrowsServerException() { // given + doReturn(new String[]{"local", "dev"}).when(environment).getActiveProfiles(); doReturn(secretKey).when(jwtProperties).secretKey(); final String malformedToken = "this.is.not.a.jwt"; @@ -296,6 +307,7 @@ void Given_MalformedToken_When_GetMemberIdFromToken_Then_ThrowsServerException() @DisplayName("Throws ServerException when token signature is invalid") void Given_TokenWithInvalidSignature_When_GetMemberIdFromToken_Then_ThrowsServerException() { // given + doReturn(new String[]{"local", "dev"}).when(environment).getActiveProfiles(); doReturn(secretKey).when(jwtProperties).secretKey(); final SecretKey anotherKey = Jwts.SIG.HS256.key().build(); final String token = Jwts.builder().subject("1").signWith(anotherKey).compact(); @@ -306,6 +318,64 @@ void Given_TokenWithInvalidSignature_When_GetMemberIdFromToken_Then_ThrowsServer .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.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(new String[]{"local", "dev"}).when(environment).getActiveProfiles(); + 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", ServerErrorResult.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(new String[]{"local", "dev"}).when(environment).getActiveProfiles(); + 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", ServerErrorResult.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(new String[]{"prod", "stg"}).when(environment).getActiveProfiles(); + 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.UNAUTHORIZED_ACCESS) + // Verify that the cause is the original exception + .cause().isInstanceOf(MalformedJwtException.class); + } + @Test @DisplayName("Gets member ID from a valid token") void Given_ValidToken_When_GetMemberIdFromToken_Then_ReturnsCorrectMemberId() { @@ -325,9 +395,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("Throws NOT_EXPIRED_TOKEN when getting member ID from a non-expired token") + void Given_NonExpiredToken_When_GetMemberIdFromExpiredAccessToken_Then_ThrowsNotExpiredTokenException() { + // given + doReturn(new String[]{"local", "dev"}).when(environment).getActiveProfiles(); + doReturn(secretKey).when(jwtProperties).secretKey(); + doReturn(issuer).when(jwtProperties).issuer(); + doReturn(3600).when(jwtProperties).accessTokenExpireTime(); + + final String token = jwtProvider.generateAccessToken(1L); + + // when & then + assertThatThrownBy(() -> jwtProvider.getMemberIdFromExpiredAccessToken(token)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.NOT_EXPIRED_TOKEN); + } + + @Test + @DisplayName("Throws INVALID_TOKEN when getting member ID from a non-expired token") + void Given_NonExpiredToken_When_GetMemberIdFromExpiredAccessToken_Then_ThrowsInvalidTokenException() { // given + doReturn(new String[]{"prod", "stg"}).when(environment).getActiveProfiles(); doReturn(secretKey).when(jwtProperties).secretKey(); doReturn(issuer).when(jwtProperties).issuer(); doReturn(3600).when(jwtProperties).accessTokenExpireTime(); From 069f5d638d8a9c6139e0e0dbebda4dbb3445a9cc Mon Sep 17 00:00:00 2001 From: choridev Date: Tue, 29 Jul 2025 18:35:08 +0900 Subject: [PATCH 04/26] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Delegate=20token=20v?= =?UTF-8?q?erification=20responsibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/und/server/entity/RefreshToken.java | 4 +- .../com/und/server/service/AuthService.java | 6 +- .../server/service/RefreshTokenService.java | 16 ++- .../RefreshTokenRepositoryTest.java | 8 +- .../und/server/service/AuthServiceTest.java | 11 +- .../service/RefreshTokenServiceTest.java | 115 +++++++++++++----- 6 files changed, 114 insertions(+), 46 deletions(-) diff --git a/src/main/java/com/und/server/entity/RefreshToken.java b/src/main/java/com/und/server/entity/RefreshToken.java index 3ac3197b..2fec024b 100644 --- a/src/main/java/com/und/server/entity/RefreshToken.java +++ b/src/main/java/com/und/server/entity/RefreshToken.java @@ -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/service/AuthService.java b/src/main/java/com/und/server/service/AuthService.java index 58b5cad3..b08d7009 100644 --- a/src/main/java/com/und/server/service/AuthService.java +++ b/src/main/java/com/und/server/service/AuthService.java @@ -72,11 +72,7 @@ public AuthResponse reissueTokens(final RefreshTokenRequest refreshTokenRequest) 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); - } + refreshTokenService.validateRefreshToken(memberId, providedRefreshToken); return issueTokens(memberId); } diff --git a/src/main/java/com/und/server/service/RefreshTokenService.java b/src/main/java/com/und/server/service/RefreshTokenService.java index 876405f6..c7ced9ae 100644 --- a/src/main/java/com/und/server/service/RefreshTokenService.java +++ b/src/main/java/com/und/server/service/RefreshTokenService.java @@ -5,6 +5,8 @@ import org.springframework.stereotype.Service; import com.und.server.entity.RefreshToken; +import com.und.server.exception.ServerErrorResult; +import com.und.server.exception.ServerException; import com.und.server.repository.RefreshTokenRepository; import lombok.RequiredArgsConstructor; @@ -21,14 +23,24 @@ public String generateRefreshToken() { public String getRefreshToken(final Long memberId) { return refreshTokenRepository.findById(memberId) - .map(RefreshToken::getRefreshToken) + .map(RefreshToken::getValue) .orElse(null); } + public void validateRefreshToken(final Long memberId, final String providedToken) { + refreshTokenRepository.findById(memberId) + .map(RefreshToken::getValue) + .filter(savedToken -> savedToken.equals(providedToken)) + .orElseThrow(() -> { + deleteRefreshToken(memberId); + return new ServerException(ServerErrorResult.INVALID_TOKEN); + }); + } + public void saveRefreshToken(final Long memberId, final String refreshToken) { final RefreshToken token = RefreshToken.builder() .memberId(memberId) - .refreshToken(refreshToken) + .value(refreshToken) .build(); refreshTokenRepository.save(token); diff --git a/src/test/java/com/und/server/repository/RefreshTokenRepositoryTest.java b/src/test/java/com/und/server/repository/RefreshTokenRepositoryTest.java index ac365fb2..53e6a63a 100644 --- a/src/test/java/com/und/server/repository/RefreshTokenRepositoryTest.java +++ b/src/test/java/com/und/server/repository/RefreshTokenRepositoryTest.java @@ -23,7 +23,7 @@ void Given_RefreshTokenDetails_When_SaveToken_Then_TokenIsPersistedCorrectly() { // given final RefreshToken token = RefreshToken.builder() .memberId(1L) - .refreshToken("uuid") + .value("uuid") .build(); // when @@ -31,7 +31,7 @@ void Given_RefreshTokenDetails_When_SaveToken_Then_TokenIsPersistedCorrectly() { // then assertThat(result.getMemberId()).isEqualTo(1L); - assertThat(result.getRefreshToken()).isEqualTo("uuid"); + assertThat(result.getValue()).isEqualTo("uuid"); } @Test @@ -40,7 +40,7 @@ void Given_ExistingRefreshToken_When_FindById_Then_ReturnsCorrectToken() { // given final RefreshToken token = RefreshToken.builder() .memberId(1L) - .refreshToken("uuid") + .value("uuid") .build(); refreshTokenRepository.save(token); @@ -50,7 +50,7 @@ void Given_ExistingRefreshToken_When_FindById_Then_ReturnsCorrectToken() { // then assertThat(foundToken).isPresent().hasValueSatisfying(result -> { assertThat(result.getMemberId()).isEqualTo(1L); - assertThat(result.getRefreshToken()).isEqualTo("uuid"); + assertThat(result.getValue()).isEqualTo("uuid"); }); } diff --git a/src/test/java/com/und/server/service/AuthServiceTest.java b/src/test/java/com/und/server/service/AuthServiceTest.java index 1abbb2e9..88ceecc6 100644 --- a/src/test/java/com/und/server/service/AuthServiceTest.java +++ b/src/test/java/com/und/server/service/AuthServiceTest.java @@ -5,6 +5,7 @@ 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; @@ -232,13 +233,13 @@ void Given_MismatchedRefreshToken_When_ReissueTokens_Then_ThrowsException() { final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, "wrong.refresh.token"); doReturn(memberId).when(jwtProvider).getMemberIdFromExpiredAccessToken(accessToken); - doReturn(refreshToken).when(refreshTokenService).getRefreshToken(memberId); + doThrow(new ServerException(ServerErrorResult.INVALID_TOKEN)) + .when(refreshTokenService).validateRefreshToken(memberId, "wrong.refresh.token"); // when & then final ServerException exception = assertThrows(ServerException.class, () -> authService.reissueTokens(request)); - verify(refreshTokenService).deleteRefreshToken(memberId); assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); } @@ -249,13 +250,13 @@ void Given_NoStoredRefreshToken_When_ReissueTokens_Then_ThrowsException() { final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); doReturn(memberId).when(jwtProvider).getMemberIdFromExpiredAccessToken(accessToken); - doReturn(null).when(refreshTokenService).getRefreshToken(memberId); + doThrow(new ServerException(ServerErrorResult.INVALID_TOKEN)) + .when(refreshTokenService).validateRefreshToken(memberId, refreshToken); // 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); } @@ -269,7 +270,7 @@ void Given_ValidRefreshToken_When_ReissueTokens_Then_Succeeds() { final String newRefreshToken = "new-refresh-token"; doReturn(memberId).when(jwtProvider).getMemberIdFromExpiredAccessToken(accessToken); - doReturn(refreshToken).when(refreshTokenService).getRefreshToken(memberId); + doNothing().when(refreshTokenService).validateRefreshToken(memberId, refreshToken); setupTokenIssuance(newAccessToken, newRefreshToken); // when diff --git a/src/test/java/com/und/server/service/RefreshTokenServiceTest.java b/src/test/java/com/und/server/service/RefreshTokenServiceTest.java index 0eb77f3e..14f0d431 100644 --- a/src/test/java/com/und/server/service/RefreshTokenServiceTest.java +++ b/src/test/java/com/und/server/service/RefreshTokenServiceTest.java @@ -1,7 +1,10 @@ package com.und.server.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; +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; @@ -9,85 +12,141 @@ 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.entity.RefreshToken; +import com.und.server.exception.ServerErrorResult; +import com.und.server.exception.ServerException; import com.und.server.repository.RefreshTokenRepository; @ExtendWith(MockitoExtension.class) class RefreshTokenServiceTest { - @Mock - private RefreshTokenRepository refreshTokenRepository; - @InjectMocks private RefreshTokenService refreshTokenService; + @Mock + private RefreshTokenRepository refreshTokenRepository; + private final Long memberId = 1L; - private final String refreshToken = UUID.randomUUID().toString(); + private final String refreshTokenValue = "test-refresh-token"; @Test - @DisplayName("Generates a new UUID-formatted refresh token") - void Given_Nothing_When_GenerateRefreshToken_Then_ReturnsUuidString() { + @DisplayName("Generates a new refresh token in UUID format") + void Given_Nothing_When_GenerateRefreshToken_Then_ReturnsUuid() { // when - final String token = refreshTokenService.generateRefreshToken(); + final String generatedToken = refreshTokenService.generateRefreshToken(); // then - assertThat(token).isNotNull(); - assertThat(token).hasSize(36); // UUID format + assertThat(generatedToken).isNotNull(); + assertDoesNotThrow(() -> UUID.fromString(generatedToken)); } @Test - @DisplayName("Saves a refresh token for a member") - void Given_MemberIdAndToken_When_SaveRefreshToken_Then_RepositorySaveIsCalled() { + @DisplayName("Returns the token value if a refresh token is stored") + void Given_StoredToken_When_GetRefreshToken_Then_ReturnsTokenValue() { + // given + final RefreshToken savedToken = RefreshToken.builder() + .memberId(memberId) + .value(refreshTokenValue) + .build(); + doReturn(Optional.of(savedToken)).when(refreshTokenRepository).findById(memberId); + + // when + final String foundToken = refreshTokenService.getRefreshToken(memberId); + + // then + assertThat(foundToken).isEqualTo(refreshTokenValue); + } + + @Test + @DisplayName("Returns null if no refresh token is stored") + void Given_NoToken_When_GetRefreshToken_Then_ReturnsNull() { + // given + doReturn(Optional.empty()).when(refreshTokenRepository).findById(memberId); + // when - refreshTokenService.saveRefreshToken(memberId, refreshToken); + final String foundToken = refreshTokenService.getRefreshToken(memberId); // then - verify(refreshTokenRepository).save(any(RefreshToken.class)); + assertThat(foundToken).isNull(); } @Test - @DisplayName("Retrieves an existing refresh token for a member") - void Given_ExistingTokenInRepository_When_GetRefreshToken_Then_ReturnsCorrectToken() { + @DisplayName("Saves a refresh token to the repository") + void Given_MemberIdAndToken_When_SaveRefreshToken_Then_CallsRepositorySave() { // given - RefreshToken token = RefreshToken.builder() + 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("Succeeds validation if the provided token matches the stored one") + void Given_MatchingToken_When_ValidateRefreshToken_Then_Succeeds() { + // given + final RefreshToken savedToken = RefreshToken.builder() .memberId(memberId) - .refreshToken(refreshToken) + .value(refreshTokenValue) .build(); + doReturn(Optional.of(savedToken)).when(refreshTokenRepository).findById(memberId); - doReturn(Optional.of(token)).when(refreshTokenRepository).findById(memberId); + // when & then + assertDoesNotThrow(() -> refreshTokenService.validateRefreshToken(memberId, refreshTokenValue)); + } + + @Test + @DisplayName("Throws an exception and deletes the token if it does not match") + void Given_MismatchedToken_When_ValidateRefreshToken_Then_ThrowsExceptionAndDeletes() { + // given + final RefreshToken savedToken = RefreshToken.builder() + .memberId(memberId) + .value(refreshTokenValue) + .build(); + doReturn(Optional.of(savedToken)).when(refreshTokenRepository).findById(memberId); // when - final String result = refreshTokenService.getRefreshToken(memberId); + final ServerException exception = assertThrows(ServerException.class, + () -> refreshTokenService.validateRefreshToken(memberId, "wrong-token")); // then - assertThat(result).isEqualTo(refreshToken); + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); + verify(refreshTokenRepository).deleteById(memberId); } @Test - @DisplayName("Returns null when refresh token is not found") - void Given_TokenNotInRepository_When_GetRefreshToken_Then_ReturnsNull() { + @DisplayName("Throws an exception if no token is stored for validation") + void Given_NoStoredToken_When_ValidateRefreshToken_Then_ThrowsException() { // given doReturn(Optional.empty()).when(refreshTokenRepository).findById(memberId); // when - final String result = refreshTokenService.getRefreshToken(memberId); + final ServerException exception = assertThrows(ServerException.class, + () -> refreshTokenService.validateRefreshToken(memberId, refreshTokenValue)); // then - assertThat(result).isNull(); + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); + verify(refreshTokenRepository).deleteById(memberId); } @Test - @DisplayName("Deletes a refresh token for a member") - void Given_MemberId_When_DeleteRefreshToken_Then_RepositoryDeleteIsCalled() { + @DisplayName("Deletes a refresh token") + void Given_MemberId_When_DeleteRefreshToken_Then_CallsRepositoryDelete() { // when refreshTokenService.deleteRefreshToken(memberId); // then verify(refreshTokenRepository).deleteById(memberId); } - } From 159ce76b991ced37a0f51dabcc8c4587e42c20b0 Mon Sep 17 00:00:00 2001 From: choridev Date: Tue, 29 Jul 2025 18:47:55 +0900 Subject: [PATCH 05/26] =?UTF-8?q?=F0=9F=94=A7=20Set=20branch=20coverage=20?= =?UTF-8?q?to=200.60?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index bf9f7fd1..30c71dd9 100644 --- a/build.gradle +++ b/build.gradle @@ -120,7 +120,7 @@ jacocoTestCoverageVerification { limit { counter = 'BRANCH' value = 'COVEREDRATIO' - minimum = 0.70 + minimum = 0.60 } // Maximum number of lines in a file From 3c6e8ca0ecc06ed6bc838d3561caab73b8725f56 Mon Sep 17 00:00:00 2001 From: choridev Date: Tue, 29 Jul 2025 18:53:10 +0900 Subject: [PATCH 06/26] =?UTF-8?q?=F0=9F=92=9A=20Change=20the=20name=20of?= =?UTF-8?q?=20branch=20from=20develop=20to=20dev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deployment-to-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deployment-to-dev.yml b/.github/workflows/deployment-to-dev.yml index 02f5b49a..dbe0d916 100644 --- a/.github/workflows/deployment-to-dev.yml +++ b/.github/workflows/deployment-to-dev.yml @@ -2,7 +2,7 @@ name: Deployment to Development Server on: push: - branches: [develop] + branches: [dev] env: AWS_IAM_ROLE_TO_ASSUME: ${{ secrets.DEV_AWS_IAM_ROLE_TO_ASSUME }} From 7ac8c9c90e6b7b575abf43928a2086db68175470 Mon Sep 17 00:00:00 2001 From: choridev Date: Tue, 29 Jul 2025 18:55:16 +0900 Subject: [PATCH 07/26] =?UTF-8?q?=F0=9F=93=9D=20Change=20the=20example=20o?= =?UTF-8?q?f=20merging=20branches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 인프라 자동화 작업 ### 🪾 반영 브랜치 - + ### ✨ 변경 사항 From 9099a4c2b22b5ef43c801a84dc152742ee6d4af5 Mon Sep 17 00:00:00 2001 From: choridev Date: Tue, 29 Jul 2025 19:30:48 +0900 Subject: [PATCH 08/26] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20Delete=20the=20RT?= =?UTF-8?q?=20if=20the=20AT=20is=20not=20expired?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/und/server/jwt/JwtProvider.java | 7 +----- .../com/und/server/service/AuthService.java | 16 +++++++++++++- .../com/und/server/jwt/JwtProviderTest.java | 22 ++----------------- .../und/server/service/AuthServiceTest.java | 22 +++++++++++++++++++ 4 files changed, 40 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/und/server/jwt/JwtProvider.java b/src/main/java/com/und/server/jwt/JwtProvider.java index 673f634d..18150e32 100644 --- a/src/main/java/com/und/server/jwt/JwtProvider.java +++ b/src/main/java/com/und/server/jwt/JwtProvider.java @@ -120,12 +120,7 @@ public Long getMemberIdFromExpiredAccessToken(final String token) { final JwtParserBuilder builder = Jwts.parser().verifyWith(jwtProperties.secretKey()); try { parseToken(token, builder); - // If the token is not expired, we throw an exception. - if (isProdOrStgProfile()) { - throw new ServerException(ServerErrorResult.INVALID_TOKEN); - } else { - throw new ServerException(ServerErrorResult.NOT_EXPIRED_TOKEN); - } + throw new ServerException(ServerErrorResult.NOT_EXPIRED_TOKEN); } catch (final ExpiredJwtException e) { // If the token is expired, we can still extract the member ID. return Long.valueOf(e.getClaims().getSubject()); diff --git a/src/main/java/com/und/server/service/AuthService.java b/src/main/java/com/und/server/service/AuthService.java index b08d7009..7a29ae3b 100644 --- a/src/main/java/com/und/server/service/AuthService.java +++ b/src/main/java/com/und/server/service/AuthService.java @@ -71,7 +71,7 @@ public AuthResponse reissueTokens(final RefreshTokenRequest refreshTokenRequest) final String accessToken = refreshTokenRequest.accessToken(); final String providedRefreshToken = refreshTokenRequest.refreshToken(); - final Long memberId = jwtProvider.getMemberIdFromExpiredAccessToken(accessToken); + final Long memberId = getMemberIdForReissue(accessToken); refreshTokenService.validateRefreshToken(memberId, providedRefreshToken); return issueTokens(memberId); @@ -133,4 +133,18 @@ private AuthResponse issueTokens(final Long memberId) { jwtProperties.refreshTokenExpireTime()); } + private Long getMemberIdForReissue(final String accessToken) { + try { + return jwtProvider.getMemberIdFromExpiredAccessToken(accessToken); + } catch (final ServerException e) { + if (e.getErrorResult() == ServerErrorResult.NOT_EXPIRED_TOKEN) { + // An attempt to reissue with a non-expired token may be a security risk. + // For security, we delete the refresh token. + final Long memberId = jwtProvider.getMemberIdFromToken(accessToken); + refreshTokenService.deleteRefreshToken(memberId); + } + throw e; + } + } + } diff --git a/src/test/java/com/und/server/jwt/JwtProviderTest.java b/src/test/java/com/und/server/jwt/JwtProviderTest.java index 4392916b..e07a1ba3 100644 --- a/src/test/java/com/und/server/jwt/JwtProviderTest.java +++ b/src/test/java/com/und/server/jwt/JwtProviderTest.java @@ -395,10 +395,9 @@ void Given_ValidToken_When_GetMemberIdFromToken_Then_ReturnsCorrectMemberId() { } @Test - @DisplayName("Throws NOT_EXPIRED_TOKEN when getting member ID from a non-expired token") - void Given_NonExpiredToken_When_GetMemberIdFromExpiredAccessToken_Then_ThrowsNotExpiredTokenException() { + @DisplayName("Throws an exception when getting member ID from a non-expired token") + void Given_NonExpiredToken_When_GetMemberIdFromExpiredAccessToken_Then_ThrowsException() { // given - doReturn(new String[]{"local", "dev"}).when(environment).getActiveProfiles(); doReturn(secretKey).when(jwtProperties).secretKey(); doReturn(issuer).when(jwtProperties).issuer(); doReturn(3600).when(jwtProperties).accessTokenExpireTime(); @@ -411,23 +410,6 @@ void Given_NonExpiredToken_When_GetMemberIdFromExpiredAccessToken_Then_ThrowsNot .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.NOT_EXPIRED_TOKEN); } - @Test - @DisplayName("Throws INVALID_TOKEN when getting member ID from a non-expired token") - void Given_NonExpiredToken_When_GetMemberIdFromExpiredAccessToken_Then_ThrowsInvalidTokenException() { - // given - doReturn(new String[]{"prod", "stg"}).when(environment).getActiveProfiles(); - doReturn(secretKey).when(jwtProperties).secretKey(); - doReturn(issuer).when(jwtProperties).issuer(); - doReturn(3600).when(jwtProperties).accessTokenExpireTime(); - - final String token = jwtProvider.generateAccessToken(1L); - - // when & then - assertThatThrownBy(() -> jwtProvider.getMemberIdFromExpiredAccessToken(token)) - .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_TOKEN); - } - @Test @DisplayName("Gets member ID from an expired access token successfully") void Given_ExpiredToken_When_GetMemberIdFromExpiredAccessToken_Then_ReturnsCorrectMemberId() { diff --git a/src/test/java/com/und/server/service/AuthServiceTest.java b/src/test/java/com/und/server/service/AuthServiceTest.java index 88ceecc6..e4d0be44 100644 --- a/src/test/java/com/und/server/service/AuthServiceTest.java +++ b/src/test/java/com/und/server/service/AuthServiceTest.java @@ -284,6 +284,28 @@ void Given_ValidRefreshToken_When_ReissueTokens_Then_Succeeds() { assertThat(response.refreshTokenExpiresIn()).isEqualTo(refreshTokenExpireTime); } + @Test + @DisplayName("Throws an exception and deletes refresh token if the access token is non-expired") + void Given_NonExpiredAccessToken_When_ReissueTokens_Then_ThrowsExceptionAndDeletesToken() { + // given + final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); + + // Simulate the case where the access token is not yet expired. + doThrow(new ServerException(ServerErrorResult.NOT_EXPIRED_TOKEN)) + .when(jwtProvider).getMemberIdFromExpiredAccessToken(accessToken); + + // When the exception is caught, the service should try to get the memberId from the valid token. + doReturn(memberId).when(jwtProvider).getMemberIdFromToken(accessToken); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> authService.reissueTokens(request)); + + // then + verify(refreshTokenService).deleteRefreshToken(memberId); + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.NOT_EXPIRED_TOKEN); + } + private void setupTokenIssuance(final String newAccessToken, final String newRefreshToken) { doReturn(newAccessToken).when(jwtProvider).generateAccessToken(memberId); doReturn(newRefreshToken).when(refreshTokenService).generateRefreshToken(); From cfea38ee7b0ada2e39c72ea1d0893e721054c811 Mon Sep 17 00:00:00 2001 From: Chori <105255517+choridev@users.noreply.github.com> Date: Wed, 30 Jul 2025 15:43:31 +0900 Subject: [PATCH 09/26] Refactor codes related to Auth and Member (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🚚 Change the name of DTO for Handshake to Nonce * ♻️ Delegate a responsibility related to member * 🗃️ Add Transactional to write actions * ♻️ Refactor parsing expired token method * 🔧 Set branch coverage to 0.50 * 🎨 Change the locations of parentheses * ♻️ Delegate a responsibility related to profile * 🎨 Isolate possible exceptions in each environment --- build.gradle | 2 +- .../und/server/controller/AuthController.java | 10 +- .../und/server/controller/TestController.java | 10 +- ...andshakeRequest.java => NonceRequest.java} | 4 +- ...dshakeResponse.java => NonceResponse.java} | 4 +- .../java/com/und/server/jwt/JwtProvider.java | 32 ++---- .../com/und/server/jwt/ParsedTokenInfo.java | 6 + .../security/JwtAuthenticationFilter.java | 3 +- .../com/und/server/service/AuthService.java | 74 +++++-------- .../com/und/server/service/MemberService.java | 48 ++++++++ .../com/und/server/service/NonceService.java | 5 + .../server/service/RefreshTokenService.java | 5 + .../com/und/server/util/ProfileManager.java | 21 ++++ .../server/controller/AuthControllerTest.java | 14 +-- .../server/controller/TestControllerTest.java | 10 +- .../com/und/server/jwt/JwtProviderTest.java | 53 +++++---- .../und/server/service/AuthServiceTest.java | 104 ++++++++++++------ .../und/server/service/MemberServiceTest.java | 94 ++++++++++++++++ .../und/server/util/ProfileManagerTest.java | 74 +++++++++++++ 19 files changed, 421 insertions(+), 152 deletions(-) rename src/main/java/com/und/server/dto/{HandshakeRequest.java => NonceRequest.java} (80%) rename src/main/java/com/und/server/dto/{HandshakeResponse.java => NonceResponse.java} (76%) create mode 100644 src/main/java/com/und/server/jwt/ParsedTokenInfo.java create mode 100644 src/main/java/com/und/server/service/MemberService.java create mode 100644 src/main/java/com/und/server/util/ProfileManager.java create mode 100644 src/test/java/com/und/server/service/MemberServiceTest.java create mode 100644 src/test/java/com/und/server/util/ProfileManagerTest.java diff --git a/build.gradle b/build.gradle index 30c71dd9..edb6c811 100644 --- a/build.gradle +++ b/build.gradle @@ -120,7 +120,7 @@ jacocoTestCoverageVerification { limit { counter = 'BRANCH' value = 'COVEREDRATIO' - minimum = 0.60 + minimum = 0.50 } // Maximum number of lines in a file diff --git a/src/main/java/com/und/server/controller/AuthController.java b/src/main/java/com/und/server/controller/AuthController.java index bbc27de4..1cd662f3 100644 --- a/src/main/java/com/und/server/controller/AuthController.java +++ b/src/main/java/com/und/server/controller/AuthController.java @@ -9,8 +9,8 @@ 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.NonceRequest; +import com.und.server.dto.NonceResponse; import com.und.server.dto.RefreshTokenRequest; import com.und.server.service.AuthService; @@ -25,10 +25,10 @@ public class AuthController { private final AuthService authService; @PostMapping("/nonce") - public ResponseEntity handshake(@RequestBody @Valid final HandshakeRequest handshakeRequest) { - final HandshakeResponse handshakeResponse = authService.handshake(handshakeRequest); + public ResponseEntity handshake(@RequestBody @Valid final NonceRequest nonceRequest) { + final NonceResponse nonceResponse = authService.handshake(nonceRequest); - return ResponseEntity.status(HttpStatus.OK).body(handshakeResponse); + return ResponseEntity.status(HttpStatus.OK).body(nonceResponse); } @PostMapping("/login") diff --git a/src/main/java/com/und/server/controller/TestController.java b/src/main/java/com/und/server/controller/TestController.java index 8e6374d9..fed35191 100644 --- a/src/main/java/com/und/server/controller/TestController.java +++ b/src/main/java/com/und/server/controller/TestController.java @@ -16,8 +16,8 @@ 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 com.und.server.service.MemberService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -28,7 +28,7 @@ public class TestController { private final AuthService authService; - private final MemberRepository memberRepository; + private final MemberService memberService; @PostMapping("/access") public ResponseEntity requireAccessToken(@RequestBody @Valid TestAuthRequest request) { @@ -38,9 +38,9 @@ public ResponseEntity requireAccessToken(@RequestBody @Valid TestA @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 Long memberId = (Long)authentication.getPrincipal(); + final Member member = memberService.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 + "!"); diff --git a/src/main/java/com/und/server/dto/HandshakeRequest.java b/src/main/java/com/und/server/dto/NonceRequest.java similarity index 80% rename from src/main/java/com/und/server/dto/HandshakeRequest.java rename to src/main/java/com/und/server/dto/NonceRequest.java index 32c814bd..0bed6a41 100644 --- a/src/main/java/com/und/server/dto/HandshakeRequest.java +++ b/src/main/java/com/und/server/dto/NonceRequest.java @@ -5,8 +5,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; -@Schema(description = "Request for Handshake") -public record HandshakeRequest( +@Schema(description = "Request for issuing a Nonce") +public record NonceRequest( @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/NonceResponse.java similarity index 76% rename from src/main/java/com/und/server/dto/HandshakeResponse.java rename to src/main/java/com/und/server/dto/NonceResponse.java index aeb753a3..c71e51db 100644 --- a/src/main/java/com/und/server/dto/HandshakeResponse.java +++ b/src/main/java/com/und/server/dto/NonceResponse.java @@ -4,8 +4,8 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "Handshake Response with Nonce") -public record HandshakeResponse( +@Schema(description = "Response with a Nonce") +public record NonceResponse( @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/jwt/JwtProvider.java b/src/main/java/com/und/server/jwt/JwtProvider.java index 18150e32..45cee15f 100644 --- a/src/main/java/com/und/server/jwt/JwtProvider.java +++ b/src/main/java/com/und/server/jwt/JwtProvider.java @@ -4,14 +4,11 @@ import java.security.PublicKey; import java.time.LocalDateTime; import java.time.ZoneId; -import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; -import java.util.Set; -import org.springframework.core.env.Environment; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -22,6 +19,7 @@ import com.und.server.exception.ServerErrorResult; import com.und.server.exception.ServerException; import com.und.server.oauth.IdTokenPayload; +import com.und.server.util.ProfileManager; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; @@ -39,8 +37,8 @@ @RequiredArgsConstructor public class JwtProvider { - private final Environment environment; private final JwtProperties jwtProperties; + private final ProfileManager profileManager; public Map getDecodedHeader(final String token) { try { @@ -71,7 +69,6 @@ public IdTokenPayload parseOidcIdToken( .verifyWith(publicKey) .requireIssuer(iss) .requireAudience(aud); - final Claims claims = parseClaims(token, builder); return new IdTokenPayload(claims.getSubject(), claims.get("nickname", String.class)); @@ -99,13 +96,7 @@ public Authentication getAuthentication(final String token) { } public Long getMemberIdFromToken(final String token) { - return Long.valueOf(parseAccessTokenClaims(token).getSubject()); - } - - private Claims parseAccessTokenClaims(final String token) { - final JwtParserBuilder builder = Jwts.parser() - .verifyWith(jwtProperties.secretKey()); - return parseClaims(token, builder); + return Long.valueOf(parseClaims(token, getAccessTokenParserBuilder()).getSubject()); } private Claims parseClaims(final String token, final JwtParserBuilder builder) { @@ -116,14 +107,13 @@ private Claims parseClaims(final String token, final JwtParserBuilder builder) { } } - public Long getMemberIdFromExpiredAccessToken(final String token) { - final JwtParserBuilder builder = Jwts.parser().verifyWith(jwtProperties.secretKey()); + public ParsedTokenInfo parseTokenForReissue(final String token) { try { - parseToken(token, builder); - throw new ServerException(ServerErrorResult.NOT_EXPIRED_TOKEN); + final Claims claims = parseToken(token, getAccessTokenParserBuilder()); + return new ParsedTokenInfo(Long.valueOf(claims.getSubject()), false); } catch (final ExpiredJwtException e) { // If the token is expired, we can still extract the member ID. - return Long.valueOf(e.getClaims().getSubject()); + return new ParsedTokenInfo(Long.valueOf(e.getClaims().getSubject()), true); } } @@ -137,7 +127,7 @@ private Claims parseToken(final String token, final JwtParserBuilder builder) { throw e; } catch (final JwtException e) { // For prod or stg environments, return a generic error to avoid leaking details. - if (isProdOrStgProfile()) { + if (profileManager.isProdOrStgProfile()) { throw new ServerException(ServerErrorResult.UNAUTHORIZED_ACCESS, e); } @@ -159,9 +149,9 @@ private Claims parseToken(final String token, final JwtParserBuilder builder) { } } - private boolean isProdOrStgProfile() { - return Arrays.stream(environment.getActiveProfiles()) - .anyMatch(Set.of("prod", "stg")::contains); + private JwtParserBuilder getAccessTokenParserBuilder() { + return Jwts.parser() + .verifyWith(jwtProperties.secretKey()); } private String decodeBase64UrlPart(final String encodedPart) { diff --git a/src/main/java/com/und/server/jwt/ParsedTokenInfo.java b/src/main/java/com/und/server/jwt/ParsedTokenInfo.java new file mode 100644 index 00000000..437226cb --- /dev/null +++ b/src/main/java/com/und/server/jwt/ParsedTokenInfo.java @@ -0,0 +1,6 @@ +package com.und.server.jwt; + +public record ParsedTokenInfo( + Long memberId, + boolean isExpired +) { } diff --git a/src/main/java/com/und/server/security/JwtAuthenticationFilter.java b/src/main/java/com/und/server/security/JwtAuthenticationFilter.java index 892f9ff9..ac209897 100644 --- a/src/main/java/com/und/server/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/und/server/security/JwtAuthenticationFilter.java @@ -41,7 +41,8 @@ protected void doFilterInternal( SecurityContextHolder.getContext().setAuthentication(authentication); } 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) { errorResponseWriter.sendErrorResponse(response, e.getErrorResult()); diff --git a/src/main/java/com/und/server/service/AuthService.java b/src/main/java/com/und/server/service/AuthService.java index 7a29ae3b..b777237b 100644 --- a/src/main/java/com/und/server/service/AuthService.java +++ b/src/main/java/com/und/server/service/AuthService.java @@ -5,8 +5,8 @@ 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.NonceRequest; +import com.und.server.dto.NonceResponse; import com.und.server.dto.OidcPublicKeys; import com.und.server.dto.RefreshTokenRequest; import com.und.server.dto.TestAuthRequest; @@ -15,12 +15,13 @@ import com.und.server.exception.ServerException; import com.und.server.jwt.JwtProperties; import com.und.server.jwt.JwtProvider; +import com.und.server.jwt.ParsedTokenInfo; 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 com.und.server.util.ProfileManager; import lombok.RequiredArgsConstructor; @@ -29,39 +30,40 @@ @Transactional(readOnly = true) public class AuthService { - private final MemberRepository memberRepository; + 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; // FIXME: Remove this method when deleting TestController @Transactional public AuthResponse issueTokensForTest(final TestAuthRequest request) { final Provider provider = convertToProvider(request.provider()); - final IdTokenPayload payload = new IdTokenPayload(request.providerId(), request.nickname()); - final Member member = findOrCreateMember(provider, payload); + final IdTokenPayload idTokenPayload = new IdTokenPayload(request.providerId(), request.nickname()); + final Member member = memberService.findOrCreateMember(provider, idTokenPayload); return issueTokens(member.getId()); } @Transactional - public HandshakeResponse handshake(final HandshakeRequest handshakeRequest) { + public NonceResponse handshake(final NonceRequest nonceRequest) { final String nonce = nonceService.generateNonceValue(); - final Provider provider = convertToProvider(handshakeRequest.provider()); + final Provider provider = convertToProvider(nonceRequest.provider()); nonceService.saveNonce(nonce, provider); - return new HandshakeResponse(nonce); + return new NonceResponse(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); + final Member member = memberService.findOrCreateMember(provider, idTokenPayload); return issueTokens(member.getId()); } @@ -72,6 +74,12 @@ public AuthResponse reissueTokens(final RefreshTokenRequest refreshTokenRequest) final String providedRefreshToken = refreshTokenRequest.refreshToken(); final Long memberId = getMemberIdForReissue(accessToken); + + memberService.findById(memberId).orElseThrow(() -> { + refreshTokenService.deleteRefreshToken(memberId); + return new ServerException(ServerErrorResult.INVALID_TOKEN); + }); + refreshTokenService.validateRefreshToken(memberId, providedRefreshToken); return issueTokens(memberId); @@ -95,31 +103,6 @@ private IdTokenPayload validateIdTokenAndGetPayload(final Provider provider, fin 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(); @@ -134,17 +117,20 @@ private AuthResponse issueTokens(final Long memberId) { } private Long getMemberIdForReissue(final String accessToken) { - try { - return jwtProvider.getMemberIdFromExpiredAccessToken(accessToken); - } catch (final ServerException e) { - if (e.getErrorResult() == ServerErrorResult.NOT_EXPIRED_TOKEN) { - // An attempt to reissue with a non-expired token may be a security risk. - // For security, we delete the refresh token. - final Long memberId = jwtProvider.getMemberIdFromToken(accessToken); - refreshTokenService.deleteRefreshToken(memberId); + 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, we delete the refresh token. + refreshTokenService.deleteRefreshToken(memberId); + if (profileManager.isProdOrStgProfile()) { + throw new ServerException(ServerErrorResult.INVALID_TOKEN); } - throw e; + throw new ServerException(ServerErrorResult.NOT_EXPIRED_TOKEN); } + + return memberId; } } diff --git a/src/main/java/com/und/server/service/MemberService.java b/src/main/java/com/und/server/service/MemberService.java new file mode 100644 index 00000000..29785c49 --- /dev/null +++ b/src/main/java/com/und/server/service/MemberService.java @@ -0,0 +1,48 @@ +package com.und.server.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.und.server.entity.Member; +import com.und.server.exception.ServerErrorResult; +import com.und.server.exception.ServerException; +import com.und.server.oauth.IdTokenPayload; +import com.und.server.oauth.Provider; +import com.und.server.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberService { + + private final MemberRepository memberRepository; + + @Transactional + public Member findOrCreateMember(final Provider provider, final IdTokenPayload payload) { + final String providerId = payload.providerId(); + return findMemberByProviderId(provider, providerId) + .orElseGet(() -> createMember(provider, providerId, payload.nickname())); + } + + public Optional findById(final Long memberId) { + return memberRepository.findById(memberId); + } + + private Optional findMemberByProviderId(final Provider provider, final String providerId) { + return switch (provider) { + case KAKAO -> memberRepository.findByKakaoId(providerId); + default -> throw new ServerException(ServerErrorResult.INVALID_PROVIDER); + }; + } + + private Member createMember(final Provider provider, final String providerId, final String nickname) { + return memberRepository.save(Member.builder() + .kakaoId(provider == Provider.KAKAO ? providerId : null) + .nickname(nickname) + .build()); + } +} diff --git a/src/main/java/com/und/server/service/NonceService.java b/src/main/java/com/und/server/service/NonceService.java index 94185400..8293ae87 100644 --- a/src/main/java/com/und/server/service/NonceService.java +++ b/src/main/java/com/und/server/service/NonceService.java @@ -3,6 +3,7 @@ import java.util.UUID; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.und.server.entity.Nonce; import com.und.server.exception.ServerErrorResult; @@ -14,6 +15,7 @@ @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class NonceService { private final NonceRepository nonceRepository; @@ -22,6 +24,7 @@ public String generateNonceValue() { return UUID.randomUUID().toString(); } + @Transactional public void validateNonce(final String nonceValue, final Provider provider) { nonceRepository.findById(nonceValue) .filter(n -> n.getProvider() == provider) @@ -31,6 +34,7 @@ public void validateNonce(final String nonceValue, final Provider provider) { } + @Transactional public void saveNonce(final String value, final Provider provider) { final Nonce nonce = Nonce.builder() .value(value) @@ -40,6 +44,7 @@ public void saveNonce(final String value, final Provider provider) { nonceRepository.save(nonce); } + @Transactional 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 index c7ced9ae..516a1387 100644 --- a/src/main/java/com/und/server/service/RefreshTokenService.java +++ b/src/main/java/com/und/server/service/RefreshTokenService.java @@ -3,6 +3,7 @@ import java.util.UUID; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.und.server.entity.RefreshToken; import com.und.server.exception.ServerErrorResult; @@ -13,6 +14,7 @@ @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class RefreshTokenService { private final RefreshTokenRepository refreshTokenRepository; @@ -27,6 +29,7 @@ public String getRefreshToken(final Long memberId) { .orElse(null); } + @Transactional public void validateRefreshToken(final Long memberId, final String providedToken) { refreshTokenRepository.findById(memberId) .map(RefreshToken::getValue) @@ -37,6 +40,7 @@ public void validateRefreshToken(final Long memberId, final String providedToken }); } + @Transactional public void saveRefreshToken(final Long memberId, final String refreshToken) { final RefreshToken token = RefreshToken.builder() .memberId(memberId) @@ -46,6 +50,7 @@ public void saveRefreshToken(final Long memberId, final String refreshToken) { refreshTokenRepository.save(token); } + @Transactional public void deleteRefreshToken(final Long memberId) { refreshTokenRepository.deleteById(memberId); } diff --git a/src/main/java/com/und/server/util/ProfileManager.java b/src/main/java/com/und/server/util/ProfileManager.java new file mode 100644 index 00000000..21547b18 --- /dev/null +++ b/src/main/java/com/und/server/util/ProfileManager.java @@ -0,0 +1,21 @@ +package com.und.server.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/test/java/com/und/server/controller/AuthControllerTest.java b/src/test/java/com/und/server/controller/AuthControllerTest.java index 34f7170f..cba5ad59 100644 --- a/src/test/java/com/und/server/controller/AuthControllerTest.java +++ b/src/test/java/com/und/server/controller/AuthControllerTest.java @@ -24,8 +24,8 @@ 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.NonceRequest; +import com.und.server.dto.NonceResponse; import com.und.server.dto.RefreshTokenRequest; import com.und.server.exception.GlobalExceptionHandler; import com.und.server.exception.ServerErrorResult; @@ -56,7 +56,7 @@ void init() { void Given_HandshakeRequestWithNullProvider_When_Handshake_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 @@ -77,7 +77,7 @@ void Given_HandshakeRequestWithNullProvider_When_Handshake_Then_ReturnsBadReques void Given_HandshakeRequestWithUnknownProvider_When_Handshake_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; @@ -102,8 +102,8 @@ void Given_HandshakeRequestWithUnknownProvider_When_Handshake_Then_ReturnsErrorR void Given_ValidHandshakeRequest_When_Handshake_Then_ReturnsOkWithNonce() 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); @@ -166,7 +166,7 @@ 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; diff --git a/src/test/java/com/und/server/controller/TestControllerTest.java b/src/test/java/com/und/server/controller/TestControllerTest.java index d886eab0..86ced0df 100644 --- a/src/test/java/com/und/server/controller/TestControllerTest.java +++ b/src/test/java/com/und/server/controller/TestControllerTest.java @@ -29,8 +29,8 @@ 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; +import com.und.server.service.MemberService; @ExtendWith(MockitoExtension.class) class TestControllerTest { @@ -39,7 +39,7 @@ class TestControllerTest { private TestController testController; @Mock - private MemberRepository memberRepository; + private MemberService memberService; @Mock private AuthService authService; @@ -130,7 +130,7 @@ void Given_AuthenticatedUserNotFoundInDb_When_Greet_Then_ReturnsUnauthorized() t final String url = "/v1/test/hello"; final Long memberId = 3L; - doReturn(Optional.empty()).when(memberRepository).findById(memberId); + doReturn(Optional.empty()).when(memberService).findById(memberId); final Authentication auth = new UsernamePasswordAuthenticationToken(memberId, null); // when @@ -152,7 +152,7 @@ void Given_AuthenticatedUserWithNickname_When_Greet_Then_ReturnsOkWithPersonaliz final Long memberId = 1L; final Member member = Member.builder().id(memberId).nickname("Chori").build(); - doReturn(Optional.of(member)).when(memberRepository).findById(memberId); + doReturn(Optional.of(member)).when(memberService).findById(memberId); final Authentication auth = new UsernamePasswordAuthenticationToken(memberId, null); // when @@ -173,7 +173,7 @@ void Given_AuthenticatedUserWithoutNickname_When_Greet_Then_ReturnsOkWithDefault final Long memberId = 2L; final Member member = Member.builder().id(memberId).nickname(null).build(); - doReturn(Optional.of(member)).when(memberRepository).findById(memberId); + doReturn(Optional.of(member)).when(memberService).findById(memberId); final Authentication auth = new UsernamePasswordAuthenticationToken(memberId, null); // when diff --git a/src/test/java/com/und/server/jwt/JwtProviderTest.java b/src/test/java/com/und/server/jwt/JwtProviderTest.java index e07a1ba3..c46f4142 100644 --- a/src/test/java/com/und/server/jwt/JwtProviderTest.java +++ b/src/test/java/com/und/server/jwt/JwtProviderTest.java @@ -21,12 +21,12 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.core.env.Environment; 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.util.ProfileManager; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; @@ -42,7 +42,7 @@ class JwtProviderTest { private JwtProperties jwtProperties; @Mock - private Environment environment; + private ProfileManager profileManager; private JwtProvider jwtProvider; @@ -54,7 +54,7 @@ class JwtProviderTest { @BeforeEach void init() { - jwtProvider = new JwtProvider(environment, jwtProperties); + jwtProvider = new JwtProvider(jwtProperties, profileManager); } @Test @@ -115,7 +115,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(new String[]{"local", "dev"}).when(environment).getActiveProfiles(); + doReturn(false).when(profileManager).isProdOrStgProfile(); final String wrongAudience = "wrong-client"; final KeyPair keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); final PublicKey publicKey = keyPair.getPublic(); @@ -293,7 +293,7 @@ void Given_ExpiredToken_When_GetMemberIdFromToken_Then_ThrowsServerException() { @DisplayName("Throws ServerException when token structure is invalid") void Given_MalformedToken_When_GetMemberIdFromToken_Then_ThrowsServerException() { // given - doReturn(new String[]{"local", "dev"}).when(environment).getActiveProfiles(); + doReturn(false).when(profileManager).isProdOrStgProfile(); doReturn(secretKey).when(jwtProperties).secretKey(); final String malformedToken = "this.is.not.a.jwt"; @@ -307,7 +307,7 @@ void Given_MalformedToken_When_GetMemberIdFromToken_Then_ThrowsServerException() @DisplayName("Throws ServerException when token signature is invalid") void Given_TokenWithInvalidSignature_When_GetMemberIdFromToken_Then_ThrowsServerException() { // given - doReturn(new String[]{"local", "dev"}).when(environment).getActiveProfiles(); + 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(); @@ -321,7 +321,7 @@ void Given_TokenWithInvalidSignature_When_GetMemberIdFromToken_Then_ThrowsServer @Test @DisplayName("Throws ServerException when the verification key is too weak for the token's algorithm") void Given_TokenWithStrongAlgAndProviderWithWeakKey_When_GetMemberIdFromToken_Then_ThrowsTokenKeyErrorException() { - doReturn(new String[]{"local", "dev"}).when(environment).getActiveProfiles(); + 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(); @@ -343,7 +343,7 @@ void Given_TokenWithStrongAlgAndProviderWithWeakKey_When_GetMemberIdFromToken_Th @DisplayName("Throws ServerException when token uses an unsupported feature (e.g., compression)") void Given_TokenWithUnsupportedFeature_When_GetMemberIdFromToken_Then_ThrowsUnsupportedTokenException() { // given - doReturn(new String[]{"local", "dev"}).when(environment).getActiveProfiles(); + doReturn(false).when(profileManager).isProdOrStgProfile(); doReturn(secretKey).when(jwtProperties).secretKey(); final String unsupportedToken = Jwts.builder() .header() @@ -364,7 +364,7 @@ void Given_TokenWithUnsupportedFeature_When_GetMemberIdFromToken_Then_ThrowsUnsu @DisplayName("Throws generic UNAUTHORIZED_ACCESS for any token error") void Given_MalformedToken_When_GetMemberIdFromToken_Then_ThrowsGenericUnauthorizedAccessException() { // given - doReturn(new String[]{"prod", "stg"}).when(environment).getActiveProfiles(); + doReturn(true).when(profileManager).isProdOrStgProfile(); doReturn(secretKey).when(jwtProperties).secretKey(); final String malformedToken = "this.is.not.a.jwt"; @@ -395,24 +395,27 @@ void Given_ValidToken_When_GetMemberIdFromToken_Then_ReturnsCorrectMemberId() { } @Test - @DisplayName("Throws an exception when getting member ID from a non-expired token") - void Given_NonExpiredToken_When_GetMemberIdFromExpiredAccessToken_Then_ThrowsException() { + @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.NOT_EXPIRED_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(); @@ -420,19 +423,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/service/AuthServiceTest.java b/src/test/java/com/und/server/service/AuthServiceTest.java index e4d0be44..f4f905f4 100644 --- a/src/test/java/com/und/server/service/AuthServiceTest.java +++ b/src/test/java/com/und/server/service/AuthServiceTest.java @@ -21,8 +21,8 @@ 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.NonceRequest; +import com.und.server.dto.NonceResponse; import com.und.server.dto.OidcPublicKeys; import com.und.server.dto.RefreshTokenRequest; import com.und.server.dto.TestAuthRequest; @@ -31,12 +31,13 @@ import com.und.server.exception.ServerException; import com.und.server.jwt.JwtProperties; import com.und.server.jwt.JwtProvider; +import com.und.server.jwt.ParsedTokenInfo; 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 com.und.server.util.ProfileManager; @ExtendWith(MockitoExtension.class) class AuthServiceTest { @@ -45,7 +46,7 @@ class AuthServiceTest { private AuthService authService; @Mock - private MemberRepository memberRepository; + private MemberService memberService; @Mock private OidcClientFactory oidcClientFactory; @@ -65,6 +66,9 @@ class AuthServiceTest { @Mock private NonceService nonceService; + @Mock + private ProfileManager profileManager; + private final String providerId = "dummyId"; private final String nickname = "dummyNickname"; private final Long memberId = 1L; @@ -81,16 +85,14 @@ 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); + doReturn(existingMember).when(memberService).findOrCreateMember(any(Provider.class), any(IdTokenPayload.class)); setupTokenIssuance(accessToken, refreshToken); // when final AuthResponse response = authService.issueTokensForTest(request); // then - verify(memberRepository).findByKakaoId(providerId); - verify(memberRepository, never()).save(any(Member.class)); + verify(memberService).findOrCreateMember(any(Provider.class), any(IdTokenPayload.class)); verify(refreshTokenService).saveRefreshToken(memberId, refreshToken); assertThat(response.accessToken()).isEqualTo(accessToken); assertThat(response.refreshToken()).isEqualTo(refreshToken); @@ -103,17 +105,14 @@ void Given_NewMemberForTest_When_IssueTokensForTest_Then_CreatesMemberAndSucceed // 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)); + doReturn(newMember).when(memberService).findOrCreateMember(any(Provider.class), any(IdTokenPayload.class)); setupTokenIssuance(accessToken, refreshToken); // when final AuthResponse response = authService.issueTokensForTest(request); // then - verify(memberRepository).findByKakaoId(providerId); - verify(memberRepository).save(any(Member.class)); + verify(memberService).findOrCreateMember(any(Provider.class), any(IdTokenPayload.class)); verify(refreshTokenService).saveRefreshToken(memberId, refreshToken); assertThat(response.accessToken()).isEqualTo(accessToken); assertThat(response.refreshToken()).isEqualTo(refreshToken); @@ -123,11 +122,11 @@ void Given_NewMemberForTest_When_IssueTokensForTest_Then_CreatesMemberAndSucceed @DisplayName("Throws an exception on handshake with an invalid provider") void Given_InvalidProvider_When_Handshake_Then_ThrowsException() { // given - final HandshakeRequest handshakeRequest = new HandshakeRequest("facebook"); + final NonceRequest nonceRequest = new NonceRequest("facebook"); // when & then final ServerException exception = assertThrows(ServerException.class, - () -> authService.handshake(handshakeRequest)); + () -> authService.handshake(nonceRequest)); assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_PROVIDER); } @@ -138,13 +137,13 @@ void Given_ValidProvider_When_Handshake_Then_ReturnsNonce() { // given final String nonce = "generated-nonce"; final String providerName = "kakao"; - final HandshakeRequest handshakeRequest = new HandshakeRequest(providerName); + final NonceRequest nonceRequest = new NonceRequest(providerName); doReturn(nonce).when(nonceService).generateNonceValue(); doNothing().when(nonceService).saveNonce(nonce, Provider.KAKAO); // when - final HandshakeResponse response = authService.handshake(handshakeRequest); + final NonceResponse response = authService.handshake(nonceRequest); // then verify(nonceService).generateNonceValue(); @@ -180,7 +179,7 @@ void Given_RegisteredMember_When_Login_Then_IssuesTokensSuccessfully() { 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); + doReturn(member).when(memberService).findOrCreateMember(Provider.KAKAO, payload); setupTokenIssuance(accessToken, refreshToken); // when @@ -188,7 +187,6 @@ void Given_RegisteredMember_When_Login_Then_IssuesTokensSuccessfully() { // 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); @@ -211,8 +209,7 @@ void Given_NewMember_When_Login_Then_CreatesMemberAndIssuesTokens() { 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)); + doReturn(newMember).when(memberService).findOrCreateMember(Provider.KAKAO, payload); setupTokenIssuance(accessToken, refreshToken); // when @@ -220,19 +217,40 @@ void Given_NewMember_When_Login_Then_CreatesMemberAndIssuesTokens() { // 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 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); + doReturn(Optional.empty()).when(memberService).findById(memberId); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> authService.reissueTokens(request)); + + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); + verify(refreshTokenService).deleteRefreshToken(memberId); + verify(refreshTokenService, never()).validateRefreshToken(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); + final Member member = Member.builder().id(memberId).build(); - doReturn(memberId).when(jwtProvider).getMemberIdFromExpiredAccessToken(accessToken); + doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); + doReturn(Optional.of(member)).when(memberService).findById(memberId); doThrow(new ServerException(ServerErrorResult.INVALID_TOKEN)) .when(refreshTokenService).validateRefreshToken(memberId, "wrong.refresh.token"); @@ -247,9 +265,12 @@ void Given_MismatchedRefreshToken_When_ReissueTokens_Then_ThrowsException() { @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 Member member = Member.builder().id(memberId).build(); final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); - doReturn(memberId).when(jwtProvider).getMemberIdFromExpiredAccessToken(accessToken); + doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); + doReturn(Optional.of(member)).when(memberService).findById(memberId); doThrow(new ServerException(ServerErrorResult.INVALID_TOKEN)) .when(refreshTokenService).validateRefreshToken(memberId, refreshToken); @@ -268,8 +289,11 @@ void Given_ValidRefreshToken_When_ReissueTokens_Then_Succeeds() { 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); + final Member member = Member.builder().id(memberId).build(); - doReturn(memberId).when(jwtProvider).getMemberIdFromExpiredAccessToken(accessToken); + doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); + doReturn(Optional.of(member)).when(memberService).findById(memberId); doNothing().when(refreshTokenService).validateRefreshToken(memberId, refreshToken); setupTokenIssuance(newAccessToken, newRefreshToken); @@ -285,17 +309,33 @@ void Given_ValidRefreshToken_When_ReissueTokens_Then_Succeeds() { } @Test - @DisplayName("Throws an exception and deletes refresh token if the access token is non-expired") - void Given_NonExpiredAccessToken_When_ReissueTokens_Then_ThrowsExceptionAndDeletesToken() { + @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)); - // Simulate the case where the access token is not yet expired. - doThrow(new ServerException(ServerErrorResult.NOT_EXPIRED_TOKEN)) - .when(jwtProvider).getMemberIdFromExpiredAccessToken(accessToken); + // then + verify(refreshTokenService).deleteRefreshToken(memberId); + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.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); - // When the exception is caught, the service should try to get the memberId from the valid token. - doReturn(memberId).when(jwtProvider).getMemberIdFromToken(accessToken); + doReturn(nonExpiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); + doReturn(false).when(profileManager).isProdOrStgProfile(); // when & then final ServerException exception = assertThrows(ServerException.class, diff --git a/src/test/java/com/und/server/service/MemberServiceTest.java b/src/test/java/com/und/server/service/MemberServiceTest.java new file mode 100644 index 00000000..f97635cc --- /dev/null +++ b/src/test/java/com/und/server/service/MemberServiceTest.java @@ -0,0 +1,94 @@ +package com.und.server.service; + +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.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.entity.Member; +import com.und.server.oauth.IdTokenPayload; +import com.und.server.oauth.Provider; +import com.und.server.repository.MemberRepository; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + @InjectMocks + private MemberService memberService; + + @Mock + private MemberRepository memberRepository; + + private final Long memberId = 1L; + private final String providerId = "test-provider-id"; + private final String nickname = "test-nickname"; + private final Provider provider = Provider.KAKAO; + + @Test + @DisplayName("Finds an existing member without creating a new one") + void Given_ExistingMember_When_FindOrCreateMember_Then_ReturnsExistingMember() { + // given + final IdTokenPayload payload = new IdTokenPayload(providerId, nickname); + 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(provider, payload); + + // then + verify(memberRepository).findByKakaoId(providerId); + verify(memberRepository, never()).save(any(Member.class)); + assertThat(foundMember).isEqualTo(existingMember); + } + + @Test + @DisplayName("Creates a new member if one does not exist") + void Given_NonExistingMember_When_FindOrCreateMember_Then_CreatesAndReturnsNewMember() { + // given + final IdTokenPayload payload = new IdTokenPayload(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)); + + // when + final Member createdMember = memberService.findOrCreateMember(provider, payload); + + // then + verify(memberRepository).findByKakaoId(providerId); + verify(memberRepository).save(any(Member.class)); + assertThat(createdMember).isEqualTo(newMember); + } + + @Test + @DisplayName("Returns an empty Optional when finding a non-existent member by ID") + void Given_NonExistingMemberId_When_FindById_Then_ReturnsEmptyOptional() { + // given + doReturn(Optional.empty()).when(memberRepository).findById(memberId); + + // when + final Optional foundMemberOptional = memberService.findById(memberId); + + // then + assertThat(foundMemberOptional).isEmpty(); + } +} diff --git a/src/test/java/com/und/server/util/ProfileManagerTest.java b/src/test/java/com/und/server/util/ProfileManagerTest.java new file mode 100644 index 00000000..14139889 --- /dev/null +++ b/src/test/java/com/und/server/util/ProfileManagerTest.java @@ -0,0 +1,74 @@ +package com.und.server.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(); + } +} From a1f7ae7cf7a2f3d61a3edb51ef6f023f50c0b0e6 Mon Sep 17 00:00:00 2001 From: Chori <105255517+choridev@users.noreply.github.com> Date: Wed, 30 Jul 2025 22:55:00 +0900 Subject: [PATCH 10/26] Add Logout API (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 💡 Update comment by renaming method * ✅ Refactor test codes with consistent form * ♻️ Throws exception if Principal is not Long * ✨ Add logout API * ➕ Add dependency spring security test * ♻️ Delegate verifiying Authentication Principal * 🧑‍💻 Notify when a new comment is added --- .github/workflows/notify-on-comment.yml | 67 +++++++++++ build.gradle | 1 + .../com/und/server/config/WebMvcConfig.java | 22 ++++ .../und/server/controller/AuthController.java | 8 ++ .../und/server/controller/TestController.java | 8 +- .../java/com/und/server/jwt/JwtProvider.java | 2 +- .../com/und/server/security/AuthMember.java | 11 ++ .../security/AuthMemberArgumentResolver.java | 34 ++++++ .../com/und/server/service/AuthService.java | 5 + .../server/controller/AuthControllerTest.java | 59 ++++++++++ .../server/controller/TestControllerTest.java | 46 +++++++- .../com/und/server/jwt/JwtProviderTest.java | 2 +- .../und/server/oauth/KakaoProviderTest.java | 8 +- .../server/oauth/OidcProviderFactoryTest.java | 4 +- .../AuthMemberArgumentResolverTest.java | 110 ++++++++++++++++++ .../security/JwtAuthenticationFilterTest.java | 13 ++- .../und/server/service/AuthServiceTest.java | 13 +++ 17 files changed, 392 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/notify-on-comment.yml create mode 100644 src/main/java/com/und/server/config/WebMvcConfig.java create mode 100644 src/main/java/com/und/server/security/AuthMember.java create mode 100644 src/main/java/com/und/server/security/AuthMemberArgumentResolver.java create mode 100644 src/test/java/com/und/server/security/AuthMemberArgumentResolverTest.java 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/build.gradle b/build.gradle index edb6c811..ceb1cea1 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' diff --git a/src/main/java/com/und/server/config/WebMvcConfig.java b/src/main/java/com/und/server/config/WebMvcConfig.java new file mode 100644 index 00000000..3b215e56 --- /dev/null +++ b/src/main/java/com/und/server/config/WebMvcConfig.java @@ -0,0 +1,22 @@ +package com.und.server.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.security.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/controller/AuthController.java b/src/main/java/com/und/server/controller/AuthController.java index 1cd662f3..f6f5a114 100644 --- a/src/main/java/com/und/server/controller/AuthController.java +++ b/src/main/java/com/und/server/controller/AuthController.java @@ -2,6 +2,7 @@ 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; @@ -12,8 +13,10 @@ import com.und.server.dto.NonceRequest; import com.und.server.dto.NonceResponse; import com.und.server.dto.RefreshTokenRequest; +import com.und.server.security.AuthMember; import com.und.server.service.AuthService; +import io.swagger.v3.oas.annotations.Parameter; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -47,4 +50,9 @@ public ResponseEntity reissueTokens( return ResponseEntity.status(HttpStatus.OK).body(authResponse); } + @DeleteMapping("/logout") + 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/controller/TestController.java b/src/main/java/com/und/server/controller/TestController.java index fed35191..33de63ad 100644 --- a/src/main/java/com/und/server/controller/TestController.java +++ b/src/main/java/com/und/server/controller/TestController.java @@ -3,7 +3,6 @@ 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; @@ -16,9 +15,11 @@ import com.und.server.entity.Member; import com.und.server.exception.ServerErrorResult; import com.und.server.exception.ServerException; +import com.und.server.security.AuthMember; import com.und.server.service.AuthService; import com.und.server.service.MemberService; +import io.swagger.v3.oas.annotations.Parameter; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -31,14 +32,13 @@ public class TestController { private final MemberService memberService; @PostMapping("/access") - public ResponseEntity requireAccessToken(@RequestBody @Valid TestAuthRequest request) { + public ResponseEntity requireAccessToken(@RequestBody @Valid final 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(); + public ResponseEntity greet(@Parameter(hidden = true) @AuthMember final Long memberId) { final Member member = memberService.findById(memberId) .orElseThrow(() -> new ServerException(ServerErrorResult.MEMBER_NOT_FOUND)); final String nickname = member.getNickname() != null ? member.getNickname() : "Member"; diff --git a/src/main/java/com/und/server/jwt/JwtProvider.java b/src/main/java/com/und/server/jwt/JwtProvider.java index 45cee15f..6717c1bb 100644 --- a/src/main/java/com/und/server/jwt/JwtProvider.java +++ b/src/main/java/com/und/server/jwt/JwtProvider.java @@ -123,7 +123,7 @@ private Claims parseToken(final String token, final JwtParserBuilder builder) { .parseSignedClaims(token) .getPayload(); } catch (final ExpiredJwtException e) { - // This must be re-thrown for getMemberIdFromExpiredAccessToken to work correctly. + // 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. diff --git a/src/main/java/com/und/server/security/AuthMember.java b/src/main/java/com/und/server/security/AuthMember.java new file mode 100644 index 00000000..c7bf4f28 --- /dev/null +++ b/src/main/java/com/und/server/security/AuthMember.java @@ -0,0 +1,11 @@ +package com.und.server.security; + +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/security/AuthMemberArgumentResolver.java b/src/main/java/com/und/server/security/AuthMemberArgumentResolver.java new file mode 100644 index 00000000..326c48ab --- /dev/null +++ b/src/main/java/com/und/server/security/AuthMemberArgumentResolver.java @@ -0,0 +1,34 @@ +package com.und.server.security; + +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.exception.ServerErrorResult; +import com.und.server.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(ServerErrorResult.UNAUTHORIZED_ACCESS); + } + return memberId; + } + +} diff --git a/src/main/java/com/und/server/service/AuthService.java b/src/main/java/com/und/server/service/AuthService.java index b777237b..522bc93e 100644 --- a/src/main/java/com/und/server/service/AuthService.java +++ b/src/main/java/com/und/server/service/AuthService.java @@ -85,6 +85,11 @@ public AuthResponse reissueTokens(final RefreshTokenRequest refreshTokenRequest) 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()); diff --git a/src/test/java/com/und/server/controller/AuthControllerTest.java b/src/test/java/com/und/server/controller/AuthControllerTest.java index cba5ad59..64d82ddb 100644 --- a/src/test/java/com/und/server/controller/AuthControllerTest.java +++ b/src/test/java/com/und/server/controller/AuthControllerTest.java @@ -1,12 +1,16 @@ package com.und.server.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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; 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.Collections; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -16,6 +20,9 @@ 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.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @@ -30,6 +37,7 @@ import com.und.server.exception.GlobalExceptionHandler; import com.und.server.exception.ServerErrorResult; import com.und.server.exception.ServerException; +import com.und.server.security.AuthMemberArgumentResolver; import com.und.server.service.AuthService; @ExtendWith(MockitoExtension.class) @@ -41,12 +49,16 @@ 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(); } @@ -331,4 +343,51 @@ void Given_ValidRefreshTokenRequest_When_ReissueTokens_Then_ReturnsOkWithNewToke assertThat(response.refreshToken()).isEqualTo("new.refresh.token"); } + @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()); + + final Authentication auth = new UsernamePasswordAuthenticationToken( + memberId, + null, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.delete(url).with(authentication(auth)) + ); + + // then + resultActions.andExpect(status().isNoContent()); + verify(authService).logout(memberId); + } + + @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 ServerErrorResult errorResult = ServerErrorResult.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())); + } } diff --git a/src/test/java/com/und/server/controller/TestControllerTest.java b/src/test/java/com/und/server/controller/TestControllerTest.java index 86ced0df..fff2a06d 100644 --- a/src/test/java/com/und/server/controller/TestControllerTest.java +++ b/src/test/java/com/und/server/controller/TestControllerTest.java @@ -1,7 +1,10 @@ package com.und.server.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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -29,6 +32,8 @@ import com.und.server.entity.Member; import com.und.server.exception.GlobalExceptionHandler; import com.und.server.exception.ServerErrorResult; +import com.und.server.exception.ServerException; +import com.und.server.security.AuthMemberArgumentResolver; import com.und.server.service.AuthService; import com.und.server.service.MemberService; @@ -44,12 +49,16 @@ class TestControllerTest { @Mock private AuthService authService; + @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(); } @@ -131,11 +140,14 @@ void Given_AuthenticatedUserNotFoundInDb_When_Greet_Then_ReturnsUnauthorized() t final Long memberId = 3L; doReturn(Optional.empty()).when(memberService).findById(memberId); + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + final Authentication auth = new UsernamePasswordAuthenticationToken(memberId, null); // when final ResultActions result = mockMvc.perform( - MockMvcRequestBuilders.get(url).principal(auth) + MockMvcRequestBuilders.get(url).with(authentication(auth)) ); // then @@ -144,6 +156,28 @@ void Given_AuthenticatedUserNotFoundInDb_When_Greet_Then_ReturnsUnauthorized() t .andExpect(jsonPath("$.message").value(ServerErrorResult.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 ServerErrorResult errorResult = ServerErrorResult.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 { @@ -153,11 +187,14 @@ void Given_AuthenticatedUserWithNickname_When_Greet_Then_ReturnsOkWithPersonaliz final Member member = Member.builder().id(memberId).nickname("Chori").build(); doReturn(Optional.of(member)).when(memberService).findById(memberId); + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + final Authentication auth = new UsernamePasswordAuthenticationToken(memberId, null); // when final ResultActions result = mockMvc.perform( - MockMvcRequestBuilders.get(url).principal(auth) + MockMvcRequestBuilders.get(url).with(authentication(auth)) ); // then @@ -174,11 +211,14 @@ void Given_AuthenticatedUserWithoutNickname_When_Greet_Then_ReturnsOkWithDefault final Member member = Member.builder().id(memberId).nickname(null).build(); doReturn(Optional.of(member)).when(memberService).findById(memberId); + doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); + doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); + final Authentication auth = new UsernamePasswordAuthenticationToken(memberId, null); // when final ResultActions result = mockMvc.perform( - MockMvcRequestBuilders.get(url).principal(auth) + MockMvcRequestBuilders.get(url).with(authentication(auth)) ); // then diff --git a/src/test/java/com/und/server/jwt/JwtProviderTest.java b/src/test/java/com/und/server/jwt/JwtProviderTest.java index c46f4142..c81c3016 100644 --- a/src/test/java/com/und/server/jwt/JwtProviderTest.java +++ b/src/test/java/com/und/server/jwt/JwtProviderTest.java @@ -243,7 +243,7 @@ void Given_ValidToken_When_GetDecodedHeader_Then_ReturnsHeaderMap() { final Map header = jwtProvider.getDecodedHeader(token); // then - assertThat(header.get("alg")).isNotBlank(); + assertThat(header.get("alg")).isEqualTo("HS256"); } @Test diff --git a/src/test/java/com/und/server/oauth/KakaoProviderTest.java b/src/test/java/com/und/server/oauth/KakaoProviderTest.java index 0cc4122b..f116f407 100644 --- a/src/test/java/com/und/server/oauth/KakaoProviderTest.java +++ b/src/test/java/com/und/server/oauth/KakaoProviderTest.java @@ -1,7 +1,7 @@ package com.und.server.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; @@ -51,9 +51,9 @@ void Given_ValidToken_When_GetIdTokenPayload_Then_ReturnsCorrectPayload() { 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(expectedPayload).when(jwtProvider).parseOidcIdToken(token, kakaoBaseUrl, kakaoAppKey, publicKey); // when final IdTokenPayload actualPayload = kakaoProvider.getIdTokenPayload(token, oidcPublicKeys); diff --git a/src/test/java/com/und/server/oauth/OidcProviderFactoryTest.java b/src/test/java/com/und/server/oauth/OidcProviderFactoryTest.java index 20229a3f..bd42f820 100644 --- a/src/test/java/com/und/server/oauth/OidcProviderFactoryTest.java +++ b/src/test/java/com/und/server/oauth/OidcProviderFactoryTest.java @@ -2,7 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doReturn; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -50,7 +50,7 @@ void Given_ValidProvider_When_GetIdTokenPayload_Then_ReturnsCorrectPayload() { // given final IdTokenPayload expectedPayload = new IdTokenPayload(providerId, nickname); - when(kakaoProvider.getIdTokenPayload(token, oidcPublicKeys)).thenReturn(expectedPayload); + doReturn(expectedPayload).when(kakaoProvider).getIdTokenPayload(token, oidcPublicKeys); // when final IdTokenPayload actualPayload = factory.getIdTokenPayload(Provider.KAKAO, token, oidcPublicKeys); diff --git a/src/test/java/com/und/server/security/AuthMemberArgumentResolverTest.java b/src/test/java/com/und/server/security/AuthMemberArgumentResolverTest.java new file mode 100644 index 00000000..454770dd --- /dev/null +++ b/src/test/java/com/und/server/security/AuthMemberArgumentResolverTest.java @@ -0,0 +1,110 @@ +package com.und.server.security; + +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.exception.ServerErrorResult; +import com.und.server.exception.ServerException; + +@ExtendWith(MockitoExtension.class) +class AuthMemberArgumentResolverTest { + + @InjectMocks + private AuthMemberArgumentResolver authMemberArgumentResolver; + + @Mock + private MethodParameter parameter; + + @Mock + private SecurityContext securityContext; + + @BeforeEach + void setUp() { + 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("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); + } + + @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", ServerErrorResult.UNAUTHORIZED_ACCESS); + } +} diff --git a/src/test/java/com/und/server/security/JwtAuthenticationFilterTest.java b/src/test/java/com/und/server/security/JwtAuthenticationFilterTest.java index 7acbf599..daf64e12 100644 --- a/src/test/java/com/und/server/security/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/und/server/security/JwtAuthenticationFilterTest.java @@ -2,10 +2,11 @@ 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; @@ -65,7 +66,7 @@ void Given_ExpiredTokenOnProtectedRoute_When_Filter_Then_ErrorResponseIsSetAndCh request.addHeader("Authorization", "Bearer " + token); final ServerErrorResult expectedError = ServerErrorResult.EXPIRED_TOKEN; - when(jwtProvider.getAuthentication(token)).thenThrow(new ServerException(expectedError)); + doThrow(new ServerException(expectedError)).when(jwtProvider).getAuthentication(token); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); @@ -95,7 +96,7 @@ void Given_TokenWithInvalidSignature_When_Filter_Then_ErrorResponseIsSetAndChain request.addHeader("Authorization", "Bearer " + token); final ServerErrorResult expectedError = ServerErrorResult.INVALID_TOKEN_SIGNATURE; - when(jwtProvider.getAuthentication(token)).thenThrow(new ServerException(expectedError)); + doThrow(new ServerException(expectedError)).when(jwtProvider).getAuthentication(token); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); @@ -125,7 +126,7 @@ void Given_ExpiredTokenOnLoginPath_When_Filter_Then_ErrorResponseIsSetAndChainSt request.addHeader("Authorization", "Bearer " + token); final ServerErrorResult expectedError = ServerErrorResult.EXPIRED_TOKEN; - when(jwtProvider.getAuthentication(token)).thenThrow(new ServerException(expectedError)); + doThrow(new ServerException(expectedError)).when(jwtProvider).getAuthentication(token); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); @@ -150,7 +151,7 @@ void Given_ExpiredTokenOnTokenReissuePath_When_Filter_Then_ChainContinues() thro request.addHeader("Authorization", "Bearer " + token); final ServerErrorResult expectedError = ServerErrorResult.EXPIRED_TOKEN; - when(jwtProvider.getAuthentication(token)).thenThrow(new ServerException(expectedError)); + 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/service/AuthServiceTest.java b/src/test/java/com/und/server/service/AuthServiceTest.java index f4f905f4..d3dd0da8 100644 --- a/src/test/java/com/und/server/service/AuthServiceTest.java +++ b/src/test/java/com/und/server/service/AuthServiceTest.java @@ -346,6 +346,19 @@ void Given_NonExpiredTokenOnDev_When_ReissueTokens_Then_ThrowsNotExpiredToken() assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.NOT_EXPIRED_TOKEN); } + @Test + @DisplayName("Deletes refresh token on logout") + void Given_MemberId_When_Logout_Then_DeletesRefreshToken() { + // given + final Long memberId = 1L; + + // 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(); From 2ff9be2546ae562ea14270304cbc6f96af5bc607 Mon Sep 17 00:00:00 2001 From: Chori <105255517+choridev@users.noreply.github.com> Date: Thu, 31 Jul 2025 18:14:22 +0900 Subject: [PATCH 11/26] Separate source code by domain (#78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🚚 Relocate files by domain * ✅ Add test cases * 🎨 Add private to Autowired RefreshTokenRepository * ✏️ Fix a space typo * ♻️ Clear SonarQubeCloud issues --- .../config/ObservabilityUserConfig.java | 2 +- .../{ => auth}/config/SecurityConfig.java | 6 +- .../{ => auth}/controller/AuthController.java | 16 ++-- .../{ => auth}/controller/TestController.java | 20 ++--- .../server/{ => auth}/dto/AuthRequest.java | 2 +- .../server/{ => auth}/dto/AuthResponse.java | 2 +- .../server/{ => auth}/dto/NonceRequest.java | 2 +- .../server/{ => auth}/dto/NonceResponse.java | 2 +- .../server/{ => auth}/dto/OidcPublicKey.java | 2 +- .../server/{ => auth}/dto/OidcPublicKeys.java | 6 +- .../{ => auth}/dto/RefreshTokenRequest.java | 2 +- .../{ => auth}/dto/TestAuthRequest.java | 2 +- .../{ => auth}/dto/TestHelloResponse.java | 2 +- .../und/server/{ => auth}/entity/Nonce.java | 4 +- .../{ => auth}/entity/RefreshToken.java | 2 +- .../{security => auth/filter}/AuthMember.java | 2 +- .../filter}/AuthMemberArgumentResolver.java | 6 +- .../CustomAuthenticationEntryPoint.java | 4 +- .../filter}/JwtAuthenticationFilter.java | 8 +- .../filter}/SecurityErrorResponseWriter.java | 6 +- .../server/{ => auth}/jwt/JwtProperties.java | 2 +- .../server/{ => auth}/jwt/JwtProvider.java | 10 +-- .../{ => auth}/jwt/ParsedTokenInfo.java | 2 +- .../{ => auth}/oauth/IdTokenPayload.java | 2 +- .../server/{ => auth}/oauth/KakaoClient.java | 4 +- .../{ => auth}/oauth/KakaoProvider.java | 6 +- .../com/und/server/auth/oauth/OidcClient.java | 9 ++ .../{ => auth}/oauth/OidcClientFactory.java | 6 +- .../server/{ => auth}/oauth/OidcProvider.java | 4 +- .../{ => auth}/oauth/OidcProviderFactory.java | 8 +- .../und/server/{ => auth}/oauth/Provider.java | 2 +- .../{ => auth}/oauth/PublicKeyProvider.java | 10 +-- .../repository/NonceRepository.java | 4 +- .../repository/RefreshTokenRepository.java | 4 +- .../{ => auth}/service/AuthService.java | 41 ++++----- .../{ => auth}/service/NonceService.java | 12 +-- .../service/RefreshTokenService.java | 10 +-- .../{ => common}/config/RedisConfig.java | 2 +- .../{ => common}/config/SwaggerConfig.java | 2 +- .../{ => common}/config/WebMvcConfig.java | 4 +- .../{ => common}/dto/ErrorResponse.java | 2 +- .../exception/GlobalExceptionHandler.java | 7 +- .../exception/ServerErrorResult.java | 2 +- .../exception/ServerException.java | 2 +- .../{ => common}/util/ProfileManager.java | 2 +- .../server/{ => member}/entity/Member.java | 2 +- .../repository/MemberRepository.java | 4 +- .../{ => member}/service/MemberService.java | 14 ++-- .../java/com/und/server/oauth/OidcClient.java | 9 -- .../controller/AuthControllerTest.java | 22 ++--- .../controller/TestControllerTest.java | 20 ++--- .../{ => auth}/dto/OidcPublicKeysTest.java | 6 +- .../AuthMemberArgumentResolverTest.java | 41 +++++---- .../CustomAuthenticationEntryPointTest.java | 6 +- .../filter}/JwtAuthenticationFilterTest.java | 10 +-- .../{ => auth}/jwt/JwtPropertiesTest.java | 2 +- .../{ => auth}/jwt/JwtProviderTest.java | 12 +-- .../{ => auth}/oauth/KakaoProviderTest.java | 6 +- .../oauth/OidcClientFactoryTest.java | 6 +- .../oauth/OidcProviderFactoryTest.java | 8 +- .../oauth/PublicKeyProviderTest.java | 10 +-- .../auth/repository/NonceRepositoryTest.java | 83 +++++++++++++++++++ .../RefreshTokenRepositoryTest.java | 81 ++++++++++++++++++ .../{ => auth}/service/AuthServiceTest.java | 44 +++++----- .../{ => auth}/service/NonceServiceTest.java | 17 ++-- .../service/RefreshTokenServiceTest.java | 10 +-- .../{ => common}/util/ProfileManagerTest.java | 2 +- .../repository/MemberRepositoryTest.java | 4 +- .../service/MemberServiceTest.java | 10 +-- .../RefreshTokenRepositoryTest.java | 57 ------------- 70 files changed, 431 insertions(+), 312 deletions(-) rename src/main/java/com/und/server/{ => auth}/config/ObservabilityUserConfig.java (96%) rename src/main/java/com/und/server/{ => auth}/config/SecurityConfig.java (94%) rename src/main/java/com/und/server/{ => auth}/controller/AuthController.java (82%) rename src/main/java/com/und/server/{ => auth}/controller/TestController.java (75%) rename src/main/java/com/und/server/{ => auth}/dto/AuthRequest.java (94%) rename src/main/java/com/und/server/{ => auth}/dto/AuthResponse.java (96%) rename src/main/java/com/und/server/{ => auth}/dto/NonceRequest.java (92%) rename src/main/java/com/und/server/{ => auth}/dto/NonceResponse.java (90%) rename src/main/java/com/und/server/{ => auth}/dto/OidcPublicKey.java (95%) rename src/main/java/com/und/server/{ => auth}/dto/OidcPublicKeys.java (80%) rename src/main/java/com/und/server/{ => auth}/dto/RefreshTokenRequest.java (94%) rename src/main/java/com/und/server/{ => auth}/dto/TestAuthRequest.java (95%) rename src/main/java/com/und/server/{ => auth}/dto/TestHelloResponse.java (90%) rename src/main/java/com/und/server/{ => auth}/entity/Nonce.java (85%) rename src/main/java/com/und/server/{ => auth}/entity/RefreshToken.java (93%) rename src/main/java/com/und/server/{security => auth/filter}/AuthMember.java (87%) rename src/main/java/com/und/server/{security => auth/filter}/AuthMemberArgumentResolver.java (89%) rename src/main/java/com/und/server/{security => auth/filter}/CustomAuthenticationEntryPoint.java (89%) rename src/main/java/com/und/server/{security => auth/filter}/JwtAuthenticationFilter.java (91%) rename src/main/java/com/und/server/{security => auth/filter}/SecurityErrorResponseWriter.java (85%) rename src/main/java/com/und/server/{ => auth}/jwt/JwtProperties.java (92%) rename src/main/java/com/und/server/{ => auth}/jwt/JwtProvider.java (95%) rename src/main/java/com/und/server/{ => auth}/jwt/ParsedTokenInfo.java (68%) rename src/main/java/com/und/server/{ => auth}/oauth/IdTokenPayload.java (67%) rename src/main/java/com/und/server/{ => auth}/oauth/KakaoClient.java (84%) rename src/main/java/com/und/server/{ => auth}/oauth/KakaoProvider.java (90%) create mode 100644 src/main/java/com/und/server/auth/oauth/OidcClient.java rename src/main/java/com/und/server/{ => auth}/oauth/OidcClientFactory.java (79%) rename src/main/java/com/und/server/{ => auth}/oauth/OidcProvider.java (61%) rename src/main/java/com/und/server/{ => auth}/oauth/OidcProviderFactory.java (80%) rename src/main/java/com/und/server/{ => auth}/oauth/Provider.java (82%) rename src/main/java/com/und/server/{ => auth}/oauth/PublicKeyProvider.java (84%) rename src/main/java/com/und/server/{ => auth}/repository/NonceRepository.java (71%) rename src/main/java/com/und/server/{ => auth}/repository/RefreshTokenRepository.java (70%) rename src/main/java/com/und/server/{ => auth}/service/AuthService.java (80%) rename src/main/java/com/und/server/{ => auth}/service/NonceService.java (77%) rename src/main/java/com/und/server/{ => auth}/service/RefreshTokenService.java (83%) rename src/main/java/com/und/server/{ => common}/config/RedisConfig.java (97%) rename src/main/java/com/und/server/{ => common}/config/SwaggerConfig.java (97%) rename src/main/java/com/und/server/{ => common}/config/WebMvcConfig.java (85%) rename src/main/java/com/und/server/{ => common}/dto/ErrorResponse.java (92%) rename src/main/java/com/und/server/{ => common}/exception/GlobalExceptionHandler.java (93%) rename src/main/java/com/und/server/{ => common}/exception/ServerErrorResult.java (96%) rename src/main/java/com/und/server/{ => common}/exception/ServerException.java (91%) rename src/main/java/com/und/server/{ => common}/util/ProfileManager.java (92%) rename src/main/java/com/und/server/{ => member}/entity/Member.java (96%) rename src/main/java/com/und/server/{ => member}/repository/MemberRepository.java (76%) rename src/main/java/com/und/server/{ => member}/service/MemberService.java (78%) delete mode 100644 src/main/java/com/und/server/oauth/OidcClient.java rename src/test/java/com/und/server/{ => auth}/controller/AuthControllerTest.java (96%) rename src/test/java/com/und/server/{ => auth}/controller/TestControllerTest.java (93%) rename src/test/java/com/und/server/{ => auth}/dto/OidcPublicKeysTest.java (92%) rename src/test/java/com/und/server/{security => auth/filter}/AuthMemberArgumentResolverTest.java (85%) rename src/test/java/com/und/server/{security => auth/filter}/CustomAuthenticationEntryPointTest.java (92%) rename src/test/java/com/und/server/{security => auth/filter}/JwtAuthenticationFilterTest.java (97%) rename src/test/java/com/und/server/{ => auth}/jwt/JwtPropertiesTest.java (97%) rename src/test/java/com/und/server/{ => auth}/jwt/JwtProviderTest.java (98%) rename src/test/java/com/und/server/{ => auth}/oauth/KakaoProviderTest.java (93%) rename src/test/java/com/und/server/{ => auth}/oauth/OidcClientFactoryTest.java (89%) rename src/test/java/com/und/server/{ => auth}/oauth/OidcProviderFactoryTest.java (89%) rename src/test/java/com/und/server/{ => auth}/oauth/PublicKeyProviderTest.java (89%) create mode 100644 src/test/java/com/und/server/auth/repository/NonceRepositoryTest.java create mode 100644 src/test/java/com/und/server/auth/repository/RefreshTokenRepositoryTest.java rename src/test/java/com/und/server/{ => auth}/service/AuthServiceTest.java (93%) rename src/test/java/com/und/server/{ => auth}/service/NonceServiceTest.java (90%) rename src/test/java/com/und/server/{ => auth}/service/RefreshTokenServiceTest.java (94%) rename src/test/java/com/und/server/{ => common}/util/ProfileManagerTest.java (98%) rename src/test/java/com/und/server/{ => member}/repository/MemberRepositoryTest.java (94%) rename src/test/java/com/und/server/{ => member}/service/MemberServiceTest.java (92%) delete mode 100644 src/test/java/com/und/server/repository/RefreshTokenRepositoryTest.java 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 96% rename from src/main/java/com/und/server/config/ObservabilityUserConfig.java rename to src/main/java/com/und/server/auth/config/ObservabilityUserConfig.java index 07a05064..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; 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 94% rename from src/main/java/com/und/server/config/SecurityConfig.java rename to src/main/java/com/und/server/auth/config/SecurityConfig.java index 330c0528..ca5b34bc 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,8 @@ 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 lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/und/server/controller/AuthController.java b/src/main/java/com/und/server/auth/controller/AuthController.java similarity index 82% rename from src/main/java/com/und/server/controller/AuthController.java rename to src/main/java/com/und/server/auth/controller/AuthController.java index f6f5a114..ed470024 100644 --- a/src/main/java/com/und/server/controller/AuthController.java +++ b/src/main/java/com/und/server/auth/controller/AuthController.java @@ -1,4 +1,4 @@ -package com.und.server.controller; +package com.und.server.auth.controller; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -8,13 +8,13 @@ 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.NonceRequest; -import com.und.server.dto.NonceResponse; -import com.und.server.dto.RefreshTokenRequest; -import com.und.server.security.AuthMember; -import com.und.server.service.AuthService; +import com.und.server.auth.dto.AuthRequest; +import com.und.server.auth.dto.AuthResponse; +import com.und.server.auth.dto.NonceRequest; +import com.und.server.auth.dto.NonceResponse; +import com.und.server.auth.dto.RefreshTokenRequest; +import com.und.server.auth.filter.AuthMember; +import com.und.server.auth.service.AuthService; import io.swagger.v3.oas.annotations.Parameter; import jakarta.validation.Valid; diff --git a/src/main/java/com/und/server/controller/TestController.java b/src/main/java/com/und/server/auth/controller/TestController.java similarity index 75% rename from src/main/java/com/und/server/controller/TestController.java rename to src/main/java/com/und/server/auth/controller/TestController.java index 33de63ad..0c3d044f 100644 --- a/src/main/java/com/und/server/controller/TestController.java +++ b/src/main/java/com/und/server/auth/controller/TestController.java @@ -1,4 +1,4 @@ -package com.und.server.controller; +package com.und.server.auth.controller; import org.springframework.http.HttpStatus; @@ -9,15 +9,15 @@ 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.security.AuthMember; -import com.und.server.service.AuthService; -import com.und.server.service.MemberService; +import com.und.server.auth.dto.AuthResponse; +import com.und.server.auth.dto.TestAuthRequest; +import com.und.server.auth.dto.TestHelloResponse; +import com.und.server.auth.filter.AuthMember; +import com.und.server.auth.service.AuthService; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; +import com.und.server.member.entity.Member; +import com.und.server.member.service.MemberService; import io.swagger.v3.oas.annotations.Parameter; import jakarta.validation.Valid; diff --git a/src/main/java/com/und/server/dto/AuthRequest.java b/src/main/java/com/und/server/auth/dto/AuthRequest.java similarity index 94% rename from src/main/java/com/und/server/dto/AuthRequest.java rename to src/main/java/com/und/server/auth/dto/AuthRequest.java index 8ae29d50..8dc0d648 100644 --- a/src/main/java/com/und/server/dto/AuthRequest.java +++ b/src/main/java/com/und/server/auth/dto/AuthRequest.java @@ -1,4 +1,4 @@ -package com.und.server.dto; +package com.und.server.auth.dto; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/und/server/dto/AuthResponse.java b/src/main/java/com/und/server/auth/dto/AuthResponse.java similarity index 96% rename from src/main/java/com/und/server/dto/AuthResponse.java rename to src/main/java/com/und/server/auth/dto/AuthResponse.java index 6a22433c..98c24489 100644 --- a/src/main/java/com/und/server/dto/AuthResponse.java +++ b/src/main/java/com/und/server/auth/dto/AuthResponse.java @@ -1,4 +1,4 @@ -package com.und.server.dto; +package com.und.server.auth.dto; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/und/server/dto/NonceRequest.java b/src/main/java/com/und/server/auth/dto/NonceRequest.java similarity index 92% rename from src/main/java/com/und/server/dto/NonceRequest.java rename to src/main/java/com/und/server/auth/dto/NonceRequest.java index 0bed6a41..7e3c46a5 100644 --- a/src/main/java/com/und/server/dto/NonceRequest.java +++ b/src/main/java/com/und/server/auth/dto/NonceRequest.java @@ -1,4 +1,4 @@ -package com.und.server.dto; +package com.und.server.auth.dto; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/und/server/dto/NonceResponse.java b/src/main/java/com/und/server/auth/dto/NonceResponse.java similarity index 90% rename from src/main/java/com/und/server/dto/NonceResponse.java rename to src/main/java/com/und/server/auth/dto/NonceResponse.java index c71e51db..cf3fc21e 100644 --- a/src/main/java/com/und/server/dto/NonceResponse.java +++ b/src/main/java/com/und/server/auth/dto/NonceResponse.java @@ -1,4 +1,4 @@ -package com.und.server.dto; +package com.und.server.auth.dto; import com.fasterxml.jackson.annotation.JsonProperty; 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 95% 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..4d019405 100644 --- a/src/main/java/com/und/server/dto/OidcPublicKey.java +++ b/src/main/java/com/und/server/auth/dto/OidcPublicKey.java @@ -1,4 +1,4 @@ -package com.und.server.dto; +package com.und.server.auth.dto; import com.fasterxml.jackson.annotation.JsonProperty; 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 80% 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..df69d955 100644 --- a/src/main/java/com/und/server/dto/OidcPublicKeys.java +++ b/src/main/java/com/und/server/auth/dto/OidcPublicKeys.java @@ -1,10 +1,10 @@ -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.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/und/server/dto/RefreshTokenRequest.java b/src/main/java/com/und/server/auth/dto/RefreshTokenRequest.java similarity index 94% rename from src/main/java/com/und/server/dto/RefreshTokenRequest.java rename to src/main/java/com/und/server/auth/dto/RefreshTokenRequest.java index 8e098fd9..b4fdb6d0 100644 --- a/src/main/java/com/und/server/dto/RefreshTokenRequest.java +++ b/src/main/java/com/und/server/auth/dto/RefreshTokenRequest.java @@ -1,4 +1,4 @@ -package com.und.server.dto; +package com.und.server.auth.dto; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/und/server/dto/TestAuthRequest.java b/src/main/java/com/und/server/auth/dto/TestAuthRequest.java similarity index 95% rename from src/main/java/com/und/server/dto/TestAuthRequest.java rename to src/main/java/com/und/server/auth/dto/TestAuthRequest.java index aabc2cd2..137cc37f 100644 --- a/src/main/java/com/und/server/dto/TestAuthRequest.java +++ b/src/main/java/com/und/server/auth/dto/TestAuthRequest.java @@ -1,4 +1,4 @@ -package com.und.server.dto; +package com.und.server.auth.dto; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/und/server/dto/TestHelloResponse.java b/src/main/java/com/und/server/auth/dto/TestHelloResponse.java similarity index 90% rename from src/main/java/com/und/server/dto/TestHelloResponse.java rename to src/main/java/com/und/server/auth/dto/TestHelloResponse.java index 2076026b..a72c7df9 100644 --- a/src/main/java/com/und/server/dto/TestHelloResponse.java +++ b/src/main/java/com/und/server/auth/dto/TestHelloResponse.java @@ -1,4 +1,4 @@ -package com.und.server.dto; +package com.und.server.auth.dto; import com.fasterxml.jackson.annotation.JsonProperty; 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 93% rename from src/main/java/com/und/server/entity/RefreshToken.java rename to src/main/java/com/und/server/auth/entity/RefreshToken.java index 2fec024b..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; diff --git a/src/main/java/com/und/server/security/AuthMember.java b/src/main/java/com/und/server/auth/filter/AuthMember.java similarity index 87% rename from src/main/java/com/und/server/security/AuthMember.java rename to src/main/java/com/und/server/auth/filter/AuthMember.java index c7bf4f28..0552c2da 100644 --- a/src/main/java/com/und/server/security/AuthMember.java +++ b/src/main/java/com/und/server/auth/filter/AuthMember.java @@ -1,4 +1,4 @@ -package com.und.server.security; +package com.und.server.auth.filter; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/src/main/java/com/und/server/security/AuthMemberArgumentResolver.java b/src/main/java/com/und/server/auth/filter/AuthMemberArgumentResolver.java similarity index 89% rename from src/main/java/com/und/server/security/AuthMemberArgumentResolver.java rename to src/main/java/com/und/server/auth/filter/AuthMemberArgumentResolver.java index 326c48ab..7295cc01 100644 --- a/src/main/java/com/und/server/security/AuthMemberArgumentResolver.java +++ b/src/main/java/com/und/server/auth/filter/AuthMemberArgumentResolver.java @@ -1,4 +1,4 @@ -package com.und.server.security; +package com.und.server.auth.filter; import org.springframework.core.MethodParameter; import org.springframework.security.core.Authentication; @@ -9,8 +9,8 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; @Component public class AuthMemberArgumentResolver implements HandlerMethodArgumentResolver { 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 89% rename from src/main/java/com/und/server/security/CustomAuthenticationEntryPoint.java rename to src/main/java/com/und/server/auth/filter/CustomAuthenticationEntryPoint.java index 4312d1ca..4c2d78d9 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.common.exception.ServerErrorResult; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; 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 91% rename from src/main/java/com/und/server/security/JwtAuthenticationFilter.java rename to src/main/java/com/und/server/auth/filter/JwtAuthenticationFilter.java index ac209897..f8b15e90 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.jwt.JwtProvider; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; 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 85% rename from src/main/java/com/und/server/security/SecurityErrorResponseWriter.java rename to src/main/java/com/und/server/auth/filter/SecurityErrorResponseWriter.java index 0977bd42..c45a6c64 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.ErrorResponse; +import com.und.server.common.exception.ServerErrorResult; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; 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/jwt/JwtProvider.java b/src/main/java/com/und/server/auth/jwt/JwtProvider.java similarity index 95% rename from src/main/java/com/und/server/jwt/JwtProvider.java rename to src/main/java/com/und/server/auth/jwt/JwtProvider.java index 6717c1bb..d69c1298 100644 --- a/src/main/java/com/und/server/jwt/JwtProvider.java +++ b/src/main/java/com/und/server/auth/jwt/JwtProvider.java @@ -1,4 +1,4 @@ -package com.und.server.jwt; +package com.und.server.auth.jwt; import java.nio.charset.StandardCharsets; import java.security.PublicKey; @@ -16,10 +16,10 @@ 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 com.und.server.util.ProfileManager; +import com.und.server.auth.oauth.IdTokenPayload; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; +import com.und.server.common.util.ProfileManager; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; diff --git a/src/main/java/com/und/server/jwt/ParsedTokenInfo.java b/src/main/java/com/und/server/auth/jwt/ParsedTokenInfo.java similarity index 68% rename from src/main/java/com/und/server/jwt/ParsedTokenInfo.java rename to src/main/java/com/und/server/auth/jwt/ParsedTokenInfo.java index 437226cb..b141d1bd 100644 --- a/src/main/java/com/und/server/jwt/ParsedTokenInfo.java +++ b/src/main/java/com/und/server/auth/jwt/ParsedTokenInfo.java @@ -1,4 +1,4 @@ -package com.und.server.jwt; +package com.und.server.auth.jwt; public record ParsedTokenInfo( Long memberId, diff --git a/src/main/java/com/und/server/oauth/IdTokenPayload.java b/src/main/java/com/und/server/auth/oauth/IdTokenPayload.java similarity index 67% rename from src/main/java/com/und/server/oauth/IdTokenPayload.java rename to src/main/java/com/und/server/auth/oauth/IdTokenPayload.java index 34236edf..7354e3f2 100644 --- a/src/main/java/com/und/server/oauth/IdTokenPayload.java +++ b/src/main/java/com/und/server/auth/oauth/IdTokenPayload.java @@ -1,4 +1,4 @@ -package com.und.server.oauth; +package com.und.server.auth.oauth; public record IdTokenPayload( String providerId, 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 90% rename from src/main/java/com/und/server/oauth/KakaoProvider.java rename to src/main/java/com/und/server/auth/oauth/KakaoProvider.java index 2237c71e..b677ddce 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 { 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/oauth/OidcClientFactory.java b/src/main/java/com/und/server/auth/oauth/OidcClientFactory.java similarity index 79% rename from src/main/java/com/und/server/oauth/OidcClientFactory.java rename to src/main/java/com/und/server/auth/oauth/OidcClientFactory.java index 51545cb0..64b553b2 100644 --- a/src/main/java/com/und/server/oauth/OidcClientFactory.java +++ b/src/main/java/com/und/server/auth/oauth/OidcClientFactory.java @@ -1,4 +1,4 @@ -package com.und.server.oauth; +package com.und.server.auth.oauth; import java.util.EnumMap; import java.util.Map; @@ -6,8 +6,8 @@ import org.springframework.stereotype.Component; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; @Component public class OidcClientFactory { diff --git a/src/main/java/com/und/server/oauth/OidcProvider.java b/src/main/java/com/und/server/auth/oauth/OidcProvider.java similarity index 61% rename from src/main/java/com/und/server/oauth/OidcProvider.java rename to src/main/java/com/und/server/auth/oauth/OidcProvider.java index b4d0782a..38b0fc82 100644 --- a/src/main/java/com/und/server/oauth/OidcProvider.java +++ b/src/main/java/com/und/server/auth/oauth/OidcProvider.java @@ -1,6 +1,6 @@ -package com.und.server.oauth; +package com.und.server.auth.oauth; -import com.und.server.dto.OidcPublicKeys; +import com.und.server.auth.dto.OidcPublicKeys; public interface OidcProvider { diff --git a/src/main/java/com/und/server/oauth/OidcProviderFactory.java b/src/main/java/com/und/server/auth/oauth/OidcProviderFactory.java similarity index 80% rename from src/main/java/com/und/server/oauth/OidcProviderFactory.java rename to src/main/java/com/und/server/auth/oauth/OidcProviderFactory.java index dc9a14f4..0829f520 100644 --- a/src/main/java/com/und/server/oauth/OidcProviderFactory.java +++ b/src/main/java/com/und/server/auth/oauth/OidcProviderFactory.java @@ -1,4 +1,4 @@ -package com.und.server.oauth; +package com.und.server.auth.oauth; import java.util.EnumMap; import java.util.Map; @@ -6,9 +6,9 @@ import org.springframework.stereotype.Component; -import com.und.server.dto.OidcPublicKeys; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; +import com.und.server.auth.dto.OidcPublicKeys; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; @Component public class OidcProviderFactory { 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 82% 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..ec9e0f80 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; 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 84% rename from src/main/java/com/und/server/oauth/PublicKeyProvider.java rename to src/main/java/com/und/server/auth/oauth/PublicKeyProvider.java index 887af17a..e153ffef 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,10 +11,10 @@ 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.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; @Component public class PublicKeyProvider { 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/service/AuthService.java b/src/main/java/com/und/server/auth/service/AuthService.java similarity index 80% rename from src/main/java/com/und/server/service/AuthService.java rename to src/main/java/com/und/server/auth/service/AuthService.java index 522bc93e..28f1ea54 100644 --- a/src/main/java/com/und/server/service/AuthService.java +++ b/src/main/java/com/und/server/auth/service/AuthService.java @@ -1,27 +1,28 @@ -package com.und.server.service; +package com.und.server.auth.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.NonceRequest; -import com.und.server.dto.NonceResponse; -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.jwt.ParsedTokenInfo; -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.util.ProfileManager; +import com.und.server.auth.dto.AuthRequest; +import com.und.server.auth.dto.AuthResponse; +import com.und.server.auth.dto.NonceRequest; +import com.und.server.auth.dto.NonceResponse; +import com.und.server.auth.dto.OidcPublicKeys; +import com.und.server.auth.dto.RefreshTokenRequest; +import com.und.server.auth.dto.TestAuthRequest; +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.IdTokenPayload; +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.exception.ServerErrorResult; +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.service.MemberService; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/und/server/service/NonceService.java b/src/main/java/com/und/server/auth/service/NonceService.java similarity index 77% rename from src/main/java/com/und/server/service/NonceService.java rename to src/main/java/com/und/server/auth/service/NonceService.java index 8293ae87..5368de44 100644 --- a/src/main/java/com/und/server/service/NonceService.java +++ b/src/main/java/com/und/server/auth/service/NonceService.java @@ -1,15 +1,15 @@ -package com.und.server.service; +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.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 com.und.server.auth.entity.Nonce; +import com.und.server.auth.oauth.Provider; +import com.und.server.auth.repository.NonceRepository; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/und/server/service/RefreshTokenService.java b/src/main/java/com/und/server/auth/service/RefreshTokenService.java similarity index 83% rename from src/main/java/com/und/server/service/RefreshTokenService.java rename to src/main/java/com/und/server/auth/service/RefreshTokenService.java index 516a1387..423d8e4f 100644 --- a/src/main/java/com/und/server/service/RefreshTokenService.java +++ b/src/main/java/com/und/server/auth/service/RefreshTokenService.java @@ -1,14 +1,14 @@ -package com.und.server.service; +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.entity.RefreshToken; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; -import com.und.server.repository.RefreshTokenRepository; +import com.und.server.auth.entity.RefreshToken; +import com.und.server.auth.repository.RefreshTokenRepository; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; import lombok.RequiredArgsConstructor; 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 97% rename from src/main/java/com/und/server/config/RedisConfig.java rename to src/main/java/com/und/server/common/config/RedisConfig.java index 6a43a66e..27a839ff 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; 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/config/WebMvcConfig.java b/src/main/java/com/und/server/common/config/WebMvcConfig.java similarity index 85% rename from src/main/java/com/und/server/config/WebMvcConfig.java rename to src/main/java/com/und/server/common/config/WebMvcConfig.java index 3b215e56..bc9205fa 100644 --- a/src/main/java/com/und/server/config/WebMvcConfig.java +++ b/src/main/java/com/und/server/common/config/WebMvcConfig.java @@ -1,4 +1,4 @@ -package com.und.server.config; +package com.und.server.common.config; import java.util.List; @@ -6,7 +6,7 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -import com.und.server.security.AuthMemberArgumentResolver; +import com.und.server.auth.filter.AuthMemberArgumentResolver; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/und/server/dto/ErrorResponse.java b/src/main/java/com/und/server/common/dto/ErrorResponse.java similarity index 92% rename from src/main/java/com/und/server/dto/ErrorResponse.java rename to src/main/java/com/und/server/common/dto/ErrorResponse.java index 6f74fffb..5a52fe0c 100644 --- a/src/main/java/com/und/server/dto/ErrorResponse.java +++ b/src/main/java/com/und/server/common/dto/ErrorResponse.java @@ -1,4 +1,4 @@ -package com.und.server.dto; +package com.und.server.common.dto; import com.fasterxml.jackson.annotation.JsonProperty; 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 93% 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..ca0337fc 100644 --- a/src/main/java/com/und/server/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java @@ -1,7 +1,6 @@ -package com.und.server.exception; +package com.und.server.common.exception; import java.util.List; -import java.util.stream.Collectors; import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.HttpHeaders; @@ -13,7 +12,7 @@ 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.ErrorResponse; import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; @@ -39,7 +38,7 @@ 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); diff --git a/src/main/java/com/und/server/exception/ServerErrorResult.java b/src/main/java/com/und/server/common/exception/ServerErrorResult.java similarity index 96% rename from src/main/java/com/und/server/exception/ServerErrorResult.java rename to src/main/java/com/und/server/common/exception/ServerErrorResult.java index a807fce1..fa95dc04 100644 --- a/src/main/java/com/und/server/exception/ServerErrorResult.java +++ b/src/main/java/com/und/server/common/exception/ServerErrorResult.java @@ -1,4 +1,4 @@ -package com.und.server.exception; +package com.und.server.common.exception; import org.springframework.http.HttpStatus; 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 91% rename from src/main/java/com/und/server/exception/ServerException.java rename to src/main/java/com/und/server/common/exception/ServerException.java index 3bc411d0..900b7e1b 100644 --- a/src/main/java/com/und/server/exception/ServerException.java +++ b/src/main/java/com/und/server/common/exception/ServerException.java @@ -1,4 +1,4 @@ -package com.und.server.exception; +package com.und.server.common.exception; import lombok.Getter; diff --git a/src/main/java/com/und/server/util/ProfileManager.java b/src/main/java/com/und/server/common/util/ProfileManager.java similarity index 92% rename from src/main/java/com/und/server/util/ProfileManager.java rename to src/main/java/com/und/server/common/util/ProfileManager.java index 21547b18..91821018 100644 --- a/src/main/java/com/und/server/util/ProfileManager.java +++ b/src/main/java/com/und/server/common/util/ProfileManager.java @@ -1,4 +1,4 @@ -package com.und.server.util; +package com.und.server.common.util; import java.util.Arrays; import java.util.Set; diff --git a/src/main/java/com/und/server/entity/Member.java b/src/main/java/com/und/server/member/entity/Member.java similarity index 96% rename from src/main/java/com/und/server/entity/Member.java rename to src/main/java/com/und/server/member/entity/Member.java index 37d63c3c..950b3a50 100644 --- a/src/main/java/com/und/server/entity/Member.java +++ b/src/main/java/com/und/server/member/entity/Member.java @@ -1,4 +1,4 @@ -package com.und.server.entity; +package com.und.server.member.entity; import java.time.LocalDateTime; 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 76% 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..7f7cd6a2 100644 --- a/src/main/java/com/und/server/repository/MemberRepository.java +++ b/src/main/java/com/und/server/member/repository/MemberRepository.java @@ -1,11 +1,11 @@ -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 { diff --git a/src/main/java/com/und/server/service/MemberService.java b/src/main/java/com/und/server/member/service/MemberService.java similarity index 78% rename from src/main/java/com/und/server/service/MemberService.java rename to src/main/java/com/und/server/member/service/MemberService.java index 29785c49..5a9870ef 100644 --- a/src/main/java/com/und/server/service/MemberService.java +++ b/src/main/java/com/und/server/member/service/MemberService.java @@ -1,16 +1,16 @@ -package com.und.server.service; +package com.und.server.member.service; import java.util.Optional; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.und.server.entity.Member; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; -import com.und.server.oauth.IdTokenPayload; -import com.und.server.oauth.Provider; -import com.und.server.repository.MemberRepository; +import com.und.server.auth.oauth.IdTokenPayload; +import com.und.server.auth.oauth.Provider; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; +import com.und.server.member.entity.Member; +import com.und.server.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; 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/test/java/com/und/server/controller/AuthControllerTest.java b/src/test/java/com/und/server/auth/controller/AuthControllerTest.java similarity index 96% rename from src/test/java/com/und/server/controller/AuthControllerTest.java rename to src/test/java/com/und/server/auth/controller/AuthControllerTest.java index 64d82ddb..4f3e77ee 100644 --- a/src/test/java/com/und/server/controller/AuthControllerTest.java +++ b/src/test/java/com/und/server/auth/controller/AuthControllerTest.java @@ -1,4 +1,4 @@ -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; @@ -29,16 +29,16 @@ 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.NonceRequest; -import com.und.server.dto.NonceResponse; -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.security.AuthMemberArgumentResolver; -import com.und.server.service.AuthService; +import com.und.server.auth.dto.AuthRequest; +import com.und.server.auth.dto.AuthResponse; +import com.und.server.auth.dto.NonceRequest; +import com.und.server.auth.dto.NonceResponse; +import com.und.server.auth.dto.RefreshTokenRequest; +import com.und.server.auth.filter.AuthMemberArgumentResolver; +import com.und.server.auth.service.AuthService; +import com.und.server.common.exception.GlobalExceptionHandler; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; @ExtendWith(MockitoExtension.class) class AuthControllerTest { diff --git a/src/test/java/com/und/server/controller/TestControllerTest.java b/src/test/java/com/und/server/auth/controller/TestControllerTest.java similarity index 93% rename from src/test/java/com/und/server/controller/TestControllerTest.java rename to src/test/java/com/und/server/auth/controller/TestControllerTest.java index fff2a06d..8c8b10fe 100644 --- a/src/test/java/com/und/server/controller/TestControllerTest.java +++ b/src/test/java/com/und/server/auth/controller/TestControllerTest.java @@ -1,4 +1,4 @@ -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; @@ -27,15 +27,15 @@ 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.exception.ServerException; -import com.und.server.security.AuthMemberArgumentResolver; -import com.und.server.service.AuthService; -import com.und.server.service.MemberService; +import com.und.server.auth.dto.AuthResponse; +import com.und.server.auth.dto.TestAuthRequest; +import com.und.server.auth.filter.AuthMemberArgumentResolver; +import com.und.server.auth.service.AuthService; +import com.und.server.common.exception.GlobalExceptionHandler; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; +import com.und.server.member.entity.Member; +import com.und.server.member.service.MemberService; @ExtendWith(MockitoExtension.class) class TestControllerTest { 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 92% 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..991ac947 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.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; class OidcPublicKeysTest { diff --git a/src/test/java/com/und/server/security/AuthMemberArgumentResolverTest.java b/src/test/java/com/und/server/auth/filter/AuthMemberArgumentResolverTest.java similarity index 85% rename from src/test/java/com/und/server/security/AuthMemberArgumentResolverTest.java rename to src/test/java/com/und/server/auth/filter/AuthMemberArgumentResolverTest.java index 454770dd..1c3373d6 100644 --- a/src/test/java/com/und/server/security/AuthMemberArgumentResolverTest.java +++ b/src/test/java/com/und/server/auth/filter/AuthMemberArgumentResolverTest.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.assertj.core.api.Assertions.assertThatThrownBy; @@ -16,8 +16,8 @@ import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; @ExtendWith(MockitoExtension.class) class AuthMemberArgumentResolverTest { @@ -78,19 +78,15 @@ void Given_AuthMemberAnnotationButNotLongType_When_SupportsParameter_Then_Return } @Test - @DisplayName("Resolves memberId successfully when principal is a Long") - void Given_ValidPrincipal_When_ResolveArgument_Then_ReturnsMemberId() { + @DisplayName("Throws ServerException when authentication is null") + void Given_NullAuthentication_When_ResolveArgument_Then_ThrowsServerException() { // 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); + doReturn(null).when(securityContext).getAuthentication(); - // then - assertThat(result).isEqualTo(memberId); + // when & then + assertThatThrownBy(() -> authMemberArgumentResolver.resolveArgument(parameter, null, null, null)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.UNAUTHORIZED_ACCESS); } @Test @@ -107,4 +103,21 @@ void Given_InvalidPrincipalType_When_ResolveArgument_Then_ThrowsServerException( .isInstanceOf(ServerException.class) .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.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 92% 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..85ca47ed 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.common.dto.ErrorResponse; +import com.und.server.common.exception.ServerErrorResult; class CustomAuthenticationEntryPointTest { 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 97% rename from src/test/java/com/und/server/security/JwtAuthenticationFilterTest.java rename to src/test/java/com/und/server/auth/filter/JwtAuthenticationFilterTest.java index daf64e12..f2457b83 100644 --- a/src/test/java/com/und/server/security/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/und/server/auth/filter/JwtAuthenticationFilterTest.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.ArgumentMatchers.anyString; @@ -22,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.jwt.JwtProvider; +import com.und.server.common.dto.ErrorResponse; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; 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 98% rename from src/test/java/com/und/server/jwt/JwtProviderTest.java rename to src/test/java/com/und/server/auth/jwt/JwtProviderTest.java index c81c3016..368caba2 100644 --- a/src/test/java/com/und/server/jwt/JwtProviderTest.java +++ b/src/test/java/com/und/server/auth/jwt/JwtProviderTest.java @@ -1,4 +1,4 @@ -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; @@ -23,10 +23,10 @@ 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.util.ProfileManager; +import com.und.server.auth.oauth.IdTokenPayload; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; +import com.und.server.common.util.ProfileManager; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; @@ -243,7 +243,7 @@ void Given_ValidToken_When_GetDecodedHeader_Then_ReturnsHeaderMap() { final Map header = jwtProvider.getDecodedHeader(token); // then - assertThat(header.get("alg")).isEqualTo("HS256"); + assertThat(header).containsEntry("alg", "HS256"); } @Test 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 93% rename from src/test/java/com/und/server/oauth/KakaoProviderTest.java rename to src/test/java/com/und/server/auth/oauth/KakaoProviderTest.java index f116f407..57cfaa88 100644 --- a/src/test/java/com/und/server/oauth/KakaoProviderTest.java +++ b/src/test/java/com/und/server/auth/oauth/KakaoProviderTest.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.mockito.Mockito.doReturn; @@ -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 { 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 89% 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..35ac7013 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.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; @ExtendWith(MockitoExtension.class) class OidcClientFactoryTest { diff --git a/src/test/java/com/und/server/oauth/OidcProviderFactoryTest.java b/src/test/java/com/und/server/auth/oauth/OidcProviderFactoryTest.java similarity index 89% rename from src/test/java/com/und/server/oauth/OidcProviderFactoryTest.java rename to src/test/java/com/und/server/auth/oauth/OidcProviderFactoryTest.java index bd42f820..337b2734 100644 --- a/src/test/java/com/und/server/oauth/OidcProviderFactoryTest.java +++ b/src/test/java/com/und/server/auth/oauth/OidcProviderFactoryTest.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; @@ -11,9 +11,9 @@ 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; +import com.und.server.auth.dto.OidcPublicKeys; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; @ExtendWith(MockitoExtension.class) class OidcProviderFactoryTest { 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 89% 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..8d9af2f3 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.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; @ExtendWith(MockitoExtension.class) class PublicKeyProviderTest { 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..9f2ac9d9 --- /dev/null +++ b/src/test/java/com/und/server/auth/repository/NonceRepositoryTest.java @@ -0,0 +1,83 @@ +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 nonce and verifies the returned entity") + void Given_Nonce_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("Finds an existing nonce by its ID") + void Given_ExistingNonce_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("Deletes an existing nonce successfully") + void Given_ExistingNonce_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(); + } + +} 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/service/AuthServiceTest.java b/src/test/java/com/und/server/auth/service/AuthServiceTest.java similarity index 93% rename from src/test/java/com/und/server/service/AuthServiceTest.java rename to src/test/java/com/und/server/auth/service/AuthServiceTest.java index d3dd0da8..1dfb7265 100644 --- a/src/test/java/com/und/server/service/AuthServiceTest.java +++ b/src/test/java/com/und/server/auth/service/AuthServiceTest.java @@ -1,4 +1,4 @@ -package com.und.server.service; +package com.und.server.auth.service; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -19,25 +19,26 @@ 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.NonceRequest; -import com.und.server.dto.NonceResponse; -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.jwt.ParsedTokenInfo; -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.util.ProfileManager; +import com.und.server.auth.dto.AuthRequest; +import com.und.server.auth.dto.AuthResponse; +import com.und.server.auth.dto.NonceRequest; +import com.und.server.auth.dto.NonceResponse; +import com.und.server.auth.dto.OidcPublicKeys; +import com.und.server.auth.dto.RefreshTokenRequest; +import com.und.server.auth.dto.TestAuthRequest; +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.IdTokenPayload; +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.exception.ServerErrorResult; +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.service.MemberService; @ExtendWith(MockitoExtension.class) class AuthServiceTest { @@ -349,9 +350,6 @@ void Given_NonExpiredTokenOnDev_When_ReissueTokens_Then_ThrowsNotExpiredToken() @Test @DisplayName("Deletes refresh token on logout") void Given_MemberId_When_Logout_Then_DeletesRefreshToken() { - // given - final Long memberId = 1L; - // when authService.logout(memberId); diff --git a/src/test/java/com/und/server/service/NonceServiceTest.java b/src/test/java/com/und/server/auth/service/NonceServiceTest.java similarity index 90% rename from src/test/java/com/und/server/service/NonceServiceTest.java rename to src/test/java/com/und/server/auth/service/NonceServiceTest.java index 86e87049..36f10e5e 100644 --- a/src/test/java/com/und/server/service/NonceServiceTest.java +++ b/src/test/java/com/und/server/auth/service/NonceServiceTest.java @@ -1,4 +1,4 @@ -package com.und.server.service; +package com.und.server.auth.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -14,11 +14,11 @@ 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; +import com.und.server.auth.entity.Nonce; +import com.und.server.auth.oauth.Provider; +import com.und.server.auth.repository.NonceRepository; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; @ExtendWith(MockitoExtension.class) class NonceServiceTest { @@ -36,8 +36,9 @@ void Given_Nothing_When_GenerateNonceValue_Then_ReturnsUuidString() { final String nonce = nonceService.generateNonceValue(); // then - assertThat(nonce).isNotNull(); - assertThat(nonce).hasSize(36); // UUID format + assertThat(nonce) + .isNotNull() + .hasSize(36); // UUID format } @Test diff --git a/src/test/java/com/und/server/service/RefreshTokenServiceTest.java b/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java similarity index 94% rename from src/test/java/com/und/server/service/RefreshTokenServiceTest.java rename to src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java index 14f0d431..d44976b0 100644 --- a/src/test/java/com/und/server/service/RefreshTokenServiceTest.java +++ b/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java @@ -1,4 +1,4 @@ -package com.und.server.service; +package com.und.server.auth.service; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -17,10 +17,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.und.server.entity.RefreshToken; -import com.und.server.exception.ServerErrorResult; -import com.und.server.exception.ServerException; -import com.und.server.repository.RefreshTokenRepository; +import com.und.server.auth.entity.RefreshToken; +import com.und.server.auth.repository.RefreshTokenRepository; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; @ExtendWith(MockitoExtension.class) class RefreshTokenServiceTest { diff --git a/src/test/java/com/und/server/util/ProfileManagerTest.java b/src/test/java/com/und/server/common/util/ProfileManagerTest.java similarity index 98% rename from src/test/java/com/und/server/util/ProfileManagerTest.java rename to src/test/java/com/und/server/common/util/ProfileManagerTest.java index 14139889..137faa9a 100644 --- a/src/test/java/com/und/server/util/ProfileManagerTest.java +++ b/src/test/java/com/und/server/common/util/ProfileManagerTest.java @@ -1,4 +1,4 @@ -package com.und.server.util; +package com.und.server.common.util; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.doReturn; diff --git a/src/test/java/com/und/server/repository/MemberRepositoryTest.java b/src/test/java/com/und/server/member/repository/MemberRepositoryTest.java similarity index 94% rename from src/test/java/com/und/server/repository/MemberRepositoryTest.java rename to src/test/java/com/und/server/member/repository/MemberRepositoryTest.java index b4d6f56c..8fed8ec2 100644 --- a/src/test/java/com/und/server/repository/MemberRepositoryTest.java +++ b/src/test/java/com/und/server/member/repository/MemberRepositoryTest.java @@ -1,4 +1,4 @@ -package com.und.server.repository; +package com.und.server.member.repository; import static org.assertj.core.api.Assertions.assertThat; @@ -9,7 +9,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import com.und.server.entity.Member; +import com.und.server.member.entity.Member; @DataJpaTest class MemberRepositoryTest { diff --git a/src/test/java/com/und/server/service/MemberServiceTest.java b/src/test/java/com/und/server/member/service/MemberServiceTest.java similarity index 92% rename from src/test/java/com/und/server/service/MemberServiceTest.java rename to src/test/java/com/und/server/member/service/MemberServiceTest.java index f97635cc..d91b5093 100644 --- a/src/test/java/com/und/server/service/MemberServiceTest.java +++ b/src/test/java/com/und/server/member/service/MemberServiceTest.java @@ -1,4 +1,4 @@ -package com.und.server.service; +package com.und.server.member.service; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -15,10 +15,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.und.server.entity.Member; -import com.und.server.oauth.IdTokenPayload; -import com.und.server.oauth.Provider; -import com.und.server.repository.MemberRepository; +import com.und.server.auth.oauth.IdTokenPayload; +import com.und.server.auth.oauth.Provider; +import com.und.server.member.entity.Member; +import com.und.server.member.repository.MemberRepository; @ExtendWith(MockitoExtension.class) class MemberServiceTest { 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 53e6a63a..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) - .value("uuid") - .build(); - - // when - final RefreshToken result = refreshTokenRepository.save(token); - - // then - assertThat(result.getMemberId()).isEqualTo(1L); - assertThat(result.getValue()).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) - .value("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.getValue()).isEqualTo("uuid"); - }); - } - -} From 3f9f34095477e33215b2f646a11eda87705374db Mon Sep 17 00:00:00 2001 From: Chori <105255517+choridev@users.noreply.github.com> Date: Fri, 1 Aug 2025 13:09:11 +0900 Subject: [PATCH 12/26] Add delete account API (#79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔥 Remove unused RefreshToken method * 🚚 Rename findById to findMemberById * 🔥 Remove unnecessary provider verification * 🦺 Change NotNull to NotBlank * 🚚 Rename nonceValue to value * 🦺 Validate whether subject is null * ✏️ Fix typos * 🚚 Rename setUp to init * 🚚 Relocate to common directory * ✅ Change the order of tests * ✨ Add deleting member API * ♻️ Clear Gemini issues * ♻️ Clear SonarQubeCloud issues --- .../com/und/server/auth/dto/AuthRequest.java | 6 +- .../com/und/server/auth/dto/NonceRequest.java | 4 +- .../server/auth/dto/RefreshTokenRequest.java | 6 +- .../com/und/server/auth/jwt/JwtProvider.java | 30 ++++- .../und/server/auth/service/AuthService.java | 4 +- .../und/server/auth/service/NonceService.java | 6 +- .../auth/service/RefreshTokenService.java | 6 - .../controller/TestController.java | 18 ++- .../{auth => common}/dto/TestAuthRequest.java | 10 +- .../dto/TestHelloResponse.java | 2 +- .../member/controller/MemberController.java | 29 +++++ .../und/server/member/dto/MemberResponse.java | 36 ++++++ .../server/member/service/MemberService.java | 31 +++++- .../auth/controller/AuthControllerTest.java | 53 ++++----- .../AuthMemberArgumentResolverTest.java | 2 +- .../und/server/auth/jwt/JwtProviderTest.java | 33 ++++++ .../server/auth/service/AuthServiceTest.java | 12 +- .../auth/service/RefreshTokenServiceTest.java | 30 ----- .../controller/TestControllerTest.java | 37 +++++- .../controller/MemberControllerTest.java | 105 ++++++++++++++++++ .../server/member/dto/MemberResponseTest.java | 33 ++++++ .../member/service/MemberServiceTest.java | 43 ++++++- 22 files changed, 427 insertions(+), 109 deletions(-) rename src/main/java/com/und/server/{auth => common}/controller/TestController.java (78%) rename src/main/java/com/und/server/{auth => common}/dto/TestAuthRequest.java (58%) rename src/main/java/com/und/server/{auth => common}/dto/TestHelloResponse.java (90%) create mode 100644 src/main/java/com/und/server/member/controller/MemberController.java create mode 100644 src/main/java/com/und/server/member/dto/MemberResponse.java rename src/test/java/com/und/server/{auth => common}/controller/TestControllerTest.java (85%) create mode 100644 src/test/java/com/und/server/member/controller/MemberControllerTest.java create mode 100644 src/test/java/com/und/server/member/dto/MemberResponseTest.java diff --git a/src/main/java/com/und/server/auth/dto/AuthRequest.java b/src/main/java/com/und/server/auth/dto/AuthRequest.java index 8dc0d648..797574a2 100644 --- a/src/main/java/com/und/server/auth/dto/AuthRequest.java +++ b/src/main/java/com/und/server/auth/dto/AuthRequest.java @@ -3,13 +3,13 @@ import com.fasterxml.jackson.annotation.JsonProperty; 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") @JsonProperty("provider") 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") @JsonProperty("id_token") String idToken ) { } diff --git a/src/main/java/com/und/server/auth/dto/NonceRequest.java b/src/main/java/com/und/server/auth/dto/NonceRequest.java index 7e3c46a5..ced3b30e 100644 --- a/src/main/java/com/und/server/auth/dto/NonceRequest.java +++ b/src/main/java/com/und/server/auth/dto/NonceRequest.java @@ -3,10 +3,10 @@ import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; @Schema(description = "Request for issuing a Nonce") public record NonceRequest( @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") @JsonProperty("provider") String provider ) { } diff --git a/src/main/java/com/und/server/auth/dto/RefreshTokenRequest.java b/src/main/java/com/und/server/auth/dto/RefreshTokenRequest.java index b4fdb6d0..e67c0e5e 100644 --- a/src/main/java/com/und/server/auth/dto/RefreshTokenRequest.java +++ b/src/main/java/com/und/server/auth/dto/RefreshTokenRequest.java @@ -3,13 +3,13 @@ import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; @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, + @NotBlank(message = "Access Token must not be blank") @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 + @NotBlank(message = "Refresh Token must not be blank") @JsonProperty("refresh_token") String refreshToken ) { } diff --git a/src/main/java/com/und/server/auth/jwt/JwtProvider.java b/src/main/java/com/und/server/auth/jwt/JwtProvider.java index d69c1298..671a6506 100644 --- a/src/main/java/com/und/server/auth/jwt/JwtProvider.java +++ b/src/main/java/com/und/server/auth/jwt/JwtProvider.java @@ -71,7 +71,10 @@ public IdTokenPayload parseOidcIdToken( .requireAudience(aud); final Claims claims = parseClaims(token, builder); - return new IdTokenPayload(claims.getSubject(), claims.get("nickname", String.class)); + return new IdTokenPayload( + getValidSubject(claims), + claims.get("nickname", String.class) + ); } public String generateAccessToken(final Long memberId) { @@ -96,7 +99,8 @@ public Authentication getAuthentication(final String token) { } public Long getMemberIdFromToken(final String token) { - return Long.valueOf(parseClaims(token, getAccessTokenParserBuilder()).getSubject()); + final Claims claims = parseClaims(token, getAccessTokenParserBuilder()); + return getMemberIdFromClaims(claims); } private Claims parseClaims(final String token, final JwtParserBuilder builder) { @@ -110,10 +114,10 @@ private Claims parseClaims(final String token, final JwtParserBuilder builder) { public ParsedTokenInfo parseTokenForReissue(final String token) { try { final Claims claims = parseToken(token, getAccessTokenParserBuilder()); - return new ParsedTokenInfo(Long.valueOf(claims.getSubject()), false); + 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(Long.valueOf(e.getClaims().getSubject()), true); + return new ParsedTokenInfo(getMemberIdFromClaims(e.getClaims()), true); } } @@ -162,4 +166,22 @@ 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(ServerErrorResult.INVALID_TOKEN, e); + } + } + + private String getValidSubject(final Claims claims) { + final String subject = claims.getSubject(); + if (subject == null) { + throw new ServerException(ServerErrorResult.INVALID_TOKEN); + } + return subject; + } + } diff --git a/src/main/java/com/und/server/auth/service/AuthService.java b/src/main/java/com/und/server/auth/service/AuthService.java index 28f1ea54..ea7b895b 100644 --- a/src/main/java/com/und/server/auth/service/AuthService.java +++ b/src/main/java/com/und/server/auth/service/AuthService.java @@ -9,7 +9,6 @@ import com.und.server.auth.dto.NonceResponse; import com.und.server.auth.dto.OidcPublicKeys; import com.und.server.auth.dto.RefreshTokenRequest; -import com.und.server.auth.dto.TestAuthRequest; import com.und.server.auth.jwt.JwtProperties; import com.und.server.auth.jwt.JwtProvider; import com.und.server.auth.jwt.ParsedTokenInfo; @@ -18,6 +17,7 @@ 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.TestAuthRequest; import com.und.server.common.exception.ServerErrorResult; import com.und.server.common.exception.ServerException; import com.und.server.common.util.ProfileManager; @@ -76,7 +76,7 @@ public AuthResponse reissueTokens(final RefreshTokenRequest refreshTokenRequest) final Long memberId = getMemberIdForReissue(accessToken); - memberService.findById(memberId).orElseThrow(() -> { + memberService.findMemberById(memberId).orElseThrow(() -> { refreshTokenService.deleteRefreshToken(memberId); return new ServerException(ServerErrorResult.INVALID_TOKEN); }); diff --git a/src/main/java/com/und/server/auth/service/NonceService.java b/src/main/java/com/und/server/auth/service/NonceService.java index 5368de44..c183698c 100644 --- a/src/main/java/com/und/server/auth/service/NonceService.java +++ b/src/main/java/com/und/server/auth/service/NonceService.java @@ -25,12 +25,12 @@ public String generateNonceValue() { } @Transactional - public void validateNonce(final String nonceValue, final Provider provider) { - nonceRepository.findById(nonceValue) + public void validateNonce(final String value, final Provider provider) { + nonceRepository.findById(value) .filter(n -> n.getProvider() == provider) .orElseThrow(() -> new ServerException(ServerErrorResult.INVALID_NONCE)); - nonceRepository.deleteById(nonceValue); + nonceRepository.deleteById(value); } diff --git a/src/main/java/com/und/server/auth/service/RefreshTokenService.java b/src/main/java/com/und/server/auth/service/RefreshTokenService.java index 423d8e4f..e4e9fc50 100644 --- a/src/main/java/com/und/server/auth/service/RefreshTokenService.java +++ b/src/main/java/com/und/server/auth/service/RefreshTokenService.java @@ -23,12 +23,6 @@ public String generateRefreshToken() { return UUID.randomUUID().toString(); } - public String getRefreshToken(final Long memberId) { - return refreshTokenRepository.findById(memberId) - .map(RefreshToken::getValue) - .orElse(null); - } - @Transactional public void validateRefreshToken(final Long memberId, final String providedToken) { refreshTokenRepository.findById(memberId) diff --git a/src/main/java/com/und/server/auth/controller/TestController.java b/src/main/java/com/und/server/common/controller/TestController.java similarity index 78% rename from src/main/java/com/und/server/auth/controller/TestController.java rename to src/main/java/com/und/server/common/controller/TestController.java index 0c3d044f..e2d6a0e7 100644 --- a/src/main/java/com/und/server/auth/controller/TestController.java +++ b/src/main/java/com/und/server/common/controller/TestController.java @@ -1,6 +1,8 @@ -package com.und.server.auth.controller; +package com.und.server.common.controller; +import java.util.List; + import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -10,12 +12,13 @@ import org.springframework.web.bind.annotation.RestController; import com.und.server.auth.dto.AuthResponse; -import com.und.server.auth.dto.TestAuthRequest; -import com.und.server.auth.dto.TestHelloResponse; import com.und.server.auth.filter.AuthMember; import com.und.server.auth.service.AuthService; +import com.und.server.common.dto.TestAuthRequest; +import com.und.server.common.dto.TestHelloResponse; import com.und.server.common.exception.ServerErrorResult; import com.und.server.common.exception.ServerException; +import com.und.server.member.dto.MemberResponse; import com.und.server.member.entity.Member; import com.und.server.member.service.MemberService; @@ -39,7 +42,7 @@ public ResponseEntity requireAccessToken(@RequestBody @Valid final @GetMapping("/hello") public ResponseEntity greet(@Parameter(hidden = true) @AuthMember final Long memberId) { - final Member member = memberService.findById(memberId) + final Member member = memberService.findMemberById(memberId) .orElseThrow(() -> new ServerException(ServerErrorResult.MEMBER_NOT_FOUND)); final String nickname = member.getNickname() != null ? member.getNickname() : "Member"; final TestHelloResponse response = new TestHelloResponse("Hello, " + nickname + "!"); @@ -47,4 +50,11 @@ public ResponseEntity greet(@Parameter(hidden = true) @AuthMe return ResponseEntity.status(HttpStatus.OK).body(response); } + @GetMapping("/members") + public ResponseEntity> getMemberList() { + final List members = memberService.getMemberList(); + + return ResponseEntity.status(HttpStatus.OK).body(members); + } + } diff --git a/src/main/java/com/und/server/auth/dto/TestAuthRequest.java b/src/main/java/com/und/server/common/dto/TestAuthRequest.java similarity index 58% rename from src/main/java/com/und/server/auth/dto/TestAuthRequest.java rename to src/main/java/com/und/server/common/dto/TestAuthRequest.java index 137cc37f..61e9a380 100644 --- a/src/main/java/com/und/server/auth/dto/TestAuthRequest.java +++ b/src/main/java/com/und/server/common/dto/TestAuthRequest.java @@ -1,21 +1,21 @@ -package com.und.server.auth.dto; +package com.und.server.common.dto; import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; @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") + @NotBlank(message = "Provider name must not be blank") @JsonProperty("provider") String provider, @Schema(description = "Unique ID from the provider", example = "123456789") - @NotNull(message = "Provider ID must not be null") @JsonProperty("provider_id") + @NotBlank(message = "Provider ID must not be blank") @JsonProperty("provider_id") String providerId, @Schema(description = "User's nickname", example = "Chori") - @NotNull(message = "Nickname must not be null") @JsonProperty("nickname") + @NotBlank(message = "Nickname must not be blank") @JsonProperty("nickname") String nickname ) { } diff --git a/src/main/java/com/und/server/auth/dto/TestHelloResponse.java b/src/main/java/com/und/server/common/dto/TestHelloResponse.java similarity index 90% rename from src/main/java/com/und/server/auth/dto/TestHelloResponse.java rename to src/main/java/com/und/server/common/dto/TestHelloResponse.java index a72c7df9..f2e96038 100644 --- a/src/main/java/com/und/server/auth/dto/TestHelloResponse.java +++ b/src/main/java/com/und/server/common/dto/TestHelloResponse.java @@ -1,4 +1,4 @@ -package com.und.server.auth.dto; +package com.und.server.common.dto; import com.fasterxml.jackson.annotation.JsonProperty; 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..3ee495bb --- /dev/null +++ b/src/main/java/com/und/server/member/controller/MemberController.java @@ -0,0 +1,29 @@ +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.und.server.auth.filter.AuthMember; +import com.und.server.member.service.MemberService; + +import io.swagger.v3.oas.annotations.Parameter; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1") +public class MemberController { + + private final MemberService memberService; + + @DeleteMapping("/member") + 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/MemberResponse.java b/src/main/java/com/und/server/member/dto/MemberResponse.java new file mode 100644 index 00000000..6cc7b16b --- /dev/null +++ b/src/main/java/com/und/server/member/dto/MemberResponse.java @@ -0,0 +1,36 @@ +package com.und.server.member.dto; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonProperty; +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") + @JsonProperty("id") Long id, + + @Schema(description = "Member's nickname", example = "Chori") + @JsonProperty("nickname") String nickname, + + @Schema(description = "Kakao ID", example = "1234567890") + @JsonProperty("kakao_id") String kakaoId, + + @Schema(description = "Creation timestamp of the member", example = "2025-07-31T22:27:36.037717") + @JsonProperty("created_at") LocalDateTime createdAt, + + @Schema(description = "Last update timestamp of the member", example = "2025-07-31T22:27:36.037744") + @JsonProperty("updated_at") LocalDateTime updatedAt +) { + public static MemberResponse from(final Member member) { + return new MemberResponse( + member.getId(), + member.getNickname(), + member.getKakaoId(), + member.getCreatedAt(), + member.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/und/server/member/service/MemberService.java b/src/main/java/com/und/server/member/service/MemberService.java index 5a9870ef..a97a62db 100644 --- a/src/main/java/com/und/server/member/service/MemberService.java +++ b/src/main/java/com/und/server/member/service/MemberService.java @@ -1,5 +1,6 @@ package com.und.server.member.service; +import java.util.List; import java.util.Optional; import org.springframework.stereotype.Service; @@ -7,8 +8,10 @@ import com.und.server.auth.oauth.IdTokenPayload; import com.und.server.auth.oauth.Provider; +import com.und.server.auth.service.RefreshTokenService; import com.und.server.common.exception.ServerErrorResult; import com.und.server.common.exception.ServerException; +import com.und.server.member.dto.MemberResponse; import com.und.server.member.entity.Member; import com.und.server.member.repository.MemberRepository; @@ -20,18 +23,32 @@ public class MemberService { private final MemberRepository memberRepository; + private final RefreshTokenService refreshTokenService; + + // FIXME: Remove this method when deleting TestController + public List getMemberList() { + return memberRepository.findAll() + .stream().map(MemberResponse::from).toList(); + } @Transactional public Member findOrCreateMember(final Provider provider, final IdTokenPayload payload) { final String providerId = payload.providerId(); + return findMemberByProviderId(provider, providerId) .orElseGet(() -> createMember(provider, providerId, payload.nickname())); } - public Optional findById(final Long memberId) { + public Optional findMemberById(final Long memberId) { return memberRepository.findById(memberId); } + @Transactional + public void deleteMemberById(final Long memberId) { + refreshTokenService.deleteRefreshToken(memberId); + memberRepository.deleteById(memberId); + } + private Optional findMemberByProviderId(final Provider provider, final String providerId) { return switch (provider) { case KAKAO -> memberRepository.findByKakaoId(providerId); @@ -40,9 +57,13 @@ private Optional findMemberByProviderId(final Provider provider, final S } private Member createMember(final Provider provider, final String providerId, final String nickname) { - return memberRepository.save(Member.builder() - .kakaoId(provider == Provider.KAKAO ? providerId : null) - .nickname(nickname) - .build()); + final Member.MemberBuilder memberBuilder = Member.builder().nickname(nickname); + switch (provider) { + case KAKAO -> memberBuilder.kakaoId(providerId); + default -> throw new ServerException(ServerErrorResult.INVALID_PROVIDER); + } + + return memberRepository.save(memberBuilder.build()); } + } diff --git a/src/test/java/com/und/server/auth/controller/AuthControllerTest.java b/src/test/java/com/und/server/auth/controller/AuthControllerTest.java index 4f3e77ee..2a61fc15 100644 --- a/src/test/java/com/und/server/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/und/server/auth/controller/AuthControllerTest.java @@ -81,7 +81,7 @@ 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("$.message[0]").value("Provider name must not be blank")); } @Test @@ -149,7 +149,7 @@ 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("$.message[0]").value("Provider name must not be blank")); } @Test @@ -170,7 +170,7 @@ 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("$.message[0]").value("ID Token must not be blank")); } @Test @@ -280,7 +280,7 @@ 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("$.message[0]").value("Access Token must not be blank")); } @Test @@ -301,7 +301,7 @@ 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("$.message[0]").value("Refresh Token must not be blank")); } @Test @@ -343,6 +343,28 @@ void Given_ValidRefreshTokenRequest_When_ReissueTokens_Then_ReturnsOkWithNewToke 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 ServerErrorResult errorResult = ServerErrorResult.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 { @@ -369,25 +391,4 @@ void Given_AuthenticatedUser_When_Logout_Then_ReturnsNoContent() throws Exceptio verify(authService).logout(memberId); } - @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 ServerErrorResult errorResult = ServerErrorResult.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())); - } } diff --git a/src/test/java/com/und/server/auth/filter/AuthMemberArgumentResolverTest.java b/src/test/java/com/und/server/auth/filter/AuthMemberArgumentResolverTest.java index 1c3373d6..b791ecd1 100644 --- a/src/test/java/com/und/server/auth/filter/AuthMemberArgumentResolverTest.java +++ b/src/test/java/com/und/server/auth/filter/AuthMemberArgumentResolverTest.java @@ -32,7 +32,7 @@ class AuthMemberArgumentResolverTest { private SecurityContext securityContext; @BeforeEach - void setUp() { + void init() { SecurityContextHolder.setContext(securityContext); } diff --git a/src/test/java/com/und/server/auth/jwt/JwtProviderTest.java b/src/test/java/com/und/server/auth/jwt/JwtProviderTest.java index 368caba2..fdf5402f 100644 --- a/src/test/java/com/und/server/auth/jwt/JwtProviderTest.java +++ b/src/test/java/com/und/server/auth/jwt/JwtProviderTest.java @@ -376,6 +376,39 @@ void Given_MalformedToken_When_GetMemberIdFromToken_Then_ThrowsGenericUnauthoriz .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", ServerErrorResult.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", ServerErrorResult.INVALID_TOKEN); + } + @Test @DisplayName("Gets member ID from a valid token") void Given_ValidToken_When_GetMemberIdFromToken_Then_ReturnsCorrectMemberId() { diff --git a/src/test/java/com/und/server/auth/service/AuthServiceTest.java b/src/test/java/com/und/server/auth/service/AuthServiceTest.java index 1dfb7265..f6a1bd7f 100644 --- a/src/test/java/com/und/server/auth/service/AuthServiceTest.java +++ b/src/test/java/com/und/server/auth/service/AuthServiceTest.java @@ -25,7 +25,6 @@ import com.und.server.auth.dto.NonceResponse; import com.und.server.auth.dto.OidcPublicKeys; import com.und.server.auth.dto.RefreshTokenRequest; -import com.und.server.auth.dto.TestAuthRequest; import com.und.server.auth.jwt.JwtProperties; import com.und.server.auth.jwt.JwtProvider; import com.und.server.auth.jwt.ParsedTokenInfo; @@ -34,6 +33,7 @@ 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.TestAuthRequest; import com.und.server.common.exception.ServerErrorResult; import com.und.server.common.exception.ServerException; import com.und.server.common.util.ProfileManager; @@ -231,7 +231,7 @@ void Given_NonExistentMember_When_ReissueTokens_Then_ThrowsExceptionAndDeletesTo final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); - doReturn(Optional.empty()).when(memberService).findById(memberId); + doReturn(Optional.empty()).when(memberService).findMemberById(memberId); // when & then final ServerException exception = assertThrows(ServerException.class, @@ -251,7 +251,7 @@ void Given_MismatchedRefreshToken_When_ReissueTokens_Then_ThrowsException() { final Member member = Member.builder().id(memberId).build(); doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); - doReturn(Optional.of(member)).when(memberService).findById(memberId); + doReturn(Optional.of(member)).when(memberService).findMemberById(memberId); doThrow(new ServerException(ServerErrorResult.INVALID_TOKEN)) .when(refreshTokenService).validateRefreshToken(memberId, "wrong.refresh.token"); @@ -271,7 +271,7 @@ void Given_NoStoredRefreshToken_When_ReissueTokens_Then_ThrowsException() { final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); - doReturn(Optional.of(member)).when(memberService).findById(memberId); + doReturn(Optional.of(member)).when(memberService).findMemberById(memberId); doThrow(new ServerException(ServerErrorResult.INVALID_TOKEN)) .when(refreshTokenService).validateRefreshToken(memberId, refreshToken); @@ -294,7 +294,7 @@ void Given_ValidRefreshToken_When_ReissueTokens_Then_Succeeds() { final Member member = Member.builder().id(memberId).build(); doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); - doReturn(Optional.of(member)).when(memberService).findById(memberId); + doReturn(Optional.of(member)).when(memberService).findMemberById(memberId); doNothing().when(refreshTokenService).validateRefreshToken(memberId, refreshToken); setupTokenIssuance(newAccessToken, newRefreshToken); @@ -323,7 +323,6 @@ void Given_NonExpiredTokenOnProd_When_ReissueTokens_Then_ThrowsInvalidToken() { final ServerException exception = assertThrows(ServerException.class, () -> authService.reissueTokens(request)); - // then verify(refreshTokenService).deleteRefreshToken(memberId); assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); } @@ -342,7 +341,6 @@ void Given_NonExpiredTokenOnDev_When_ReissueTokens_Then_ThrowsNotExpiredToken() final ServerException exception = assertThrows(ServerException.class, () -> authService.reissueTokens(request)); - // then verify(refreshTokenService).deleteRefreshToken(memberId); assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.NOT_EXPIRED_TOKEN); } diff --git a/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java b/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java index d44976b0..9bb5e7ea 100644 --- a/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java +++ b/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java @@ -45,36 +45,6 @@ void Given_Nothing_When_GenerateRefreshToken_Then_ReturnsUuid() { assertDoesNotThrow(() -> UUID.fromString(generatedToken)); } - @Test - @DisplayName("Returns the token value if a refresh token is stored") - void Given_StoredToken_When_GetRefreshToken_Then_ReturnsTokenValue() { - // given - final RefreshToken savedToken = RefreshToken.builder() - .memberId(memberId) - .value(refreshTokenValue) - .build(); - doReturn(Optional.of(savedToken)).when(refreshTokenRepository).findById(memberId); - - // when - final String foundToken = refreshTokenService.getRefreshToken(memberId); - - // then - assertThat(foundToken).isEqualTo(refreshTokenValue); - } - - @Test - @DisplayName("Returns null if no refresh token is stored") - void Given_NoToken_When_GetRefreshToken_Then_ReturnsNull() { - // given - doReturn(Optional.empty()).when(refreshTokenRepository).findById(memberId); - - // when - final String foundToken = refreshTokenService.getRefreshToken(memberId); - - // then - assertThat(foundToken).isNull(); - } - @Test @DisplayName("Saves a refresh token to the repository") void Given_MemberIdAndToken_When_SaveRefreshToken_Then_CallsRepositorySave() { diff --git a/src/test/java/com/und/server/auth/controller/TestControllerTest.java b/src/test/java/com/und/server/common/controller/TestControllerTest.java similarity index 85% rename from src/test/java/com/und/server/auth/controller/TestControllerTest.java rename to src/test/java/com/und/server/common/controller/TestControllerTest.java index 8c8b10fe..c6fabc01 100644 --- a/src/test/java/com/und/server/auth/controller/TestControllerTest.java +++ b/src/test/java/com/und/server/common/controller/TestControllerTest.java @@ -1,4 +1,4 @@ -package com.und.server.auth.controller; +package com.und.server.common.controller; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -9,6 +9,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -28,12 +29,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.und.server.auth.dto.AuthResponse; -import com.und.server.auth.dto.TestAuthRequest; import com.und.server.auth.filter.AuthMemberArgumentResolver; import com.und.server.auth.service.AuthService; +import com.und.server.common.dto.TestAuthRequest; import com.und.server.common.exception.GlobalExceptionHandler; import com.und.server.common.exception.ServerErrorResult; import com.und.server.common.exception.ServerException; +import com.und.server.member.dto.MemberResponse; import com.und.server.member.entity.Member; import com.und.server.member.service.MemberService; @@ -139,7 +141,7 @@ void Given_AuthenticatedUserNotFoundInDb_When_Greet_Then_ReturnsUnauthorized() t final String url = "/v1/test/hello"; final Long memberId = 3L; - doReturn(Optional.empty()).when(memberService).findById(memberId); + doReturn(Optional.empty()).when(memberService).findMemberById(memberId); doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); @@ -186,7 +188,7 @@ void Given_AuthenticatedUserWithNickname_When_Greet_Then_ReturnsOkWithPersonaliz final Long memberId = 1L; final Member member = Member.builder().id(memberId).nickname("Chori").build(); - doReturn(Optional.of(member)).when(memberService).findById(memberId); + doReturn(Optional.of(member)).when(memberService).findMemberById(memberId); doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); @@ -210,7 +212,7 @@ void Given_AuthenticatedUserWithoutNickname_When_Greet_Then_ReturnsOkWithDefault final Long memberId = 2L; final Member member = Member.builder().id(memberId).nickname(null).build(); - doReturn(Optional.of(member)).when(memberService).findById(memberId); + doReturn(Optional.of(member)).when(memberService).findMemberById(memberId); doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); @@ -226,4 +228,29 @@ void Given_AuthenticatedUserWithoutNickname_When_Greet_Then_ReturnsOkWithDefault .andExpect(jsonPath("$.message").value("Hello, Member!")); } + @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", "123", null, null), + new MemberResponse(2L, "user2", "456", 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")); + } } 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..205a0cd4 --- /dev/null +++ b/src/test/java/com/und/server/member/controller/MemberControllerTest.java @@ -0,0 +1,105 @@ +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.never; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Collections; + +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.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +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.und.server.auth.filter.AuthMemberArgumentResolver; +import com.und.server.common.exception.GlobalExceptionHandler; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; +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; + + @BeforeEach + void init() { + mockMvc = MockMvcBuilders.standaloneSetup(memberController) + .setCustomArgumentResolvers(authMemberArgumentResolver) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + @DisplayName("Fails member deletion and returns unauthorized when user is not authenticated") + void Given_UnauthenticatedUser_When_DeleteMember_Then_ReturnsUnauthorized() throws Exception { + // given + final String url = "/v1/member"; + final ServerErrorResult errorResult = ServerErrorResult.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())); + + verify(memberService, never()).deleteMemberById(any(Long.class)); + } + + @Test + @DisplayName("Succeeds in deleting the member and returns no content") + void Given_MemberId_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()); + + final Authentication auth = new UsernamePasswordAuthenticationToken( + memberId, + null, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + + // when & then + mockMvc.perform( + MockMvcRequestBuilders.delete(url).with(authentication(auth)) + ).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..2051d5f8 --- /dev/null +++ b/src/test/java/com/und/server/member/dto/MemberResponseTest.java @@ -0,0 +1,33 @@ +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.entity.Member; + +class MemberResponseTest { + + @Test + @DisplayName("Correctly converts a Member entity to a MemberResponse DTO") + void Given_MemberEntity_When_From_Then_ReturnsCorrectMemberResponse() { + // given + final Member member = Member.builder() + .id(1L) + .nickname("Chori") + .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.createdAt()).isEqualTo(member.getCreatedAt()); + assertThat(response.updatedAt()).isEqualTo(member.getUpdatedAt()); + } + +} diff --git a/src/test/java/com/und/server/member/service/MemberServiceTest.java b/src/test/java/com/und/server/member/service/MemberServiceTest.java index d91b5093..868b7d76 100644 --- a/src/test/java/com/und/server/member/service/MemberServiceTest.java +++ b/src/test/java/com/und/server/member/service/MemberServiceTest.java @@ -6,6 +6,7 @@ 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; @@ -17,6 +18,8 @@ import com.und.server.auth.oauth.IdTokenPayload; import com.und.server.auth.oauth.Provider; +import com.und.server.auth.service.RefreshTokenService; +import com.und.server.member.dto.MemberResponse; import com.und.server.member.entity.Member; import com.und.server.member.repository.MemberRepository; @@ -29,6 +32,9 @@ class MemberServiceTest { @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"; @@ -81,14 +87,47 @@ void Given_NonExistingMember_When_FindOrCreateMember_Then_CreatesAndReturnsNewMe @Test @DisplayName("Returns an empty Optional when finding a non-existent member by ID") - void Given_NonExistingMemberId_When_FindById_Then_ReturnsEmptyOptional() { + void Given_NonExistingMemberId_When_FindMemberById_Then_ReturnsEmptyOptional() { // given doReturn(Optional.empty()).when(memberRepository).findById(memberId); // when - final Optional foundMemberOptional = memberService.findById(memberId); + final Optional foundMemberOptional = memberService.findMemberById(memberId); // then assertThat(foundMemberOptional).isEmpty(); } + + @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") + void Given_MemberId_When_DeleteMemberById_Then_DeletesMemberAndRefreshToken() { + // given + final Long memberIdToDelete = 1L; + + // when + memberService.deleteMemberById(memberIdToDelete); + + // then + verify(memberRepository).deleteById(memberIdToDelete); + verify(refreshTokenService).deleteRefreshToken(memberIdToDelete); + } + } From 1165e68556045d62931e586cfd17d3af447bacc0 Mon Sep 17 00:00:00 2001 From: Chori <105255517+choridev@users.noreply.github.com> Date: Fri, 1 Aug 2025 18:03:59 +0900 Subject: [PATCH 13/26] Add change nickname API (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🎨 Remove JsonProperty * 🦺 Validate whether parameters are null * ✨ Add change nickname API * 🦺 Validate whether provider is null --- .../com/und/server/auth/dto/AuthRequest.java | 6 +- .../com/und/server/auth/dto/AuthResponse.java | 12 +- .../com/und/server/auth/dto/NonceRequest.java | 4 +- .../und/server/auth/dto/NonceResponse.java | 4 +- .../und/server/auth/dto/OidcPublicKey.java | 14 +- .../und/server/auth/dto/OidcPublicKeys.java | 3 +- .../server/auth/dto/RefreshTokenRequest.java | 6 +- .../com/und/server/auth/oauth/Provider.java | 3 +- .../und/server/auth/service/AuthService.java | 19 ++- .../und/server/auth/service/NonceService.java | 20 ++- .../auth/service/RefreshTokenService.java | 27 +++- .../common/controller/TestController.java | 5 +- .../und/server/common/dto/ErrorResponse.java | 4 - .../server/common/dto/TestAuthRequest.java | 8 +- .../server/common/dto/TestHelloResponse.java | 3 - .../common/exception/ServerErrorResult.java | 5 +- .../member/controller/MemberController.java | 15 ++ .../und/server/member/dto/MemberResponse.java | 11 +- .../server/member/dto/NicknameRequest.java | 10 ++ .../com/und/server/member/entity/Member.java | 4 + .../server/member/service/MemberService.java | 46 +++++- .../auth/controller/AuthControllerTest.java | 13 +- .../server/auth/service/AuthServiceTest.java | 36 +++-- .../server/auth/service/NonceServiceTest.java | 139 ++++++++++-------- .../auth/service/RefreshTokenServiceTest.java | 71 +++++++++ .../common/controller/TestControllerTest.java | 28 ++-- .../controller/MemberControllerTest.java | 112 +++++++++++--- .../member/service/MemberServiceTest.java | 119 ++++++++++++++- 28 files changed, 548 insertions(+), 199 deletions(-) create mode 100644 src/main/java/com/und/server/member/dto/NicknameRequest.java diff --git a/src/main/java/com/und/server/auth/dto/AuthRequest.java b/src/main/java/com/und/server/auth/dto/AuthRequest.java index 797574a2..25a73bf5 100644 --- a/src/main/java/com/und/server/auth/dto/AuthRequest.java +++ b/src/main/java/com/und/server/auth/dto/AuthRequest.java @@ -1,15 +1,13 @@ package com.und.server.auth.dto; -import com.fasterxml.jackson.annotation.JsonProperty; - import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; @Schema(description = "Request for Authentication with ID Token") public record AuthRequest( @Schema(description = "OAuth provider name", example = "kakao") - @NotBlank(message = "Provider name must not be blank") @JsonProperty("provider") String provider, + @NotBlank(message = "Provider name must not be blank") String provider, @Schema(description = "ID Token from the OAuth provider", example = "eyJhbGciOiJIUzI1Ni...") - @NotBlank(message = "ID Token must not be blank") @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/AuthResponse.java b/src/main/java/com/und/server/auth/dto/AuthResponse.java index 98c24489..6372b0b9 100644 --- a/src/main/java/com/und/server/auth/dto/AuthResponse.java +++ b/src/main/java/com/und/server/auth/dto/AuthResponse.java @@ -1,23 +1,21 @@ package com.und.server.auth.dto; -import com.fasterxml.jackson.annotation.JsonProperty; - 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/NonceRequest.java b/src/main/java/com/und/server/auth/dto/NonceRequest.java index ced3b30e..922f4a52 100644 --- a/src/main/java/com/und/server/auth/dto/NonceRequest.java +++ b/src/main/java/com/und/server/auth/dto/NonceRequest.java @@ -1,12 +1,10 @@ package com.und.server.auth.dto; -import com.fasterxml.jackson.annotation.JsonProperty; - 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") @JsonProperty("provider") String provider + @NotBlank(message = "Provider name must not be blank") String provider ) { } diff --git a/src/main/java/com/und/server/auth/dto/NonceResponse.java b/src/main/java/com/und/server/auth/dto/NonceResponse.java index cf3fc21e..88f4a843 100644 --- a/src/main/java/com/und/server/auth/dto/NonceResponse.java +++ b/src/main/java/com/und/server/auth/dto/NonceResponse.java @@ -1,11 +1,9 @@ package com.und.server.auth.dto; -import com.fasterxml.jackson.annotation.JsonProperty; - 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...") - @JsonProperty("nonce") String nonce + String nonce ) { } diff --git a/src/main/java/com/und/server/auth/dto/OidcPublicKey.java b/src/main/java/com/und/server/auth/dto/OidcPublicKey.java index 4d019405..f5178eef 100644 --- a/src/main/java/com/und/server/auth/dto/OidcPublicKey.java +++ b/src/main/java/com/und/server/auth/dto/OidcPublicKey.java @@ -1,26 +1,24 @@ package com.und.server.auth.dto; -import com.fasterxml.jackson.annotation.JsonProperty; - 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/auth/dto/OidcPublicKeys.java b/src/main/java/com/und/server/auth/dto/OidcPublicKeys.java index df69d955..5f2ac2c9 100644 --- a/src/main/java/com/und/server/auth/dto/OidcPublicKeys.java +++ b/src/main/java/com/und/server/auth/dto/OidcPublicKeys.java @@ -2,7 +2,6 @@ import java.util.List; -import com.fasterxml.jackson.annotation.JsonProperty; import com.und.server.common.exception.ServerErrorResult; import com.und.server.common.exception.ServerException; @@ -11,7 +10,7 @@ @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() diff --git a/src/main/java/com/und/server/auth/dto/RefreshTokenRequest.java b/src/main/java/com/und/server/auth/dto/RefreshTokenRequest.java index e67c0e5e..a472a42c 100644 --- a/src/main/java/com/und/server/auth/dto/RefreshTokenRequest.java +++ b/src/main/java/com/und/server/auth/dto/RefreshTokenRequest.java @@ -1,15 +1,13 @@ package com.und.server.auth.dto; -import com.fasterxml.jackson.annotation.JsonProperty; - 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") @JsonProperty("access_token") String accessToken, + @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") @JsonProperty("refresh_token") String refreshToken + @NotBlank(message = "Refresh Token must not be blank") String refreshToken ) { } diff --git a/src/main/java/com/und/server/auth/oauth/Provider.java b/src/main/java/com/und/server/auth/oauth/Provider.java index ec9e0f80..7e14b5cf 100644 --- a/src/main/java/com/und/server/auth/oauth/Provider.java +++ b/src/main/java/com/und/server/auth/oauth/Provider.java @@ -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/auth/service/AuthService.java b/src/main/java/com/und/server/auth/service/AuthService.java index ea7b895b..fef65696 100644 --- a/src/main/java/com/und/server/auth/service/AuthService.java +++ b/src/main/java/com/und/server/auth/service/AuthService.java @@ -76,10 +76,17 @@ public AuthResponse reissueTokens(final RefreshTokenRequest refreshTokenRequest) final Long memberId = getMemberIdForReissue(accessToken); - memberService.findMemberById(memberId).orElseThrow(() -> { - refreshTokenService.deleteRefreshToken(memberId); - return new ServerException(ServerErrorResult.INVALID_TOKEN); - }); + try { + memberService.validateMemberExists(memberId); + } catch (final ServerException e) { + if (e.getErrorResult() == ServerErrorResult.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(ServerErrorResult.INVALID_TOKEN, e); + } refreshTokenService.validateRefreshToken(memberId, providedRefreshToken); @@ -94,7 +101,7 @@ public void logout(final Long memberId) { private Provider convertToProvider(final String providerName) { try { return Provider.valueOf(providerName.toUpperCase()); - } catch (IllegalArgumentException e) { + } catch (final IllegalArgumentException e) { throw new ServerException(ServerErrorResult.INVALID_PROVIDER); } } @@ -128,7 +135,7 @@ private Long getMemberIdForReissue(final String accessToken) { if (!tokenInfo.isExpired()) { // An attempt to reissue with a non-expired token may be a security risk. - // For security, we delete the refresh token. + // For security, delete the refresh token. refreshTokenService.deleteRefreshToken(memberId); if (profileManager.isProdOrStgProfile()) { throw new ServerException(ServerErrorResult.INVALID_TOKEN); diff --git a/src/main/java/com/und/server/auth/service/NonceService.java b/src/main/java/com/und/server/auth/service/NonceService.java index c183698c..c24e75f4 100644 --- a/src/main/java/com/und/server/auth/service/NonceService.java +++ b/src/main/java/com/und/server/auth/service/NonceService.java @@ -26,6 +26,9 @@ public String generateNonceValue() { @Transactional public void validateNonce(final String value, final Provider provider) { + validateNonceValue(value); + validateProvider(provider); + nonceRepository.findById(value) .filter(n -> n.getProvider() == provider) .orElseThrow(() -> new ServerException(ServerErrorResult.INVALID_NONCE)); @@ -33,9 +36,11 @@ public void validateNonce(final String value, final Provider provider) { 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) @@ -44,9 +49,16 @@ public void saveNonce(final String value, final Provider provider) { nonceRepository.save(nonce); } - @Transactional - public void deleteNonce(final String value) { - nonceRepository.deleteById(value); + private void validateNonceValue(final String value) { + if (value == null) { + throw new ServerException(ServerErrorResult.INVALID_NONCE); + } + } + + private void validateProvider(final Provider provider) { + if (provider == null) { + throw new ServerException(ServerErrorResult.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 index e4e9fc50..e93f0ef4 100644 --- a/src/main/java/com/und/server/auth/service/RefreshTokenService.java +++ b/src/main/java/com/und/server/auth/service/RefreshTokenService.java @@ -25,9 +25,12 @@ public String generateRefreshToken() { @Transactional public void validateRefreshToken(final Long memberId, final String providedToken) { + validateMemberIdIsNotNull(memberId); + validateTokenValueIsNotNull(providedToken); + refreshTokenRepository.findById(memberId) .map(RefreshToken::getValue) - .filter(savedToken -> savedToken.equals(providedToken)) + .filter(savedToken -> providedToken.equals(savedToken)) .orElseThrow(() -> { deleteRefreshToken(memberId); return new ServerException(ServerErrorResult.INVALID_TOKEN); @@ -35,10 +38,13 @@ public void validateRefreshToken(final Long memberId, final String providedToken } @Transactional - public void saveRefreshToken(final Long memberId, final String refreshToken) { + public void saveRefreshToken(final Long memberId, final String value) { + validateMemberIdIsNotNull(memberId); + validateTokenValueIsNotNull(value); + final RefreshToken token = RefreshToken.builder() .memberId(memberId) - .value(refreshToken) + .value(value) .build(); refreshTokenRepository.save(token); @@ -46,6 +52,21 @@ public void saveRefreshToken(final Long memberId, final String refreshToken) { @Transactional public void deleteRefreshToken(final Long memberId) { + validateMemberIdIsNotNull(memberId); + refreshTokenRepository.deleteById(memberId); } + + private void validateMemberIdIsNotNull(final Long memberId) { + if (memberId == null) { + throw new ServerException(ServerErrorResult.INVALID_MEMBER_ID); + } + } + + private void validateTokenValueIsNotNull(final String token) { + if (token == null) { + throw new ServerException(ServerErrorResult.INVALID_TOKEN); + } + } + } diff --git a/src/main/java/com/und/server/common/controller/TestController.java b/src/main/java/com/und/server/common/controller/TestController.java index e2d6a0e7..fbe0282f 100644 --- a/src/main/java/com/und/server/common/controller/TestController.java +++ b/src/main/java/com/und/server/common/controller/TestController.java @@ -16,8 +16,6 @@ import com.und.server.auth.service.AuthService; import com.und.server.common.dto.TestAuthRequest; import com.und.server.common.dto.TestHelloResponse; -import com.und.server.common.exception.ServerErrorResult; -import com.und.server.common.exception.ServerException; import com.und.server.member.dto.MemberResponse; import com.und.server.member.entity.Member; import com.und.server.member.service.MemberService; @@ -42,8 +40,7 @@ public ResponseEntity requireAccessToken(@RequestBody @Valid final @GetMapping("/hello") public ResponseEntity greet(@Parameter(hidden = true) @AuthMember final Long memberId) { - final Member member = memberService.findMemberById(memberId) - .orElseThrow(() -> new ServerException(ServerErrorResult.MEMBER_NOT_FOUND)); + final Member member = memberService.findMemberById(memberId); final String nickname = member.getNickname() != null ? member.getNickname() : "Member"; final TestHelloResponse response = new TestHelloResponse("Hello, " + nickname + "!"); diff --git a/src/main/java/com/und/server/common/dto/ErrorResponse.java b/src/main/java/com/und/server/common/dto/ErrorResponse.java index 5a52fe0c..f3839e49 100644 --- a/src/main/java/com/und/server/common/dto/ErrorResponse.java +++ b/src/main/java/com/und/server/common/dto/ErrorResponse.java @@ -1,16 +1,12 @@ package com.und.server.common.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 = "UNAUTHORIZED_ACCESS") - @JsonProperty("code") String code, @Schema(description = "Error Message", example = "Unauthorized Access") - @JsonProperty("message") Object message ) { } diff --git a/src/main/java/com/und/server/common/dto/TestAuthRequest.java b/src/main/java/com/und/server/common/dto/TestAuthRequest.java index 61e9a380..019e98b6 100644 --- a/src/main/java/com/und/server/common/dto/TestAuthRequest.java +++ b/src/main/java/com/und/server/common/dto/TestAuthRequest.java @@ -1,21 +1,19 @@ package com.und.server.common.dto; -import com.fasterxml.jackson.annotation.JsonProperty; - 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") @JsonProperty("provider") + @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") @JsonProperty("provider_id") + @NotBlank(message = "Provider ID must not be blank") String providerId, @Schema(description = "User's nickname", example = "Chori") - @NotBlank(message = "Nickname must not be blank") @JsonProperty("nickname") + @NotBlank(message = "Nickname must not be blank") String nickname ) { } diff --git a/src/main/java/com/und/server/common/dto/TestHelloResponse.java b/src/main/java/com/und/server/common/dto/TestHelloResponse.java index f2e96038..3fd4837b 100644 --- a/src/main/java/com/und/server/common/dto/TestHelloResponse.java +++ b/src/main/java/com/und/server/common/dto/TestHelloResponse.java @@ -1,12 +1,9 @@ package com.und.server.common.dto; -import com.fasterxml.jackson.annotation.JsonProperty; - 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/exception/ServerErrorResult.java b/src/main/java/com/und/server/common/exception/ServerErrorResult.java index fa95dc04..382ca25f 100644 --- a/src/main/java/com/und/server/common/exception/ServerErrorResult.java +++ b/src/main/java/com/und/server/common/exception/ServerErrorResult.java @@ -12,6 +12,7 @@ public enum ServerErrorResult { 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"), @@ -22,8 +23,8 @@ public enum ServerErrorResult { 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"), + INVALID_MEMBER_ID(HttpStatus.BAD_REQUEST, "Invalid Member ID"), + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "Member Not Found"), UNKNOWN_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "Unknown Exception"); private final HttpStatus httpStatus; diff --git a/src/main/java/com/und/server/member/controller/MemberController.java b/src/main/java/com/und/server/member/controller/MemberController.java index 3ee495bb..e31b7709 100644 --- a/src/main/java/com/und/server/member/controller/MemberController.java +++ b/src/main/java/com/und/server/member/controller/MemberController.java @@ -3,13 +3,18 @@ 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.MemberResponse; +import com.und.server.member.dto.NicknameRequest; import com.und.server.member.service.MemberService; import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @@ -19,6 +24,16 @@ 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") public ResponseEntity deleteMember(@Parameter(hidden = true) @AuthMember final Long memberId) { memberService.deleteMemberById(memberId); diff --git a/src/main/java/com/und/server/member/dto/MemberResponse.java b/src/main/java/com/und/server/member/dto/MemberResponse.java index 6cc7b16b..47475708 100644 --- a/src/main/java/com/und/server/member/dto/MemberResponse.java +++ b/src/main/java/com/und/server/member/dto/MemberResponse.java @@ -2,7 +2,6 @@ import java.time.LocalDateTime; -import com.fasterxml.jackson.annotation.JsonProperty; import com.und.server.member.entity.Member; import io.swagger.v3.oas.annotations.media.Schema; @@ -10,19 +9,19 @@ @Schema(description = "Member Response DTO") public record MemberResponse( @Schema(description = "Member ID", example = "1") - @JsonProperty("id") Long id, + Long id, @Schema(description = "Member's nickname", example = "Chori") - @JsonProperty("nickname") String nickname, + String nickname, @Schema(description = "Kakao ID", example = "1234567890") - @JsonProperty("kakao_id") String kakaoId, + String kakaoId, @Schema(description = "Creation timestamp of the member", example = "2025-07-31T22:27:36.037717") - @JsonProperty("created_at") LocalDateTime createdAt, + LocalDateTime createdAt, @Schema(description = "Last update timestamp of the member", example = "2025-07-31T22:27:36.037744") - @JsonProperty("updated_at") LocalDateTime updatedAt + LocalDateTime updatedAt ) { public static MemberResponse from(final Member member) { return new MemberResponse( diff --git a/src/main/java/com/und/server/member/dto/NicknameRequest.java b/src/main/java/com/und/server/member/dto/NicknameRequest.java new file mode 100644 index 00000000..841abebe --- /dev/null +++ b/src/main/java/com/und/server/member/dto/NicknameRequest.java @@ -0,0 +1,10 @@ +package com.und.server.member.dto; + +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/entity/Member.java b/src/main/java/com/und/server/member/entity/Member.java index 950b3a50..14bff091 100644 --- a/src/main/java/com/und/server/member/entity/Member.java +++ b/src/main/java/com/und/server/member/entity/Member.java @@ -43,4 +43,8 @@ public class Member { @Column private LocalDateTime updatedAt; + public void updateNickname(final String nickname) { + this.nickname = nickname; + } + } diff --git a/src/main/java/com/und/server/member/service/MemberService.java b/src/main/java/com/und/server/member/service/MemberService.java index a97a62db..43b7442b 100644 --- a/src/main/java/com/und/server/member/service/MemberService.java +++ b/src/main/java/com/und/server/member/service/MemberService.java @@ -12,6 +12,7 @@ import com.und.server.common.exception.ServerErrorResult; import com.und.server.common.exception.ServerException; import com.und.server.member.dto.MemberResponse; +import com.und.server.member.dto.NicknameRequest; import com.und.server.member.entity.Member; import com.und.server.member.repository.MemberRepository; @@ -33,18 +34,41 @@ public List getMemberList() { @Transactional public Member findOrCreateMember(final Provider provider, final IdTokenPayload payload) { + validateProviderIsNotNull(provider); final String providerId = payload.providerId(); + validateProviderIdIsNotNull(providerId); return findMemberByProviderId(provider, providerId) .orElseGet(() -> createMember(provider, providerId, payload.nickname())); } - public Optional findMemberById(final Long memberId) { - return memberRepository.findById(memberId); + public Member findMemberById(final Long memberId) { + validateMemberIdIsNotNull(memberId); + + return memberRepository.findById(memberId) + .orElseThrow(() -> new ServerException(ServerErrorResult.MEMBER_NOT_FOUND)); + } + + public void validateMemberExists(final Long memberId) { + validateMemberIdIsNotNull(memberId); + + if (!memberRepository.existsById(memberId)) { + throw new ServerException(ServerErrorResult.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); + refreshTokenService.deleteRefreshToken(memberId); memberRepository.deleteById(memberId); } @@ -66,4 +90,22 @@ private Member createMember(final Provider provider, final String providerId, fi return memberRepository.save(memberBuilder.build()); } + private void validateMemberIdIsNotNull(final Long memberId) { + if (memberId == null) { + throw new ServerException(ServerErrorResult.INVALID_MEMBER_ID); + } + } + + private void validateProviderIsNotNull(final Provider provider) { + if (provider == null) { + throw new ServerException(ServerErrorResult.INVALID_PROVIDER); + } + } + + private void validateProviderIdIsNotNull(final String providerId) { + if (providerId == null) { + throw new ServerException(ServerErrorResult.INVALID_PROVIDER_ID); + } + } + } diff --git a/src/test/java/com/und/server/auth/controller/AuthControllerTest.java b/src/test/java/com/und/server/auth/controller/AuthControllerTest.java index 2a61fc15..d42fea39 100644 --- a/src/test/java/com/und/server/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/und/server/auth/controller/AuthControllerTest.java @@ -5,12 +5,10 @@ import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; 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.Collections; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -20,9 +18,6 @@ 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.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @@ -375,15 +370,9 @@ void Given_AuthenticatedUser_When_Logout_Then_ReturnsNoContent() throws Exceptio doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); - final Authentication auth = new UsernamePasswordAuthenticationToken( - memberId, - null, - Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) - ); - // when final ResultActions resultActions = mockMvc.perform( - MockMvcRequestBuilders.delete(url).with(authentication(auth)) + MockMvcRequestBuilders.delete(url) ); // then diff --git a/src/test/java/com/und/server/auth/service/AuthServiceTest.java b/src/test/java/com/und/server/auth/service/AuthServiceTest.java index f6a1bd7f..1aed7e31 100644 --- a/src/test/java/com/und/server/auth/service/AuthServiceTest.java +++ b/src/test/java/com/und/server/auth/service/AuthServiceTest.java @@ -10,8 +10,6 @@ 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; @@ -223,6 +221,28 @@ void Given_NewMember_When_Login_Then_CreatesMemberAndIssuesTokens() { 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(ServerErrorResult.INVALID_MEMBER_ID)) + .when(memberService).validateMemberExists(nullMemberId); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> authService.reissueTokens(request)); + + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); + // Crucially, we should not attempt to delete a refresh token with a null ID. + verify(refreshTokenService, never()).deleteRefreshToken(any()); + verify(refreshTokenService, never()).validateRefreshToken(any(), any()); + } + @Test @DisplayName("Throws an exception on token reissue if the member does not exist") void Given_NonExistentMember_When_ReissueTokens_Then_ThrowsExceptionAndDeletesToken() { @@ -231,7 +251,8 @@ void Given_NonExistentMember_When_ReissueTokens_Then_ThrowsExceptionAndDeletesTo final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); - doReturn(Optional.empty()).when(memberService).findMemberById(memberId); + doThrow(new ServerException(ServerErrorResult.MEMBER_NOT_FOUND)) + .when(memberService).validateMemberExists(memberId); // when & then final ServerException exception = assertThrows(ServerException.class, @@ -248,10 +269,9 @@ void Given_MismatchedRefreshToken_When_ReissueTokens_Then_ThrowsException() { // given final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, "wrong.refresh.token"); final ParsedTokenInfo expiredTokenInfo = new ParsedTokenInfo(memberId, true); - final Member member = Member.builder().id(memberId).build(); doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); - doReturn(Optional.of(member)).when(memberService).findMemberById(memberId); + doNothing().when(memberService).validateMemberExists(memberId); doThrow(new ServerException(ServerErrorResult.INVALID_TOKEN)) .when(refreshTokenService).validateRefreshToken(memberId, "wrong.refresh.token"); @@ -267,11 +287,10 @@ void Given_MismatchedRefreshToken_When_ReissueTokens_Then_ThrowsException() { void Given_NoStoredRefreshToken_When_ReissueTokens_Then_ThrowsException() { // given final ParsedTokenInfo expiredTokenInfo = new ParsedTokenInfo(memberId, true); - final Member member = Member.builder().id(memberId).build(); final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); - doReturn(Optional.of(member)).when(memberService).findMemberById(memberId); + doNothing().when(memberService).validateMemberExists(memberId); doThrow(new ServerException(ServerErrorResult.INVALID_TOKEN)) .when(refreshTokenService).validateRefreshToken(memberId, refreshToken); @@ -291,10 +310,9 @@ void Given_ValidRefreshToken_When_ReissueTokens_Then_Succeeds() { final String newAccessToken = "new-access-token"; final String newRefreshToken = "new-refresh-token"; final ParsedTokenInfo expiredTokenInfo = new ParsedTokenInfo(memberId, true); - final Member member = Member.builder().id(memberId).build(); doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); - doReturn(Optional.of(member)).when(memberService).findMemberById(memberId); + doNothing().when(memberService).validateMemberExists(memberId); doNothing().when(refreshTokenService).validateRefreshToken(memberId, refreshToken); setupTokenIssuance(newAccessToken, newRefreshToken); diff --git a/src/test/java/com/und/server/auth/service/NonceServiceTest.java b/src/test/java/com/und/server/auth/service/NonceServiceTest.java index 36f10e5e..39278ab4 100644 --- a/src/test/java/com/und/server/auth/service/NonceServiceTest.java +++ b/src/test/java/com/und/server/auth/service/NonceServiceTest.java @@ -1,8 +1,12 @@ package com.und.server.auth.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.*; +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; @@ -23,105 +27,114 @@ @ExtendWith(MockitoExtension.class) class NonceServiceTest { + @InjectMocks + private NonceService nonceService; + @Mock private NonceRepository nonceRepository; - @InjectMocks - private NonceService nonceService; + private final String nonceValue = "test-nonce"; + private final Provider provider = Provider.KAKAO; @Test - @DisplayName("Generates a UUID-formatted nonce value") - void Given_Nothing_When_GenerateNonceValue_Then_ReturnsUuidString() { + @DisplayName("Generates a new nonce in UUID format") + void Given_Nothing_When_GenerateNonceValue_Then_ReturnsUuid() { // when - final String nonce = nonceService.generateNonceValue(); + final String generatedNonce = nonceService.generateNonceValue(); // then - assertThat(nonce) - .isNotNull() - .hasSize(36); // UUID format + assertThat(generatedNonce).isNotNull(); + assertDoesNotThrow(() -> UUID.fromString(generatedNonce)); } @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; + @DisplayName("Throws an exception when validating with a null nonce value") + void Given_NullNonceValue_When_ValidateNonce_Then_ThrowsException() { + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> nonceService.validateNonce(null, provider)); + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_NONCE); + } - // when - nonceService.saveNonce(nonceValue, provider); + @Test + @DisplayName("Throws an exception when validating with a null provider") + void Given_NullProvider_When_ValidateNonce_Then_ThrowsException() { + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> nonceService.validateNonce(nonceValue, null)); + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_PROVIDER); + } - // then - verify(nonceRepository).save(any(Nonce.class)); + @Test + @DisplayName("Throws an exception when saving with a null nonce value") + void Given_NullNonceValue_When_SaveNonce_Then_ThrowsException() { + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> nonceService.saveNonce(null, provider)); + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_NONCE); } @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; + @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(ServerErrorResult.INVALID_PROVIDER); + } - doReturn(Optional.empty()).when(nonceRepository).findById(nonceValue); + @Test + @DisplayName("Succeeds validation for a valid nonce and provider") + void Given_ValidNonceAndProvider_When_ValidateNonce_Then_SucceedsAndDeletesNonce() { + // given + final Nonce savedNonce = Nonce.builder().value(nonceValue).provider(provider).build(); + doReturn(Optional.of(savedNonce)).when(nonceRepository).findById(nonceValue); // when & then - assertThatThrownBy(() -> nonceService.validateNonce(nonceValue, provider)) - .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_NONCE); - verify(nonceRepository, never()).deleteById(anyString()); + assertDoesNotThrow(() -> nonceService.validateNonce(nonceValue, provider)); + verify(nonceRepository).deleteById(nonceValue); } @Test - @DisplayName("Throws an exception when the provider does not match") - void Given_NonceWithMismatchedProvider_When_ValidateNonce_Then_ThrowsInvalidNonceException() { + @DisplayName("Throws an exception for a non-existent nonce") + void Given_NonExistentNonce_When_ValidateNonce_Then_ThrowsException() { // 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); + doReturn(Optional.empty()).when(nonceRepository).findById(nonceValue); // when & then - assertThatThrownBy(() -> nonceService.validateNonce(nonceValue, requestProvider)) - .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_NONCE); - verify(nonceRepository, never()).deleteById(anyString()); + final ServerException exception = assertThrows(ServerException.class, + () -> nonceService.validateNonce(nonceValue, provider)); + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_NONCE); } @Test - @DisplayName("Validates a nonce successfully and deletes it") - void Given_ValidNonceInRepository_When_ValidateNonce_Then_DeletesNonce() { + @DisplayName("Throws an exception for a nonce with a mismatched provider") + void Given_MismatchedProvider_When_ValidateNonce_Then_ThrowsException() { // given - final String nonceValue = UUID.randomUUID().toString(); - final Provider provider = Provider.KAKAO; - final Nonce nonce = Nonce.builder() - .value(nonceValue) - .provider(provider) - .build(); + // 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); - doReturn(Optional.of(nonce)).when(nonceRepository).findById(nonceValue); + // but validation is attempted with a different provider + final Provider differentProvider = Provider.APPLE; - // when - nonceService.validateNonce(nonceValue, provider); + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> nonceService.validateNonce(nonceValue, differentProvider)); - // then - verify(nonceRepository).deleteById(nonceValue); + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_NONCE); + // The nonce should not be deleted if the provider does not match + verify(nonceRepository, never()).deleteById(nonceValue); } @Test - @DisplayName("Deletes a nonce successfully") - void Given_NonceValue_When_DeleteNonce_Then_RepositoryDeleteIsCalled() { - // given - final String nonceValue = UUID.randomUUID().toString(); - + @DisplayName("Saves a nonce successfully") + void Given_NonceValueAndProvider_When_SaveNonce_Then_SavesToRepository() { // when - nonceService.deleteNonce(nonceValue); + nonceService.saveNonce(nonceValue, provider); // then - verify(nonceRepository).deleteById(nonceValue); + 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 index 9bb5e7ea..6d3ec47b 100644 --- a/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java +++ b/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java @@ -62,6 +62,58 @@ void Given_MemberIdAndToken_When_SaveRefreshToken_Then_CallsRepositorySave() { 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(ServerErrorResult.INVALID_TOKEN); + } + + @Test + @DisplayName("Throws an exception when validating with a null token") + void Given_NullToken_When_ValidateRefreshToken_Then_ThrowsException() { + // when + final ServerException exception = assertThrows(ServerException.class, + () -> refreshTokenService.validateRefreshToken(memberId, null)); + + // then + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.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(ServerErrorResult.INVALID_MEMBER_ID); + } + + @Test + @DisplayName("Throws an exception when validating a refresh token with a null member ID") + void Given_NullMemberId_When_ValidateRefreshToken_Then_ThrowsException() { + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> refreshTokenService.validateRefreshToken(null, refreshTokenValue)); + + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.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(ServerErrorResult.INVALID_MEMBER_ID); + } + @Test @DisplayName("Succeeds validation if the provided token matches the stored one") void Given_MatchingToken_When_ValidateRefreshToken_Then_Succeeds() { @@ -95,6 +147,25 @@ void Given_MismatchedToken_When_ValidateRefreshToken_Then_ThrowsExceptionAndDele verify(refreshTokenRepository).deleteById(memberId); } + @Test + @DisplayName("Throws an exception if the stored token value is null") + void Given_StoredTokenWithValueNull_When_ValidateRefreshToken_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.validateRefreshToken(memberId, refreshTokenValue)); + + // then + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); + verify(refreshTokenRepository).deleteById(memberId); + } + @Test @DisplayName("Throws an exception if no token is stored for validation") void Given_NoStoredToken_When_ValidateRefreshToken_Then_ThrowsException() { diff --git a/src/test/java/com/und/server/common/controller/TestControllerTest.java b/src/test/java/com/und/server/common/controller/TestControllerTest.java index c6fabc01..4794c693 100644 --- a/src/test/java/com/und/server/common/controller/TestControllerTest.java +++ b/src/test/java/com/und/server/common/controller/TestControllerTest.java @@ -4,13 +4,11 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; 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 java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -20,8 +18,6 @@ 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; @@ -135,25 +131,23 @@ void Given_NonExistingMember_When_RequestAccessToken_Then_CreatesMemberAndReturn } @Test - @DisplayName("Fails to greet and returns unauthorized when the authenticated member is not found") - void Given_AuthenticatedUserNotFoundInDb_When_Greet_Then_ReturnsUnauthorized() throws Exception { + @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; - doReturn(Optional.empty()).when(memberService).findMemberById(memberId); + doThrow(new ServerException(ServerErrorResult.MEMBER_NOT_FOUND)).when(memberService).findMemberById(memberId); doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); - final Authentication auth = new UsernamePasswordAuthenticationToken(memberId, null); - // when final ResultActions result = mockMvc.perform( - MockMvcRequestBuilders.get(url).with(authentication(auth)) + MockMvcRequestBuilders.get(url) ); // then - result.andExpect(status().isUnauthorized()) + result.andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value(ServerErrorResult.MEMBER_NOT_FOUND.name())) .andExpect(jsonPath("$.message").value(ServerErrorResult.MEMBER_NOT_FOUND.getMessage())); } @@ -188,15 +182,13 @@ void Given_AuthenticatedUserWithNickname_When_Greet_Then_ReturnsOkWithPersonaliz final Long memberId = 1L; final Member member = Member.builder().id(memberId).nickname("Chori").build(); - doReturn(Optional.of(member)).when(memberService).findMemberById(memberId); + doReturn(member).when(memberService).findMemberById(memberId); doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); - final Authentication auth = new UsernamePasswordAuthenticationToken(memberId, null); - // when final ResultActions result = mockMvc.perform( - MockMvcRequestBuilders.get(url).with(authentication(auth)) + MockMvcRequestBuilders.get(url) ); // then @@ -212,15 +204,13 @@ void Given_AuthenticatedUserWithoutNickname_When_Greet_Then_ReturnsOkWithDefault final Long memberId = 2L; final Member member = Member.builder().id(memberId).nickname(null).build(); - doReturn(Optional.of(member)).when(memberService).findMemberById(memberId); + doReturn(member).when(memberService).findMemberById(memberId); doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); - final Authentication auth = new UsernamePasswordAuthenticationToken(memberId, null); - // when final ResultActions result = mockMvc.perform( - MockMvcRequestBuilders.get(url).with(authentication(auth)) + MockMvcRequestBuilders.get(url) ); // then diff --git a/src/test/java/com/und/server/member/controller/MemberControllerTest.java b/src/test/java/com/und/server/member/controller/MemberControllerTest.java index 205a0cd4..fa698204 100644 --- a/src/test/java/com/und/server/member/controller/MemberControllerTest.java +++ b/src/test/java/com/und/server/member/controller/MemberControllerTest.java @@ -3,14 +3,10 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import java.util.Collections; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -18,18 +14,19 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; +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.ServerErrorResult; import com.und.server.common.exception.ServerException; +import com.und.server.member.dto.MemberResponse; +import com.und.server.member.dto.NicknameRequest; import com.und.server.member.service.MemberService; @ExtendWith(MockitoExtension.class) @@ -45,6 +42,7 @@ class MemberControllerTest { private AuthMemberArgumentResolver authMemberArgumentResolver; private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); @BeforeEach void init() { @@ -55,7 +53,85 @@ void init() { } @Test - @DisplayName("Fails member deletion and returns unauthorized when user is not authenticated") + @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(ServerErrorResult.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 ServerErrorResult errorResult = ServerErrorResult.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); + + 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"; @@ -74,13 +150,11 @@ void Given_UnauthenticatedUser_When_DeleteMember_Then_ReturnsUnauthorized() thro resultActions.andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.code").value(errorResult.name())) .andExpect(jsonPath("$.message").value(errorResult.getMessage())); - - verify(memberService, never()).deleteMemberById(any(Long.class)); } @Test - @DisplayName("Succeeds in deleting the member and returns no content") - void Given_MemberId_When_DeleteMember_Then_ReturnsNoContent() throws Exception { + @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; @@ -88,17 +162,13 @@ void Given_MemberId_When_DeleteMember_Then_ReturnsNoContent() throws Exception { doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); doReturn(memberId).when(authMemberArgumentResolver).resolveArgument(any(), any(), any(), any()); - final Authentication auth = new UsernamePasswordAuthenticationToken( - memberId, - null, - Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.delete(url) ); - // when & then - mockMvc.perform( - MockMvcRequestBuilders.delete(url).with(authentication(auth)) - ).andExpect(status().isNoContent()); - + // then + resultActions.andExpect(status().isNoContent()); verify(memberService).deleteMemberById(memberId); } diff --git a/src/test/java/com/und/server/member/service/MemberServiceTest.java b/src/test/java/com/und/server/member/service/MemberServiceTest.java index 868b7d76..39739694 100644 --- a/src/test/java/com/und/server/member/service/MemberServiceTest.java +++ b/src/test/java/com/und/server/member/service/MemberServiceTest.java @@ -1,6 +1,8 @@ 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; @@ -19,7 +21,10 @@ import com.und.server.auth.oauth.IdTokenPayload; import com.und.server.auth.oauth.Provider; import com.und.server.auth.service.RefreshTokenService; +import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ServerException; import com.und.server.member.dto.MemberResponse; +import com.und.server.member.dto.NicknameRequest; import com.und.server.member.entity.Member; import com.und.server.member.repository.MemberRepository; @@ -86,16 +91,122 @@ void Given_NonExistingMember_When_FindOrCreateMember_Then_CreatesAndReturnsNewMe } @Test - @DisplayName("Returns an empty Optional when finding a non-existent member by ID") - void Given_NonExistingMemberId_When_FindMemberById_Then_ReturnsEmptyOptional() { + @DisplayName("Throws an exception when finding or creating a member with an unsupported provider") + void Given_UnsupportedProvider_When_FindOrCreateMember_Then_ThrowsException() { + // given + final IdTokenPayload payload = new IdTokenPayload(providerId, nickname); + final Provider unsupportedProvider = Provider.APPLE; + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> memberService.findOrCreateMember(unsupportedProvider, payload)); + + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_PROVIDER); + } + + @Test + @DisplayName("Throws an exception when finding or creating a member with a null provider") + void Given_NullProvider_When_FindOrCreateMember_Then_ThrowsException() { + // given + final IdTokenPayload payload = new IdTokenPayload(providerId, nickname); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> memberService.findOrCreateMember(null, payload)); + + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.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() { + // given + final IdTokenPayload payloadWithNullId = new IdTokenPayload(null, nickname); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> memberService.findOrCreateMember(provider, payloadWithNullId)); + + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.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(ServerErrorResult.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(ServerErrorResult.INVALID_MEMBER_ID); + } + + @Test + @DisplayName("Throws an exception when validating a null member ID") + void Given_NullMemberId_When_ValidateMemberExists_Then_ThrowsException() { + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> memberService.validateMemberExists(null)); + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_MEMBER_ID); + } + + @Test + @DisplayName("Throws an exception when validating a non-existent member") + void Given_NonExistentMemberId_When_ValidateMemberExists_Then_ThrowsException() { + // given + doReturn(false).when(memberRepository).existsById(memberId); + + // when & then + final ServerException exception = assertThrows(ServerException.class, + () -> memberService.validateMemberExists(memberId)); + assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("Does not throw an exception when validating an existing member") + void Given_ExistingMemberId_When_ValidateMemberExists_Then_Succeeds() { + // given + doReturn(true).when(memberRepository).existsById(memberId); + + // when & then + assertDoesNotThrow(() -> memberService.validateMemberExists(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 Optional foundMemberOptional = memberService.findMemberById(memberId); + final MemberResponse response = memberService.updateNickname(memberId, request); // then - assertThat(foundMemberOptional).isEmpty(); + verify(memberRepository).findById(memberId); + assertThat(member.getNickname()).isEqualTo(newNickname); + assertThat(response.id()).isEqualTo(memberId); + assertThat(response.nickname()).isEqualTo(newNickname); } @Test From 2baeb765d9d7ffa37131e07f92739d0ebcb1d617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?sukipi=20=EC=88=98=ED=82=A4=ED=94=BC?= <108535526+sotogito@users.noreply.github.com> Date: Sat, 2 Aug 2025 15:19:31 +0900 Subject: [PATCH 14/26] Refactor/#81 common exception (#82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🎨 Refactor GlobalException * 🚚 Rename deployment-to-stg.yml * 🚚 Rename ErrorCode to ErrorResult --- ...eployemnt-to-stg.yml => deployment-to-stg.yml} | 0 .../auth/filter/SecurityErrorResponseWriter.java | 4 ++-- .../und/server/common/exception/ErrorResult.java | 15 +++++++++++++++ .../common/exception/GlobalExceptionHandler.java | 8 +++++--- .../common/exception/ServerErrorResult.java | 2 +- .../server/common/exception/ServerException.java | 6 +++--- 6 files changed, 26 insertions(+), 9 deletions(-) rename .github/workflows/{deployemnt-to-stg.yml => deployment-to-stg.yml} (100%) create mode 100644 src/main/java/com/und/server/common/exception/ErrorResult.java diff --git a/.github/workflows/deployemnt-to-stg.yml b/.github/workflows/deployment-to-stg.yml similarity index 100% rename from .github/workflows/deployemnt-to-stg.yml rename to .github/workflows/deployment-to-stg.yml diff --git a/src/main/java/com/und/server/auth/filter/SecurityErrorResponseWriter.java b/src/main/java/com/und/server/auth/filter/SecurityErrorResponseWriter.java index c45a6c64..8dff6e3e 100644 --- a/src/main/java/com/und/server/auth/filter/SecurityErrorResponseWriter.java +++ b/src/main/java/com/und/server/auth/filter/SecurityErrorResponseWriter.java @@ -7,7 +7,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.und.server.common.dto.ErrorResponse; -import com.und.server.common.exception.ServerErrorResult; +import com.und.server.common.exception.ErrorResult; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -20,7 +20,7 @@ public class SecurityErrorResponseWriter { public void sendErrorResponse( final HttpServletResponse response, - final ServerErrorResult errorResult + final ErrorResult errorResult ) throws IOException { response.setStatus(errorResult.getHttpStatus().value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); 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/common/exception/GlobalExceptionHandler.java b/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java index ca0337fc..57e0006b 100644 --- a/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java @@ -22,7 +22,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)); } @@ -46,11 +46,12 @@ protected ResponseEntity handleMethodArgumentNotValid( @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() ); } @@ -63,4 +64,5 @@ public ResponseEntity handleException(final Exception exception) { ServerErrorResult.UNKNOWN_EXCEPTION.getMessage() ); } + } diff --git a/src/main/java/com/und/server/common/exception/ServerErrorResult.java b/src/main/java/com/und/server/common/exception/ServerErrorResult.java index 382ca25f..26e9c02e 100644 --- a/src/main/java/com/und/server/common/exception/ServerErrorResult.java +++ b/src/main/java/com/und/server/common/exception/ServerErrorResult.java @@ -7,7 +7,7 @@ @Getter @RequiredArgsConstructor -public enum ServerErrorResult { +public enum ServerErrorResult implements ErrorResult { INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "Invalid Parameter"), INVALID_NONCE(HttpStatus.BAD_REQUEST, "Invalid nonce"), diff --git a/src/main/java/com/und/server/common/exception/ServerException.java b/src/main/java/com/und/server/common/exception/ServerException.java index 900b7e1b..0200a157 100644 --- a/src/main/java/com/und/server/common/exception/ServerException.java +++ b/src/main/java/com/und/server/common/exception/ServerException.java @@ -5,14 +5,14 @@ @Getter public class ServerException extends RuntimeException { - private final ServerErrorResult errorResult; + private final ErrorResult errorResult; - public ServerException(final ServerErrorResult errorResult) { + public ServerException(final ErrorResult errorResult) { super(errorResult.getMessage()); this.errorResult = errorResult; } - public ServerException(final ServerErrorResult errorResult, final Throwable cause) { + public ServerException(final ErrorResult errorResult, final Throwable cause) { super(errorResult.getMessage(), cause); this.errorResult = errorResult; } From bdbd75bda0928c1e28367008d45d5c519a87c1a2 Mon Sep 17 00:00:00 2001 From: Chori <105255517+choridev@users.noreply.github.com> Date: Sun, 3 Aug 2025 17:00:12 +0900 Subject: [PATCH 15/26] Separate exceptions by domain (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ♻️ Separate exceptions by domain * 🔧 Set branch coverage to 0.70 * 🎨 Add name to Checkout step * ♻️ Clear the review by Gemini * 📝 Add ApiResponse to delete method --- .github/workflows/validate-pr.yml | 3 +- build.gradle | 2 +- .../auth/controller/AuthController.java | 3 ++ .../und/server/auth/dto/OidcPublicKeys.java | 4 +-- .../exception/AuthErrorResult.java} | 12 +++---- .../filter/AuthMemberArgumentResolver.java | 4 +-- .../CustomAuthenticationEntryPoint.java | 4 +-- .../auth/filter/JwtAuthenticationFilter.java | 4 +-- .../com/und/server/auth/jwt/JwtProvider.java | 36 +++++++++---------- .../server/auth/oauth/OidcClientFactory.java | 4 +-- .../auth/oauth/OidcProviderFactory.java | 4 +-- .../server/auth/oauth/PublicKeyProvider.java | 4 +-- .../und/server/auth/service/AuthService.java | 13 +++---- .../und/server/auth/service/NonceService.java | 8 ++--- .../auth/service/RefreshTokenService.java | 9 ++--- .../common/exception/CommonErrorResult.java | 18 ++++++++++ .../exception/GlobalExceptionHandler.java | 6 ++-- .../member/controller/MemberController.java | 2 ++ .../member/exception/MemberErrorResult.java | 20 +++++++++++ .../server/member/service/MemberService.java | 17 ++++----- .../auth/controller/AuthControllerTest.java | 21 +++++------ .../server/auth/dto/OidcPublicKeysTest.java | 6 ++-- .../AuthMemberArgumentResolverTest.java | 6 ++-- .../CustomAuthenticationEntryPointTest.java | 4 +-- .../filter/JwtAuthenticationFilterTest.java | 10 +++--- .../und/server/auth/jwt/JwtProviderTest.java | 26 +++++++------- .../auth/oauth/OidcClientFactoryTest.java | 4 +-- .../auth/oauth/OidcProviderFactoryTest.java | 4 +-- .../auth/oauth/PublicKeyProviderTest.java | 4 +-- .../server/auth/service/AuthServiceTest.java | 27 +++++++------- .../server/auth/service/NonceServiceTest.java | 14 ++++---- .../auth/service/RefreshTokenServiceTest.java | 19 +++++----- .../common/controller/TestControllerTest.java | 11 +++--- .../controller/MemberControllerTest.java | 9 ++--- .../member/service/MemberServiceTest.java | 17 ++++----- 35 files changed, 204 insertions(+), 155 deletions(-) rename src/main/java/com/und/server/{common/exception/ServerErrorResult.java => auth/exception/AuthErrorResult.java} (74%) create mode 100644 src/main/java/com/und/server/common/exception/CommonErrorResult.java create mode 100644 src/main/java/com/und/server/member/exception/MemberErrorResult.java diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index e5791ffa..7ba5b493 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 diff --git a/build.gradle b/build.gradle index ceb1cea1..f5b82160 100644 --- a/build.gradle +++ b/build.gradle @@ -121,7 +121,7 @@ jacocoTestCoverageVerification { limit { counter = 'BRANCH' value = 'COVEREDRATIO' - minimum = 0.50 + minimum = 0.70 } // Maximum number of lines in a file diff --git a/src/main/java/com/und/server/auth/controller/AuthController.java b/src/main/java/com/und/server/auth/controller/AuthController.java index ed470024..1c6fdbe6 100644 --- a/src/main/java/com/und/server/auth/controller/AuthController.java +++ b/src/main/java/com/und/server/auth/controller/AuthController.java @@ -17,6 +17,7 @@ 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; @@ -51,8 +52,10 @@ public ResponseEntity reissueTokens( } @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/auth/dto/OidcPublicKeys.java b/src/main/java/com/und/server/auth/dto/OidcPublicKeys.java index 5f2ac2c9..3d48a124 100644 --- a/src/main/java/com/und/server/auth/dto/OidcPublicKeys.java +++ b/src/main/java/com/und/server/auth/dto/OidcPublicKeys.java @@ -2,7 +2,7 @@ import java.util.List; -import com.und.server.common.exception.ServerErrorResult; +import com.und.server.auth.exception.AuthErrorResult; import com.und.server.common.exception.ServerException; import io.swagger.v3.oas.annotations.media.Schema; @@ -16,6 +16,6 @@ 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/common/exception/ServerErrorResult.java b/src/main/java/com/und/server/auth/exception/AuthErrorResult.java similarity index 74% rename from src/main/java/com/und/server/common/exception/ServerErrorResult.java rename to src/main/java/com/und/server/auth/exception/AuthErrorResult.java index 26e9c02e..26f154e5 100644 --- a/src/main/java/com/und/server/common/exception/ServerErrorResult.java +++ b/src/main/java/com/und/server/auth/exception/AuthErrorResult.java @@ -1,15 +1,16 @@ -package com.und.server.common.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 implements ErrorResult { +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"), @@ -22,10 +23,7 @@ public enum ServerErrorResult implements ErrorResult { 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"), - INVALID_MEMBER_ID(HttpStatus.BAD_REQUEST, "Invalid Member ID"), - MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "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/AuthMemberArgumentResolver.java b/src/main/java/com/und/server/auth/filter/AuthMemberArgumentResolver.java index 7295cc01..23035c38 100644 --- a/src/main/java/com/und/server/auth/filter/AuthMemberArgumentResolver.java +++ b/src/main/java/com/und/server/auth/filter/AuthMemberArgumentResolver.java @@ -9,7 +9,7 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; -import com.und.server.common.exception.ServerErrorResult; +import com.und.server.auth.exception.AuthErrorResult; import com.und.server.common.exception.ServerException; @Component @@ -26,7 +26,7 @@ public Object resolveArgument(final MethodParameter parameter, final ModelAndVie final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) { final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null || !(authentication.getPrincipal() instanceof final Long memberId)) { - throw new ServerException(ServerErrorResult.UNAUTHORIZED_ACCESS); + throw new ServerException(AuthErrorResult.UNAUTHORIZED_ACCESS); } return memberId; } diff --git a/src/main/java/com/und/server/auth/filter/CustomAuthenticationEntryPoint.java b/src/main/java/com/und/server/auth/filter/CustomAuthenticationEntryPoint.java index 4c2d78d9..e82bdf86 100644 --- a/src/main/java/com/und/server/auth/filter/CustomAuthenticationEntryPoint.java +++ b/src/main/java/com/und/server/auth/filter/CustomAuthenticationEntryPoint.java @@ -6,7 +6,7 @@ import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; -import com.und.server.common.exception.ServerErrorResult; +import com.und.server.auth.exception.AuthErrorResult; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -24,6 +24,6 @@ public void commence( 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/auth/filter/JwtAuthenticationFilter.java b/src/main/java/com/und/server/auth/filter/JwtAuthenticationFilter.java index f8b15e90..89eac499 100644 --- a/src/main/java/com/und/server/auth/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/und/server/auth/filter/JwtAuthenticationFilter.java @@ -9,8 +9,8 @@ import org.springframework.util.AntPathMatcher; import org.springframework.web.filter.OncePerRequestFilter; +import com.und.server.auth.exception.AuthErrorResult; import com.und.server.auth.jwt.JwtProvider; -import com.und.server.common.exception.ServerErrorResult; import com.und.server.common.exception.ServerException; import jakarta.servlet.FilterChain; @@ -44,7 +44,7 @@ protected void doFilterInternal( 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; } diff --git a/src/main/java/com/und/server/auth/jwt/JwtProvider.java b/src/main/java/com/und/server/auth/jwt/JwtProvider.java index 671a6506..d56045f2 100644 --- a/src/main/java/com/und/server/auth/jwt/JwtProvider.java +++ b/src/main/java/com/und/server/auth/jwt/JwtProvider.java @@ -16,8 +16,8 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.und.server.auth.exception.AuthErrorResult; import com.und.server.auth.oauth.IdTokenPayload; -import com.und.server.common.exception.ServerErrorResult; import com.und.server.common.exception.ServerException; import com.und.server.common.util.ProfileManager; @@ -45,7 +45,7 @@ public Map getDecodedHeader(final String token) { final String decodedHeader = decodeBase64UrlPart(token.split("\\.")[0]); return new ObjectMapper().readValue(decodedHeader, new TypeReference<>() { }); } catch (final Exception e) { - throw new ServerException(ServerErrorResult.INVALID_TOKEN, e); + throw new ServerException(AuthErrorResult.INVALID_TOKEN, e); } } @@ -55,7 +55,7 @@ public String extractNonce(final String idToken) { final Map claims = new ObjectMapper().readValue(payloadJson, new TypeReference<>() { }); return (String) claims.get("nonce"); } catch (final Exception e) { - throw new ServerException(ServerErrorResult.INVALID_TOKEN, e); + throw new ServerException(AuthErrorResult.INVALID_TOKEN, e); } } @@ -107,7 +107,7 @@ private Claims parseClaims(final String token, final JwtParserBuilder builder) { try { return parseToken(token, builder); } catch (final ExpiredJwtException e) { - throw new ServerException(ServerErrorResult.EXPIRED_TOKEN, e); + throw new ServerException(AuthErrorResult.EXPIRED_TOKEN, e); } } @@ -132,24 +132,22 @@ private Claims parseToken(final String token, final JwtParserBuilder builder) { } catch (final JwtException e) { // For prod or stg environments, return a generic error to avoid leaking details. if (profileManager.isProdOrStgProfile()) { - throw new ServerException(ServerErrorResult.UNAUTHORIZED_ACCESS, e); + throw new ServerException(AuthErrorResult.UNAUTHORIZED_ACCESS, e); } // For non-production environments, provide detailed error messages. if (e instanceof MalformedJwtException) { - throw new ServerException(ServerErrorResult.MALFORMED_TOKEN, e); + 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); } - if (e instanceof UnsupportedJwtException) { - throw new ServerException(ServerErrorResult.UNSUPPORTED_TOKEN, e); - } - if (e instanceof WeakKeyException) { - throw new ServerException(ServerErrorResult.WEAK_TOKEN_KEY, e); - } - if (e instanceof SignatureException) { - throw new ServerException(ServerErrorResult.INVALID_TOKEN_SIGNATURE, e); - } - // Fallback for any other JWT-related exceptions. - throw new ServerException(ServerErrorResult.INVALID_TOKEN, e); } } @@ -172,14 +170,14 @@ private Long getMemberIdFromClaims(final Claims claims) { return Long.valueOf(subject); } catch (final NumberFormatException e) { // The subject was not a valid Long, which is unexpected for our tokens. - throw new ServerException(ServerErrorResult.INVALID_TOKEN, e); + throw new ServerException(AuthErrorResult.INVALID_TOKEN, e); } } private String getValidSubject(final Claims claims) { final String subject = claims.getSubject(); if (subject == null) { - throw new ServerException(ServerErrorResult.INVALID_TOKEN); + throw new ServerException(AuthErrorResult.INVALID_TOKEN); } return subject; } diff --git a/src/main/java/com/und/server/auth/oauth/OidcClientFactory.java b/src/main/java/com/und/server/auth/oauth/OidcClientFactory.java index 64b553b2..84b04d64 100644 --- a/src/main/java/com/und/server/auth/oauth/OidcClientFactory.java +++ b/src/main/java/com/und/server/auth/oauth/OidcClientFactory.java @@ -6,7 +6,7 @@ import org.springframework.stereotype.Component; -import com.und.server.common.exception.ServerErrorResult; +import com.und.server.auth.exception.AuthErrorResult; import com.und.server.common.exception.ServerException; @Component @@ -21,7 +21,7 @@ public OidcClientFactory(final KakaoClient kakaoClient) { public OidcClient getOidcClient(final Provider provider) { return Optional.ofNullable(oidcClients.get(provider)) - .orElseThrow(() -> new ServerException(ServerErrorResult.INVALID_PROVIDER)); + .orElseThrow(() -> new ServerException(AuthErrorResult.INVALID_PROVIDER)); } } diff --git a/src/main/java/com/und/server/auth/oauth/OidcProviderFactory.java b/src/main/java/com/und/server/auth/oauth/OidcProviderFactory.java index 0829f520..8db4a399 100644 --- a/src/main/java/com/und/server/auth/oauth/OidcProviderFactory.java +++ b/src/main/java/com/und/server/auth/oauth/OidcProviderFactory.java @@ -7,7 +7,7 @@ import org.springframework.stereotype.Component; import com.und.server.auth.dto.OidcPublicKeys; -import com.und.server.common.exception.ServerErrorResult; +import com.und.server.auth.exception.AuthErrorResult; import com.und.server.common.exception.ServerException; @Component @@ -30,7 +30,7 @@ public IdTokenPayload getIdTokenPayload( private OidcProvider getOidcProvider(final Provider provider) { return Optional.ofNullable(oidcProviders.get(provider)) - .orElseThrow(() -> new ServerException(ServerErrorResult.INVALID_PROVIDER)); + .orElseThrow(() -> new ServerException(AuthErrorResult.INVALID_PROVIDER)); } } diff --git a/src/main/java/com/und/server/auth/oauth/PublicKeyProvider.java b/src/main/java/com/und/server/auth/oauth/PublicKeyProvider.java index e153ffef..b5bf5e06 100644 --- a/src/main/java/com/und/server/auth/oauth/PublicKeyProvider.java +++ b/src/main/java/com/und/server/auth/oauth/PublicKeyProvider.java @@ -13,7 +13,7 @@ import com.und.server.auth.dto.OidcPublicKey; import com.und.server.auth.dto.OidcPublicKeys; -import com.und.server.common.exception.ServerErrorResult; +import com.und.server.auth.exception.AuthErrorResult; import com.und.server.common.exception.ServerException; @Component @@ -37,7 +37,7 @@ private PublicKey getPublicKey(final OidcPublicKey matchingKey) { return KeyFactory.getInstance(matchingKey.kty()).generatePublic(publicKeySpec); } catch (final IllegalArgumentException | NoSuchAlgorithmException | InvalidKeySpecException e) { - throw new ServerException(ServerErrorResult.INVALID_PUBLIC_KEY, e); + throw new ServerException(AuthErrorResult.INVALID_PUBLIC_KEY, e); } } diff --git a/src/main/java/com/und/server/auth/service/AuthService.java b/src/main/java/com/und/server/auth/service/AuthService.java index fef65696..56a67fcf 100644 --- a/src/main/java/com/und/server/auth/service/AuthService.java +++ b/src/main/java/com/und/server/auth/service/AuthService.java @@ -9,6 +9,7 @@ import com.und.server.auth.dto.NonceResponse; import com.und.server.auth.dto.OidcPublicKeys; import com.und.server.auth.dto.RefreshTokenRequest; +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; @@ -18,10 +19,10 @@ import com.und.server.auth.oauth.OidcProviderFactory; import com.und.server.auth.oauth.Provider; import com.und.server.common.dto.TestAuthRequest; -import com.und.server.common.exception.ServerErrorResult; 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; @@ -79,13 +80,13 @@ public AuthResponse reissueTokens(final RefreshTokenRequest refreshTokenRequest) try { memberService.validateMemberExists(memberId); } catch (final ServerException e) { - if (e.getErrorResult() == ServerErrorResult.MEMBER_NOT_FOUND) { + 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(ServerErrorResult.INVALID_TOKEN, e); + throw new ServerException(AuthErrorResult.INVALID_TOKEN, e); } refreshTokenService.validateRefreshToken(memberId, providedRefreshToken); @@ -102,7 +103,7 @@ private Provider convertToProvider(final String providerName) { try { return Provider.valueOf(providerName.toUpperCase()); } catch (final IllegalArgumentException e) { - throw new ServerException(ServerErrorResult.INVALID_PROVIDER); + throw new ServerException(AuthErrorResult.INVALID_PROVIDER); } } @@ -138,9 +139,9 @@ private Long getMemberIdForReissue(final String accessToken) { // For security, delete the refresh token. refreshTokenService.deleteRefreshToken(memberId); if (profileManager.isProdOrStgProfile()) { - throw new ServerException(ServerErrorResult.INVALID_TOKEN); + throw new ServerException(AuthErrorResult.INVALID_TOKEN); } - throw new ServerException(ServerErrorResult.NOT_EXPIRED_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 index c24e75f4..bdff657c 100644 --- a/src/main/java/com/und/server/auth/service/NonceService.java +++ b/src/main/java/com/und/server/auth/service/NonceService.java @@ -6,9 +6,9 @@ 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.ServerErrorResult; import com.und.server.common.exception.ServerException; import lombok.RequiredArgsConstructor; @@ -31,7 +31,7 @@ public void validateNonce(final String value, final Provider provider) { nonceRepository.findById(value) .filter(n -> n.getProvider() == provider) - .orElseThrow(() -> new ServerException(ServerErrorResult.INVALID_NONCE)); + .orElseThrow(() -> new ServerException(AuthErrorResult.INVALID_NONCE)); nonceRepository.deleteById(value); } @@ -51,13 +51,13 @@ public void saveNonce(final String value, final Provider provider) { private void validateNonceValue(final String value) { if (value == null) { - throw new ServerException(ServerErrorResult.INVALID_NONCE); + throw new ServerException(AuthErrorResult.INVALID_NONCE); } } private void validateProvider(final Provider provider) { if (provider == null) { - throw new ServerException(ServerErrorResult.INVALID_PROVIDER); + 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 index e93f0ef4..4e6533e5 100644 --- a/src/main/java/com/und/server/auth/service/RefreshTokenService.java +++ b/src/main/java/com/und/server/auth/service/RefreshTokenService.java @@ -6,9 +6,10 @@ 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.ServerErrorResult; import com.und.server.common.exception.ServerException; +import com.und.server.member.exception.MemberErrorResult; import lombok.RequiredArgsConstructor; @@ -33,7 +34,7 @@ public void validateRefreshToken(final Long memberId, final String providedToken .filter(savedToken -> providedToken.equals(savedToken)) .orElseThrow(() -> { deleteRefreshToken(memberId); - return new ServerException(ServerErrorResult.INVALID_TOKEN); + return new ServerException(AuthErrorResult.INVALID_TOKEN); }); } @@ -59,13 +60,13 @@ public void deleteRefreshToken(final Long memberId) { private void validateMemberIdIsNotNull(final Long memberId) { if (memberId == null) { - throw new ServerException(ServerErrorResult.INVALID_MEMBER_ID); + throw new ServerException(MemberErrorResult.INVALID_MEMBER_ID); } } private void validateTokenValueIsNotNull(final String token) { if (token == null) { - throw new ServerException(ServerErrorResult.INVALID_TOKEN); + throw new ServerException(AuthErrorResult.INVALID_TOKEN); } } 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..01dec192 --- /dev/null +++ b/src/main/java/com/und/server/common/exception/CommonErrorResult.java @@ -0,0 +1,18 @@ +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"), + 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/GlobalExceptionHandler.java b/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java index 57e0006b..b05913c3 100644 --- a/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java @@ -41,7 +41,7 @@ protected ResponseEntity handleMethodArgumentNotValid( .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}) @@ -60,8 +60,8 @@ 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/member/controller/MemberController.java b/src/main/java/com/und/server/member/controller/MemberController.java index e31b7709..e2ded8b5 100644 --- a/src/main/java/com/und/server/member/controller/MemberController.java +++ b/src/main/java/com/und/server/member/controller/MemberController.java @@ -14,6 +14,7 @@ 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; @@ -35,6 +36,7 @@ public ResponseEntity updateNickname( } @DeleteMapping("/member") + @ApiResponse(responseCode = "204", description = "Delete member successful") public ResponseEntity deleteMember(@Parameter(hidden = true) @AuthMember final Long memberId) { memberService.deleteMemberById(memberId); 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..906f05a9 --- /dev/null +++ b/src/main/java/com/und/server/member/exception/MemberErrorResult.java @@ -0,0 +1,20 @@ +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"); + + private final HttpStatus httpStatus; + private final String message; + +} diff --git a/src/main/java/com/und/server/member/service/MemberService.java b/src/main/java/com/und/server/member/service/MemberService.java index 43b7442b..ec7f30fc 100644 --- a/src/main/java/com/und/server/member/service/MemberService.java +++ b/src/main/java/com/und/server/member/service/MemberService.java @@ -6,14 +6,15 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.und.server.auth.exception.AuthErrorResult; import com.und.server.auth.oauth.IdTokenPayload; import com.und.server.auth.oauth.Provider; import com.und.server.auth.service.RefreshTokenService; -import com.und.server.common.exception.ServerErrorResult; import com.und.server.common.exception.ServerException; import com.und.server.member.dto.MemberResponse; import com.und.server.member.dto.NicknameRequest; import com.und.server.member.entity.Member; +import com.und.server.member.exception.MemberErrorResult; import com.und.server.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; @@ -46,14 +47,14 @@ public Member findMemberById(final Long memberId) { validateMemberIdIsNotNull(memberId); return memberRepository.findById(memberId) - .orElseThrow(() -> new ServerException(ServerErrorResult.MEMBER_NOT_FOUND)); + .orElseThrow(() -> new ServerException(MemberErrorResult.MEMBER_NOT_FOUND)); } public void validateMemberExists(final Long memberId) { validateMemberIdIsNotNull(memberId); if (!memberRepository.existsById(memberId)) { - throw new ServerException(ServerErrorResult.MEMBER_NOT_FOUND); + throw new ServerException(MemberErrorResult.MEMBER_NOT_FOUND); } } @@ -76,7 +77,7 @@ public void deleteMemberById(final Long memberId) { private Optional findMemberByProviderId(final Provider provider, final String providerId) { return switch (provider) { case KAKAO -> memberRepository.findByKakaoId(providerId); - default -> throw new ServerException(ServerErrorResult.INVALID_PROVIDER); + default -> throw new ServerException(AuthErrorResult.INVALID_PROVIDER); }; } @@ -84,7 +85,7 @@ private Member createMember(final Provider provider, final String providerId, fi final Member.MemberBuilder memberBuilder = Member.builder().nickname(nickname); switch (provider) { case KAKAO -> memberBuilder.kakaoId(providerId); - default -> throw new ServerException(ServerErrorResult.INVALID_PROVIDER); + default -> throw new ServerException(AuthErrorResult.INVALID_PROVIDER); } return memberRepository.save(memberBuilder.build()); @@ -92,19 +93,19 @@ private Member createMember(final Provider provider, final String providerId, fi private void validateMemberIdIsNotNull(final Long memberId) { if (memberId == null) { - throw new ServerException(ServerErrorResult.INVALID_MEMBER_ID); + throw new ServerException(MemberErrorResult.INVALID_MEMBER_ID); } } private void validateProviderIsNotNull(final Provider provider) { if (provider == null) { - throw new ServerException(ServerErrorResult.INVALID_PROVIDER); + throw new ServerException(AuthErrorResult.INVALID_PROVIDER); } } private void validateProviderIdIsNotNull(final String providerId) { if (providerId == null) { - throw new ServerException(ServerErrorResult.INVALID_PROVIDER_ID); + throw new ServerException(AuthErrorResult.INVALID_PROVIDER_ID); } } diff --git a/src/test/java/com/und/server/auth/controller/AuthControllerTest.java b/src/test/java/com/und/server/auth/controller/AuthControllerTest.java index d42fea39..7ba52598 100644 --- a/src/test/java/com/und/server/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/und/server/auth/controller/AuthControllerTest.java @@ -29,10 +29,11 @@ import com.und.server.auth.dto.NonceRequest; import com.und.server.auth.dto.NonceResponse; import com.und.server.auth.dto.RefreshTokenRequest; +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.ServerErrorResult; import com.und.server.common.exception.ServerException; @ExtendWith(MockitoExtension.class) @@ -75,7 +76,7 @@ void Given_HandshakeRequestWithNullProvider_When_Handshake_Then_ReturnsBadReques // then resultActions.andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(ServerErrorResult.INVALID_PARAMETER.name())) + .andExpect(jsonPath("$.code").value(CommonErrorResult.INVALID_PARAMETER.name())) .andExpect(jsonPath("$.message[0]").value("Provider name must not be blank")); } @@ -86,7 +87,7 @@ void Given_HandshakeRequestWithUnknownProvider_When_Handshake_Then_ReturnsErrorR final String url = "/v1/auth/nonce"; 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); @@ -143,7 +144,7 @@ void Given_LoginRequestWithNullProvider_When_Login_Then_ReturnsBadRequest() thro // then resultActions.andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(ServerErrorResult.INVALID_PARAMETER.name())) + .andExpect(jsonPath("$.code").value(CommonErrorResult.INVALID_PARAMETER.name())) .andExpect(jsonPath("$.message[0]").value("Provider name must not be blank")); } @@ -164,7 +165,7 @@ void Given_LoginRequestWithNullIdToken_When_Login_Then_ReturnsBadRequest() throw // then resultActions.andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(ServerErrorResult.INVALID_PARAMETER.name())) + .andExpect(jsonPath("$.code").value(CommonErrorResult.INVALID_PARAMETER.name())) .andExpect(jsonPath("$.message[0]").value("ID Token must not be blank")); } @@ -175,7 +176,7 @@ void Given_LoginRequestWithUnknownProvider_When_Login_Then_ReturnsErrorResponse( final String url = "/v1/auth/login"; 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); @@ -200,7 +201,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); @@ -274,7 +275,7 @@ void Given_RefreshTokenRequestWithNullAccessToken_When_ReissueTokens_Then_Return // then resultActions.andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(ServerErrorResult.INVALID_PARAMETER.name())) + .andExpect(jsonPath("$.code").value(CommonErrorResult.INVALID_PARAMETER.name())) .andExpect(jsonPath("$.message[0]").value("Access Token must not be blank")); } @@ -295,7 +296,7 @@ void Given_RefreshTokenRequestWithNullRefreshToken_When_ReissueTokens_Then_Retur // then resultActions.andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(ServerErrorResult.INVALID_PARAMETER.name())) + .andExpect(jsonPath("$.code").value(CommonErrorResult.INVALID_PARAMETER.name())) .andExpect(jsonPath("$.message[0]").value("Refresh Token must not be blank")); } @@ -343,7 +344,7 @@ void Given_ValidRefreshTokenRequest_When_ReissueTokens_Then_ReturnsOkWithNewToke void Given_UnauthenticatedUser_When_Logout_Then_ReturnsUnauthorized() throws Exception { // given final String url = "/v1/auth/logout"; - final ServerErrorResult errorResult = ServerErrorResult.UNAUTHORIZED_ACCESS; + final AuthErrorResult errorResult = AuthErrorResult.UNAUTHORIZED_ACCESS; doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); doThrow(new ServerException(errorResult)) diff --git a/src/test/java/com/und/server/auth/dto/OidcPublicKeysTest.java b/src/test/java/com/und/server/auth/dto/OidcPublicKeysTest.java index 991ac947..f56e7123 100644 --- a/src/test/java/com/und/server/auth/dto/OidcPublicKeysTest.java +++ b/src/test/java/com/und/server/auth/dto/OidcPublicKeysTest.java @@ -8,7 +8,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import com.und.server.common.exception.ServerErrorResult; +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 index b791ecd1..d086bdb8 100644 --- a/src/test/java/com/und/server/auth/filter/AuthMemberArgumentResolverTest.java +++ b/src/test/java/com/und/server/auth/filter/AuthMemberArgumentResolverTest.java @@ -16,7 +16,7 @@ import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; -import com.und.server.common.exception.ServerErrorResult; +import com.und.server.auth.exception.AuthErrorResult; import com.und.server.common.exception.ServerException; @ExtendWith(MockitoExtension.class) @@ -86,7 +86,7 @@ void Given_NullAuthentication_When_ResolveArgument_Then_ThrowsServerException() // when & then assertThatThrownBy(() -> authMemberArgumentResolver.resolveArgument(parameter, null, null, null)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.UNAUTHORIZED_ACCESS); + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.UNAUTHORIZED_ACCESS); } @Test @@ -101,7 +101,7 @@ void Given_InvalidPrincipalType_When_ResolveArgument_Then_ThrowsServerException( // when & then assertThatThrownBy(() -> authMemberArgumentResolver.resolveArgument(parameter, null, null, null)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.UNAUTHORIZED_ACCESS); + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.UNAUTHORIZED_ACCESS); } @Test diff --git a/src/test/java/com/und/server/auth/filter/CustomAuthenticationEntryPointTest.java b/src/test/java/com/und/server/auth/filter/CustomAuthenticationEntryPointTest.java index 85ca47ed..d082b5aa 100644 --- a/src/test/java/com/und/server/auth/filter/CustomAuthenticationEntryPointTest.java +++ b/src/test/java/com/und/server/auth/filter/CustomAuthenticationEntryPointTest.java @@ -10,8 +10,8 @@ import org.springframework.security.core.AuthenticationException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.und.server.auth.exception.AuthErrorResult; import com.und.server.common.dto.ErrorResponse; -import com.und.server.common.exception.ServerErrorResult; 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/auth/filter/JwtAuthenticationFilterTest.java b/src/test/java/com/und/server/auth/filter/JwtAuthenticationFilterTest.java index f2457b83..f71b51d2 100644 --- a/src/test/java/com/und/server/auth/filter/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/und/server/auth/filter/JwtAuthenticationFilterTest.java @@ -22,9 +22,9 @@ import org.springframework.security.core.context.SecurityContextHolder; import com.fasterxml.jackson.databind.ObjectMapper; +import com.und.server.auth.exception.AuthErrorResult; import com.und.server.auth.jwt.JwtProvider; import com.und.server.common.dto.ErrorResponse; -import com.und.server.common.exception.ServerErrorResult; import com.und.server.common.exception.ServerException; import jakarta.servlet.FilterChain; @@ -65,7 +65,7 @@ void Given_ExpiredTokenOnProtectedRoute_When_Filter_Then_ErrorResponseIsSetAndCh request.setServletPath(protectedPath); request.addHeader("Authorization", "Bearer " + token); - final ServerErrorResult expectedError = ServerErrorResult.EXPIRED_TOKEN; + final AuthErrorResult expectedError = AuthErrorResult.EXPIRED_TOKEN; doThrow(new ServerException(expectedError)).when(jwtProvider).getAuthentication(token); // when @@ -95,7 +95,7 @@ void Given_TokenWithInvalidSignature_When_Filter_Then_ErrorResponseIsSetAndChain request.setServletPath(path); request.addHeader("Authorization", "Bearer " + token); - final ServerErrorResult expectedError = ServerErrorResult.INVALID_TOKEN_SIGNATURE; + final AuthErrorResult expectedError = AuthErrorResult.INVALID_TOKEN_SIGNATURE; doThrow(new ServerException(expectedError)).when(jwtProvider).getAuthentication(token); // when @@ -125,7 +125,7 @@ void Given_ExpiredTokenOnLoginPath_When_Filter_Then_ErrorResponseIsSetAndChainSt request.setServletPath(loginPath); request.addHeader("Authorization", "Bearer " + token); - final ServerErrorResult expectedError = ServerErrorResult.EXPIRED_TOKEN; + final AuthErrorResult expectedError = AuthErrorResult.EXPIRED_TOKEN; doThrow(new ServerException(expectedError)).when(jwtProvider).getAuthentication(token); // when @@ -150,7 +150,7 @@ void Given_ExpiredTokenOnTokenReissuePath_When_Filter_Then_ChainContinues() thro request.setServletPath(permissivePath); request.addHeader("Authorization", "Bearer " + token); - final ServerErrorResult expectedError = ServerErrorResult.EXPIRED_TOKEN; + final AuthErrorResult expectedError = AuthErrorResult.EXPIRED_TOKEN; doThrow(new ServerException(expectedError)).when(jwtProvider).getAuthentication(token); // when diff --git a/src/test/java/com/und/server/auth/jwt/JwtProviderTest.java b/src/test/java/com/und/server/auth/jwt/JwtProviderTest.java index fdf5402f..a81afc03 100644 --- a/src/test/java/com/und/server/auth/jwt/JwtProviderTest.java +++ b/src/test/java/com/und/server/auth/jwt/JwtProviderTest.java @@ -23,8 +23,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.core.Authentication; +import com.und.server.auth.exception.AuthErrorResult; import com.und.server.auth.oauth.IdTokenPayload; -import com.und.server.common.exception.ServerErrorResult; import com.und.server.common.exception.ServerException; import com.und.server.common.util.ProfileManager; @@ -66,7 +66,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 @@ -79,7 +79,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 @@ -91,7 +91,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 @@ -133,7 +133,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 @@ -286,7 +286,7 @@ 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 @@ -300,7 +300,7 @@ void Given_MalformedToken_When_GetMemberIdFromToken_Then_ThrowsServerException() // when & then assertThatThrownBy(() -> jwtProvider.getMemberIdFromToken(malformedToken)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.MALFORMED_TOKEN); + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.MALFORMED_TOKEN); } @Test @@ -315,7 +315,7 @@ 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 @@ -335,7 +335,7 @@ void Given_TokenWithStrongAlgAndProviderWithWeakKey_When_GetMemberIdFromToken_Th // when & then assertThatThrownBy(() -> jwtProvider.getMemberIdFromToken(tokenWithStrongAlg)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.WEAK_TOKEN_KEY) + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.WEAK_TOKEN_KEY) .cause().isInstanceOf(WeakKeyException.class); } @@ -356,7 +356,7 @@ void Given_TokenWithUnsupportedFeature_When_GetMemberIdFromToken_Then_ThrowsUnsu // when & then assertThatThrownBy(() -> jwtProvider.getMemberIdFromToken(unsupportedToken)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.UNSUPPORTED_TOKEN) + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.UNSUPPORTED_TOKEN) .cause().isInstanceOf(UnsupportedJwtException.class); } @@ -371,7 +371,7 @@ void Given_MalformedToken_When_GetMemberIdFromToken_Then_ThrowsGenericUnauthoriz // when & then assertThatThrownBy(() -> jwtProvider.getMemberIdFromToken(malformedToken)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.UNAUTHORIZED_ACCESS) + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.UNAUTHORIZED_ACCESS) // Verify that the cause is the original exception .cause().isInstanceOf(MalformedJwtException.class); } @@ -389,7 +389,7 @@ void Given_TokenWithNonNumericSubject_When_GetMemberIdFromToken_Then_ThrowsServe // when & then assertThatThrownBy(() -> jwtProvider.getMemberIdFromToken(token)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_TOKEN) + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.INVALID_TOKEN) .cause().isInstanceOf(NumberFormatException.class); } @@ -406,7 +406,7 @@ void Given_TokenWithNullSubject_When_GetMemberIdFromToken_Then_ThrowsServerExcep // when & then assertThatThrownBy(() -> jwtProvider.getMemberIdFromToken(token)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_TOKEN); + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.INVALID_TOKEN); } @Test diff --git a/src/test/java/com/und/server/auth/oauth/OidcClientFactoryTest.java b/src/test/java/com/und/server/auth/oauth/OidcClientFactoryTest.java index 35ac7013..b04738d5 100644 --- a/src/test/java/com/und/server/auth/oauth/OidcClientFactoryTest.java +++ b/src/test/java/com/und/server/auth/oauth/OidcClientFactoryTest.java @@ -10,7 +10,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.und.server.common.exception.ServerErrorResult; +import com.und.server.auth.exception.AuthErrorResult; import com.und.server.common.exception.ServerException; @ExtendWith(MockitoExtension.class) @@ -32,7 +32,7 @@ void Given_NullProvider_When_GetOidcClient_Then_ThrowsServerException() { // when & then assertThatThrownBy(() -> oidcClientFactory.getOidcClient(null)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_PROVIDER); + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.INVALID_PROVIDER); } @Test diff --git a/src/test/java/com/und/server/auth/oauth/OidcProviderFactoryTest.java b/src/test/java/com/und/server/auth/oauth/OidcProviderFactoryTest.java index 337b2734..0723c1fc 100644 --- a/src/test/java/com/und/server/auth/oauth/OidcProviderFactoryTest.java +++ b/src/test/java/com/und/server/auth/oauth/OidcProviderFactoryTest.java @@ -12,7 +12,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.und.server.auth.dto.OidcPublicKeys; -import com.und.server.common.exception.ServerErrorResult; +import com.und.server.auth.exception.AuthErrorResult; import com.und.server.common.exception.ServerException; @ExtendWith(MockitoExtension.class) @@ -41,7 +41,7 @@ void Given_NullProvider_When_GetIdTokenPayload_Then_ThrowsServerException() { // when & then assertThatThrownBy(() -> factory.getIdTokenPayload(null, token, oidcPublicKeys)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ServerErrorResult.INVALID_PROVIDER); + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.INVALID_PROVIDER); } @Test diff --git a/src/test/java/com/und/server/auth/oauth/PublicKeyProviderTest.java b/src/test/java/com/und/server/auth/oauth/PublicKeyProviderTest.java index 8d9af2f3..8f499b09 100644 --- a/src/test/java/com/und/server/auth/oauth/PublicKeyProviderTest.java +++ b/src/test/java/com/und/server/auth/oauth/PublicKeyProviderTest.java @@ -15,7 +15,7 @@ import com.und.server.auth.dto.OidcPublicKey; import com.und.server.auth.dto.OidcPublicKeys; -import com.und.server.common.exception.ServerErrorResult; +import com.und.server.auth.exception.AuthErrorResult; import com.und.server.common.exception.ServerException; @ExtendWith(MockitoExtension.class) @@ -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/service/AuthServiceTest.java b/src/test/java/com/und/server/auth/service/AuthServiceTest.java index 1aed7e31..b4481a68 100644 --- a/src/test/java/com/und/server/auth/service/AuthServiceTest.java +++ b/src/test/java/com/und/server/auth/service/AuthServiceTest.java @@ -23,6 +23,7 @@ import com.und.server.auth.dto.NonceResponse; import com.und.server.auth.dto.OidcPublicKeys; import com.und.server.auth.dto.RefreshTokenRequest; +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; @@ -32,10 +33,10 @@ import com.und.server.auth.oauth.OidcProviderFactory; import com.und.server.auth.oauth.Provider; import com.und.server.common.dto.TestAuthRequest; -import com.und.server.common.exception.ServerErrorResult; 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) @@ -127,7 +128,7 @@ void Given_InvalidProvider_When_Handshake_Then_ThrowsException() { final ServerException exception = assertThrows(ServerException.class, () -> authService.handshake(nonceRequest)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_PROVIDER); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER); } @Test @@ -160,7 +161,7 @@ void Given_InvalidProvider_When_Login_Then_ThrowsException() { final ServerException exception = assertThrows(ServerException.class, () -> authService.login(authRequest)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_PROVIDER); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER); } @Test @@ -230,14 +231,14 @@ void Given_TokenWithInvalidMemberId_When_ReissueTokens_Then_ThrowsExceptionAndDo final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); doReturn(invalidTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); - doThrow(new ServerException(ServerErrorResult.INVALID_MEMBER_ID)) + doThrow(new ServerException(MemberErrorResult.INVALID_MEMBER_ID)) .when(memberService).validateMemberExists(nullMemberId); // when & then final ServerException exception = assertThrows(ServerException.class, () -> authService.reissueTokens(request)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); + 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()).validateRefreshToken(any(), any()); @@ -251,14 +252,14 @@ void Given_NonExistentMember_When_ReissueTokens_Then_ThrowsExceptionAndDeletesTo final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); - doThrow(new ServerException(ServerErrorResult.MEMBER_NOT_FOUND)) + doThrow(new ServerException(MemberErrorResult.MEMBER_NOT_FOUND)) .when(memberService).validateMemberExists(memberId); // when & then final ServerException exception = assertThrows(ServerException.class, () -> authService.reissueTokens(request)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); verify(refreshTokenService).deleteRefreshToken(memberId); verify(refreshTokenService, never()).validateRefreshToken(any(), any()); } @@ -272,14 +273,14 @@ void Given_MismatchedRefreshToken_When_ReissueTokens_Then_ThrowsException() { doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); doNothing().when(memberService).validateMemberExists(memberId); - doThrow(new ServerException(ServerErrorResult.INVALID_TOKEN)) + doThrow(new ServerException(AuthErrorResult.INVALID_TOKEN)) .when(refreshTokenService).validateRefreshToken(memberId, "wrong.refresh.token"); // when & then final ServerException exception = assertThrows(ServerException.class, () -> authService.reissueTokens(request)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); } @Test @@ -291,7 +292,7 @@ void Given_NoStoredRefreshToken_When_ReissueTokens_Then_ThrowsException() { doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); doNothing().when(memberService).validateMemberExists(memberId); - doThrow(new ServerException(ServerErrorResult.INVALID_TOKEN)) + doThrow(new ServerException(AuthErrorResult.INVALID_TOKEN)) .when(refreshTokenService).validateRefreshToken(memberId, refreshToken); // when & then @@ -299,7 +300,7 @@ void Given_NoStoredRefreshToken_When_ReissueTokens_Then_ThrowsException() { () -> authService.reissueTokens(request)); verify(jwtProvider, never()).generateAccessToken(any()); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); } @Test @@ -342,7 +343,7 @@ void Given_NonExpiredTokenOnProd_When_ReissueTokens_Then_ThrowsInvalidToken() { () -> authService.reissueTokens(request)); verify(refreshTokenService).deleteRefreshToken(memberId); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); } @Test @@ -360,7 +361,7 @@ void Given_NonExpiredTokenOnDev_When_ReissueTokens_Then_ThrowsNotExpiredToken() () -> authService.reissueTokens(request)); verify(refreshTokenService).deleteRefreshToken(memberId); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.NOT_EXPIRED_TOKEN); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.NOT_EXPIRED_TOKEN); } @Test diff --git a/src/test/java/com/und/server/auth/service/NonceServiceTest.java b/src/test/java/com/und/server/auth/service/NonceServiceTest.java index 39278ab4..ba5b08e3 100644 --- a/src/test/java/com/und/server/auth/service/NonceServiceTest.java +++ b/src/test/java/com/und/server/auth/service/NonceServiceTest.java @@ -19,9 +19,9 @@ 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.ServerErrorResult; import com.und.server.common.exception.ServerException; @ExtendWith(MockitoExtension.class) @@ -53,7 +53,7 @@ void Given_NullNonceValue_When_ValidateNonce_Then_ThrowsException() { // when & then final ServerException exception = assertThrows(ServerException.class, () -> nonceService.validateNonce(null, provider)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_NONCE); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_NONCE); } @Test @@ -62,7 +62,7 @@ void Given_NullProvider_When_ValidateNonce_Then_ThrowsException() { // when & then final ServerException exception = assertThrows(ServerException.class, () -> nonceService.validateNonce(nonceValue, null)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_PROVIDER); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER); } @Test @@ -71,7 +71,7 @@ void Given_NullNonceValue_When_SaveNonce_Then_ThrowsException() { // when & then final ServerException exception = assertThrows(ServerException.class, () -> nonceService.saveNonce(null, provider)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_NONCE); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_NONCE); } @Test @@ -80,7 +80,7 @@ void Given_NullProvider_When_SaveNonce_Then_ThrowsException() { // when & then final ServerException exception = assertThrows(ServerException.class, () -> nonceService.saveNonce(nonceValue, null)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_PROVIDER); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER); } @Test @@ -104,7 +104,7 @@ void Given_NonExistentNonce_When_ValidateNonce_Then_ThrowsException() { // when & then final ServerException exception = assertThrows(ServerException.class, () -> nonceService.validateNonce(nonceValue, provider)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_NONCE); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_NONCE); } @Test @@ -122,7 +122,7 @@ void Given_MismatchedProvider_When_ValidateNonce_Then_ThrowsException() { final ServerException exception = assertThrows(ServerException.class, () -> nonceService.validateNonce(nonceValue, differentProvider)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_NONCE); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_NONCE); // The nonce should not be deleted if the provider does not match verify(nonceRepository, never()).deleteById(nonceValue); } diff --git a/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java b/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java index 6d3ec47b..1e1e64f5 100644 --- a/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java +++ b/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java @@ -18,9 +18,10 @@ 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.ServerErrorResult; import com.und.server.common.exception.ServerException; +import com.und.server.member.exception.MemberErrorResult; @ExtendWith(MockitoExtension.class) class RefreshTokenServiceTest { @@ -70,7 +71,7 @@ void Given_NullToken_When_SaveRefreshToken_Then_ThrowsException() { () -> refreshTokenService.saveRefreshToken(memberId, null)); // then - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); } @Test @@ -81,7 +82,7 @@ void Given_NullToken_When_ValidateRefreshToken_Then_ThrowsException() { () -> refreshTokenService.validateRefreshToken(memberId, null)); // then - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); } @Test @@ -91,7 +92,7 @@ void Given_NullMemberId_When_SaveRefreshToken_Then_ThrowsException() { final ServerException exception = assertThrows(ServerException.class, () -> refreshTokenService.saveRefreshToken(null, refreshTokenValue)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_MEMBER_ID); + assertThat(exception.getErrorResult()).isEqualTo(MemberErrorResult.INVALID_MEMBER_ID); } @Test @@ -101,7 +102,7 @@ void Given_NullMemberId_When_ValidateRefreshToken_Then_ThrowsException() { final ServerException exception = assertThrows(ServerException.class, () -> refreshTokenService.validateRefreshToken(null, refreshTokenValue)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_MEMBER_ID); + assertThat(exception.getErrorResult()).isEqualTo(MemberErrorResult.INVALID_MEMBER_ID); } @Test @@ -111,7 +112,7 @@ void Given_NullMemberId_When_DeleteRefreshToken_Then_ThrowsException() { final ServerException exception = assertThrows(ServerException.class, () -> refreshTokenService.deleteRefreshToken(null)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_MEMBER_ID); + assertThat(exception.getErrorResult()).isEqualTo(MemberErrorResult.INVALID_MEMBER_ID); } @Test @@ -143,7 +144,7 @@ void Given_MismatchedToken_When_ValidateRefreshToken_Then_ThrowsExceptionAndDele () -> refreshTokenService.validateRefreshToken(memberId, "wrong-token")); // then - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); verify(refreshTokenRepository).deleteById(memberId); } @@ -162,7 +163,7 @@ void Given_StoredTokenWithValueNull_When_ValidateRefreshToken_Then_ThrowsExcepti () -> refreshTokenService.validateRefreshToken(memberId, refreshTokenValue)); // then - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); verify(refreshTokenRepository).deleteById(memberId); } @@ -177,7 +178,7 @@ void Given_NoStoredToken_When_ValidateRefreshToken_Then_ThrowsException() { () -> refreshTokenService.validateRefreshToken(memberId, refreshTokenValue)); // then - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_TOKEN); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); 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 index 4794c693..b3c41e48 100644 --- a/src/test/java/com/und/server/common/controller/TestControllerTest.java +++ b/src/test/java/com/und/server/common/controller/TestControllerTest.java @@ -25,14 +25,15 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.und.server.auth.dto.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.TestAuthRequest; import com.und.server.common.exception.GlobalExceptionHandler; -import com.und.server.common.exception.ServerErrorResult; import com.und.server.common.exception.ServerException; import com.und.server.member.dto.MemberResponse; import com.und.server.member.entity.Member; +import com.und.server.member.exception.MemberErrorResult; import com.und.server.member.service.MemberService; @ExtendWith(MockitoExtension.class) @@ -137,7 +138,7 @@ void Given_AuthenticatedUserNotFoundInDb_When_Greet_Then_ReturnsNotFound() throw final String url = "/v1/test/hello"; final Long memberId = 3L; - doThrow(new ServerException(ServerErrorResult.MEMBER_NOT_FOUND)).when(memberService).findMemberById(memberId); + 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()); @@ -148,8 +149,8 @@ void Given_AuthenticatedUserNotFoundInDb_When_Greet_Then_ReturnsNotFound() throw // then result.andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value(ServerErrorResult.MEMBER_NOT_FOUND.name())) - .andExpect(jsonPath("$.message").value(ServerErrorResult.MEMBER_NOT_FOUND.getMessage())); + .andExpect(jsonPath("$.code").value(MemberErrorResult.MEMBER_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").value(MemberErrorResult.MEMBER_NOT_FOUND.getMessage())); } @Test @@ -157,7 +158,7 @@ void Given_AuthenticatedUserNotFoundInDb_When_Greet_Then_ReturnsNotFound() throw void Given_UnauthenticatedUser_When_Greet_Then_ReturnsUnauthorized() throws Exception { // given final String url = "/v1/test/hello"; - final ServerErrorResult errorResult = ServerErrorResult.UNAUTHORIZED_ACCESS; + final AuthErrorResult errorResult = AuthErrorResult.UNAUTHORIZED_ACCESS; doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); doThrow(new ServerException(errorResult)) diff --git a/src/test/java/com/und/server/member/controller/MemberControllerTest.java b/src/test/java/com/und/server/member/controller/MemberControllerTest.java index fa698204..63d39d6b 100644 --- a/src/test/java/com/und/server/member/controller/MemberControllerTest.java +++ b/src/test/java/com/und/server/member/controller/MemberControllerTest.java @@ -21,9 +21,10 @@ 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.ServerErrorResult; import com.und.server.common.exception.ServerException; import com.und.server.member.dto.MemberResponse; import com.und.server.member.dto.NicknameRequest; @@ -73,7 +74,7 @@ void Given_NullNickname_When_UpdateNickname_Then_ReturnsBadRequest() throws Exce // then resultActions.andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(ServerErrorResult.INVALID_PARAMETER.name())) + .andExpect(jsonPath("$.code").value(CommonErrorResult.INVALID_PARAMETER.name())) .andExpect(jsonPath("$.message[0]").value("Nickname must not be blank")); } @@ -84,7 +85,7 @@ void Given_UnauthenticatedUser_When_UpdateNickname_Then_ReturnsUnauthorized() th final String url = "/v1/member/nickname"; final NicknameRequest request = new NicknameRequest("new-nickname"); final String requestBody = objectMapper.writeValueAsString(request); - final ServerErrorResult errorResult = ServerErrorResult.UNAUTHORIZED_ACCESS; + final AuthErrorResult errorResult = AuthErrorResult.UNAUTHORIZED_ACCESS; doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); doThrow(new ServerException(errorResult)) @@ -135,7 +136,7 @@ void Given_AuthenticatedUser_When_UpdateNickname_Then_ReturnsOkWithUpdatedInfo() void Given_UnauthenticatedUser_When_DeleteMember_Then_ReturnsUnauthorized() throws Exception { // given final String url = "/v1/member"; - final ServerErrorResult errorResult = ServerErrorResult.UNAUTHORIZED_ACCESS; + final AuthErrorResult errorResult = AuthErrorResult.UNAUTHORIZED_ACCESS; doReturn(true).when(authMemberArgumentResolver).supportsParameter(any()); doThrow(new ServerException(errorResult)) diff --git a/src/test/java/com/und/server/member/service/MemberServiceTest.java b/src/test/java/com/und/server/member/service/MemberServiceTest.java index 39739694..54f59345 100644 --- a/src/test/java/com/und/server/member/service/MemberServiceTest.java +++ b/src/test/java/com/und/server/member/service/MemberServiceTest.java @@ -18,14 +18,15 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.und.server.auth.exception.AuthErrorResult; import com.und.server.auth.oauth.IdTokenPayload; import com.und.server.auth.oauth.Provider; import com.und.server.auth.service.RefreshTokenService; -import com.und.server.common.exception.ServerErrorResult; import com.und.server.common.exception.ServerException; import com.und.server.member.dto.MemberResponse; import com.und.server.member.dto.NicknameRequest; import com.und.server.member.entity.Member; +import com.und.server.member.exception.MemberErrorResult; import com.und.server.member.repository.MemberRepository; @ExtendWith(MockitoExtension.class) @@ -101,7 +102,7 @@ void Given_UnsupportedProvider_When_FindOrCreateMember_Then_ThrowsException() { final ServerException exception = assertThrows(ServerException.class, () -> memberService.findOrCreateMember(unsupportedProvider, payload)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_PROVIDER); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER); } @Test @@ -114,7 +115,7 @@ void Given_NullProvider_When_FindOrCreateMember_Then_ThrowsException() { final ServerException exception = assertThrows(ServerException.class, () -> memberService.findOrCreateMember(null, payload)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_PROVIDER); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER); } @Test @@ -127,7 +128,7 @@ void Given_NullProviderId_When_FindOrCreateMember_Then_ThrowsException() { final ServerException exception = assertThrows(ServerException.class, () -> memberService.findOrCreateMember(provider, payloadWithNullId)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_PROVIDER_ID); + assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER_ID); } @Test @@ -140,7 +141,7 @@ void Given_NonExistingMemberId_When_FindMemberById_Then_ThrowsException() { final ServerException exception = assertThrows(ServerException.class, () -> memberService.findMemberById(memberId)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.MEMBER_NOT_FOUND); + assertThat(exception.getErrorResult()).isEqualTo(MemberErrorResult.MEMBER_NOT_FOUND); } @Test @@ -150,7 +151,7 @@ void Given_NullMemberId_When_FindMemberById_Then_ThrowsException() { final ServerException exception = assertThrows(ServerException.class, () -> memberService.findMemberById(null)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_MEMBER_ID); + assertThat(exception.getErrorResult()).isEqualTo(MemberErrorResult.INVALID_MEMBER_ID); } @Test @@ -159,7 +160,7 @@ void Given_NullMemberId_When_ValidateMemberExists_Then_ThrowsException() { // when & then final ServerException exception = assertThrows(ServerException.class, () -> memberService.validateMemberExists(null)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.INVALID_MEMBER_ID); + assertThat(exception.getErrorResult()).isEqualTo(MemberErrorResult.INVALID_MEMBER_ID); } @Test @@ -171,7 +172,7 @@ void Given_NonExistentMemberId_When_ValidateMemberExists_Then_ThrowsException() // when & then final ServerException exception = assertThrows(ServerException.class, () -> memberService.validateMemberExists(memberId)); - assertThat(exception.getErrorResult()).isEqualTo(ServerErrorResult.MEMBER_NOT_FOUND); + assertThat(exception.getErrorResult()).isEqualTo(MemberErrorResult.MEMBER_NOT_FOUND); } @Test From 0d350fc5b8f22a154ed71c15ce3e89a025e268dd Mon Sep 17 00:00:00 2001 From: Chori <105255517+choridev@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:24:45 +0900 Subject: [PATCH 16/26] =?UTF-8?q?=F0=9F=94=80=20Verify=20ID=20Token=20from?= =?UTF-8?q?=20Apple=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🧑‍💻 Add buttons to link of Github * 🚚 Rename deployment to deploy * 🚧 Add Apple Provider and Client * 🔒️ Activate TestController only on prod or stg * ✨ Verify ID Token from Apple * 🚚 Rename validate to verify * 🗃️ Add Apple ID column to member table * 🗃️ Add constraints to columns * 🗃️ Change the time zone of DB server to UTC * ✏️ Fix a typo * ♻️ Clear the review by Gemini --- ...eployment-to-dev.yml => deploy-to-dev.yml} | 6 +- ...loyment-to-prod.yml => deploy-to-prod.yml} | 6 +- ...eployment-to-stg.yml => deploy-to-stg.yml} | 6 +- .github/workflows/notify-assigned-issue.yml | 2 +- .github/workflows/validate-pr.yml | 4 +- build.gradle | 3 +- .../server/auth/config/SecurityConfig.java | 28 +-- .../com/und/server/auth/jwt/JwtProvider.java | 8 +- .../und/server/auth/oauth/AppleClient.java | 17 ++ .../und/server/auth/oauth/AppleProvider.java | 45 +++++ .../und/server/auth/oauth/IdTokenPayload.java | 6 - .../und/server/auth/oauth/KakaoProvider.java | 2 +- .../server/auth/oauth/OidcClientFactory.java | 6 +- .../und/server/auth/oauth/OidcProvider.java | 2 +- .../auth/oauth/OidcProviderFactory.java | 10 +- .../und/server/auth/service/AuthService.java | 25 +-- .../und/server/auth/service/NonceService.java | 2 +- .../auth/service/RefreshTokenService.java | 2 +- .../common/controller/TestController.java | 5 +- .../server/common/dto/TestAuthRequest.java | 6 +- .../common/exception/CommonErrorResult.java | 1 + .../exception/GlobalExceptionHandler.java | 23 +++ .../und/server/member/dto/MemberResponse.java | 4 + .../com/und/server/member/entity/Member.java | 14 +- .../member/exception/MemberErrorResult.java | 4 +- .../member/repository/MemberRepository.java | 2 + .../server/member/service/MemberService.java | 18 +- src/main/resources/application-dev.yml | 2 +- src/main/resources/application-prod.yml | 2 +- src/main/resources/application.yml | 6 +- .../db/migration/V1__create_member_table.sql | 9 +- .../auth/controller/AuthControllerTest.java | 67 +++++++- .../und/server/auth/jwt/JwtProviderTest.java | 18 +- .../server/auth/oauth/AppleProviderTest.java | 63 +++++++ .../server/auth/oauth/KakaoProviderTest.java | 12 +- .../auth/oauth/OidcClientFactoryTest.java | 23 ++- .../auth/oauth/OidcProviderFactoryTest.java | 37 ++-- .../auth/repository/NonceRepositoryTest.java | 73 +++++++- .../server/auth/service/AuthServiceTest.java | 159 +++++++++++++----- .../server/auth/service/NonceServiceTest.java | 56 ++++-- .../auth/service/RefreshTokenServiceTest.java | 24 +-- .../common/controller/TestControllerTest.java | 30 +--- .../controller/MemberControllerTest.java | 2 +- .../server/member/dto/MemberResponseTest.java | 29 +++- .../repository/MemberRepositoryTest.java | 65 +++++-- .../member/service/MemberServiceTest.java | 112 ++++++++---- 46 files changed, 778 insertions(+), 268 deletions(-) rename .github/workflows/{deployment-to-dev.yml => deploy-to-dev.yml} (95%) rename .github/workflows/{deployment-to-prod.yml => deploy-to-prod.yml} (95%) rename .github/workflows/{deployment-to-stg.yml => deploy-to-stg.yml} (95%) create mode 100644 src/main/java/com/und/server/auth/oauth/AppleClient.java create mode 100644 src/main/java/com/und/server/auth/oauth/AppleProvider.java delete mode 100644 src/main/java/com/und/server/auth/oauth/IdTokenPayload.java create mode 100644 src/test/java/com/und/server/auth/oauth/AppleProviderTest.java diff --git a/.github/workflows/deployment-to-dev.yml b/.github/workflows/deploy-to-dev.yml similarity index 95% rename from .github/workflows/deployment-to-dev.yml rename to .github/workflows/deploy-to-dev.yml index dbe0d916..fabf2485 100644 --- a/.github/workflows/deployment-to-dev.yml +++ b/.github/workflows/deploy-to-dev.yml @@ -1,4 +1,4 @@ -name: Deployment to Development Server +name: Deploy to Development Server on: push: @@ -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/deployment-to-stg.yml b/.github/workflows/deploy-to-stg.yml similarity index 95% rename from .github/workflows/deployment-to-stg.yml rename to .github/workflows/deploy-to-stg.yml index 64514061..347d7040 100644 --- a/.github/workflows/deployment-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/validate-pr.yml b/.github/workflows/validate-pr.yml index 7ba5b493..2916aaa9 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -72,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", @@ -103,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 f5b82160..66a36107 100644 --- a/build.gradle +++ b/build.gradle @@ -138,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/src/main/java/com/und/server/auth/config/SecurityConfig.java b/src/main/java/com/und/server/auth/config/SecurityConfig.java index ca5b34bc..7340d0bd 100644 --- a/src/main/java/com/und/server/auth/config/SecurityConfig.java +++ b/src/main/java/com/und/server/auth/config/SecurityConfig.java @@ -17,6 +17,7 @@ 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() { @@ -53,19 +55,25 @@ public SecurityFilterChain actuatorSecurityFilterChain(final HttpSecurity http) @Order(2) 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/jwt/JwtProvider.java b/src/main/java/com/und/server/auth/jwt/JwtProvider.java index d56045f2..20ee0f42 100644 --- a/src/main/java/com/und/server/auth/jwt/JwtProvider.java +++ b/src/main/java/com/und/server/auth/jwt/JwtProvider.java @@ -17,7 +17,6 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.und.server.auth.exception.AuthErrorResult; -import com.und.server.auth.oauth.IdTokenPayload; import com.und.server.common.exception.ServerException; import com.und.server.common.util.ProfileManager; @@ -59,7 +58,7 @@ public String extractNonce(final String idToken) { } } - public IdTokenPayload parseOidcIdToken( + public String parseOidcIdToken( final String token, final String iss, final String aud, @@ -71,10 +70,7 @@ public IdTokenPayload parseOidcIdToken( .requireAudience(aud); final Claims claims = parseClaims(token, builder); - return new IdTokenPayload( - getValidSubject(claims), - claims.get("nickname", String.class) - ); + return getValidSubject(claims); } public String generateAccessToken(final Long memberId) { 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/auth/oauth/IdTokenPayload.java b/src/main/java/com/und/server/auth/oauth/IdTokenPayload.java deleted file mode 100644 index 7354e3f2..00000000 --- a/src/main/java/com/und/server/auth/oauth/IdTokenPayload.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.und.server.auth.oauth; - -public record IdTokenPayload( - String providerId, - String nickname -) { } diff --git a/src/main/java/com/und/server/auth/oauth/KakaoProvider.java b/src/main/java/com/und/server/auth/oauth/KakaoProvider.java index b677ddce..7fb84265 100644 --- a/src/main/java/com/und/server/auth/oauth/KakaoProvider.java +++ b/src/main/java/com/und/server/auth/oauth/KakaoProvider.java @@ -30,7 +30,7 @@ public KakaoProvider( } @Override - public IdTokenPayload getIdTokenPayload(final String token, final 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/OidcClientFactory.java b/src/main/java/com/und/server/auth/oauth/OidcClientFactory.java index 84b04d64..73a67905 100644 --- a/src/main/java/com/und/server/auth/oauth/OidcClientFactory.java +++ b/src/main/java/com/und/server/auth/oauth/OidcClientFactory.java @@ -14,9 +14,13 @@ public class OidcClientFactory { private final Map oidcClients; - public OidcClientFactory(final KakaoClient kakaoClient) { + 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) { diff --git a/src/main/java/com/und/server/auth/oauth/OidcProvider.java b/src/main/java/com/und/server/auth/oauth/OidcProvider.java index 38b0fc82..86a376fd 100644 --- a/src/main/java/com/und/server/auth/oauth/OidcProvider.java +++ b/src/main/java/com/und/server/auth/oauth/OidcProvider.java @@ -4,6 +4,6 @@ public interface OidcProvider { - IdTokenPayload getIdTokenPayload(final String token, final OidcPublicKeys oidcPublicKeys); + 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 index 8db4a399..a2a8f7cf 100644 --- a/src/main/java/com/und/server/auth/oauth/OidcProviderFactory.java +++ b/src/main/java/com/und/server/auth/oauth/OidcProviderFactory.java @@ -15,17 +15,21 @@ public class OidcProviderFactory { private final Map oidcProviders; - public OidcProviderFactory(final KakaoProvider kakaoProvider) { + 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 IdTokenPayload getIdTokenPayload( + public String getProviderId( final Provider provider, final String token, final OidcPublicKeys oidcPublicKeys ) { - return getOidcProvider(provider).getIdTokenPayload(token, oidcPublicKeys); + return getOidcProvider(provider).getProviderId(token, oidcPublicKeys); } private OidcProvider getOidcProvider(final Provider provider) { diff --git a/src/main/java/com/und/server/auth/service/AuthService.java b/src/main/java/com/und/server/auth/service/AuthService.java index 56a67fcf..aeb27580 100644 --- a/src/main/java/com/und/server/auth/service/AuthService.java +++ b/src/main/java/com/und/server/auth/service/AuthService.java @@ -13,7 +13,6 @@ 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.IdTokenPayload; import com.und.server.auth.oauth.OidcClient; import com.und.server.auth.oauth.OidcClientFactory; import com.und.server.auth.oauth.OidcProviderFactory; @@ -41,12 +40,11 @@ public class AuthService { private final RefreshTokenService refreshTokenService; private final ProfileManager profileManager; - // FIXME: Remove this method when deleting TestController @Transactional public AuthResponse issueTokensForTest(final TestAuthRequest request) { final Provider provider = convertToProvider(request.provider()); - final IdTokenPayload idTokenPayload = new IdTokenPayload(request.providerId(), request.nickname()); - final Member member = memberService.findOrCreateMember(provider, idTokenPayload); + final String providerId = request.providerId(); + final Member member = memberService.findOrCreateMember(provider, providerId); return issueTokens(member.getId()); } @@ -64,8 +62,11 @@ public NonceResponse handshake(final NonceRequest nonceRequest) { @Transactional public AuthResponse login(final AuthRequest authRequest) { final Provider provider = convertToProvider(authRequest.provider()); - final IdTokenPayload idTokenPayload = validateIdTokenAndGetPayload(provider, authRequest.idToken()); - final Member member = memberService.findOrCreateMember(provider, idTokenPayload); + 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()); } @@ -78,7 +79,7 @@ public AuthResponse reissueTokens(final RefreshTokenRequest refreshTokenRequest) final Long memberId = getMemberIdForReissue(accessToken); try { - memberService.validateMemberExists(memberId); + 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. @@ -89,7 +90,7 @@ public AuthResponse reissueTokens(final RefreshTokenRequest refreshTokenRequest) throw new ServerException(AuthErrorResult.INVALID_TOKEN, e); } - refreshTokenService.validateRefreshToken(memberId, providedRefreshToken); + refreshTokenService.verifyRefreshToken(memberId, providedRefreshToken); return issueTokens(memberId); } @@ -107,14 +108,16 @@ private Provider convertToProvider(final String providerName) { } } - private IdTokenPayload validateIdTokenAndGetPayload(final Provider provider, final String idToken) { + private void verifyIdTokenNonce(final Provider provider, final String idToken) { final String nonce = jwtProvider.extractNonce(idToken); - nonceService.validateNonce(nonce, provider); + 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.getIdTokenPayload(provider, idToken, oidcPublicKeys); + return oidcProviderFactory.getProviderId(provider, idToken, oidcPublicKeys); } private AuthResponse issueTokens(final Long 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 index bdff657c..7cfd44d6 100644 --- a/src/main/java/com/und/server/auth/service/NonceService.java +++ b/src/main/java/com/und/server/auth/service/NonceService.java @@ -25,7 +25,7 @@ public String generateNonceValue() { } @Transactional - public void validateNonce(final String value, final Provider provider) { + public void verifyNonce(final String value, final Provider provider) { validateNonceValue(value); validateProvider(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 index 4e6533e5..24604e32 100644 --- a/src/main/java/com/und/server/auth/service/RefreshTokenService.java +++ b/src/main/java/com/und/server/auth/service/RefreshTokenService.java @@ -25,7 +25,7 @@ public String generateRefreshToken() { } @Transactional - public void validateRefreshToken(final Long memberId, final String providedToken) { + public void verifyRefreshToken(final Long memberId, final String providedToken) { validateMemberIdIsNotNull(memberId); validateTokenValueIsNotNull(providedToken); diff --git a/src/main/java/com/und/server/common/controller/TestController.java b/src/main/java/com/und/server/common/controller/TestController.java index fbe0282f..6ae6b502 100644 --- a/src/main/java/com/und/server/common/controller/TestController.java +++ b/src/main/java/com/und/server/common/controller/TestController.java @@ -3,6 +3,7 @@ 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; @@ -24,6 +25,8 @@ 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") @@ -41,7 +44,7 @@ public ResponseEntity requireAccessToken(@RequestBody @Valid final @GetMapping("/hello") public ResponseEntity greet(@Parameter(hidden = true) @AuthMember final Long memberId) { final Member member = memberService.findMemberById(memberId); - final String nickname = member.getNickname() != null ? member.getNickname() : "Member"; + final String nickname = member.getNickname(); final TestHelloResponse response = new TestHelloResponse("Hello, " + nickname + "!"); return ResponseEntity.status(HttpStatus.OK).body(response); diff --git a/src/main/java/com/und/server/common/dto/TestAuthRequest.java b/src/main/java/com/und/server/common/dto/TestAuthRequest.java index 019e98b6..e57c1daa 100644 --- a/src/main/java/com/und/server/common/dto/TestAuthRequest.java +++ b/src/main/java/com/und/server/common/dto/TestAuthRequest.java @@ -11,9 +11,5 @@ public record TestAuthRequest( @Schema(description = "Unique ID from the provider", example = "123456789") @NotBlank(message = "Provider ID must not be blank") - String providerId, - - @Schema(description = "User's nickname", example = "Chori") - @NotBlank(message = "Nickname must not be blank") - String nickname + String providerId ) { } diff --git a/src/main/java/com/und/server/common/exception/CommonErrorResult.java b/src/main/java/com/und/server/common/exception/CommonErrorResult.java index 01dec192..1fa032fa 100644 --- a/src/main/java/com/und/server/common/exception/CommonErrorResult.java +++ b/src/main/java/com/und/server/common/exception/CommonErrorResult.java @@ -10,6 +10,7 @@ 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; diff --git a/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java b/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java index b05913c3..cd5310a2 100644 --- a/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java @@ -2,7 +2,9 @@ import java.util.List; +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,6 +15,7 @@ import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import com.und.server.common.dto.ErrorResponse; +import com.und.server.member.exception.MemberErrorResult; import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; @@ -55,6 +58,26 @@ public ResponseEntity handleRestApiException(final ServerException excep ); } + @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); diff --git a/src/main/java/com/und/server/member/dto/MemberResponse.java b/src/main/java/com/und/server/member/dto/MemberResponse.java index 47475708..8a42ea7f 100644 --- a/src/main/java/com/und/server/member/dto/MemberResponse.java +++ b/src/main/java/com/und/server/member/dto/MemberResponse.java @@ -17,6 +17,9 @@ public record MemberResponse( @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:36.037717") LocalDateTime createdAt, @@ -28,6 +31,7 @@ public static MemberResponse from(final Member member) { 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 index 14bff091..3e23747f 100644 --- a/src/main/java/com/und/server/member/entity/Member.java +++ b/src/main/java/com/und/server/member/entity/Member.java @@ -19,7 +19,7 @@ @Entity @Getter -@Table +@Table(name = "member") @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder @@ -29,18 +29,22 @@ public class Member { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column - private String nickname; + @Column(nullable = false) + @Builder.Default + private String nickname = "워리"; - @Column + @Column(nullable = true, unique = true) private String kakaoId; + @Column(nullable = true, unique = true) + private String appleId; + @CreationTimestamp @Column(nullable = false, updatable = false) private LocalDateTime createdAt; @UpdateTimestamp - @Column + @Column(nullable = false) private LocalDateTime updatedAt; public void updateNickname(final String 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 index 906f05a9..b482b9fc 100644 --- a/src/main/java/com/und/server/member/exception/MemberErrorResult.java +++ b/src/main/java/com/und/server/member/exception/MemberErrorResult.java @@ -12,7 +12,9 @@ public enum MemberErrorResult implements ErrorResult { INVALID_MEMBER_ID(HttpStatus.BAD_REQUEST, "Invalid Member ID"), - MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "Member Not Found"); + 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/member/repository/MemberRepository.java b/src/main/java/com/und/server/member/repository/MemberRepository.java index 7f7cd6a2..4a1d2287 100644 --- a/src/main/java/com/und/server/member/repository/MemberRepository.java +++ b/src/main/java/com/und/server/member/repository/MemberRepository.java @@ -12,4 +12,6 @@ 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 index ec7f30fc..50397a84 100644 --- a/src/main/java/com/und/server/member/service/MemberService.java +++ b/src/main/java/com/und/server/member/service/MemberService.java @@ -7,7 +7,6 @@ import org.springframework.transaction.annotation.Transactional; import com.und.server.auth.exception.AuthErrorResult; -import com.und.server.auth.oauth.IdTokenPayload; import com.und.server.auth.oauth.Provider; import com.und.server.auth.service.RefreshTokenService; import com.und.server.common.exception.ServerException; @@ -27,20 +26,18 @@ public class MemberService { private final MemberRepository memberRepository; private final RefreshTokenService refreshTokenService; - // FIXME: Remove this method when deleting TestController public List getMemberList() { return memberRepository.findAll() .stream().map(MemberResponse::from).toList(); } @Transactional - public Member findOrCreateMember(final Provider provider, final IdTokenPayload payload) { + public Member findOrCreateMember(final Provider provider, final String providerId) { validateProviderIsNotNull(provider); - final String providerId = payload.providerId(); validateProviderIdIsNotNull(providerId); return findMemberByProviderId(provider, providerId) - .orElseGet(() -> createMember(provider, providerId, payload.nickname())); + .orElseGet(() -> createMember(provider, providerId)); } public Member findMemberById(final Long memberId) { @@ -50,7 +47,7 @@ public Member findMemberById(final Long memberId) { .orElseThrow(() -> new ServerException(MemberErrorResult.MEMBER_NOT_FOUND)); } - public void validateMemberExists(final Long memberId) { + public void checkMemberExists(final Long memberId) { validateMemberIdIsNotNull(memberId); if (!memberRepository.existsById(memberId)) { @@ -69,6 +66,7 @@ public MemberResponse updateNickname(final Long memberId, final NicknameRequest @Transactional public void deleteMemberById(final Long memberId) { validateMemberIdIsNotNull(memberId); + checkMemberExists(memberId); refreshTokenService.deleteRefreshToken(memberId); memberRepository.deleteById(memberId); @@ -77,15 +75,15 @@ public void deleteMemberById(final Long memberId) { private Optional findMemberByProviderId(final Provider provider, final String providerId) { return switch (provider) { case KAKAO -> memberRepository.findByKakaoId(providerId); - default -> throw new ServerException(AuthErrorResult.INVALID_PROVIDER); + case APPLE -> memberRepository.findByAppleId(providerId); }; } - private Member createMember(final Provider provider, final String providerId, final String nickname) { - final Member.MemberBuilder memberBuilder = Member.builder().nickname(nickname); + private Member createMember(final Provider provider, final String providerId) { + final Member.MemberBuilder memberBuilder = Member.builder(); switch (provider) { case KAKAO -> memberBuilder.kakaoId(providerId); - default -> throw new ServerException(AuthErrorResult.INVALID_PROVIDER); + case APPLE -> memberBuilder.appleId(providerId); } return memberRepository.save(memberBuilder.build()); 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..19ae1e5d 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: 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/test/java/com/und/server/auth/controller/AuthControllerTest.java b/src/test/java/com/und/server/auth/controller/AuthControllerTest.java index 7ba52598..1d716ab3 100644 --- a/src/test/java/com/und/server/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/und/server/auth/controller/AuthControllerTest.java @@ -106,8 +106,8 @@ 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 handshake and returns nonce for a valid Kakao request") + void Given_ValidKakaoHandshakeRequest_When_Handshake_Then_ReturnsOkWithNonce() throws Exception { // given final String url = "/v1/auth/nonce"; final NonceRequest request = new NonceRequest("kakao"); @@ -127,6 +127,28 @@ void Given_ValidHandshakeRequest_When_Handshake_Then_ReturnsOkWithNonce() throws .andExpect(jsonPath("$.nonce").value("generated-nonce")); } + @Test + @DisplayName("Succeeds handshake and returns nonce for a valid Apple request") + void Given_ValidAppleHandshakeRequest_When_Handshake_Then_ReturnsOkWithNonce() 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).handshake(request); + + // when + final ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders.post(url) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions.andExpect(status().isOk()) + .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 { @@ -220,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"); @@ -258,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 { diff --git a/src/test/java/com/und/server/auth/jwt/JwtProviderTest.java b/src/test/java/com/und/server/auth/jwt/JwtProviderTest.java index a81afc03..7cfcbccc 100644 --- a/src/test/java/com/und/server/auth/jwt/JwtProviderTest.java +++ b/src/test/java/com/und/server/auth/jwt/JwtProviderTest.java @@ -24,7 +24,6 @@ import org.springframework.security.core.Authentication; import com.und.server.auth.exception.AuthErrorResult; -import com.und.server.auth.oauth.IdTokenPayload; import com.und.server.common.exception.ServerException; import com.und.server.common.util.ProfileManager; @@ -153,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 @@ -174,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()); @@ -194,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 @@ -220,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(); 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/auth/oauth/KakaoProviderTest.java b/src/test/java/com/und/server/auth/oauth/KakaoProviderTest.java index 57cfaa88..00abf9ee 100644 --- a/src/test/java/com/und/server/auth/oauth/KakaoProviderTest.java +++ b/src/test/java/com/und/server/auth/oauth/KakaoProviderTest.java @@ -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); doReturn(decodedHeader).when(jwtProvider).getDecodedHeader(token); doReturn(publicKey).when(publicKeyProvider).generatePublicKey(decodedHeader, oidcPublicKeys); - doReturn(expectedPayload).when(jwtProvider).parseOidcIdToken(token, kakaoBaseUrl, kakaoAppKey, publicKey); + 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/auth/oauth/OidcClientFactoryTest.java b/src/test/java/com/und/server/auth/oauth/OidcClientFactoryTest.java index b04738d5..a444522a 100644 --- a/src/test/java/com/und/server/auth/oauth/OidcClientFactoryTest.java +++ b/src/test/java/com/und/server/auth/oauth/OidcClientFactoryTest.java @@ -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", AuthErrorResult.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 index 0723c1fc..fd6fa5ac 100644 --- a/src/test/java/com/und/server/auth/oauth/OidcProviderFactoryTest.java +++ b/src/test/java/com/und/server/auth/oauth/OidcProviderFactoryTest.java @@ -21,6 +21,9 @@ class OidcProviderFactoryTest { @Mock private KakaoProvider kakaoProvider; + @Mock + private AppleProvider appleProvider; + @Mock private OidcPublicKeys oidcPublicKeys; @@ -28,35 +31,45 @@ class OidcProviderFactoryTest { private final String token = "dummyToken"; private final String providerId = "dummyId"; - private final String nickname = "dummyNickname"; @BeforeEach void init() { - factory = new OidcProviderFactory(kakaoProvider); + factory = new OidcProviderFactory(kakaoProvider, appleProvider); } @Test @DisplayName("Throws an exception when the provider is null") - void Given_NullProvider_When_GetIdTokenPayload_Then_ThrowsServerException() { + void Given_NullProvider_When_GetProviderId_Then_ThrowsServerException() { // when & then - assertThatThrownBy(() -> factory.getIdTokenPayload(null, token, oidcPublicKeys)) - .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.INVALID_PROVIDER); + assertThatThrownBy(() -> factory.getProviderId(null, token, oidcPublicKeys)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", AuthErrorResult.INVALID_PROVIDER); } @Test - @DisplayName("Retrieves ID token payload successfully for a given provider") - void Given_ValidProvider_When_GetIdTokenPayload_Then_ReturnsCorrectPayload() { + @DisplayName("Retrieves Provider ID successfully for the Kakao provider") + void Given_KakaoProvider_When_GetProviderId_Then_ReturnsCorrectProviderId() { // given - final IdTokenPayload expectedPayload = new IdTokenPayload(providerId, nickname); + doReturn(providerId).when(kakaoProvider).getProviderId(token, oidcPublicKeys); - doReturn(expectedPayload).when(kakaoProvider).getIdTokenPayload(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 IdTokenPayload actualPayload = factory.getIdTokenPayload(Provider.KAKAO, token, oidcPublicKeys); + final String actualProviderId = factory.getProviderId(Provider.APPLE, token, oidcPublicKeys); // then - assertThat(actualPayload).isEqualTo(expectedPayload); + assertThat(actualProviderId).isEqualTo(providerId); } } diff --git a/src/test/java/com/und/server/auth/repository/NonceRepositoryTest.java b/src/test/java/com/und/server/auth/repository/NonceRepositoryTest.java index 9f2ac9d9..97e4a5f8 100644 --- a/src/test/java/com/und/server/auth/repository/NonceRepositoryTest.java +++ b/src/test/java/com/und/server/auth/repository/NonceRepositoryTest.java @@ -20,8 +20,8 @@ class NonceRepositoryTest { private NonceRepository nonceRepository; @Test - @DisplayName("Saves a nonce and verifies the returned entity") - void Given_Nonce_When_Save_Then_ReturnsSavedNonce() { + @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; @@ -40,8 +40,28 @@ void Given_Nonce_When_Save_Then_ReturnsSavedNonce() { } @Test - @DisplayName("Finds an existing nonce by its ID") - void Given_ExistingNonce_When_FindById_Then_ReturnsCorrectNonce() { + @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; @@ -62,8 +82,30 @@ void Given_ExistingNonce_When_FindById_Then_ReturnsCorrectNonce() { } @Test - @DisplayName("Deletes an existing nonce successfully") - void Given_ExistingNonce_When_DeleteById_Then_NonceIsRemoved() { + @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; @@ -80,4 +122,23 @@ void Given_ExistingNonce_When_DeleteById_Then_NonceIsRemoved() { 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/service/AuthServiceTest.java b/src/test/java/com/und/server/auth/service/AuthServiceTest.java index b4481a68..529d2603 100644 --- a/src/test/java/com/und/server/auth/service/AuthServiceTest.java +++ b/src/test/java/com/und/server/auth/service/AuthServiceTest.java @@ -27,7 +27,6 @@ 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.IdTokenPayload; import com.und.server.auth.oauth.OidcClient; import com.und.server.auth.oauth.OidcClientFactory; import com.und.server.auth.oauth.OidcProviderFactory; @@ -69,8 +68,6 @@ class AuthServiceTest { @Mock private ProfileManager profileManager; - 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"; @@ -78,41 +75,41 @@ class AuthServiceTest { 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() { + @DisplayName("Issues tokens for an existing Kakao member for testing purposes") + void Given_ExistingKakaoMemberForTest_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(existingMember).when(memberService).findOrCreateMember(any(Provider.class), any(IdTokenPayload.class)); + 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(IdTokenPayload.class)); + verify(memberService).findOrCreateMember(any(Provider.class), any(String.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() { + @DisplayName("Creates a new Kakao member and issues tokens for testing purposes") + void Given_NewKakaoMemberForTest_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(newMember).when(memberService).findOrCreateMember(any(Provider.class), any(IdTokenPayload.class)); + 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(IdTokenPayload.class)); + verify(memberService).findOrCreateMember(any(Provider.class), any(String.class)); verify(refreshTokenService).saveRefreshToken(memberId, refreshToken); assertThat(response.accessToken()).isEqualTo(accessToken); assertThat(response.refreshToken()).isEqualTo(refreshToken); @@ -132,8 +129,8 @@ void Given_InvalidProvider_When_Handshake_Then_ThrowsException() { } @Test - @DisplayName("Returns a nonce on a successful handshake") - void Given_ValidProvider_When_Handshake_Then_ReturnsNonce() { + @DisplayName("Returns a nonce on a successful handshake for Kakao") + void Given_KakaoProvider_When_Handshake_Then_ReturnsNonce() { // given final String nonce = "generated-nonce"; final String providerName = "kakao"; @@ -151,6 +148,26 @@ void Given_ValidProvider_When_Handshake_Then_ReturnsNonce() { assertThat(response.nonce()).isEqualTo(nonce); } + @Test + @DisplayName("Returns a nonce on a successful handshake for Apple") + void Given_AppleProvider_When_Handshake_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.handshake(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() { @@ -165,28 +182,28 @@ void Given_InvalidProvider_When_Login_Then_ThrowsException() { } @Test - @DisplayName("Issues tokens successfully when a registered member logs in") - void Given_RegisteredMember_When_Login_Then_IssuesTokensSuccessfully() { + @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 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); + doNothing().when(nonceService).verifyNonce("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(member).when(memberService).findOrCreateMember(Provider.KAKAO, payload); + 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).validateNonce("nonce", Provider.KAKAO); + verify(nonceService).verifyNonce("nonce", Provider.KAKAO); verify(refreshTokenService).saveRefreshToken(memberId, refreshToken); assertThat(response.tokenType()).isEqualTo("Bearer"); assertThat(response.accessToken()).isEqualTo(accessToken); @@ -196,27 +213,85 @@ void Given_RegisteredMember_When_Login_Then_IssuesTokensSuccessfully() { } @Test - @DisplayName("Creates a new member and issues tokens on the first login") - void Given_NewMember_When_Login_Then_CreatesMemberAndIssuesTokens() { + @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 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(newMember).when(memberService).findOrCreateMember(Provider.KAKAO, payload); + 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).validateNonce("nonce", Provider.KAKAO); + verify(nonceService).verifyNonce("nonce", Provider.APPLE); verify(refreshTokenService).saveRefreshToken(memberId, refreshToken); assertThat(response.accessToken()).isEqualTo(accessToken); assertThat(response.refreshToken()).isEqualTo(refreshToken); @@ -232,7 +307,7 @@ void Given_TokenWithInvalidMemberId_When_ReissueTokens_Then_ThrowsExceptionAndDo doReturn(invalidTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); doThrow(new ServerException(MemberErrorResult.INVALID_MEMBER_ID)) - .when(memberService).validateMemberExists(nullMemberId); + .when(memberService).checkMemberExists(nullMemberId); // when & then final ServerException exception = assertThrows(ServerException.class, @@ -241,7 +316,7 @@ void Given_TokenWithInvalidMemberId_When_ReissueTokens_Then_ThrowsExceptionAndDo 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()).validateRefreshToken(any(), any()); + verify(refreshTokenService, never()).verifyRefreshToken(any(), any()); } @Test @@ -253,7 +328,7 @@ void Given_NonExistentMember_When_ReissueTokens_Then_ThrowsExceptionAndDeletesTo doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); doThrow(new ServerException(MemberErrorResult.MEMBER_NOT_FOUND)) - .when(memberService).validateMemberExists(memberId); + .when(memberService).checkMemberExists(memberId); // when & then final ServerException exception = assertThrows(ServerException.class, @@ -261,7 +336,7 @@ void Given_NonExistentMember_When_ReissueTokens_Then_ThrowsExceptionAndDeletesTo assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); verify(refreshTokenService).deleteRefreshToken(memberId); - verify(refreshTokenService, never()).validateRefreshToken(any(), any()); + verify(refreshTokenService, never()).verifyRefreshToken(any(), any()); } @Test @@ -272,9 +347,9 @@ void Given_MismatchedRefreshToken_When_ReissueTokens_Then_ThrowsException() { final ParsedTokenInfo expiredTokenInfo = new ParsedTokenInfo(memberId, true); doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); - doNothing().when(memberService).validateMemberExists(memberId); + doNothing().when(memberService).checkMemberExists(memberId); doThrow(new ServerException(AuthErrorResult.INVALID_TOKEN)) - .when(refreshTokenService).validateRefreshToken(memberId, "wrong.refresh.token"); + .when(refreshTokenService).verifyRefreshToken(memberId, "wrong.refresh.token"); // when & then final ServerException exception = assertThrows(ServerException.class, @@ -291,9 +366,9 @@ void Given_NoStoredRefreshToken_When_ReissueTokens_Then_ThrowsException() { final RefreshTokenRequest request = new RefreshTokenRequest(accessToken, refreshToken); doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); - doNothing().when(memberService).validateMemberExists(memberId); + doNothing().when(memberService).checkMemberExists(memberId); doThrow(new ServerException(AuthErrorResult.INVALID_TOKEN)) - .when(refreshTokenService).validateRefreshToken(memberId, refreshToken); + .when(refreshTokenService).verifyRefreshToken(memberId, refreshToken); // when & then final ServerException exception = assertThrows(ServerException.class, @@ -313,8 +388,8 @@ void Given_ValidRefreshToken_When_ReissueTokens_Then_Succeeds() { final ParsedTokenInfo expiredTokenInfo = new ParsedTokenInfo(memberId, true); doReturn(expiredTokenInfo).when(jwtProvider).parseTokenForReissue(accessToken); - doNothing().when(memberService).validateMemberExists(memberId); - doNothing().when(refreshTokenService).validateRefreshToken(memberId, refreshToken); + doNothing().when(memberService).checkMemberExists(memberId); + doNothing().when(refreshTokenService).verifyRefreshToken(memberId, refreshToken); setupTokenIssuance(newAccessToken, newRefreshToken); // when diff --git a/src/test/java/com/und/server/auth/service/NonceServiceTest.java b/src/test/java/com/und/server/auth/service/NonceServiceTest.java index ba5b08e3..b9b704f1 100644 --- a/src/test/java/com/und/server/auth/service/NonceServiceTest.java +++ b/src/test/java/com/und/server/auth/service/NonceServiceTest.java @@ -34,7 +34,6 @@ class NonceServiceTest { private NonceRepository nonceRepository; private final String nonceValue = "test-nonce"; - private final Provider provider = Provider.KAKAO; @Test @DisplayName("Generates a new nonce in UUID format") @@ -49,19 +48,20 @@ void Given_Nothing_When_GenerateNonceValue_Then_ReturnsUuid() { @Test @DisplayName("Throws an exception when validating with a null nonce value") - void Given_NullNonceValue_When_ValidateNonce_Then_ThrowsException() { + void Given_NullNonceValue_When_VerifyNonce_Then_ThrowsException() { // when & then + final Provider provider = Provider.KAKAO; final ServerException exception = assertThrows(ServerException.class, - () -> nonceService.validateNonce(null, provider)); + () -> 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_ValidateNonce_Then_ThrowsException() { + void Given_NullProvider_When_VerifyNonce_Then_ThrowsException() { // when & then final ServerException exception = assertThrows(ServerException.class, - () -> nonceService.validateNonce(nonceValue, null)); + () -> nonceService.verifyNonce(nonceValue, null)); assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER); } @@ -69,6 +69,7 @@ void Given_NullProvider_When_ValidateNonce_Then_ThrowsException() { @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); @@ -84,32 +85,47 @@ void Given_NullProvider_When_SaveNonce_Then_ThrowsException() { } @Test - @DisplayName("Succeeds validation for a valid nonce and provider") - void Given_ValidNonceAndProvider_When_ValidateNonce_Then_SucceedsAndDeletesNonce() { + @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.validateNonce(nonceValue, provider)); + 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_ValidateNonce_Then_ThrowsException() { + 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.validateNonce(nonceValue, provider)); + () -> 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_ValidateNonce_Then_ThrowsException() { + 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(); @@ -120,7 +136,7 @@ void Given_MismatchedProvider_When_ValidateNonce_Then_ThrowsException() { // when & then final ServerException exception = assertThrows(ServerException.class, - () -> nonceService.validateNonce(nonceValue, differentProvider)); + () -> nonceService.verifyNonce(nonceValue, differentProvider)); assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_NONCE); // The nonce should not be deleted if the provider does not match @@ -128,9 +144,21 @@ void Given_MismatchedProvider_When_ValidateNonce_Then_ThrowsException() { } @Test - @DisplayName("Saves a nonce successfully") - void Given_NonceValueAndProvider_When_SaveNonce_Then_SavesToRepository() { + @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 diff --git a/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java b/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java index 1e1e64f5..db9f549e 100644 --- a/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java +++ b/src/test/java/com/und/server/auth/service/RefreshTokenServiceTest.java @@ -76,10 +76,10 @@ void Given_NullToken_When_SaveRefreshToken_Then_ThrowsException() { @Test @DisplayName("Throws an exception when validating with a null token") - void Given_NullToken_When_ValidateRefreshToken_Then_ThrowsException() { + void Given_NullToken_When_VerifyRefreshToken_Then_ThrowsException() { // when final ServerException exception = assertThrows(ServerException.class, - () -> refreshTokenService.validateRefreshToken(memberId, null)); + () -> refreshTokenService.verifyRefreshToken(memberId, null)); // then assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); @@ -97,10 +97,10 @@ void Given_NullMemberId_When_SaveRefreshToken_Then_ThrowsException() { @Test @DisplayName("Throws an exception when validating a refresh token with a null member ID") - void Given_NullMemberId_When_ValidateRefreshToken_Then_ThrowsException() { + void Given_NullMemberId_When_VerifyRefreshToken_Then_ThrowsException() { // when & then final ServerException exception = assertThrows(ServerException.class, - () -> refreshTokenService.validateRefreshToken(null, refreshTokenValue)); + () -> refreshTokenService.verifyRefreshToken(null, refreshTokenValue)); assertThat(exception.getErrorResult()).isEqualTo(MemberErrorResult.INVALID_MEMBER_ID); } @@ -117,7 +117,7 @@ void Given_NullMemberId_When_DeleteRefreshToken_Then_ThrowsException() { @Test @DisplayName("Succeeds validation if the provided token matches the stored one") - void Given_MatchingToken_When_ValidateRefreshToken_Then_Succeeds() { + void Given_MatchingToken_When_VerifyRefreshToken_Then_Succeeds() { // given final RefreshToken savedToken = RefreshToken.builder() .memberId(memberId) @@ -126,12 +126,12 @@ void Given_MatchingToken_When_ValidateRefreshToken_Then_Succeeds() { doReturn(Optional.of(savedToken)).when(refreshTokenRepository).findById(memberId); // when & then - assertDoesNotThrow(() -> refreshTokenService.validateRefreshToken(memberId, refreshTokenValue)); + assertDoesNotThrow(() -> refreshTokenService.verifyRefreshToken(memberId, refreshTokenValue)); } @Test @DisplayName("Throws an exception and deletes the token if it does not match") - void Given_MismatchedToken_When_ValidateRefreshToken_Then_ThrowsExceptionAndDeletes() { + void Given_MismatchedToken_When_VerifyRefreshToken_Then_ThrowsExceptionAndDeletes() { // given final RefreshToken savedToken = RefreshToken.builder() .memberId(memberId) @@ -141,7 +141,7 @@ void Given_MismatchedToken_When_ValidateRefreshToken_Then_ThrowsExceptionAndDele // when final ServerException exception = assertThrows(ServerException.class, - () -> refreshTokenService.validateRefreshToken(memberId, "wrong-token")); + () -> refreshTokenService.verifyRefreshToken(memberId, "wrong-token")); // then assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); @@ -150,7 +150,7 @@ void Given_MismatchedToken_When_ValidateRefreshToken_Then_ThrowsExceptionAndDele @Test @DisplayName("Throws an exception if the stored token value is null") - void Given_StoredTokenWithValueNull_When_ValidateRefreshToken_Then_ThrowsException() { + void Given_StoredTokenWithValueNull_When_VerifyRefreshToken_Then_ThrowsException() { // given final RefreshToken savedTokenWithNullValue = RefreshToken.builder() .memberId(memberId) @@ -160,7 +160,7 @@ void Given_StoredTokenWithValueNull_When_ValidateRefreshToken_Then_ThrowsExcepti // when final ServerException exception = assertThrows(ServerException.class, - () -> refreshTokenService.validateRefreshToken(memberId, refreshTokenValue)); + () -> refreshTokenService.verifyRefreshToken(memberId, refreshTokenValue)); // then assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); @@ -169,13 +169,13 @@ void Given_StoredTokenWithValueNull_When_ValidateRefreshToken_Then_ThrowsExcepti @Test @DisplayName("Throws an exception if no token is stored for validation") - void Given_NoStoredToken_When_ValidateRefreshToken_Then_ThrowsException() { + void Given_NoStoredToken_When_VerifyRefreshToken_Then_ThrowsException() { // given doReturn(Optional.empty()).when(refreshTokenRepository).findById(memberId); // when final ServerException exception = assertThrows(ServerException.class, - () -> refreshTokenService.validateRefreshToken(memberId, refreshTokenValue)); + () -> refreshTokenService.verifyRefreshToken(memberId, refreshTokenValue)); // then assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_TOKEN); diff --git a/src/test/java/com/und/server/common/controller/TestControllerTest.java b/src/test/java/com/und/server/common/controller/TestControllerTest.java index b3c41e48..98e67a67 100644 --- a/src/test/java/com/und/server/common/controller/TestControllerTest.java +++ b/src/test/java/com/und/server/common/controller/TestControllerTest.java @@ -67,7 +67,7 @@ void init() { 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 TestAuthRequest request = new TestAuthRequest("kakao", "dummy.provider.id"); final AuthResponse expectedResponse = new AuthResponse( "Bearer", "access-token", @@ -101,7 +101,7 @@ void Given_ExistingMember_When_RequestAccessToken_Then_ReturnsOkWithTokens() thr 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 TestAuthRequest request = new TestAuthRequest("kakao", "provider-id-456"); final AuthResponse expectedResponse = new AuthResponse( "Bearer", "new-access-token", @@ -197,36 +197,14 @@ void Given_AuthenticatedUserWithNickname_When_Greet_Then_ReturnsOkWithPersonaliz .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(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, Member!")); - } - @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", "123", null, null), - new MemberResponse(2L, "user2", "456", null, null) + new MemberResponse(1L, "user1", "dummyKakaoId", null, null, null), + new MemberResponse(2L, "user2", null, "dummyAppleId", null, null) ); doReturn(expectedResponse).when(memberService).getMemberList(); diff --git a/src/test/java/com/und/server/member/controller/MemberControllerTest.java b/src/test/java/com/und/server/member/controller/MemberControllerTest.java index 63d39d6b..aa798d91 100644 --- a/src/test/java/com/und/server/member/controller/MemberControllerTest.java +++ b/src/test/java/com/und/server/member/controller/MemberControllerTest.java @@ -112,7 +112,7 @@ void Given_AuthenticatedUser_When_UpdateNickname_Then_ReturnsOkWithUpdatedInfo() 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); + 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()); diff --git a/src/test/java/com/und/server/member/dto/MemberResponseTest.java b/src/test/java/com/und/server/member/dto/MemberResponseTest.java index 2051d5f8..f4255e85 100644 --- a/src/test/java/com/und/server/member/dto/MemberResponseTest.java +++ b/src/test/java/com/und/server/member/dto/MemberResponseTest.java @@ -10,12 +10,12 @@ class MemberResponseTest { @Test - @DisplayName("Correctly converts a Member entity to a MemberResponse DTO") - void Given_MemberEntity_When_From_Then_ReturnsCorrectMemberResponse() { + @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("Chori") + .nickname("KakaoUser") .kakaoId("1234567890") .build(); @@ -26,6 +26,29 @@ void Given_MemberEntity_When_From_Then_ReturnsCorrectMemberResponse() { 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 index 8fed8ec2..45de8422 100644 --- a/src/test/java/com/und/server/member/repository/MemberRepositoryTest.java +++ b/src/test/java/com/und/server/member/repository/MemberRepositoryTest.java @@ -18,12 +18,12 @@ class MemberRepositoryTest { private MemberRepository memberRepository; @Test - @DisplayName("Saves a member and verifies its properties") - void Given_MemberDetails_When_SaveMember_Then_MemberIsPersistedWithCorrectDetails() { + @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("Chori") - .kakaoId("166959") + .nickname("KakaoUser") + .kakaoId("kakao-id-123") .build(); // when @@ -31,29 +31,70 @@ void Given_MemberDetails_When_SaveMember_Then_MemberIsPersistedWithCorrectDetail // then assertThat(result.getId()).isNotNull(); - assertThat(result.getNickname()).isEqualTo("Chori"); - assertThat(result.getKakaoId()).isEqualTo("166959"); + 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_ExistingMember_When_FindByKakaoId_Then_ReturnsCorrectMember() { + void Given_ExistingMemberWithKakaoId_When_FindByKakaoId_Then_ReturnsCorrectMember() { // given final Member member = Member.builder() - .nickname("Chori") - .kakaoId("166959") + .nickname("KakaoUser") + .kakaoId("kakao-id-123") .build(); memberRepository.save(member); // when - final Optional foundMember = memberRepository.findByKakaoId("166959"); + final Optional foundMember = memberRepository.findByKakaoId("kakao-id-123"); // then assertThat(foundMember).isPresent().hasValueSatisfying(result -> { assertThat(result.getId()).isNotNull(); - assertThat(result.getNickname()).isEqualTo("Chori"); - assertThat(result.getKakaoId()).isEqualTo("166959"); + 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 index 54f59345..6b558b51 100644 --- a/src/test/java/com/und/server/member/service/MemberServiceTest.java +++ b/src/test/java/com/und/server/member/service/MemberServiceTest.java @@ -19,7 +19,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.und.server.auth.exception.AuthErrorResult; -import com.und.server.auth.oauth.IdTokenPayload; import com.und.server.auth.oauth.Provider; import com.und.server.auth.service.RefreshTokenService; import com.und.server.common.exception.ServerException; @@ -44,13 +43,12 @@ class MemberServiceTest { private final Long memberId = 1L; private final String providerId = "test-provider-id"; private final String nickname = "test-nickname"; - private final Provider provider = Provider.KAKAO; @Test - @DisplayName("Finds an existing member without creating a new one") - void Given_ExistingMember_When_FindOrCreateMember_Then_ReturnsExistingMember() { + @DisplayName("Finds an existing member with a Kakao ID") + void Given_ExistingKakaoMember_When_FindOrCreateMember_Then_ReturnsExistingMember() { // given - final IdTokenPayload payload = new IdTokenPayload(providerId, nickname); + final Provider kakaoProvider = Provider.KAKAO; final Member existingMember = Member.builder() .id(memberId) .kakaoId(providerId) @@ -60,7 +58,7 @@ void Given_ExistingMember_When_FindOrCreateMember_Then_ReturnsExistingMember() { doReturn(Optional.of(existingMember)).when(memberRepository).findByKakaoId(providerId); // when - final Member foundMember = memberService.findOrCreateMember(provider, payload); + final Member foundMember = memberService.findOrCreateMember(kakaoProvider, providerId); // then verify(memberRepository).findByKakaoId(providerId); @@ -69,10 +67,10 @@ void Given_ExistingMember_When_FindOrCreateMember_Then_ReturnsExistingMember() { } @Test - @DisplayName("Creates a new member if one does not exist") - void Given_NonExistingMember_When_FindOrCreateMember_Then_CreatesAndReturnsNewMember() { + @DisplayName("Creates a new member with a Kakao ID") + void Given_NonExistingKakaoMember_When_FindOrCreateMember_Then_CreatesAndReturnsNewMember() { // given - final IdTokenPayload payload = new IdTokenPayload(providerId, nickname); + final Provider kakaoProvider = Provider.KAKAO; final Member newMember = Member.builder() .id(memberId) .kakaoId(providerId) @@ -83,7 +81,7 @@ void Given_NonExistingMember_When_FindOrCreateMember_Then_CreatesAndReturnsNewMe doReturn(newMember).when(memberRepository).save(any(Member.class)); // when - final Member createdMember = memberService.findOrCreateMember(provider, payload); + final Member createdMember = memberService.findOrCreateMember(kakaoProvider, providerId); // then verify(memberRepository).findByKakaoId(providerId); @@ -92,28 +90,56 @@ void Given_NonExistingMember_When_FindOrCreateMember_Then_CreatesAndReturnsNewMe } @Test - @DisplayName("Throws an exception when finding or creating a member with an unsupported provider") - void Given_UnsupportedProvider_When_FindOrCreateMember_Then_ThrowsException() { + @DisplayName("Finds an existing member with an Apple ID") + void Given_ExistingAppleMember_When_FindOrCreateMember_Then_ReturnsExistingMember() { // given - final IdTokenPayload payload = new IdTokenPayload(providerId, nickname); - final Provider unsupportedProvider = Provider.APPLE; + final Provider appleProvider = Provider.APPLE; + final Member existingMember = Member.builder() + .id(memberId) + .appleId(providerId) + .nickname(nickname) + .build(); - // when & then - final ServerException exception = assertThrows(ServerException.class, - () -> memberService.findOrCreateMember(unsupportedProvider, payload)); + doReturn(Optional.of(existingMember)).when(memberRepository).findByAppleId(providerId); - assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER); + // 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("Throws an exception when finding or creating a member with a null provider") - void Given_NullProvider_When_FindOrCreateMember_Then_ThrowsException() { + @DisplayName("Creates a new member with an Apple ID") + void Given_NonExistingAppleMember_When_FindOrCreateMember_Then_CreatesAndReturnsNewMember() { // given - final IdTokenPayload payload = new IdTokenPayload(providerId, nickname); + 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, payload)); + () -> memberService.findOrCreateMember(null, providerId)); assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER); } @@ -121,12 +147,10 @@ void Given_NullProvider_When_FindOrCreateMember_Then_ThrowsException() { @Test @DisplayName("Throws an exception when finding or creating a member with a null provider ID") void Given_NullProviderId_When_FindOrCreateMember_Then_ThrowsException() { - // given - final IdTokenPayload payloadWithNullId = new IdTokenPayload(null, nickname); - // when & then + final Provider provider = Provider.KAKAO; final ServerException exception = assertThrows(ServerException.class, - () -> memberService.findOrCreateMember(provider, payloadWithNullId)); + () -> memberService.findOrCreateMember(provider, null)); assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER_ID); } @@ -156,33 +180,33 @@ void Given_NullMemberId_When_FindMemberById_Then_ThrowsException() { @Test @DisplayName("Throws an exception when validating a null member ID") - void Given_NullMemberId_When_ValidateMemberExists_Then_ThrowsException() { + void Given_NullMemberId_When_CheckMemberExists_Then_ThrowsException() { // when & then final ServerException exception = assertThrows(ServerException.class, - () -> memberService.validateMemberExists(null)); + () -> 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_ValidateMemberExists_Then_ThrowsException() { + void Given_NonExistentMemberId_When_CheckMemberExists_Then_ThrowsException() { // given doReturn(false).when(memberRepository).existsById(memberId); // when & then final ServerException exception = assertThrows(ServerException.class, - () -> memberService.validateMemberExists(memberId)); + () -> 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_ValidateMemberExists_Then_Succeeds() { + void Given_ExistingMemberId_When_CheckMemberExists_Then_Succeeds() { // given doReturn(true).when(memberRepository).existsById(memberId); // when & then - assertDoesNotThrow(() -> memberService.validateMemberExists(memberId)); + assertDoesNotThrow(() -> memberService.checkMemberExists(memberId)); verify(memberRepository).existsById(memberId); } @@ -229,17 +253,35 @@ void Given_ExistingMembers_When_GetMemberList_Then_ReturnsListOfMemberResponses( } @Test - @DisplayName("Deletes a member and their refresh token by ID") - void Given_MemberId_When_DeleteMemberById_Then_DeletesMemberAndRefreshToken() { + @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).deleteById(memberIdToDelete); + 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()); } } From 82e8a4bd4a126832ea00d5540461e43fba6e8673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?sukipi=20=EC=88=98=ED=82=A4=ED=94=BC?= <108535526+sotogito@users.noreply.github.com> Date: Mon, 18 Aug 2025 16:53:35 +0900 Subject: [PATCH 17/26] Add scenario and mission domain CRUD feat (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Create Notification entity * ✨ Create TimeNotif entity * ✨ Create NotifType Enum constatnt * ✨ Create TimeNotifResponse DTO * ✨ Create Scenario entity * ✨ Create Mission entity * ✨ Create BaseTimeEntity entity * ⚡️ Create NotificationResponse Interface * 🎨 Implements Notification to TimeNotifResponse * ✨ Create ScenarioDetailResponse DTO * ✨ Create ScenarioResponse DTO * ✨ Create MissionResponse DTO * ✨ Create MissionType Enum constatnt * 🎨 Add ScenarioResponse DTO conversion static method * ✨ Add return scenario list feat * ✅ Test return scenario list by member * 🚧 Commit ScenarioUdateReq, MissionRepository * 🎨 Modify Member entity import * ✨ Create notification resolver * 🎨 Refactor NotificationResponse * 🚚 Move NotifType Package * 🚚 Move BaseTimeEntity to common package * 🎨 Add Scenario notification filed FetchType * 🎨 Change NotifType import * 🚧 Commit Return Scenario Detail feat * 🎨 Rearrang import * ✨ Add notification error * ✨ Add scenario error * ✨ Apply ServerException to mission ans scenario domain layer * 🎨 Add Index to TimeNotif * ✨ Add LocationNotif entity * ✨ Add NotificationInfoDto * ✨ Add LocationNotifResponse dto * ✨ Add NotifDayIfWeekResponse dto * 🎨 Modify TimeNotifResponse field * 🎨 Add Notification error to NotificationErrorResult * 🎨 Refcator Notification Resolver * 🎨 Refactor Scenario detail api feat * 🔥 Remove list return method from TimeNotifResponse * 🎨 Modify field name * ✅ Add NotificationResolverSelector test * ✅ Add NotificationService test * ✅ Add TimeNotifResolver test * ✅ Add MissionController test * ✅ Add ScenarioController test * ✅ Add MissionService test * ✅ Add ScenarioService test * ✨ Add notification type * 🎨 Modify mission list return dto * ✨ Add Scenario error type * ✨ Create MissionTypeGrouper * 🎨 Modify mission list type at Returning Scenario detail * ✅ Add MissionTypeGrouper Mock * ✅ Add MissionTypeGrouper test * 🎨 Add createdAt and updateAt filed to entity * 🎨 Modify Today mission sorting * ✨ Add insert new today type mission feat * ✨ Create request dto required for add new scenario * 🚚 Rename Notification abbreviated representation * 🎨 Apply renamed Notification to dto * ✨Create OrderCalculator component * ✨ Add new scenario error type * ✨ Create custom exception ReorderRequeiredExceoption * 🎨 Add chacking accessableMmember to Scenario entity * 🚚 Rename Selector * 🔥 NotificationInfoDto * 🐛 Add NotificationType when get mission list * ✨ Add new Scenario feat * 🐛 Fix calculate order code when add new scenario * 🚚 Modify NotificationConditionSelector paramter * 🎨 Fix Indentation * ✅ Add NotificationConditionSelector test * 🚚 Modify NotificationService parameter * 🎨 Move Everyday constant * ✅ Update NotificationService test * 🔥 Remove TimeNotifResolverTest * 🔥 Remove NotificationResolverSelectorTest * ✅ Add TimeNotificationService test * ✅ Update MissionService test * 🐛 Fix Scenario member validation * 🎨 Modify validate member access modifiers * 🎨 Update validate when mission list empty * ✅ Update ScenarioService test * ✅ Update MissionTypeGrouper test * 📝 Delete memo in OrderCalculator * ✅ Add OrderCalculator test * ✅ Update MissionService test * ✅ Update ScenarioService test * ✏️ Modify request package name * ✏️ Modify table indexing name * ✨ Add return home api * 🔥 Remove NotificationResponse * 🎨 Add setter to entity for update feat * ✨ Add Scenario detail update feat * 🔥 Remove NotificationDayOfWeekRequest * ✨ Change Notification every day feat * ✨ Change Notification everyday feat * 🎨 Add memberId condition * 🎨 Add LocalDate to handle Mission entity useDate * ✅ Update NotificationConditionSelector test * ✅ Update NotificationService test * ✅ Update TimeNotiicationService test * ✅ Add MissionSearchType test * ✅ Add HomeController test * ✅ Update MissionController test * ✅ Update SceanrioController test * ✅ Update MissionService test * ✅ Update ScenarioService test * ✅ Update ScenarioService test * ✨ Add Mission delete feat * ✅ Add Mission delete feat test * ✨ Add Mission check status update feat * ✅ Add Mission check status update test * ✨ Add Scenario delete with all missions feat * ✨ Add Notification delete when delete scenario * 🎨 Update Scenario entity cascade * 🎨 Modify repository method name * ✨ Add Scenario order update feat * 🎨 Add Scenario max count validation when add new Scenario * 🎨 Add Colum name from Mission and Scenario entity * 🎨 Add Column name * 🎨 Adjust line * 🎨 Modify Scenario find query * 🎨 Modify Scenario member filed constaint * 🎨 Add NotificationRequest validate constraint * 🎨 Modify Notification delete * 🔥 Remove comment * ✨ Add EnableJpaAuditing annotaion to ServerApplication * 🎨 Add Default Builder annotation * ✅ Update Notification domain test * ✅ Update TimeNotification test * ✅ Update Scenario domain test * 🐛 Replace Spring Data auditing with Hibernate timestamps * 🐛 Modify to jakarta NotBlank valid annotation * 🎨 Refactor NotificationConditionSelector * 🎨 Modify MissionSearchType rangeDays * 🔥Remove line * 🔥 Remove annotation blank link * ✅ Update MissionSearchType test * 🔥 Remove parenthesis line * 🎨 Modify entity constraint * 🎨 Modify entity filed name * 🎨 Remove entity setter * ✅ Update Scenario domain test * 🚚 Rename Notification constant name * 🎨 Rename Notif to Notification * ✅ Rename Notif to Notification * 🎨 Refactor response dto * 🎨 Update where use changed response dto * 🎨 Refactor HomeController * 🎨 Add JsonCreator to Enum * 📝 Add Schema to request dto * ✨ Add Scenario without notification Create Update feat * 🎨 Update Notification entity * 🎨 Refactor request dto from POJO to record * 🎨 Refactor find missions by mission type * 🔥 Delete Scenario entity upadte Notification method * 🎨 Refactor ScenarioService validation * 🎨 Refactor MissionService validation * ✅ Update notification domain test * ✅ Update scenario domain test * 🎨 Modify annotation order * 📝 Modify Dto schema * 📝 Add controller ApiResponse schema * ✅ Update Notification service test * 🎨 Modify parameter name * 🎨 Modify annotation order * 📝 Add Controller Exception schema * 🎨 Modify NotificationConditionSelector find Service method * 🎨 Add Valid annotation to ScenarioDetailRequest * 🎨 Modify entity annotation order * 🎨 Modify list parameter * 🐛 Modify created Today Mission to be returned * 🎨 Refactor reorder scenario order * ✅ Update Scenario ordering test * 🎨 Modify find missions date RequestParam * 🎨 Modify Update Scenario Order controller schema * 🔥 Delete HomeController * 🔥Delete HomeController test * 🎨 Modify ResponseBody when create Scenario * 🎨 Modify MissionController HttpStatus * ✅ Update controller ResponseBody and HttpStatus * 🎨 Refactor notification domain add parameter final * 🎨 Refactor scenario domain add parameter final * 🎨 Refactor Enum constants add parameter final * ✅ Update MissionService test --- .../server/common/entity/BaseTimeEntity.java | 24 + .../constants/LocationTrackingRadiusType.java | 19 + .../constants/NotificationMethodType.java | 17 + .../constants/NotificationType.java | 17 + .../notification/dto/NotificationInfoDto.java | 13 + .../request/NotificationConditionRequest.java | 21 + .../dto/request/NotificationRequest.java | 54 + .../dto/request/TimeNotificationRequest.java | 58 ++ .../NotificationConditionResponse.java | 21 + .../dto/response/NotificationResponse.java | 60 ++ .../response/TimeNotificationResponse.java | 40 + .../entity/LocationNotification.java | 93 ++ .../notification/entity/Notification.java | 63 ++ .../notification/entity/TimeNotification.java | 67 ++ .../exception/NotificationErrorResult.java | 22 + .../repository/NotificationRepository.java | 7 + .../TimeNotificationRepository.java | 16 + .../NotificationConditionSelector.java | 63 ++ .../service/NotificationConditionService.java | 28 + .../service/NotificationService.java | 107 ++ .../service/TimeNotificationService.java | 142 +++ .../scenario/constants/MissionSearchType.java | 43 + .../scenario/constants/MissionType.java | 17 + .../controller/MissionController.java | 112 ++ .../controller/ScenarioController.java | 173 ++++ .../dto/request/BasicMissionRequest.java | 37 + .../dto/request/ScenarioDetailRequest.java | 55 + .../ScenarioNoNotificationRequest.java | 38 + .../request/ScenarioOrderUpdateRequest.java | 19 + .../dto/request/TodayMissionRequest.java | 33 + .../dto/response/MissionGroupResponse.java | 34 + .../dto/response/MissionResponse.java | 44 + .../scenario/dto/response/OrderResponse.java | 27 + .../dto/response/OrderUpdateResponse.java | 42 + .../dto/response/ScenarioDetailResponse.java | 72 ++ .../dto/response/ScenarioResponse.java | 43 + .../und/server/scenario/entity/Mission.java | 69 ++ .../und/server/scenario/entity/Scenario.java | 75 ++ .../exception/ReorderRequiredException.java | 17 + .../exception/ScenarioErrorResult.java | 36 + .../repository/MissionRepository.java | 40 + .../repository/ScenarioRepository.java | 48 + .../scenario/service/MissionService.java | 189 ++++ .../scenario/service/ScenarioService.java | 297 ++++++ .../scenario/util/MissionTypeGroupSorter.java | 40 + .../scenario/util/MissionValidator.java | 49 + .../server/scenario/util/OrderCalculator.java | 106 ++ .../scenario/util/ScenarioValidator.java | 32 + .../constants/NotificationMethodTypeTest.java | 32 + .../constants/NotificationTypeTest.java | 32 + .../request/TimeNotificationRequestTest.java | 46 + .../NotificationConditionSelectorTest.java | 247 +++++ .../service/NotificationServiceTest.java | 225 ++++ .../service/TimeNotificationServiceTest.java | 458 ++++++++ .../constants/MissionSearchTypeTest.java | 215 ++++ .../scenario/constants/MissionTypeTest.java | 32 + .../controller/MissionControllerTest.java | 167 +++ .../controller/ScenarioControllerTest.java | 311 ++++++ .../ScenarioNoNotificationRequestTest.java | 30 + .../scenario/service/MissionServiceTest.java | 682 ++++++++++++ .../scenario/service/ScenarioServiceTest.java | 974 ++++++++++++++++++ .../scenario/util/MissionTypeGrouperTest.java | 98 ++ .../scenario/util/MissionValidatorTest.java | 157 +++ .../scenario/util/OrderCalculatorTest.java | 131 +++ .../scenario/util/ScenarioValidatorTest.java | 82 ++ 65 files changed, 6658 insertions(+) create mode 100644 src/main/java/com/und/server/common/entity/BaseTimeEntity.java create mode 100644 src/main/java/com/und/server/notification/constants/LocationTrackingRadiusType.java create mode 100644 src/main/java/com/und/server/notification/constants/NotificationMethodType.java create mode 100644 src/main/java/com/und/server/notification/constants/NotificationType.java create mode 100644 src/main/java/com/und/server/notification/dto/NotificationInfoDto.java create mode 100644 src/main/java/com/und/server/notification/dto/request/NotificationConditionRequest.java create mode 100644 src/main/java/com/und/server/notification/dto/request/NotificationRequest.java create mode 100644 src/main/java/com/und/server/notification/dto/request/TimeNotificationRequest.java create mode 100644 src/main/java/com/und/server/notification/dto/response/NotificationConditionResponse.java create mode 100644 src/main/java/com/und/server/notification/dto/response/NotificationResponse.java create mode 100644 src/main/java/com/und/server/notification/dto/response/TimeNotificationResponse.java create mode 100644 src/main/java/com/und/server/notification/entity/LocationNotification.java create mode 100644 src/main/java/com/und/server/notification/entity/Notification.java create mode 100644 src/main/java/com/und/server/notification/entity/TimeNotification.java create mode 100644 src/main/java/com/und/server/notification/exception/NotificationErrorResult.java create mode 100644 src/main/java/com/und/server/notification/repository/NotificationRepository.java create mode 100644 src/main/java/com/und/server/notification/repository/TimeNotificationRepository.java create mode 100644 src/main/java/com/und/server/notification/service/NotificationConditionSelector.java create mode 100644 src/main/java/com/und/server/notification/service/NotificationConditionService.java create mode 100644 src/main/java/com/und/server/notification/service/NotificationService.java create mode 100644 src/main/java/com/und/server/notification/service/TimeNotificationService.java create mode 100644 src/main/java/com/und/server/scenario/constants/MissionSearchType.java create mode 100644 src/main/java/com/und/server/scenario/constants/MissionType.java create mode 100644 src/main/java/com/und/server/scenario/controller/MissionController.java create mode 100644 src/main/java/com/und/server/scenario/controller/ScenarioController.java create mode 100644 src/main/java/com/und/server/scenario/dto/request/BasicMissionRequest.java create mode 100644 src/main/java/com/und/server/scenario/dto/request/ScenarioDetailRequest.java create mode 100644 src/main/java/com/und/server/scenario/dto/request/ScenarioNoNotificationRequest.java create mode 100644 src/main/java/com/und/server/scenario/dto/request/ScenarioOrderUpdateRequest.java create mode 100644 src/main/java/com/und/server/scenario/dto/request/TodayMissionRequest.java create mode 100644 src/main/java/com/und/server/scenario/dto/response/MissionGroupResponse.java create mode 100644 src/main/java/com/und/server/scenario/dto/response/MissionResponse.java create mode 100644 src/main/java/com/und/server/scenario/dto/response/OrderResponse.java create mode 100644 src/main/java/com/und/server/scenario/dto/response/OrderUpdateResponse.java create mode 100644 src/main/java/com/und/server/scenario/dto/response/ScenarioDetailResponse.java create mode 100644 src/main/java/com/und/server/scenario/dto/response/ScenarioResponse.java create mode 100644 src/main/java/com/und/server/scenario/entity/Mission.java create mode 100644 src/main/java/com/und/server/scenario/entity/Scenario.java create mode 100644 src/main/java/com/und/server/scenario/exception/ReorderRequiredException.java create mode 100644 src/main/java/com/und/server/scenario/exception/ScenarioErrorResult.java create mode 100644 src/main/java/com/und/server/scenario/repository/MissionRepository.java create mode 100644 src/main/java/com/und/server/scenario/repository/ScenarioRepository.java create mode 100644 src/main/java/com/und/server/scenario/service/MissionService.java create mode 100644 src/main/java/com/und/server/scenario/service/ScenarioService.java create mode 100644 src/main/java/com/und/server/scenario/util/MissionTypeGroupSorter.java create mode 100644 src/main/java/com/und/server/scenario/util/MissionValidator.java create mode 100644 src/main/java/com/und/server/scenario/util/OrderCalculator.java create mode 100644 src/main/java/com/und/server/scenario/util/ScenarioValidator.java create mode 100644 src/test/java/com/und/server/notification/constants/NotificationMethodTypeTest.java create mode 100644 src/test/java/com/und/server/notification/constants/NotificationTypeTest.java create mode 100644 src/test/java/com/und/server/notification/dto/request/TimeNotificationRequestTest.java create mode 100644 src/test/java/com/und/server/notification/service/NotificationConditionSelectorTest.java create mode 100644 src/test/java/com/und/server/notification/service/NotificationServiceTest.java create mode 100644 src/test/java/com/und/server/notification/service/TimeNotificationServiceTest.java create mode 100644 src/test/java/com/und/server/scenario/constants/MissionSearchTypeTest.java create mode 100644 src/test/java/com/und/server/scenario/constants/MissionTypeTest.java create mode 100644 src/test/java/com/und/server/scenario/controller/MissionControllerTest.java create mode 100644 src/test/java/com/und/server/scenario/controller/ScenarioControllerTest.java create mode 100644 src/test/java/com/und/server/scenario/dto/request/ScenarioNoNotificationRequestTest.java create mode 100644 src/test/java/com/und/server/scenario/service/MissionServiceTest.java create mode 100644 src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java create mode 100644 src/test/java/com/und/server/scenario/util/MissionTypeGrouperTest.java create mode 100644 src/test/java/com/und/server/scenario/util/MissionValidatorTest.java create mode 100644 src/test/java/com/und/server/scenario/util/OrderCalculatorTest.java create mode 100644 src/test/java/com/und/server/scenario/util/ScenarioValidatorTest.java 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/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/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/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..c77abd9f --- /dev/null +++ b/src/main/java/com/und/server/notification/dto/request/NotificationRequest.java @@ -0,0 +1,54 @@ +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.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 = "Notification type", example = "time") + @NotNull(message = "notificationType must not be null") + NotificationType notificationType, + + @Schema(description = "Notification method type", example = "push") + @NotNull(message = "notificationMethod must not be null") + 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]") + @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 + +) { + + public Notification toEntity() { + return Notification.builder() + .isActive(true) + .notificationType(notificationType) + .notificationMethodType(notificationMethodType) + .build(); + } + +} 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..7bc174df --- /dev/null +++ b/src/main/java/com/und/server/notification/dto/request/TimeNotificationRequest.java @@ -0,0 +1,58 @@ +package com.und.server.notification.dto.request; + +import java.time.DayOfWeek; + +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, final DayOfWeek dayOfWeek) { + return TimeNotification.builder() + .notification(notification) + .dayOfWeek(dayOfWeek) + .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..4072f769 --- /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 request. The request body structure changes depending on the 'notificationType'.", + discriminatorProperty = "notificationType" +) +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..6509e681 --- /dev/null +++ b/src/main/java/com/und/server/notification/dto/response/NotificationResponse.java @@ -0,0 +1,60 @@ +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, + final Boolean isEveryDay, + final List daysOfWeekOrdinal + ) { + return NotificationResponse.builder() + .notificationId(notification.getId()) + .isActive(notification.isActive()) + .notificationType(notification.getNotificationType()) + .notificationMethodType(notification.getNotificationMethodType()) + .isEveryDay(isEveryDay) + .daysOfWeekOrdinal(daysOfWeekOrdinal) + .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..07c7ff8c --- /dev/null +++ b/src/main/java/com/und/server/notification/entity/LocationNotification.java @@ -0,0 +1,93 @@ +package com.und.server.notification.entity; + +import java.math.BigDecimal; +import java.time.DayOfWeek; + +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.Index; +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", + indexes = { + @Index(name = "idx_day_location_notification", columnList = "day_of_week, start_hour, start_minute") + } +) +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; + + @Enumerated(EnumType.ORDINAL) + @Column(nullable = false) + private DayOfWeek dayOfWeek; + + @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..723f1c1e --- /dev/null +++ b/src/main/java/com/und/server/notification/entity/Notification.java @@ -0,0 +1,63 @@ +package com.und.server.notification.entity; + +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 +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; + + public boolean isActive() { + return isActive; + } + + public void updateActiveStatus(final Boolean isActive) { + this.isActive = isActive; + } + + public void updateNotification( + final NotificationType notificationType, + final NotificationMethodType notificationMethodType + ) { + this.notificationType = notificationType; + this.notificationMethodType = notificationMethodType; + } + + public void deleteNotificationMethodType() { + this.notificationMethodType = null; + } + +} 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..d6550923 --- /dev/null +++ b/src/main/java/com/und/server/notification/entity/TimeNotification.java @@ -0,0 +1,67 @@ +package com.und.server.notification.entity; + +import java.time.DayOfWeek; + +import com.und.server.common.entity.BaseTimeEntity; + +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.Index; +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", + indexes = { + @Index(name = "idx_day_time_notification", columnList = "day_of_week, startHour, startMinute") + } +) +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; + + @Enumerated(EnumType.ORDINAL) + @Column(nullable = false) + private DayOfWeek dayOfWeek; + + @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/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..ec44919e --- /dev/null +++ b/src/main/java/com/und/server/notification/repository/TimeNotificationRepository.java @@ -0,0 +1,16 @@ +package com.und.server.notification.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.und.server.notification.entity.TimeNotification; + +import jakarta.validation.constraints.NotNull; + +public interface TimeNotificationRepository extends JpaRepository { + + @NotNull + List findByNotificationId(@NotNull Long notificationId); + +} 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..7abc6cf5 --- /dev/null +++ b/src/main/java/com/und/server/notification/service/NotificationConditionSelector.java @@ -0,0 +1,63 @@ +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.NotificationInfoDto; +import com.und.server.notification.dto.request.NotificationConditionRequest; +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 NotificationInfoDto findNotificationCondition(final Notification notification) { + NotificationConditionService service = findServiceByNotificationType(notification.getNotificationType()); + + return service.findNotificationInfoByType(notification); + } + + + public void addNotificationCondition( + final Notification notification, + final List daysOfWeekOrdinal, + final NotificationConditionRequest notificationConditionRequest + ) { + NotificationConditionService service = findServiceByNotificationType(notification.getNotificationType()); + service.addNotificationCondition(notification, daysOfWeekOrdinal, notificationConditionRequest); + } + + + public void updateNotificationCondition( + final Notification notification, + final List daysOfWeekOrdinal, + final NotificationConditionRequest notificationConditionRequest + ) { + NotificationConditionService service = findServiceByNotificationType(notification.getNotificationType()); + service.updateNotificationCondition(notification, daysOfWeekOrdinal, 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..0dd75885 --- /dev/null +++ b/src/main/java/com/und/server/notification/service/NotificationConditionService.java @@ -0,0 +1,28 @@ +package com.und.server.notification.service; + +import java.util.List; + +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.NotificationInfoDto; +import com.und.server.notification.dto.request.NotificationConditionRequest; +import com.und.server.notification.entity.Notification; + +public interface NotificationConditionService { + + boolean supports(final NotificationType notificationType); + + NotificationInfoDto findNotificationInfoByType(final Notification notification); + + void addNotificationCondition( + final Notification notification, + final List daysOfWeekOrdinal, + final NotificationConditionRequest notificationConditionRequest); + + void updateNotificationCondition( + final Notification oldNotification, + final List daysOfWeekOrdinal, + 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..0946009c --- /dev/null +++ b/src/main/java/com/und/server/notification/service/NotificationService.java @@ -0,0 +1,107 @@ +package com.und.server.notification.service; + +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.NotificationInfoDto; +import com.und.server.notification.dto.request.NotificationConditionRequest; +import com.und.server.notification.dto.request.NotificationRequest; +import com.und.server.notification.entity.Notification; +import com.und.server.notification.repository.NotificationRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final NotificationConditionSelector notificationConditionSelector; + + + @Transactional(readOnly = true) + public NotificationInfoDto findNotificationDetails(final Notification notification) { + return notificationConditionSelector.findNotificationCondition(notification); + } + + + @Transactional + public Notification addNotification( + final NotificationRequest notificationInfo, + final NotificationConditionRequest notificationConditionRequest + ) { + Notification notification = notificationInfo.toEntity(); + List daysOfWeekOrdinal = notificationInfo.daysOfWeekOrdinal(); + + notificationRepository.save(notification); + notificationConditionSelector.addNotificationCondition( + notification, daysOfWeekOrdinal, notificationConditionRequest); + + return notification; + } + + + @Transactional + public Notification addWithoutNotification(final NotificationType notificationType) { + Notification notification = Notification.builder() + .isActive(false) + .notificationType(notificationType) + .build(); + notificationRepository.save(notification); + + return notification; + } + + + @Transactional + public void updateNotification( + final Notification notification, + final NotificationRequest notificationInfo, + final NotificationConditionRequest notificationConditionRequest + ) { + List daysOfWeekOrdinal = notificationInfo.daysOfWeekOrdinal(); + + NotificationType oldNotificationType = notification.getNotificationType(); + NotificationType newNotificationtype = notificationInfo.notificationType(); + boolean isChangeNotificationType = oldNotificationType != newNotificationtype; + + notification.updateNotification( + newNotificationtype, + notificationInfo.notificationMethodType() + ); + notification.updateActiveStatus(true); + + if (isChangeNotificationType) { + notificationConditionSelector.deleteNotificationCondition(oldNotificationType, notification.getId()); + notificationConditionSelector.addNotificationCondition( + notification, daysOfWeekOrdinal, notificationConditionRequest); + return; + } + + notificationConditionSelector.updateNotificationCondition( + notification, daysOfWeekOrdinal, notificationConditionRequest); + } + + + @Transactional + public void updateWithoutNotification(final Notification oldNotification) { + notificationConditionSelector.deleteNotificationCondition( + oldNotification.getNotificationType(), oldNotification.getId()); + + oldNotification.updateActiveStatus(false); + oldNotification.deleteNotificationMethodType(); + } + + + @Transactional + public void deleteNotification(final Notification notification) { + notificationConditionSelector.deleteNotificationCondition( + notification.getNotificationType(), + notification.getId() + ); + } + +} 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..7f394223 --- /dev/null +++ b/src/main/java/com/und/server/notification/service/TimeNotificationService.java @@ -0,0 +1,142 @@ +package com.und.server.notification.service; + +import java.time.DayOfWeek; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +import com.und.server.notification.constants.NotificationType; +import com.und.server.notification.dto.NotificationInfoDto; +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 { + + public static final int EVERYDAY = 7; + private final TimeNotificationRepository timeNotificationRepository; + + + @Override + public boolean supports(final NotificationType notificationType) { + return notificationType == NotificationType.TIME; + } + + + @Override + public NotificationInfoDto findNotificationInfoByType(final Notification notification) { + if (!notification.isActive()) { + return null; + } + + List timeNotifications = + timeNotificationRepository.findByNotificationId(notification.getId()); + + TimeNotification baseTimeNotification = timeNotifications.get(0); + + List daysOfWeekOrdinal = timeNotifications.stream() + .map(tn -> tn.getDayOfWeek().ordinal()) + .toList(); + + boolean isEveryDay = daysOfWeekOrdinal.size() == EVERYDAY; + NotificationConditionResponse timeNotificationResponse = TimeNotificationResponse.from(baseTimeNotification); + + return new NotificationInfoDto(isEveryDay, daysOfWeekOrdinal, timeNotificationResponse); + } + + + @Override + public void addNotificationCondition( + final Notification notification, + final List daysOfWeekOrdinal, + final NotificationConditionRequest notificationConditionRequest + ) { + if (!notification.isActive()) { + return; + } + + TimeNotificationRequest timeNotificationRequest = (TimeNotificationRequest) notificationConditionRequest; + + List timeNotifications = daysOfWeekOrdinal.stream() + .map(ordinal -> DayOfWeek.values()[ordinal]) + .map(dayOfWeek -> timeNotificationRequest.toEntity(notification, dayOfWeek)) + .toList(); + timeNotificationRepository.saveAll(timeNotifications); + } + + + @Override + public void updateNotificationCondition( + final Notification oldNotification, + final List daysOfWeekOrdinal, + final NotificationConditionRequest notificationConditionRequest + ) { + TimeNotificationRequest timeNotificationInfo = (TimeNotificationRequest) notificationConditionRequest; + List oldTimeNotifications = + timeNotificationRepository.findByNotificationId(oldNotification.getId()); + + Set oldOrdinals = oldTimeNotifications.stream() + .map(tn -> tn.getDayOfWeek().ordinal()) + .collect(Collectors.toSet()); + + Set newOrdinals = new HashSet<>(daysOfWeekOrdinal); + + Set toDeleteOrdinals = oldOrdinals.stream() + .filter(ordinal -> !newOrdinals.contains(ordinal)) + .collect(Collectors.toSet()); + + Set toAddOrdinals = newOrdinals.stream() + .filter(ordinal -> !oldOrdinals.contains(ordinal)) + .collect(Collectors.toSet()); + + Set toUpdateOrdinals = oldOrdinals.stream() + .filter(newOrdinals::contains) + .collect(Collectors.toSet()); + + if (!toDeleteOrdinals.isEmpty()) { + List toDelete = oldTimeNotifications.stream() + .filter(tn -> toDeleteOrdinals.contains(tn.getDayOfWeek().ordinal())) + .toList(); + timeNotificationRepository.deleteAll(toDelete); + } + + if (!toAddOrdinals.isEmpty()) { + List toAdd = toAddOrdinals.stream() + .map(ordinal -> DayOfWeek.values()[ordinal]) + .map(dayOfWeek -> timeNotificationInfo.toEntity(oldNotification, dayOfWeek)) + .toList(); + timeNotificationRepository.saveAll(toAdd); + } + + if (!toUpdateOrdinals.isEmpty()) { + List toUpdate = oldTimeNotifications.stream() + .filter(tn -> toUpdateOrdinals.contains(tn.getDayOfWeek().ordinal())) + .peek(tn -> { + tn.updateTimeCondition( + timeNotificationInfo.startHour(), timeNotificationInfo.startMinute()); + }) + .toList(); + timeNotificationRepository.saveAll(toUpdate); + } + } + + + @Override + public void deleteNotificationCondition(final Long notificationId) { + List timeNotifications = + timeNotificationRepository.findByNotificationId(notificationId); + timeNotificationRepository.deleteAll(timeNotifications); + } + +} 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/MissionController.java b/src/main/java/com/und/server/scenario/controller/MissionController.java new file mode 100644 index 00000000..bea07afc --- /dev/null +++ b/src/main/java/com/und/server/scenario/controller/MissionController.java @@ -0,0 +1,112 @@ +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 io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@PreAuthorize("isAuthenticated()") +@RequestMapping("/v1") +public class MissionController { + + private final ScenarioService scenarioService; + private final MissionService missionService; + + + @GetMapping("/scenarios/{scenarioId}/missions") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Get missions successful"), + @ApiResponse(responseCode = "400", description = "Invalid parameter"), + @ApiResponse(responseCode = "404", description = "Scenario not found") + }) + 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); + } + + + @PostMapping("/scenarios/{scenarioId}/missions/today") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Create Today Mission successful"), + @ApiResponse(responseCode = "400", description = "Invalid parameter"), + @ApiResponse(responseCode = "404", description = "Scenario not found") + }) + 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); + } + + + @PatchMapping("/missions/{missionId}/check") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Update check status successful"), + @ApiResponse(responseCode = "400", description = "Invalid parameter"), + @ApiResponse(responseCode = "401", description = "Unauthorized access"), + @ApiResponse(responseCode = "404", description = "Mission not found") + }) + public ResponseEntity updateMissionCheck( + @AuthMember final Long memberId, + @PathVariable final Long missionId, + @RequestBody @NotNull final Boolean isChecked + ) { + missionService.updateMissionCheck(memberId, missionId, isChecked); + + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + + @DeleteMapping("/missions/{missionId}") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Delete Today Mission successful"), + @ApiResponse(responseCode = "401", description = "Unauthorized access"), + @ApiResponse(responseCode = "404", description = "Mission not found") + }) + 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/ScenarioController.java b/src/main/java/com/und/server/scenario/controller/ScenarioController.java new file mode 100644 index 00000000..41492966 --- /dev/null +++ b/src/main/java/com/und/server/scenario/controller/ScenarioController.java @@ -0,0 +1,173 @@ +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.ScenarioNoNotificationRequest; +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 io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@PreAuthorize("isAuthenticated()") +@RequestMapping("/v1") +public class ScenarioController { + + private final ScenarioService scenarioService; + + + @GetMapping("/scenarios") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Get scenarios successful"), + @ApiResponse(responseCode = "400", description = "Invalid parameter") + }) + 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); + } + + + @GetMapping("/scenarios/{scenarioId}") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Get scenario detail successful"), + @ApiResponse(responseCode = "400", description = "Invalid parameter"), + @ApiResponse(responseCode = "404", description = "Scenario not found") + }) + public ResponseEntity getScenarioDetail( + @AuthMember final Long memberId, + @PathVariable final Long scenarioId + ) { + final ScenarioDetailResponse scenarioDetail = + scenarioService.findScenarioDetailByScenarioId(memberId, scenarioId); + + return ResponseEntity.ok().body(scenarioDetail); + } + + + @PostMapping("/scenarios") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Create Scenario successful"), + @ApiResponse(responseCode = "400", description = "Invalid parameter") + }) + public ResponseEntity addScenario( + @AuthMember final Long memberId, + @RequestBody @Valid final ScenarioDetailRequest scenarioRequest + ) { + final Long scenarioId = scenarioService.addScenario(memberId, scenarioRequest); + + return ResponseEntity.status(HttpStatus.CREATED).body(scenarioId); + } + + + @PostMapping("/scenarios/without-notification") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Create Scenario without notification successful"), + @ApiResponse(responseCode = "400", description = "Invalid parameter") + }) + public ResponseEntity addScenarioWithoutNotification( + @AuthMember final Long memberId, + @RequestBody @Valid final ScenarioNoNotificationRequest scenarioNoNotificationResponse + ) { + final Long scenarioId = + scenarioService.addScenarioWithoutNotification(memberId, scenarioNoNotificationResponse); + + return ResponseEntity.status(HttpStatus.CREATED).body(scenarioId); + } + + + @PutMapping("/scenarios/{scenarioId}") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Update Scenario successful"), + @ApiResponse(responseCode = "400", description = "Invalid parameter"), + @ApiResponse(responseCode = "404", description = "Scenario not found") + }) + public ResponseEntity updateScenario( + @AuthMember final Long memberId, + @PathVariable final Long scenarioId, + @RequestBody @Valid final ScenarioDetailRequest scenarioRequest + ) { + scenarioService.updateScenario(memberId, scenarioId, scenarioRequest); + + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + + @PutMapping("/scenarios/{scenarioId}/without-notification") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Update Scenario without notification successful"), + @ApiResponse(responseCode = "400", description = "Invalid parameter"), + @ApiResponse(responseCode = "404", description = "Scenario not found") + }) + public ResponseEntity updateScenarioWithoutNotification( + @AuthMember final Long memberId, + @PathVariable final Long scenarioId, + @RequestBody @Valid final ScenarioNoNotificationRequest scenarioNoNotificationRequest + ) { + scenarioService.updateScenarioWithoutNotification(memberId, scenarioId, scenarioNoNotificationRequest); + + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + + @PatchMapping("/scenarios/{scenarioId}/order") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Update Scenario order successful"), + @ApiResponse(responseCode = "400", description = "Invalid parameter"), + @ApiResponse(responseCode = "404", description = "Scenario not found") + }) + 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); + } + + + @DeleteMapping("/scenarios/{scenarioId}") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Delete Scenario successful"), + @ApiResponse(responseCode = "404", description = "Scenario not found") + }) + 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..cb933cce --- /dev/null +++ b/src/main/java/com/und/server/scenario/dto/request/ScenarioDetailRequest.java @@ -0,0 +1,55 @@ +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.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +@Schema(description = "Scenario detail 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 + NotificationRequest notification, + + @Schema( + description = "Notification details condition that are included only when the notification is active", + discriminatorProperty = "notificationType", + discriminatorMapping = { + @DiscriminatorMapping(value = "time", schema = TimeNotificationRequest.class) + } + ) + @Valid + NotificationConditionRequest notificationCondition + +) { } diff --git a/src/main/java/com/und/server/scenario/dto/request/ScenarioNoNotificationRequest.java b/src/main/java/com/und/server/scenario/dto/request/ScenarioNoNotificationRequest.java new file mode 100644 index 00000000..3c741a7e --- /dev/null +++ b/src/main/java/com/und/server/scenario/dto/request/ScenarioNoNotificationRequest.java @@ -0,0 +1,38 @@ +package com.und.server.scenario.dto.request; + +import java.util.List; + +import com.und.server.notification.constants.NotificationType; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@Schema(description = "Scenario without notification request for create and update") +public record ScenarioNoNotificationRequest( + + @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 type", example = "time") + @NotNull(message = "notificationType must not be null") + NotificationType notificationType + +) { } 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..f5f3ffe5 --- /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 = "1000") + @Min(value = 0, message = "prevOrder must be greater than or equal to 1") + Integer prevOrder, + + @Schema(description = "Next Scenario order", example = "2000") + @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..4fbfb9ec --- /dev/null +++ b/src/main/java/com/und/server/scenario/dto/response/MissionGroupResponse.java @@ -0,0 +1,34 @@ +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; + +@Schema(description = "Home display Mission group by Mission type response") +public record MissionGroupResponse( + + @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 new MissionGroupResponse( + MissionResponse.listFrom(basic), + MissionResponse.listFrom(today) + ); + } + +} 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..41106ac4 --- /dev/null +++ b/src/main/java/com/und/server/scenario/dto/response/MissionResponse.java @@ -0,0 +1,44 @@ +package com.und.server.scenario.dto.response; + +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 List listFrom(final List missionList) { + 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..9eea0b0a --- /dev/null +++ b/src/main/java/com/und/server/scenario/dto/response/ScenarioDetailResponse.java @@ -0,0 +1,72 @@ +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.notification.dto.response.TimeNotificationResponse; +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.DiscriminatorMapping; +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 details condition that are included only when the notification is active", + discriminatorProperty = "notificationType", + discriminatorMapping = { + @DiscriminatorMapping(value = "TIME", schema = TimeNotificationResponse.class) + }, + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + nullable = true + ) + 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..422637bc --- /dev/null +++ b/src/main/java/com/und/server/scenario/entity/Mission.java @@ -0,0 +1,69 @@ +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 +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 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; + } + +} 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..62450b25 --- /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 +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..446c8f55 --- /dev/null +++ b/src/main/java/com/und/server/scenario/exception/ScenarioErrorResult.java @@ -0,0 +1,36 @@ +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"), + UNAUTHORIZED_ACCESS( + HttpStatus.UNAUTHORIZED, "Unauthorized access"), + 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..4683eb2c --- /dev/null +++ b/src/main/java/com/und/server/scenario/repository/MissionRepository.java @@ -0,0 +1,40 @@ +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.scenario.entity.Mission; + +import jakarta.validation.constraints.NotNull; + +public interface MissionRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"scenario", "scenario.member"}) + Optional findById(@NotNull Long id); + + @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 findDefaultMissions(@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 findMissionsByDate(@NotNull Long memberId, @NotNull Long scenarioId, @NotNull LocalDate date); + +} 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..64d5d596 --- /dev/null +++ b/src/main/java/com/und/server/scenario/repository/ScenarioRepository.java @@ -0,0 +1,48 @@ +package com.und.server.scenario.repository; + +import java.util.List; +import java.util.Optional; + +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 { + + Optional findByIdAndMemberId(@NotNull Long id, @NotNull Long memberId); + + @Query(""" + SELECT s FROM Scenario s + LEFT JOIN FETCH s.notification + LEFT JOIN FETCH s.missions + WHERE s.id = :id + AND s.member.id = :memberId + """) + Optional findFetchByIdAndMemberId(@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/service/MissionService.java b/src/main/java/com/und/server/scenario/service/MissionService.java new file mode 100644 index 00000000..c6f6070f --- /dev/null +++ b/src/main/java/com/und/server/scenario/service/MissionService.java @@ -0,0 +1,189 @@ +package com.und.server.scenario.service; + +import java.time.LocalDate; +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; + + + @Transactional(readOnly = true) + public MissionGroupResponse findMissionsByScenarioId( + final Long memberId, final Long scenarioId, final LocalDate date + ) { + scenarioValidator.validateScenarioExists(scenarioId); + + List missions = getMissionsByDate(memberId, scenarioId, date); + + if (missions == null || missions.isEmpty()) { + return MissionGroupResponse.from(List.of(), List.of()); + } + + List groupedBasicMissions = + missionTypeGroupSorter.groupAndSortByType(missions, MissionType.BASIC); + List groupedTodayMissions = + missionTypeGroupSorter.groupAndSortByType(missions, MissionType.TODAY); + + return MissionGroupResponse.from(groupedBasicMissions, groupedTodayMissions); + } + + + @Transactional + public void addBasicMission(final Scenario scenario, final List missionRequests) { + if (missionRequests.isEmpty()) { + return; + } + + 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); + + missionRepository.saveAll(missions); + } + + + @Transactional + public MissionResponse addTodayMission( + final Scenario scenario, + final TodayMissionRequest todayMissionRequest, + final LocalDate date + ) { + LocalDate today = LocalDate.now(); + 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.saveAll(toAdd); + } + + + @Transactional + public void updateMissionCheck( + final Long memberId, final Long missionId, final Boolean isChecked + ) { + Mission mission = missionRepository.findById(missionId) + .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_MISSION)); + missionValidator.validateMissionAccessibleMember(mission, memberId); + + mission.updateCheckStatus(isChecked); + } + + + @Transactional + public void deleteTodayMission(final Long memberId, final Long missionId) { + Mission mission = missionRepository.findById(missionId) + .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_MISSION)); + missionValidator.validateMissionAccessibleMember(mission, memberId); + + missionRepository.delete(mission); + } + + + private List getMissionsByDate( + final Long memberId, final Long scenarioId, final LocalDate date + ) { + LocalDate today = LocalDate.now(); + MissionSearchType missionSearchType = MissionSearchType.getMissionSearchType(today, date); + + switch (missionSearchType) { + case TODAY -> { + return missionRepository.findDefaultMissions(memberId, scenarioId, date); + } + case PAST, FUTURE -> { + return missionRepository.findMissionsByDate(memberId, scenarioId, date); + } + } + throw new ServerException(ScenarioErrorResult.INVALID_MISSION_FOUND_DATE); + } + +} 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..ee5944e3 --- /dev/null +++ b/src/main/java/com/und/server/scenario/service/ScenarioService.java @@ -0,0 +1,297 @@ +package com.und.server.scenario.service; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; + +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.NotificationInfoDto; +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.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.ScenarioNoNotificationRequest; +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; + + + @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.findFetchByIdAndMemberId(memberId, scenarioId) + .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_SCENARIO)); + + List basicMissions = + missionTypeGroupSorter.groupAndSortByType(scenario.getMissions(), MissionType.BASIC); + + Notification notification = scenario.getNotification(); + NotificationInfoDto notificationInfo = notificationService.findNotificationDetails(notification); + + return getScenarioDetailResponse(scenario, basicMissions, notificationInfo); + } + + + @Transactional + public MissionResponse addTodayMissionToScenario( + final Long memberId, + final Long scenarioId, + final TodayMissionRequest todayMissionRequest, + final LocalDate date + ) { + Scenario scenario = scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId) + .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_SCENARIO)); + + return missionService.addTodayMission(scenario, todayMissionRequest, date); + } + + + @Transactional + public Long addScenario(final Long memberId, final ScenarioDetailRequest scenarioDetailRequest) { + return addScenarioInternal( + memberId, + scenarioDetailRequest.scenarioName(), + scenarioDetailRequest.memo(), + scenarioDetailRequest.basicMissions(), + scenarioDetailRequest.notification().notificationType(), + () -> notificationService.addNotification( + scenarioDetailRequest.notification(), + scenarioDetailRequest.notificationCondition() + ) + ); + } + + + @Transactional + public Long addScenarioWithoutNotification( + final Long memberId, final ScenarioNoNotificationRequest scenarioNoNotificationRequest + ) { + return addScenarioInternal( + memberId, + scenarioNoNotificationRequest.scenarioName(), + scenarioNoNotificationRequest.memo(), + scenarioNoNotificationRequest.basicMissions(), + scenarioNoNotificationRequest.notificationType(), + () -> notificationService.addWithoutNotification(scenarioNoNotificationRequest.notificationType()) + ); + } + + + @Transactional + public void updateScenario( + final Long memberId, + final Long scenarioId, + final ScenarioDetailRequest scenarioDetailRequest + ) { + updateScenarioInternal( + memberId, + scenarioId, + scenarioDetailRequest.scenarioName(), + scenarioDetailRequest.memo(), + scenarioDetailRequest.basicMissions(), + notification -> notificationService.updateNotification( + notification, + scenarioDetailRequest.notification(), + scenarioDetailRequest.notificationCondition() + ) + ); + } + + + @Transactional + public void updateScenarioWithoutNotification( + final Long memberId, + final Long scenarioId, + final ScenarioNoNotificationRequest scenarioNoNotificationRequest + ) { + updateScenarioInternal( + memberId, + scenarioId, + scenarioNoNotificationRequest.scenarioName(), + scenarioNoNotificationRequest.memo(), + scenarioNoNotificationRequest.basicMissions(), + notificationService::updateWithoutNotification + ); + } + + + @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.findFetchByIdAndMemberId(memberId, scenarioId) + .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_SCENARIO)); + + notificationService.deleteNotification(scenario.getNotification()); + scenarioRepository.delete(scenario); + } + + + private Long addScenarioInternal( + final Long memberId, + final String scenarioName, + final String memo, + final List missions, + final NotificationType notificationType, + final Supplier notificationSupplier + ) { + Member member = em.getReference(Member.class, memberId); + + List orders = + scenarioRepository.findOrdersByMemberIdAndNotificationType(memberId, notificationType); + scenarioValidator.validateMaxScenarioCount(orders); + + int order = orders.isEmpty() + ? OrderCalculator.START_ORDER + : getValidScenarioOrder(Collections.max(orders), memberId, notificationType); + + Notification notification = notificationSupplier.get(); + + Scenario scenario = Scenario.builder() + .member(member) + .scenarioName(scenarioName) + .memo(memo) + .scenarioOrder(order) + .notification(notification) + .build(); + + scenarioRepository.save(scenario); + missionService.addBasicMission(scenario, missions); + + return scenario.getId(); + } + + private void updateScenarioInternal( + final Long memberId, + final Long scenarioId, + final String scenarioName, + final String memo, + final List newBasicMissions, + final Consumer notificationUpdater + ) { + Scenario oldScenario = scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId) + .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_SCENARIO)); + + notificationUpdater.accept(oldScenario.getNotification()); + + missionService.updateBasicMission(oldScenario, newBasicMissions); + + oldScenario.updateScenarioName(scenarioName); + oldScenario.updateMemo(memo); + } + + private ScenarioDetailResponse getScenarioDetailResponse( + final Scenario scenario, + final List basicMissions, + final NotificationInfoDto notificationInfo + ) { + Notification notification = scenario.getNotification(); + + NotificationResponse notificationResponse; + NotificationConditionResponse notificationConditionResponse = null; + + if (notificationInfo == null) { + notificationResponse = NotificationResponse.from( + notification, null, null); + } else { + notificationResponse = NotificationResponse.from( + notification, + notificationInfo.isEveryDay(), + notificationInfo.daysOfWeekOrdinal() + ); + notificationConditionResponse = notificationInfo.notificationConditionResponse(); + } + + return ScenarioDetailResponse.from( + scenario, basicMissions, notificationResponse, notificationConditionResponse); + } + + private int getValidScenarioOrder( + final int maxScenarioOrder, + final Long memberId, + final NotificationType notificationType + ) { + try { + return orderCalculator.getOrder(maxScenarioOrder, null); + } catch (ReorderRequiredException e) { + List scenarios = + scenarioRepository.findByMemberIdAndNotificationType(memberId, notificationType); + + return orderCalculator.getMaxOrderAfterReorder(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..eded0d75 --- /dev/null +++ b/src/main/java/com/und/server/scenario/util/MissionTypeGroupSorter.java @@ -0,0 +1,40 @@ +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); + 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..738083ee --- /dev/null +++ b/src/main/java/com/und/server/scenario/util/MissionValidator.java @@ -0,0 +1,49 @@ +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.member.entity.Member; +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 validateMissionAccessibleMember(final Mission mission, final Long memberId) { + Member member = mission.getScenario().getMember(); + if (!memberId.equals(member.getId())) { + throw new ServerException(ScenarioErrorResult.UNAUTHORIZED_ACCESS); + } + } + + 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..f872e3ac --- /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 = 1000; + 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 getMaxOrderAfterReorder(final List scenarios) { + if (scenarios.isEmpty()) { + return START_ORDER; + } + scenarios.sort(Comparator.comparing(Scenario::getScenarioOrder)); + + assignSequentialOrders(scenarios); + Scenario lastScenario = scenarios.get(scenarios.size() - 1); + + return calculateLastOrder(lastScenario.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/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/dto/request/TimeNotificationRequestTest.java b/src/test/java/com/und/server/notification/dto/request/TimeNotificationRequestTest.java new file mode 100644 index 00000000..94587590 --- /dev/null +++ b/src/test/java/com/und/server/notification/dto/request/TimeNotificationRequestTest.java @@ -0,0 +1,46 @@ +package com.und.server.notification.dto.request; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.DayOfWeek; + +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, DayOfWeek.MONDAY); + + assertThat(entity.getNotification()).isEqualTo(notification); + assertThat(entity.getDayOfWeek()).isEqualTo(DayOfWeek.MONDAY); + assertThat(entity.getStartHour()).isEqualTo(8); + assertThat(entity.getStartMinute()).isEqualTo(45); + } + +} + + 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..dc608507 --- /dev/null +++ b/src/test/java/com/und/server/notification/service/NotificationConditionSelectorTest.java @@ -0,0 +1,247 @@ +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.NotificationInfoDto; +import com.und.server.notification.dto.request.NotificationConditionRequest; +import com.und.server.notification.dto.request.TimeNotificationRequest; +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; + NotificationInfoDto expectedDto = + new NotificationInfoDto(true, List.of(0, 1, 2), null); + + when(notification.getNotificationType()).thenReturn(notifType); + when(timeNotificationService.supports(notifType)).thenReturn(true); + when(timeNotificationService.findNotificationInfoByType(notification)).thenReturn(expectedDto); + + // when + NotificationInfoDto 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; + List dayOfWeekList = List.of(0, 1, 2); + TimeNotificationRequest timeRequest = TimeNotificationRequest.builder() + .startHour(9) + .startMinute(0) + .build(); + + when(notification.getNotificationType()).thenReturn(notifType); + when(timeNotificationService.supports(notifType)).thenReturn(true); + + // when + selector.addNotificationCondition(notification, dayOfWeekList, timeRequest); + + // then + verify(timeNotificationService).supports(notifType); + verify(timeNotificationService).addNotificationCondition(notification, dayOfWeekList, timeRequest); + } + + + @Test + void Given_UnsupportedNotificationType_When_AddNotificationCondition_Then_ThrowServerException() { + // given + NotificationType notifType = NotificationType.LOCATION; + List dayOfWeekList = List.of(0, 1, 2); + + when(notification.getNotificationType()).thenReturn(notifType); + when(timeNotificationService.supports(notifType)).thenReturn(false); + when(locationNotificationService.supports(notifType)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> selector.addNotificationCondition(notification, dayOfWeekList, 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; + List dayOfWeekList = List.of(0, 1, 2); + TimeNotificationRequest timeRequest = TimeNotificationRequest.builder() + .startHour(9) + .startMinute(0) + .build(); + + when(notification.getNotificationType()).thenReturn(notifType); + when(timeNotificationService.supports(notifType)).thenReturn(true); + + // when + selector.updateNotificationCondition(notification, dayOfWeekList, timeRequest); + + // then + verify(timeNotificationService).supports(notifType); + verify(timeNotificationService).updateNotificationCondition(notification, dayOfWeekList, timeRequest); + } + + + @Test + void Given_UnsupportedNotificationType_When_UpdateNotificationCondition_Then_ThrowServerException() { + // given + NotificationType notifType = NotificationType.LOCATION; + List dayOfWeekList = List.of(0, 1, 2); + + when(notification.getNotificationType()).thenReturn(notifType); + when(timeNotificationService.supports(notifType)).thenReturn(false); + when(locationNotificationService.supports(notifType)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> selector.updateNotificationCondition(notification, dayOfWeekList, conditionRequest)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", NotificationErrorResult.UNSUPPORTED_NOTIFICATION); + } + + + @Test + void Given_MultipleServices_When_FirstServiceSupports_Then_UseFirstService() { + // given + NotificationType notifType = NotificationType.TIME; + NotificationInfoDto expectedDto = + new NotificationInfoDto(true, List.of(0, 1, 2), null); + + when(notification.getNotificationType()).thenReturn(notifType); + when(timeNotificationService.supports(notifType)).thenReturn(true); + when(timeNotificationService.findNotificationInfoByType(notification)).thenReturn(expectedDto); + + // when + NotificationInfoDto 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; + NotificationInfoDto expectedDto = + new NotificationInfoDto(false, List.of(0, 1), null); + + when(notification.getNotificationType()).thenReturn(notifType); + when(timeNotificationService.supports(notifType)).thenReturn(false); + when(locationNotificationService.supports(notifType)).thenReturn(true); + when(locationNotificationService.findNotificationInfoByType(notification)).thenReturn(expectedDto); + + // when + NotificationInfoDto 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..43804822 --- /dev/null +++ b/src/test/java/com/und/server/notification/service/NotificationServiceTest.java @@ -0,0 +1,225 @@ +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.eq; +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.NotificationInfoDto; +import com.und.server.notification.dto.request.NotificationRequest; +import com.und.server.notification.dto.request.TimeNotificationRequest; +import com.und.server.notification.entity.Notification; +import com.und.server.notification.repository.NotificationRepository; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock + private NotificationRepository notificationRepository; + + @Mock + private NotificationConditionSelector notificationConditionSelector; + + @InjectMocks + private NotificationService notificationService; + + + @Test + void Given_Notification_When_FindNotificationDetails_Then_ReturnNotificationInfo() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + + NotificationInfoDto expectedInfo = + new NotificationInfoDto(true, List.of(0, 1, 2), null); + when(notificationConditionSelector.findNotificationCondition(notification)) + .thenReturn(expectedInfo); + + // when + NotificationInfoDto 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() + .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(); + assertThat(result.getNotificationType()).isEqualTo(NotificationType.TIME); + assertThat(result.getNotificationMethodType()).isEqualTo(NotificationMethodType.PUSH); + assertThat(result.getIsActive()).isTrue(); + verify(notificationRepository).save(any(Notification.class)); + verify(notificationConditionSelector) + .addNotificationCondition(any(Notification.class), eq(List.of(0, 1, 2)), 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() + .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.getNotificationType()).isEqualTo(NotificationType.TIME); + assertThat(oldNotification.getNotificationMethodType()).isEqualTo(NotificationMethodType.ALARM); + assertThat(oldNotification.isActive()).isTrue(); + verify(notificationConditionSelector) + .updateNotificationCondition(oldNotification, List.of(0, 1, 2, 3), 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() + .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.getNotificationType()).isEqualTo(NotificationType.LOCATION); + assertThat(oldNotification.getNotificationMethodType()).isEqualTo(NotificationMethodType.ALARM); + assertThat(oldNotification.getIsActive()).isTrue(); + verify(notificationConditionSelector) + .deleteNotificationCondition(NotificationType.TIME, oldNotification.getId()); + verify(notificationConditionSelector) + .addNotificationCondition(oldNotification, List.of(0, 1, 2), 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(); + + // when + notificationService.updateWithoutNotification(oldNotification); + + // then + assertThat(oldNotification.isActive()).isFalse(); + assertThat(oldNotification.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 + Notification result = notificationService.addWithoutNotification(type); + + // then + assertThat(result.getNotificationType()).isEqualTo(type); + assertThat(result.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); + } + +} 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..82f2ebfa --- /dev/null +++ b/src/test/java/com/und/server/notification/service/TimeNotificationServiceTest.java @@ -0,0 +1,458 @@ +package com.und.server.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.DayOfWeek; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +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.NotificationInfoDto; +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.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) + .build(); + + TimeNotification monday = TimeNotification.builder() + .id(10L) + .dayOfWeek(DayOfWeek.MONDAY) + .startHour(9) + .startMinute(0) + .build(); + TimeNotification tuesday = TimeNotification.builder() + .id(11L) + .dayOfWeek(DayOfWeek.TUESDAY) + .startHour(9) + .startMinute(0) + .build(); + TimeNotification wednesday = TimeNotification.builder() + .id(12L) + .dayOfWeek(DayOfWeek.WEDNESDAY) + .startHour(9) + .startMinute(0) + .build(); + TimeNotification thursday = TimeNotification.builder() + .id(13L) + .dayOfWeek(DayOfWeek.THURSDAY) + .startHour(9) + .startMinute(0) + .build(); + TimeNotification friday = TimeNotification.builder() + .id(14L) + .dayOfWeek(DayOfWeek.FRIDAY) + .startHour(9) + .startMinute(0) + .build(); + TimeNotification saturday = TimeNotification.builder() + .id(15L) + .dayOfWeek(DayOfWeek.SATURDAY) + .startHour(9) + .startMinute(0) + .build(); + TimeNotification sunday = TimeNotification.builder() + .id(16L) + .dayOfWeek(DayOfWeek.SUNDAY) + .startHour(9) + .startMinute(0) + .build(); + + when(timeNotifRepository.findByNotificationId(notification.getId())) + .thenReturn(List.of(monday, tuesday, wednesday, thursday, friday, saturday, sunday)); + + // when + NotificationInfoDto result = timeNotificationService.findNotificationInfoByType(notification); + + // then + assertThat(result).isNotNull(); + assertThat(result.isEveryDay()).isTrue(); + assertThat(result.daysOfWeekOrdinal()).hasSize(7); + assertThat(result.daysOfWeekOrdinal()).containsExactlyInAnyOrder(0, 1, 2, 3, 4, 5, 6); + assertThat(result.notificationConditionResponse()).isInstanceOf(TimeNotificationResponse.class); + } + + + @Test + void Given_SpecificDaysNotification_When_FindNotificationInfoByType_Then_ReturnDayList() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + + TimeNotification monday = TimeNotification.builder() + .id(10L) + .dayOfWeek(DayOfWeek.MONDAY) + .startHour(10) + .startMinute(30) + .build(); + + TimeNotification wednesday = TimeNotification.builder() + .id(11L) + .dayOfWeek(DayOfWeek.WEDNESDAY) + .startHour(10) + .startMinute(30) + .build(); + + when(timeNotifRepository.findByNotificationId(notification.getId())) + .thenReturn(List.of(monday, wednesday)); + + // when + NotificationInfoDto result = timeNotificationService.findNotificationInfoByType(notification); + + // then + assertThat(result).isNotNull(); + assertThat(result.isEveryDay()).isFalse(); + assertThat(result.daysOfWeekOrdinal()).hasSize(2); + assertThat(result.daysOfWeekOrdinal()).containsExactlyInAnyOrder(0, 2); + assertThat(result.notificationConditionResponse()).isInstanceOf(TimeNotificationResponse.class); + } + + + @Test + void Given_7DaysAndActiveNotification_When_AddNotificationCondition_Then_SaveAll7Days() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + + TimeNotificationRequest request = TimeNotificationRequest.builder() + .startHour(8) + .startMinute(30) + .build(); + List allDays = List.of(0, 1, 2, 3, 4, 5, 6); + + // when + timeNotificationService.addNotificationCondition(notification, allDays, request); + + // then + verify(timeNotifRepository).saveAll(anyList()); + verify(timeNotifRepository).saveAll(argThat(list -> { + assertThat(list).hasSize(7); + Set savedDays = ((List) list).stream() + .map(TimeNotification::getDayOfWeek) + .collect(Collectors.toSet()); + assertThat(savedDays).containsExactlyInAnyOrder( + DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY, DayOfWeek.SUNDAY + ); + return true; + })); + } + + + @Test + void Given_SomeDaysAndActiveNotification_When_AddNotificationCondition_Then_SaveAll() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + + TimeNotificationRequest request = TimeNotificationRequest.builder() + .startHour(7) + .startMinute(45) + .build(); + List days = List.of(1, 3, 5); // TUESDAY, THURSDAY, SATURDAY + + // when + timeNotificationService.addNotificationCondition(notification, days, request); + + // then + verify(timeNotifRepository).saveAll(anyList()); + } + + + @Test + void Given_NotificationId_When_DeleteNotificationCondition_Then_DeleteAll() { + // given + Long notificationId = 1L; + List timeNotifications = List.of( + TimeNotification.builder().id(1L).build(), + TimeNotification.builder().id(2L).build() + ); + when(timeNotifRepository.findByNotificationId(notificationId)) + .thenReturn(timeNotifications); + + // when + timeNotificationService.deleteNotificationCondition(notificationId); + + // then + verify(timeNotifRepository).findByNotificationId(notificationId); + verify(timeNotifRepository).deleteAll(timeNotifications); + } + + + @Test + void Given_EverydayNotificationAnd7Days_When_UpdateNotificationCondition_Then_UpdateAll7Days() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + + TimeNotificationRequest request = TimeNotificationRequest.builder() + .startHour(10) + .startMinute(30) + .build(); + List allDays = List.of(0, 1, 2, 3, 4, 5, 6); + + TimeNotification existingMonday = TimeNotification.builder() + .id(10L) + .dayOfWeek(DayOfWeek.MONDAY) + .startHour(9) + .startMinute(0) + .build(); + TimeNotification existingTuesday = TimeNotification.builder() + .id(11L) + .dayOfWeek(DayOfWeek.TUESDAY) + .startHour(9) + .startMinute(0) + .build(); + TimeNotification existingWednesday = TimeNotification.builder() + .id(12L) + .dayOfWeek(DayOfWeek.WEDNESDAY) + .startHour(9) + .startMinute(0) + .build(); + TimeNotification existingThursday = TimeNotification.builder() + .id(13L) + .dayOfWeek(DayOfWeek.THURSDAY) + .startHour(9) + .startMinute(0) + .build(); + TimeNotification existingFriday = TimeNotification.builder() + .id(14L) + .dayOfWeek(DayOfWeek.FRIDAY) + .startHour(9) + .startMinute(0) + .build(); + TimeNotification existingSaturday = TimeNotification.builder() + .id(15L) + .dayOfWeek(DayOfWeek.SATURDAY) + .startHour(9) + .startMinute(0) + .build(); + TimeNotification existingSunday = TimeNotification.builder() + .id(16L) + .dayOfWeek(DayOfWeek.SUNDAY) + .startHour(9) + .startMinute(0) + .build(); + + when(timeNotifRepository.findByNotificationId(notification.getId())) + .thenReturn(List.of(existingMonday, existingTuesday, existingWednesday, + existingThursday, existingFriday, existingSaturday, existingSunday)); + + // when + timeNotificationService.updateNotificationCondition(notification, allDays, request); + + // then + verify(timeNotifRepository).findByNotificationId(notification.getId()); + verify(timeNotifRepository).saveAll(anyList()); + assertThat(existingMonday.getStartHour()).isEqualTo(10); + assertThat(existingMonday.getStartMinute()).isEqualTo(30); + assertThat(existingTuesday.getStartHour()).isEqualTo(10); + assertThat(existingTuesday.getStartMinute()).isEqualTo(30); + assertThat(existingWednesday.getStartHour()).isEqualTo(10); + assertThat(existingWednesday.getStartMinute()).isEqualTo(30); + assertThat(existingThursday.getStartHour()).isEqualTo(10); + assertThat(existingThursday.getStartMinute()).isEqualTo(30); + assertThat(existingFriday.getStartHour()).isEqualTo(10); + assertThat(existingFriday.getStartMinute()).isEqualTo(30); + assertThat(existingSaturday.getStartHour()).isEqualTo(10); + assertThat(existingSaturday.getStartMinute()).isEqualTo(30); + assertThat(existingSunday.getStartHour()).isEqualTo(10); + assertThat(existingSunday.getStartMinute()).isEqualTo(30); + } + + + @Test + void Given_SpecificDaysNotificationAndSameDays_When_UpdateNotificationCondition_Then_UpdateExistingDays() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + + TimeNotificationRequest request = TimeNotificationRequest.builder() + .startHour(10) + .startMinute(30) + .build(); + List sameDays = List.of(0, 2); // MONDAY, WEDNESDAY + + TimeNotification existingMonday = TimeNotification.builder() + .id(10L) + .dayOfWeek(DayOfWeek.MONDAY) + .startHour(9) + .startMinute(0) + .build(); + TimeNotification existingWednesday = TimeNotification.builder() + .id(11L) + .dayOfWeek(DayOfWeek.WEDNESDAY) + .startHour(9) + .startMinute(0) + .build(); + + when(timeNotifRepository.findByNotificationId(notification.getId())) + .thenReturn(List.of(existingMonday, existingWednesday)); + + // when + timeNotificationService.updateNotificationCondition(notification, sameDays, request); + + // then + verify(timeNotifRepository).findByNotificationId(notification.getId()); + verify(timeNotifRepository).saveAll(anyList()); + assertThat(existingMonday.getStartHour()).isEqualTo(10); + assertThat(existingMonday.getStartMinute()).isEqualTo(30); + assertThat(existingWednesday.getStartHour()).isEqualTo(10); + assertThat(existingWednesday.getStartMinute()).isEqualTo(30); + } + + + @Test + void Given_SpecificDaysNotificationAndDifferentDays_When_UpdateNotificationCondition_Then_DeleteOldAndAddNew() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + + TimeNotificationRequest request = TimeNotificationRequest.builder() + .startHour(10) + .startMinute(30) + .build(); + List newDays = List.of(1, 3); // TUESDAY, THURSDAY + + TimeNotification existingMonday = TimeNotification.builder() + .id(10L) + .dayOfWeek(DayOfWeek.MONDAY) + .startHour(9) + .startMinute(0) + .build(); + TimeNotification existingWednesday = TimeNotification.builder() + .id(11L) + .dayOfWeek(DayOfWeek.WEDNESDAY) + .startHour(9) + .startMinute(0) + .build(); + + when(timeNotifRepository.findByNotificationId(notification.getId())) + .thenReturn(List.of(existingMonday, existingWednesday)); + + // when + timeNotificationService.updateNotificationCondition(notification, newDays, request); + + // then + verify(timeNotifRepository).findByNotificationId(notification.getId()); + verify(timeNotifRepository).deleteAll(anyList()); + verify(timeNotifRepository).saveAll(anyList()); + } + + + @Test + void Given_SpecificDaysNotificationAndMixedDays_When_UpdateNotificationCondition_Then_DeleteAddAndUpdate() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .build(); + + TimeNotificationRequest request = TimeNotificationRequest.builder() + .startHour(10) + .startMinute(30) + .build(); + List mixedDays = List.of(0, 1, 3); // MONDAY, TUESDAY, THURSDAY + + TimeNotification existingMonday = TimeNotification.builder() + .id(10L) + .dayOfWeek(DayOfWeek.MONDAY) + .startHour(9) + .startMinute(0) + .build(); + TimeNotification existingWednesday = TimeNotification.builder() + .id(11L) + .dayOfWeek(DayOfWeek.WEDNESDAY) + .startHour(9) + .startMinute(0) + .build(); + + when(timeNotifRepository.findByNotificationId(notification.getId())) + .thenReturn(List.of(existingMonday, existingWednesday)); + + // when + timeNotificationService.updateNotificationCondition(notification, mixedDays, request); + + // then + verify(timeNotifRepository).findByNotificationId(notification.getId()); + verify(timeNotifRepository).deleteAll(anyList()); + verify(timeNotifRepository, org.mockito.Mockito.times(2)).saveAll(anyList()); + assertThat(existingMonday.getStartHour()).isEqualTo(10); + assertThat(existingMonday.getStartMinute()).isEqualTo(30); + } + +} 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..5ce84289 --- /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.now(); + 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.now(); + 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.now(); + 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.now(); + 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.now(); + 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.now(); + 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.now(); + 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.now(); + 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.now(); + + // 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.now(); + + // 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.now(); + + // 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.now(); + + // 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..39cfa3c5 --- /dev/null +++ b/src/test/java/com/und/server/scenario/controller/MissionControllerTest.java @@ -0,0 +1,167 @@ +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.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 = new MissionGroupResponse( + List.of(), List.of() + ); + + 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_ReturnNoContent() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate date = LocalDate.now(); + TodayMissionRequest missionAddRequest = new TodayMissionRequest("오늘 미션"); + + // when + ResponseEntity response = + missionController.addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, date); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNull(); + verify(scenarioService).addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, date); + } + + + @Test + void Given_EmptyContentRequest_When_AddTodayMissionToScenario_Then_ReturnNoContent() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate date = LocalDate.now(); + TodayMissionRequest missionAddRequest = new TodayMissionRequest(""); + + // when + ResponseEntity response = + missionController.addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, date); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNull(); + verify(scenarioService).addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, date); + } + + + @Test + void Given_LongContentRequest_When_AddTodayMissionToScenario_Then_ReturnNoContent() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate date = LocalDate.now(); + TodayMissionRequest missionAddRequest = new TodayMissionRequest("매우 긴 미션 내용입니다"); + + // when + ResponseEntity response = + missionController.addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, date); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNull(); + 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; + + // when + ResponseEntity response = missionController.updateMissionCheck(memberId, missionId, isChecked); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(response.getBody()).isNull(); + verify(missionService).updateMissionCheck(memberId, missionId, isChecked); + } + + + @Test + void Given_ValidMissionIdAndIsUnchecked_When_UpdateMissionCheck_Then_ReturnNoContent() { + // given + Long memberId = 1L; + Long missionId = 1L; + Boolean isChecked = false; + + // when + ResponseEntity response = missionController.updateMissionCheck(memberId, missionId, isChecked); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(response.getBody()).isNull(); + verify(missionService).updateMissionCheck(memberId, missionId, isChecked); + } + +} 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..f674806f --- /dev/null +++ b/src/test/java/com/und/server/scenario/controller/ScenarioControllerTest.java @@ -0,0 +1,311 @@ +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.scenario.dto.request.ScenarioDetailRequest; +import com.und.server.scenario.dto.request.ScenarioNoNotificationRequest; +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(); + + when(scenarioService.addScenario(memberId, scenarioRequest)) + .thenReturn(expectedScenarioId); + + // when + ResponseEntity response = scenarioController.addScenario(memberId, scenarioRequest); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isEqualTo(expectedScenarioId); + verify(scenarioService).addScenario(memberId, scenarioRequest); + } + + @Test + void Given_ValidMemberIdAndScenarioRequest_When_AddScenarioWithoutNotification_Then_ReturnCreated() { + // given + Long memberId = 1L; + Long expectedScenarioId = 456L; + ScenarioNoNotificationRequest request = new ScenarioNoNotificationRequest( + "새 시나리오", + "메모", + null, + NotificationType.TIME + ); + + when(scenarioService.addScenarioWithoutNotification(memberId, request)) + .thenReturn(expectedScenarioId); + + // when + ResponseEntity response = scenarioController.addScenarioWithoutNotification(memberId, request); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isEqualTo(expectedScenarioId); + verify(scenarioService).addScenarioWithoutNotification(memberId, request); + } + + @Test + void Given_ValidMemberIdAndScenarioId_When_UpdateScenarioWithoutNotification_Then_ReturnNoContent() { + // given + Long memberId = 1L; + Long scenarioId = 2L; + ScenarioNoNotificationRequest request = new ScenarioNoNotificationRequest( + "수정 시나리오", + "메모", + null, + NotificationType.LOCATION + ); + + // when + ResponseEntity response = scenarioController + .updateScenarioWithoutNotification(memberId, scenarioId, request); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(scenarioService).updateScenarioWithoutNotification(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(); + + when(scenarioService.addScenario(memberId, scenarioRequest)) + .thenReturn(expectedScenarioId); + + // when + ResponseEntity response = scenarioController.addScenario(memberId, scenarioRequest); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isEqualTo(expectedScenarioId); + verify(scenarioService).addScenario(memberId, scenarioRequest); + } + + + @Test + void Given_ValidMemberIdAndScenarioIdAndRequest_When_UpdateScenario_Then_ReturnNoContent() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("수정된 시나리오") + .memo("수정된 시나리오 설명") + .build(); + + // when + ResponseEntity response = scenarioController.updateScenario(memberId, scenarioId, scenarioRequest); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(response.getBody()).isNull(); + verify(scenarioService).updateScenario(memberId, scenarioId, scenarioRequest); + } + + + @Test + void Given_EmptyTitleRequest_When_UpdateScenario_Then_ReturnNoContent() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("") + .memo("수정된 빈 제목 시나리오") + .build(); + + // when + ResponseEntity response = scenarioController.updateScenario(memberId, scenarioId, scenarioRequest); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(response.getBody()).isNull(); + 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(); + + when(scenarioService.addScenario(memberId, scenarioRequest)) + .thenReturn(expectedScenarioId); + + // when + ResponseEntity response = scenarioController.addScenario(memberId, scenarioRequest); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isEqualTo(expectedScenarioId); + verify(scenarioService).addScenario(memberId, scenarioRequest); + } + + + @Test + void Given_LongTitleRequest_When_UpdateScenario_Then_ReturnNoContent() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("매우 긴 수정된 시나리오 제목입니다") + .memo("긴 제목 수정 시나리오") + .build(); + + // when + ResponseEntity response = scenarioController.updateScenario(memberId, scenarioId, scenarioRequest); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(response.getBody()).isNull(); + 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/ScenarioNoNotificationRequestTest.java b/src/test/java/com/und/server/scenario/dto/request/ScenarioNoNotificationRequestTest.java new file mode 100644 index 00000000..46b31092 --- /dev/null +++ b/src/test/java/com/und/server/scenario/dto/request/ScenarioNoNotificationRequestTest.java @@ -0,0 +1,30 @@ +package com.und.server.scenario.dto.request; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.und.server.notification.constants.NotificationType; + +class ScenarioNoNotificationRequestTest { + + @Test + void construct_holdsValues() { + ScenarioNoNotificationRequest req = new ScenarioNoNotificationRequest( + "name", + "memo", + List.of(BasicMissionRequest.builder().content("A").build()), + NotificationType.TIME + ); + + assertThat(req.scenarioName()).isEqualTo("name"); + assertThat(req.memo()).isEqualTo("memo"); + assertThat(req.basicMissions()).hasSize(1); + assertThat(req.notificationType()).isEqualTo(NotificationType.TIME); + } + +} + + 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..f7bdbd33 --- /dev/null +++ b/src/test/java/com/und/server/scenario/service/MissionServiceTest.java @@ -0,0 +1,682 @@ +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.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +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.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.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) +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; + + @InjectMocks + private MissionService missionService; + + + @Test + void Given_ValidScenarioId_When_FindMissionsByScenarioId_Then_ReturnMissionGroupResponse() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate date = LocalDate.now(); + + 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.findDefaultMissions(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(); + assertThat(result.basicMissions()).isNotEmpty(); + assertThat(result.todayMissions()).isNotEmpty(); + verify(missionRepository).findDefaultMissions(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.now(); + + when(missionRepository.findDefaultMissions(memberId, scenarioId, date)).thenReturn( + List.of()); + + // when + MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, date); + + // then + assertThat(result).isNotNull(); + assertThat(result.basicMissions()).isEmpty(); + assertThat(result.todayMissions()).isEmpty(); + verify(missionRepository).findDefaultMissions(memberId, scenarioId, date); + } + + + @Test + void Given_UnauthorizedMember_When_FindMissionsByScenarioId_Then_ThrowServerException() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate date = LocalDate.now(); + + when(missionRepository.findDefaultMissions(memberId, scenarioId, date)).thenReturn( + List.of()); + + // when & then + MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, date); + + assertThat(result).isNotNull(); + assertThat(result.basicMissions()).isEmpty(); + assertThat(result.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.now(); + + // 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.findById(missionId)).thenReturn(java.util.Optional.of(mission)); + + // when + missionService.deleteTodayMission(memberId, missionId); + + // then + verify(missionRepository).findById(missionId); + verify(missionRepository).delete(mission); + } + + @Test + void Given_NonExistentMissionId_When_DeleteTodayMission_Then_ThrowNotFoundException() { + // given + Long memberId = 1L; + Long missionId = 999L; + + when(missionRepository.findById(missionId)).thenReturn(java.util.Optional.empty()); + + // when & then + assertThatThrownBy(() -> missionService.deleteTodayMission(memberId, missionId)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", ScenarioErrorResult.NOT_FOUND_MISSION); + verify(missionRepository).findById(missionId); + 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.findById(missionId)).thenReturn(java.util.Optional.of(mission)); + doThrow(new ServerException(ScenarioErrorResult.UNAUTHORIZED_ACCESS)) + .when(missionValidator).validateMissionAccessibleMember(mission, unauthorizedMemberId); + + // when & then + assertThatThrownBy(() -> missionService.deleteTodayMission(unauthorizedMemberId, missionId)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", ScenarioErrorResult.UNAUTHORIZED_ACCESS); + verify(missionRepository).findById(missionId); + 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; + + 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.findById(missionId)).thenReturn(java.util.Optional.of(mission)); + + // when + missionService.updateMissionCheck(memberId, missionId, isChecked); + + // then + verify(missionRepository).findById(missionId); + assertThat(mission.getIsChecked()).isEqualTo(isChecked); + } + + @Test + void Given_NonExistentMissionId_When_UpdateMissionCheck_Then_ThrowNotFoundException() { + // given + Long memberId = 1L; + Long missionId = 999L; + Boolean isChecked = true; + + when(missionRepository.findById(missionId)).thenReturn(java.util.Optional.empty()); + + // when & then + assertThatThrownBy(() -> missionService.updateMissionCheck(memberId, missionId, isChecked)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", ScenarioErrorResult.NOT_FOUND_MISSION); + verify(missionRepository).findById(missionId); + } + + @Test + void Given_UnauthorizedMember_When_UpdateMissionCheck_Then_ThrowUnauthorizedException() { + // given + Long authorizedMemberId = 1L; + Long unauthorizedMemberId = 2L; + Long missionId = 1L; + Boolean isChecked = true; + + 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.findById(missionId)).thenReturn(java.util.Optional.of(mission)); + doThrow(new ServerException(ScenarioErrorResult.UNAUTHORIZED_ACCESS)) + .when(missionValidator).validateMissionAccessibleMember(mission, unauthorizedMemberId); + + // when & then + assertThatThrownBy(() -> missionService.updateMissionCheck(unauthorizedMemberId, missionId, isChecked)) + .isInstanceOf(ServerException.class) + .hasFieldOrPropertyWithValue("errorResult", ScenarioErrorResult.UNAUTHORIZED_ACCESS); + verify(missionRepository).findById(missionId); + } + + + @Test + void Given_ValidMissionIdAndUncheck_When_UpdateMissionCheck_Then_UpdateMissionToUnchecked() { + // given + Long memberId = 1L; + Long missionId = 1L; + Boolean isChecked = false; + + 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.findById(missionId)) + .thenReturn(Optional.of(mission)); + + // when + missionService.updateMissionCheck(memberId, missionId, isChecked); + + // then + assertThat(mission.getIsChecked()).isFalse(); + verify(missionRepository).findById(missionId); + } + + + @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.findDefaultMissions(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(); + assertThat(result.basicMissions()).isNotEmpty(); + assertThat(result.todayMissions()).isEmpty(); + verify(missionRepository).findDefaultMissions(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.now().minusDays(1); + + 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.findMissionsByDate(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(); + assertThat(result.basicMissions()).isEmpty(); + assertThat(result.todayMissions()).isNotEmpty(); + verify(missionRepository).findMissionsByDate(memberId, scenarioId, pastDate); + } + + + @Test + void Given_FutureDate_When_FindMissionsByScenarioId_Then_UseFutureDate() { + // given + Long memberId = 1L; + Long scenarioId = 1L; + LocalDate futureDate = LocalDate.now().plusDays(1); + + 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.findMissionsByDate(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(); + assertThat(result.basicMissions()).isEmpty(); + assertThat(result.todayMissions()).isNotEmpty(); + verify(missionRepository).findMissionsByDate(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()); + } + +} 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..9dfab423 --- /dev/null +++ b/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java @@ -0,0 +1,974 @@ +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 java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +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 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.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.ScenarioNoNotificationRequest; +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.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) +class ScenarioServiceTest { + + @InjectMocks + private ScenarioService scenarioService; + + @Mock + private MissionService missionService; + + @Mock + private NotificationService notificationService; + + @Mock + private ScenarioRepository scenarioRepository; + + @Mock + private MissionTypeGroupSorter missionTypeGrouper; + + @Mock + private OrderCalculator orderCalculator; + + @Mock + private EntityManager em; + + @Mock + private com.und.server.scenario.util.ScenarioValidator scenarioValidator; + + + @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.size()).isEqualTo(2); + assertThat(result.get(0).scenarioName()).isEqualTo("시나리오A"); + assertThat(result.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.findFetchByIdAndMemberId(memberId, scenarioId)) + .thenReturn(Optional.of(scenario)); + Mockito.when(notificationService.findNotificationDetails(notification)).thenReturn(notifInfoDto); + Mockito.when(missionTypeGrouper.groupAndSortByType(scenario.getMissions(), MissionType.BASIC)) + .thenReturn(List.of()); + + // when + ScenarioDetailResponse response = scenarioService.findScenarioDetailByScenarioId(memberId, scenarioId); + + // then + assertNotNull(response); + assertThat(response.scenarioId()).isEqualTo(scenarioId); + assertThat(response.notificationCondition()).isInstanceOf(TimeNotificationResponse.class); + TimeNotificationResponse detail = (TimeNotificationResponse) response.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.findFetchByIdAndMemberId(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.findFetchByIdAndMemberId(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.findFetchByIdAndMemberId(memberId, scenarioId)) + .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.findFetchByIdAndMemberId(requestMemberId, scenarioId)) + .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 = 1000; + + 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() + .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); + + // when + scenarioService.addScenario(memberId, scenarioRequest); + + // then + verify(notificationService).addNotification(notifRequest, condition); + verify(missionService).addBasicMission(any(Scenario.class), eq(missionList)); + verify(scenarioRepository).save(scenarioCaptor.capture()); + + Scenario saved = scenarioCaptor.getValue(); + + assertThat(saved.getScenarioName()).isEqualTo("Morning"); + assertThat(saved.getMemo()).isEqualTo("Routine"); + assertThat(saved.getScenarioOrder()).isEqualTo(calculatedOrder); + assertThat(saved.getNotification()).isEqualTo(savedNotification); + assertThat(saved.getMember().getId()).isEqualTo(member.getId()); + } + + + @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() + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.ALARM) + .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.getMaxOrderAfterReorder(List.of(s1))) + .willReturn(reorderedOrder); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Scenario.class); + + // when + scenarioService.addScenario(memberId, scenarioRequest); + + // then + verify(scenarioRepository).save(captor.capture()); + + Scenario saved = captor.getValue(); + assertThat(saved.getScenarioOrder()).isEqualTo(reorderedOrder); + } + + @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.findFetchByIdAndMemberId(memberId, scenarioId)) + .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) + .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() + .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.findFetchByIdAndMemberId(memberId, scenarioId)) + .thenReturn(Optional.of(oldScenario)); + Mockito.doAnswer(invocation -> { + Notification target = invocation.getArgument(0); + target.updateNotification(notifRequest.notificationType(), notifRequest.notificationMethodType()); + target.updateActiveStatus(true); + return null; + }).when(notificationService).updateNotification(oldNotification, notifRequest, condition); + + // when + scenarioService.updateScenario(memberId, scenarioId, scenarioRequest); + + // then + assertThat(oldScenario.getScenarioName()).isEqualTo("수정된 시나리오"); + assertThat(oldScenario.getMemo()).isEqualTo("수정된 메모"); + assertThat(oldScenario.getNotification().getNotificationType()).isEqualTo(notifRequest.notificationType()); + assertThat(oldScenario.getNotification().getNotificationMethodType()) + .isEqualTo(notifRequest.notificationMethodType()); + assertThat(oldScenario.getNotification().isActive()).isTrue(); + verify(notificationService).updateNotification(oldNotification, notifRequest, condition); + verify(missionService).updateBasicMission(oldScenario, List.of()); + } + + + @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) + .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.isReorder()).isFalse(); + assertThat(response.orderUpdates()).hasSize(1); + assertThat(response.orderUpdates().get(0).id()).isEqualTo(scenarioId); + assertThat(response.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) + .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.isReorder()).isTrue(); + assertThat(response.orderUpdates()).hasSize(2); + verify(orderCalculator).reorder(anyList(), eq(scenarioId), eq(errorOrder)); + } + + @Test + void Given_ValidRequest_When_AddScenarioWithoutNotification_Then_CreateInactiveNotificationAndSave() { + // given + Long memberId = 1L; + ScenarioNoNotificationRequest request = new ScenarioNoNotificationRequest( + "시나리오", + "메모", + List.of(), + NotificationType.TIME + ); + + given(scenarioRepository.findOrdersByMemberIdAndNotificationType(memberId, NotificationType.TIME)) + .willReturn(List.of()); + Notification saved = Notification.builder().id(1L).notificationType(NotificationType.TIME).build(); + given(notificationService.addWithoutNotification(NotificationType.TIME)).willReturn(saved); + + // when + scenarioService.addScenarioWithoutNotification(memberId, request); + + // then + verify(notificationService).addWithoutNotification(NotificationType.TIME); + verify(scenarioRepository).save(any(Scenario.class)); + verify(missionService).addBasicMission(any(Scenario.class), eq(List.of())); + } + + + @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) + .notificationType(NotificationType.TIME) + .build(); + + Scenario scenario = Scenario.builder() + .id(scenarioId) + .member(member) + .notification(notification) + .build(); + + Mockito.when(scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId)) + .thenReturn(Optional.of(scenario)); + + // when + scenarioService.deleteScenarioWithAllMissions(memberId, scenarioId); + + // then + verify(notificationService).deleteNotification(notification); + verify(scenarioRepository).delete(scenario); + } + + + @Test + void Given_NotExistScenario_When_UpdateScenario_Then_ThrowNotFoundException() { + // given + Long memberId = 1L; + Long scenarioId = 99L; + + ScenarioDetailRequest scenarioRequest = ScenarioDetailRequest.builder() + .scenarioName("수정할 시나리오") + .memo("수정할 메모") + .build(); + + Mockito.when(scenarioRepository.findFetchByIdAndMemberId(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.findFetchByIdAndMemberId(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) + .notificationType(NotificationType.TIME) + .build(); + + Scenario oldScenario = Scenario.builder() + .id(scenarioId) + .member(member) + .scenarioName("기존 시나리오") + .memo("기존 메모") + .notification(oldNotification) + .missions(new java.util.ArrayList<>()) + .build(); + + ScenarioNoNotificationRequest scenarioRequest = new ScenarioNoNotificationRequest( + "수정된 시나리오", + "수정된 메모", + List.of(), + NotificationType.TIME + ); + + Mockito.when(scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId)) + .thenReturn(Optional.of(oldScenario)); + + // when + scenarioService.updateScenarioWithoutNotification(memberId, scenarioId, scenarioRequest); + + // then + assertThat(oldScenario.getScenarioName()).isEqualTo("수정된 시나리오"); + assertThat(oldScenario.getMemo()).isEqualTo("수정된 메모"); + verify(notificationService).updateWithoutNotification(oldNotification); + verify(missionService).updateBasicMission(oldScenario, List.of()); + } + + + @Test + void Given_NotExistScenario_When_UpdateScenarioWithoutNotification_Then_ThrowNotFoundException() { + // given + Long memberId = 1L; + Long scenarioId = 99L; + + ScenarioNoNotificationRequest scenarioRequest = new ScenarioNoNotificationRequest( + "수정할 시나리오", + "수정할 메모", + List.of(), + NotificationType.TIME + ); + + Mockito.when(scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> + scenarioService.updateScenarioWithoutNotification(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() + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.ALARM) + .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; + + ScenarioNoNotificationRequest request = new ScenarioNoNotificationRequest( + "시나리오", + "메모", + List.of(), + NotificationType.TIME + ); + + // 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.addScenarioWithoutNotification(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.findFetchByIdAndMemberId(memberId, scenarioId)) + .thenReturn(Optional.of(scenario)); + Mockito.when(notificationService.findNotificationDetails(notification)).thenReturn(null); + Mockito.when(missionTypeGrouper.groupAndSortByType(scenario.getMissions(), MissionType.BASIC)) + .thenReturn(List.of()); + + // when + ScenarioDetailResponse response = scenarioService.findScenarioDetailByScenarioId(memberId, scenarioId); + + // then + assertNotNull(response); + assertThat(response.scenarioId()).isEqualTo(scenarioId); + assertThat(response.notification().isEveryDay()).isNull(); + assertThat(response.notification().daysOfWeekOrdinal()).isNull(); + assertThat(response.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() + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.ALARM) + .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()); + + 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.findFetchByIdAndMemberId(memberId, scenarioId)) + .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..6154e52e --- /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.now(); + + 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..79bd9cee --- /dev/null +++ b/src/test/java/com/und/server/scenario/util/MissionValidatorTest.java @@ -0,0 +1,157 @@ +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.member.entity.Member; +import com.und.server.scenario.entity.Mission; +import com.und.server.scenario.entity.Scenario; +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.now(); + LocalDate requestDate = LocalDate.now(); + + // when & then + assertDoesNotThrow(() -> missionValidator.validateTodayMissionDateRange(today, requestDate)); + } + + @Test + void Given_FutureDate_When_ValidateTodayMissionDateRange_Then_NoException() { + // given + LocalDate today = LocalDate.now(); + LocalDate futureDate = LocalDate.now().plusDays(1); + + // when & then + assertDoesNotThrow(() -> missionValidator.validateTodayMissionDateRange(today, futureDate)); + } + + @Test + void Given_PastDate_When_ValidateTodayMissionDateRange_Then_ThrowException() { + // given + LocalDate today = LocalDate.now(); + LocalDate pastDate = LocalDate.now().minusDays(1); + + // when & then + assertThatThrownBy(() -> missionValidator.validateTodayMissionDateRange(today, pastDate)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.INVALID_TODAY_MISSION_DATE.getMessage()); + } + + @Test + void Given_SameMemberId_When_ValidateMissionAccessibleMember_Then_NoException() { + // given + Long memberId = 1L; + Member member = Member.builder().id(memberId).build(); + Scenario scenario = Scenario.builder().member(member).build(); + Mission mission = Mission.builder().scenario(scenario).build(); + + // when & then + assertDoesNotThrow(() -> missionValidator.validateMissionAccessibleMember(mission, memberId)); + } + + @Test + void Given_DifferentMemberId_When_ValidateMissionAccessibleMember_Then_ThrowException() { + // given + Long memberId = 1L; + Long otherMemberId = 2L; + Member member = Member.builder().id(memberId).build(); + Scenario scenario = Scenario.builder().member(member).build(); + Mission mission = Mission.builder().scenario(scenario).build(); + + // when & then + assertThatThrownBy(() -> missionValidator.validateMissionAccessibleMember(mission, otherMemberId)) + .isInstanceOf(ServerException.class) + .hasMessageContaining(ScenarioErrorResult.UNAUTHORIZED_ACCESS.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..9365c5cb --- /dev/null +++ b/src/test/java/com/und/server/scenario/util/OrderCalculatorTest.java @@ -0,0 +1,131 @@ +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_GetMaxOrderAfterReorder_Then_ReturnStartOrder() { + // given + List emptyScenarios = List.of(); + + // when + Integer result = orderCalculator.getMaxOrderAfterReorder(emptyScenarios); + + // then + assertThat(result).isEqualTo(OrderCalculator.START_ORDER); + } + + @Test + void Given_ScenarioList_When_GetMaxOrderAfterReorder_Then_ReturnMaxOrderPlusDefault() { + // 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.getMaxOrderAfterReorder(scenarios); + + // then + // 리오더링 후 마지막 시나리오의 order + DEFAULT_ORDER + assertThat(result).isEqualTo( + OrderCalculator.START_ORDER + OrderCalculator.DEFAULT_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)); + } + +} From 3b6dce395b29d913c98104ac7484edf3f1e160dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?sukipi=20=EC=88=98=ED=82=A4=ED=94=BC?= <108535526+sotogito@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:58:03 +0900 Subject: [PATCH 18/26] Refactor/#89 scenario api (#90) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔥 Delete ScenarioNoNotificationRequest * 🎨 Refactor Scenario api integration * ✅ Update Scenario api integrate test * 🎨 Modify Scenario Start Order constatnt * ✅ Modify ScenarioService test calculateOrder * 🎨 Refactor Notification active status process * ✅ Update NotificationService test * ✅ Update ScenarioService test * 🎨 Modify notification and notification condition entity * 🎨 Modify notification days of week * ✅ Update Notification days of week test * 🎨 Modify Returing mission list in response body on create and update * 🔥 Remove MissionGroupResponse JsonInclude annotation * ✅ Update Returning mission list int body * 🎨 Add Entity table name * ✨ Add migration DDL for entity --- .../dto/request/NotificationRequest.java | 37 +- .../dto/request/TimeNotificationRequest.java | 5 +- .../dto/response/NotificationResponse.java | 30 +- .../entity/LocationNotification.java | 13 +- .../notification/entity/Notification.java | 38 +- .../notification/entity/TimeNotification.java | 16 +- .../TimeNotificationRepository.java | 8 +- .../NotificationConditionSelector.java | 10 +- .../service/NotificationConditionService.java | 10 +- .../service/NotificationService.java | 90 +++-- .../service/TimeNotificationService.java | 92 +---- .../controller/ScenarioController.java | 49 +-- .../dto/request/ScenarioDetailRequest.java | 27 +- .../ScenarioNoNotificationRequest.java | 38 -- .../request/ScenarioOrderUpdateRequest.java | 4 +- .../dto/response/MissionGroupResponse.java | 23 +- .../dto/response/MissionResponse.java | 4 + .../und/server/scenario/entity/Mission.java | 2 +- .../und/server/scenario/entity/Scenario.java | 2 +- .../scenario/service/MissionService.java | 8 +- .../scenario/service/ScenarioService.java | 188 +++------- .../server/scenario/util/OrderCalculator.java | 2 +- .../V2__create_notification_table.sql | 11 + .../V3__create_time_notification_table.sql | 12 + ...V4__create_location_notification_table.sql | 23 ++ .../migration/V5__create_scenario_table.sql | 14 + .../db/migration/V6__create_mission_table.sql | 14 + .../dto/request/NotificationRequestTest.java | 208 +++++++++++ .../request/TimeNotificationRequestTest.java | 5 +- .../NotificationConditionSelectorTest.java | 43 ++- .../service/NotificationServiceTest.java | 274 +++++++++++++- .../service/TimeNotificationServiceTest.java | 307 +++------------- .../controller/MissionControllerTest.java | 51 ++- .../controller/ScenarioControllerTest.java | 167 ++++++--- .../request/ScenarioDetailRequestTest.java | 343 ++++++++++++++++++ .../ScenarioNoNotificationRequestTest.java | 30 -- .../scenario/service/MissionServiceTest.java | 1 - .../scenario/service/ScenarioServiceTest.java | 180 +++++++-- 38 files changed, 1559 insertions(+), 820 deletions(-) delete mode 100644 src/main/java/com/und/server/scenario/dto/request/ScenarioNoNotificationRequest.java create mode 100644 src/main/resources/db/migration/V2__create_notification_table.sql create mode 100644 src/main/resources/db/migration/V3__create_time_notification_table.sql create mode 100644 src/main/resources/db/migration/V4__create_location_notification_table.sql create mode 100644 src/main/resources/db/migration/V5__create_scenario_table.sql create mode 100644 src/main/resources/db/migration/V6__create_mission_table.sql create mode 100644 src/test/java/com/und/server/notification/dto/request/NotificationRequestTest.java create mode 100644 src/test/java/com/und/server/scenario/dto/request/ScenarioDetailRequestTest.java delete mode 100644 src/test/java/com/und/server/scenario/dto/request/ScenarioNoNotificationRequestTest.java 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 index c77abd9f..14b98fd2 100644 --- a/src/main/java/com/und/server/notification/dto/request/NotificationRequest.java +++ b/src/main/java/com/und/server/notification/dto/request/NotificationRequest.java @@ -10,6 +10,7 @@ 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; @@ -20,17 +21,22 @@ @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", example = "push") - @NotNull(message = "notificationMethod must not be null") + @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)"), + 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]") @@ -43,12 +49,33 @@ public record NotificationRequest( ) { + @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() { - return Notification.builder() - .isActive(true) + 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 index 7bc174df..bb6034b9 100644 --- a/src/main/java/com/und/server/notification/dto/request/TimeNotificationRequest.java +++ b/src/main/java/com/und/server/notification/dto/request/TimeNotificationRequest.java @@ -1,7 +1,5 @@ package com.und.server.notification.dto.request; -import java.time.DayOfWeek; - import com.und.server.notification.constants.NotificationType; import com.und.server.notification.entity.Notification; import com.und.server.notification.entity.TimeNotification; @@ -46,10 +44,9 @@ public record TimeNotificationRequest( } } - public TimeNotification toEntity(final Notification notification, final DayOfWeek dayOfWeek) { + public TimeNotification toEntity(final Notification notification) { return TimeNotification.builder() .notification(notification) - .dayOfWeek(dayOfWeek) .startHour(startHour) .startMinute(startMinute) .build(); 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 index 6509e681..ec021d6d 100644 --- a/src/main/java/com/und/server/notification/dto/response/NotificationResponse.java +++ b/src/main/java/com/und/server/notification/dto/response/NotificationResponse.java @@ -42,19 +42,23 @@ public record NotificationResponse( ) { - public static NotificationResponse from( - final Notification notification, - final Boolean isEveryDay, - final List daysOfWeekOrdinal - ) { - return NotificationResponse.builder() - .notificationId(notification.getId()) - .isActive(notification.isActive()) - .notificationType(notification.getNotificationType()) - .notificationMethodType(notification.getNotificationMethodType()) - .isEveryDay(isEveryDay) - .daysOfWeekOrdinal(daysOfWeekOrdinal) - .build(); + 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/entity/LocationNotification.java b/src/main/java/com/und/server/notification/entity/LocationNotification.java index 07c7ff8c..8a40740a 100644 --- a/src/main/java/com/und/server/notification/entity/LocationNotification.java +++ b/src/main/java/com/und/server/notification/entity/LocationNotification.java @@ -1,7 +1,6 @@ package com.und.server.notification.entity; import java.math.BigDecimal; -import java.time.DayOfWeek; import com.und.server.common.entity.BaseTimeEntity; import com.und.server.notification.constants.LocationTrackingRadiusType; @@ -14,7 +13,6 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; @@ -34,12 +32,7 @@ @AllArgsConstructor @Getter @Builder -@Table( - name = "location_notification", - indexes = { - @Index(name = "idx_day_location_notification", columnList = "day_of_week, start_hour, start_minute") - } -) +@Table(name = "location_notification") public class LocationNotification extends BaseTimeEntity { @Id @@ -50,10 +43,6 @@ public class LocationNotification extends BaseTimeEntity { @JoinColumn(name = "notification_id", nullable = false) private Notification notification; - @Enumerated(EnumType.ORDINAL) - @Column(nullable = false) - private DayOfWeek dayOfWeek; - @Column(nullable = false, precision = 9, scale = 6) @DecimalMin("-90.0") @DecimalMax("90.0") diff --git a/src/main/java/com/und/server/notification/entity/Notification.java b/src/main/java/com/und/server/notification/entity/Notification.java index 723f1c1e..50768f76 100644 --- a/src/main/java/com/und/server/notification/entity/Notification.java +++ b/src/main/java/com/und/server/notification/entity/Notification.java @@ -1,5 +1,9 @@ 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; @@ -23,7 +27,7 @@ @AllArgsConstructor @Getter @Builder -@Table +@Table(name = "notification") public class Notification extends BaseTimeEntity { @Id @@ -40,10 +44,28 @@ public class Notification extends BaseTimeEntity { @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 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 updateActiveStatus(final Boolean isActive) { this.isActive = isActive; } @@ -56,8 +78,22 @@ public void updateNotification( this.notificationMethodType = notificationMethodType; } + public void updateDaysOfWeekOrdinal(List daysOfWeekOrdinal) { + if (!isActive || daysOfWeekOrdinal == null || daysOfWeekOrdinal.isEmpty()) { + this.daysOfWeek = null; + return; + } + this.daysOfWeek = daysOfWeekOrdinal.stream() + .map(String::valueOf) + .collect(Collectors.joining(",")); + } + public void deleteNotificationMethodType() { this.notificationMethodType = null; } + public void deleteDaysOfWeekOrdinal() { + this.daysOfWeek = null; + } + } diff --git a/src/main/java/com/und/server/notification/entity/TimeNotification.java b/src/main/java/com/und/server/notification/entity/TimeNotification.java index d6550923..f678d7a8 100644 --- a/src/main/java/com/und/server/notification/entity/TimeNotification.java +++ b/src/main/java/com/und/server/notification/entity/TimeNotification.java @@ -1,18 +1,13 @@ package com.und.server.notification.entity; -import java.time.DayOfWeek; - import com.und.server.common.entity.BaseTimeEntity; 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.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; @@ -29,12 +24,7 @@ @AllArgsConstructor @Getter @Builder -@Table( - name = "time_notification", - indexes = { - @Index(name = "idx_day_time_notification", columnList = "day_of_week, startHour, startMinute") - } -) +@Table(name = "time_notification") public class TimeNotification extends BaseTimeEntity { @Id @@ -45,10 +35,6 @@ public class TimeNotification extends BaseTimeEntity { @JoinColumn(name = "notification_id", nullable = false) private Notification notification; - @Enumerated(EnumType.ORDINAL) - @Column(nullable = false) - private DayOfWeek dayOfWeek; - @Column(nullable = false) @Min(0) @Max(23) diff --git a/src/main/java/com/und/server/notification/repository/TimeNotificationRepository.java b/src/main/java/com/und/server/notification/repository/TimeNotificationRepository.java index ec44919e..2055d2d9 100644 --- a/src/main/java/com/und/server/notification/repository/TimeNotificationRepository.java +++ b/src/main/java/com/und/server/notification/repository/TimeNotificationRepository.java @@ -1,8 +1,7 @@ package com.und.server.notification.repository; -import java.util.List; - import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import com.und.server.notification.entity.TimeNotification; @@ -11,6 +10,9 @@ public interface TimeNotificationRepository extends JpaRepository { @NotNull - List findByNotificationId(@NotNull Long notificationId); + TimeNotification findByNotificationId(@NotNull Long notificationId); + + @Modifying + void deleteByNotificationId(@NotNull Long notificationId); } diff --git a/src/main/java/com/und/server/notification/service/NotificationConditionSelector.java b/src/main/java/com/und/server/notification/service/NotificationConditionSelector.java index 7abc6cf5..e6e6a5e0 100644 --- a/src/main/java/com/und/server/notification/service/NotificationConditionSelector.java +++ b/src/main/java/com/und/server/notification/service/NotificationConditionSelector.java @@ -6,8 +6,8 @@ import com.und.server.common.exception.ServerException; import com.und.server.notification.constants.NotificationType; -import com.und.server.notification.dto.NotificationInfoDto; 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; @@ -20,7 +20,7 @@ public class NotificationConditionSelector { private final List services; - public NotificationInfoDto findNotificationCondition(final Notification notification) { + public NotificationConditionResponse findNotificationCondition(final Notification notification) { NotificationConditionService service = findServiceByNotificationType(notification.getNotificationType()); return service.findNotificationInfoByType(notification); @@ -29,21 +29,19 @@ public NotificationInfoDto findNotificationCondition(final Notification notifica public void addNotificationCondition( final Notification notification, - final List daysOfWeekOrdinal, final NotificationConditionRequest notificationConditionRequest ) { NotificationConditionService service = findServiceByNotificationType(notification.getNotificationType()); - service.addNotificationCondition(notification, daysOfWeekOrdinal, notificationConditionRequest); + service.addNotificationCondition(notification, notificationConditionRequest); } public void updateNotificationCondition( final Notification notification, - final List daysOfWeekOrdinal, final NotificationConditionRequest notificationConditionRequest ) { NotificationConditionService service = findServiceByNotificationType(notification.getNotificationType()); - service.updateNotificationCondition(notification, daysOfWeekOrdinal, notificationConditionRequest); + service.updateNotificationCondition(notification, notificationConditionRequest); } diff --git a/src/main/java/com/und/server/notification/service/NotificationConditionService.java b/src/main/java/com/und/server/notification/service/NotificationConditionService.java index 0dd75885..42972350 100644 --- a/src/main/java/com/und/server/notification/service/NotificationConditionService.java +++ b/src/main/java/com/und/server/notification/service/NotificationConditionService.java @@ -1,26 +1,22 @@ package com.und.server.notification.service; -import java.util.List; - import com.und.server.notification.constants.NotificationType; -import com.und.server.notification.dto.NotificationInfoDto; 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); - NotificationInfoDto findNotificationInfoByType(final Notification notification); + NotificationConditionResponse findNotificationInfoByType(final Notification notification); void addNotificationCondition( final Notification notification, - final List daysOfWeekOrdinal, final NotificationConditionRequest notificationConditionRequest); void updateNotificationCondition( - final Notification oldNotification, - final List daysOfWeekOrdinal, + 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 index 0946009c..44d30509 100644 --- a/src/main/java/com/und/server/notification/service/NotificationService.java +++ b/src/main/java/com/und/server/notification/service/NotificationService.java @@ -1,14 +1,12 @@ package com.und.server.notification.service; -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.NotificationInfoDto; 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.repository.NotificationRepository; @@ -23,85 +21,103 @@ public class NotificationService { @Transactional(readOnly = true) - public NotificationInfoDto findNotificationDetails(final Notification notification) { + public NotificationConditionResponse findNotificationDetails(final Notification notification) { return notificationConditionSelector.findNotificationCondition(notification); } @Transactional public Notification addNotification( - final NotificationRequest notificationInfo, + final NotificationRequest notificationRequest, final NotificationConditionRequest notificationConditionRequest ) { - Notification notification = notificationInfo.toEntity(); - List daysOfWeekOrdinal = notificationInfo.daysOfWeekOrdinal(); + boolean isNotificationActive = notificationRequest.isActive(); + if (isNotificationActive) { + return addWithNotification(notificationRequest, notificationConditionRequest); + } else { + return addWithoutNotification(notificationRequest); + } + } - notificationRepository.save(notification); - notificationConditionSelector.addNotificationCondition( - notification, daysOfWeekOrdinal, notificationConditionRequest); - return notification; + @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 Notification addWithoutNotification(final NotificationType notificationType) { - Notification notification = Notification.builder() - .isActive(false) - .notificationType(notificationType) - .build(); + 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); - @Transactional - public void updateNotification( + return notification; + } + + private void updateWithNotification( final Notification notification, - final NotificationRequest notificationInfo, + final NotificationRequest notificationRequest, final NotificationConditionRequest notificationConditionRequest ) { - List daysOfWeekOrdinal = notificationInfo.daysOfWeekOrdinal(); - NotificationType oldNotificationType = notification.getNotificationType(); - NotificationType newNotificationtype = notificationInfo.notificationType(); + NotificationType newNotificationtype = notificationRequest.notificationType(); boolean isChangeNotificationType = oldNotificationType != newNotificationtype; notification.updateNotification( newNotificationtype, - notificationInfo.notificationMethodType() + notificationRequest.notificationMethodType() ); notification.updateActiveStatus(true); + notification.updateDaysOfWeekOrdinal(notificationRequest.daysOfWeekOrdinal()); if (isChangeNotificationType) { notificationConditionSelector.deleteNotificationCondition(oldNotificationType, notification.getId()); notificationConditionSelector.addNotificationCondition( - notification, daysOfWeekOrdinal, notificationConditionRequest); + notification, notificationConditionRequest); return; } notificationConditionSelector.updateNotificationCondition( - notification, daysOfWeekOrdinal, notificationConditionRequest); + notification, notificationConditionRequest); } - - @Transactional - public void updateWithoutNotification(final Notification oldNotification) { + private void updateWithoutNotification(final Notification oldNotification) { notificationConditionSelector.deleteNotificationCondition( oldNotification.getNotificationType(), oldNotification.getId()); oldNotification.updateActiveStatus(false); oldNotification.deleteNotificationMethodType(); - } - - - @Transactional - public void deleteNotification(final Notification notification) { - notificationConditionSelector.deleteNotificationCondition( - notification.getNotificationType(), - notification.getId() - ); + oldNotification.deleteDaysOfWeekOrdinal(); } } diff --git a/src/main/java/com/und/server/notification/service/TimeNotificationService.java b/src/main/java/com/und/server/notification/service/TimeNotificationService.java index 7f394223..23142a7f 100644 --- a/src/main/java/com/und/server/notification/service/TimeNotificationService.java +++ b/src/main/java/com/und/server/notification/service/TimeNotificationService.java @@ -1,15 +1,8 @@ package com.und.server.notification.service; -import java.time.DayOfWeek; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - import org.springframework.stereotype.Service; import com.und.server.notification.constants.NotificationType; -import com.und.server.notification.dto.NotificationInfoDto; import com.und.server.notification.dto.request.NotificationConditionRequest; import com.und.server.notification.dto.request.TimeNotificationRequest; import com.und.server.notification.dto.response.NotificationConditionResponse; @@ -24,7 +17,6 @@ @RequiredArgsConstructor public class TimeNotificationService implements NotificationConditionService { - public static final int EVERYDAY = 7; private final TimeNotificationRepository timeNotificationRepository; @@ -35,31 +27,21 @@ public boolean supports(final NotificationType notificationType) { @Override - public NotificationInfoDto findNotificationInfoByType(final Notification notification) { + public NotificationConditionResponse findNotificationInfoByType(final Notification notification) { if (!notification.isActive()) { return null; } - List timeNotifications = + TimeNotification timeNotifications = timeNotificationRepository.findByNotificationId(notification.getId()); - TimeNotification baseTimeNotification = timeNotifications.get(0); - - List daysOfWeekOrdinal = timeNotifications.stream() - .map(tn -> tn.getDayOfWeek().ordinal()) - .toList(); - - boolean isEveryDay = daysOfWeekOrdinal.size() == EVERYDAY; - NotificationConditionResponse timeNotificationResponse = TimeNotificationResponse.from(baseTimeNotification); - - return new NotificationInfoDto(isEveryDay, daysOfWeekOrdinal, timeNotificationResponse); + return TimeNotificationResponse.from(timeNotifications); } @Override public void addNotificationCondition( final Notification notification, - final List daysOfWeekOrdinal, final NotificationConditionRequest notificationConditionRequest ) { if (!notification.isActive()) { @@ -68,75 +50,35 @@ public void addNotificationCondition( TimeNotificationRequest timeNotificationRequest = (TimeNotificationRequest) notificationConditionRequest; - List timeNotifications = daysOfWeekOrdinal.stream() - .map(ordinal -> DayOfWeek.values()[ordinal]) - .map(dayOfWeek -> timeNotificationRequest.toEntity(notification, dayOfWeek)) - .toList(); - timeNotificationRepository.saveAll(timeNotifications); + TimeNotification timeNotification = timeNotificationRequest.toEntity(notification); + timeNotificationRepository.save(timeNotification); } @Override public void updateNotificationCondition( - final Notification oldNotification, - final List daysOfWeekOrdinal, + final Notification notification, final NotificationConditionRequest notificationConditionRequest ) { - TimeNotificationRequest timeNotificationInfo = (TimeNotificationRequest) notificationConditionRequest; - List oldTimeNotifications = - timeNotificationRepository.findByNotificationId(oldNotification.getId()); - - Set oldOrdinals = oldTimeNotifications.stream() - .map(tn -> tn.getDayOfWeek().ordinal()) - .collect(Collectors.toSet()); - - Set newOrdinals = new HashSet<>(daysOfWeekOrdinal); - - Set toDeleteOrdinals = oldOrdinals.stream() - .filter(ordinal -> !newOrdinals.contains(ordinal)) - .collect(Collectors.toSet()); - - Set toAddOrdinals = newOrdinals.stream() - .filter(ordinal -> !oldOrdinals.contains(ordinal)) - .collect(Collectors.toSet()); - - Set toUpdateOrdinals = oldOrdinals.stream() - .filter(newOrdinals::contains) - .collect(Collectors.toSet()); - - if (!toDeleteOrdinals.isEmpty()) { - List toDelete = oldTimeNotifications.stream() - .filter(tn -> toDeleteOrdinals.contains(tn.getDayOfWeek().ordinal())) - .toList(); - timeNotificationRepository.deleteAll(toDelete); - } + TimeNotificationRequest timeNotificationRequest = (TimeNotificationRequest) notificationConditionRequest; + TimeNotification oldTimeNotifications = + timeNotificationRepository.findByNotificationId(notification.getId()); - if (!toAddOrdinals.isEmpty()) { - List toAdd = toAddOrdinals.stream() - .map(ordinal -> DayOfWeek.values()[ordinal]) - .map(dayOfWeek -> timeNotificationInfo.toEntity(oldNotification, dayOfWeek)) - .toList(); - timeNotificationRepository.saveAll(toAdd); + if (oldTimeNotifications == null) { + addNotificationCondition(notification, notificationConditionRequest); + return; } - if (!toUpdateOrdinals.isEmpty()) { - List toUpdate = oldTimeNotifications.stream() - .filter(tn -> toUpdateOrdinals.contains(tn.getDayOfWeek().ordinal())) - .peek(tn -> { - tn.updateTimeCondition( - timeNotificationInfo.startHour(), timeNotificationInfo.startMinute()); - }) - .toList(); - timeNotificationRepository.saveAll(toUpdate); - } + oldTimeNotifications.updateTimeCondition( + timeNotificationRequest.startHour(), + timeNotificationRequest.startMinute() + ); } @Override public void deleteNotificationCondition(final Long notificationId) { - List timeNotifications = - timeNotificationRepository.findByNotificationId(notificationId); - timeNotificationRepository.deleteAll(timeNotifications); + timeNotificationRepository.deleteByNotificationId(notificationId); } } diff --git a/src/main/java/com/und/server/scenario/controller/ScenarioController.java b/src/main/java/com/und/server/scenario/controller/ScenarioController.java index 41492966..187807fd 100644 --- a/src/main/java/com/und/server/scenario/controller/ScenarioController.java +++ b/src/main/java/com/und/server/scenario/controller/ScenarioController.java @@ -19,8 +19,8 @@ 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.ScenarioNoNotificationRequest; 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; @@ -78,29 +78,14 @@ public ResponseEntity getScenarioDetail( @ApiResponse(responseCode = "201", description = "Create Scenario successful"), @ApiResponse(responseCode = "400", description = "Invalid parameter") }) - public ResponseEntity addScenario( + public ResponseEntity addScenario( @AuthMember final Long memberId, @RequestBody @Valid final ScenarioDetailRequest scenarioRequest ) { - final Long scenarioId = scenarioService.addScenario(memberId, scenarioRequest); + final MissionGroupResponse missionGroupResponse = + scenarioService.addScenario(memberId, scenarioRequest); - return ResponseEntity.status(HttpStatus.CREATED).body(scenarioId); - } - - - @PostMapping("/scenarios/without-notification") - @ApiResponses({ - @ApiResponse(responseCode = "201", description = "Create Scenario without notification successful"), - @ApiResponse(responseCode = "400", description = "Invalid parameter") - }) - public ResponseEntity addScenarioWithoutNotification( - @AuthMember final Long memberId, - @RequestBody @Valid final ScenarioNoNotificationRequest scenarioNoNotificationResponse - ) { - final Long scenarioId = - scenarioService.addScenarioWithoutNotification(memberId, scenarioNoNotificationResponse); - - return ResponseEntity.status(HttpStatus.CREATED).body(scenarioId); + return ResponseEntity.status(HttpStatus.CREATED).body(missionGroupResponse); } @@ -110,31 +95,15 @@ public ResponseEntity addScenarioWithoutNotification( @ApiResponse(responseCode = "400", description = "Invalid parameter"), @ApiResponse(responseCode = "404", description = "Scenario not found") }) - public ResponseEntity updateScenario( + public ResponseEntity updateScenario( @AuthMember final Long memberId, @PathVariable final Long scenarioId, @RequestBody @Valid final ScenarioDetailRequest scenarioRequest ) { - scenarioService.updateScenario(memberId, scenarioId, scenarioRequest); - - return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); - } - - - @PutMapping("/scenarios/{scenarioId}/without-notification") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "Update Scenario without notification successful"), - @ApiResponse(responseCode = "400", description = "Invalid parameter"), - @ApiResponse(responseCode = "404", description = "Scenario not found") - }) - public ResponseEntity updateScenarioWithoutNotification( - @AuthMember final Long memberId, - @PathVariable final Long scenarioId, - @RequestBody @Valid final ScenarioNoNotificationRequest scenarioNoNotificationRequest - ) { - scenarioService.updateScenarioWithoutNotification(memberId, scenarioId, scenarioNoNotificationRequest); + MissionGroupResponse missionGroupResponse = + scenarioService.updateScenario(memberId, scenarioId, scenarioRequest); - return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + return ResponseEntity.ok().body(missionGroupResponse); } 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 index cb933cce..f7e7c117 100644 --- a/src/main/java/com/und/server/scenario/dto/request/ScenarioDetailRequest.java +++ b/src/main/java/com/und/server/scenario/dto/request/ScenarioDetailRequest.java @@ -10,12 +10,14 @@ 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 detail request for create and update") +@Schema(description = "Scenario request for create and update") public record ScenarioDetailRequest( @Schema(description = "Scenario name", example = "Home out") @@ -40,10 +42,11 @@ public record ScenarioDetailRequest( implementation = NotificationRequest.class ) @Valid + @NotNull(message = "notification must not be null") NotificationRequest notification, @Schema( - description = "Notification details condition that are included only when the notification is active", + description = "Notification details condition - required when notification is active", discriminatorProperty = "notificationType", discriminatorMapping = { @DiscriminatorMapping(value = "time", schema = TimeNotificationRequest.class) @@ -52,4 +55,22 @@ public record ScenarioDetailRequest( @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/ScenarioNoNotificationRequest.java b/src/main/java/com/und/server/scenario/dto/request/ScenarioNoNotificationRequest.java deleted file mode 100644 index 3c741a7e..00000000 --- a/src/main/java/com/und/server/scenario/dto/request/ScenarioNoNotificationRequest.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.und.server.scenario.dto.request; - -import java.util.List; - -import com.und.server.notification.constants.NotificationType; - -import io.swagger.v3.oas.annotations.media.ArraySchema; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -@Schema(description = "Scenario without notification request for create and update") -public record ScenarioNoNotificationRequest( - - @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 type", example = "time") - @NotNull(message = "notificationType must not be null") - NotificationType notificationType - -) { } 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 index f5f3ffe5..ea49009d 100644 --- a/src/main/java/com/und/server/scenario/dto/request/ScenarioOrderUpdateRequest.java +++ b/src/main/java/com/und/server/scenario/dto/request/ScenarioOrderUpdateRequest.java @@ -8,11 +8,11 @@ @Schema(description = "Scenario order update request") public record ScenarioOrderUpdateRequest( - @Schema(description = "Previous Scenario order", example = "1000") + @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 = "2000") + @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/response/MissionGroupResponse.java b/src/main/java/com/und/server/scenario/dto/response/MissionGroupResponse.java index 4fbfb9ec..00387549 100644 --- a/src/main/java/com/und/server/scenario/dto/response/MissionGroupResponse.java +++ b/src/main/java/com/und/server/scenario/dto/response/MissionGroupResponse.java @@ -6,10 +6,15 @@ 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 @@ -25,10 +30,20 @@ public record MissionGroupResponse( ) { public static MissionGroupResponse from(final List basic, final List today) { - return new MissionGroupResponse( - MissionResponse.listFrom(basic), - MissionResponse.listFrom(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(); } } 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 index 41106ac4..e3f64443 100644 --- a/src/main/java/com/und/server/scenario/dto/response/MissionResponse.java +++ b/src/main/java/com/und/server/scenario/dto/response/MissionResponse.java @@ -1,5 +1,6 @@ package com.und.server.scenario.dto.response; +import java.util.ArrayList; import java.util.List; import com.und.server.scenario.constants.MissionType; @@ -36,6 +37,9 @@ public static MissionResponse from(final Mission mission) { } 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/entity/Mission.java b/src/main/java/com/und/server/scenario/entity/Mission.java index 422637bc..5bac82a8 100644 --- a/src/main/java/com/und/server/scenario/entity/Mission.java +++ b/src/main/java/com/und/server/scenario/entity/Mission.java @@ -29,7 +29,7 @@ @AllArgsConstructor @Getter @Builder -@Table +@Table(name = "mission") public class Mission extends BaseTimeEntity { @Id diff --git a/src/main/java/com/und/server/scenario/entity/Scenario.java b/src/main/java/com/und/server/scenario/entity/Scenario.java index 62450b25..fc963962 100644 --- a/src/main/java/com/und/server/scenario/entity/Scenario.java +++ b/src/main/java/com/und/server/scenario/entity/Scenario.java @@ -31,7 +31,7 @@ @AllArgsConstructor @Getter @Builder -@Table +@Table(name = "scenario") public class Scenario extends BaseTimeEntity { @Id diff --git a/src/main/java/com/und/server/scenario/service/MissionService.java b/src/main/java/com/und/server/scenario/service/MissionService.java index c6f6070f..29e6e674 100644 --- a/src/main/java/com/und/server/scenario/service/MissionService.java +++ b/src/main/java/com/und/server/scenario/service/MissionService.java @@ -55,14 +55,14 @@ public MissionGroupResponse findMissionsByScenarioId( List groupedTodayMissions = missionTypeGroupSorter.groupAndSortByType(missions, MissionType.TODAY); - return MissionGroupResponse.from(groupedBasicMissions, groupedTodayMissions); + return MissionGroupResponse.from(scenarioId, groupedBasicMissions, groupedTodayMissions); } @Transactional - public void addBasicMission(final Scenario scenario, final List missionRequests) { + public List addBasicMission(final Scenario scenario, final List missionRequests) { if (missionRequests.isEmpty()) { - return; + return List.of(); } List missions = new ArrayList<>(); @@ -74,7 +74,7 @@ public void addBasicMission(final Scenario scenario, final List notificationService.addNotification( - scenarioDetailRequest.notification(), - scenarioDetailRequest.notificationCondition() - ) - ); - } + public MissionGroupResponse addScenario(final Long memberId, final ScenarioDetailRequest scenarioDetailRequest) { + Member member = em.getReference(Member.class, memberId); + NotificationRequest notificationRequest = scenarioDetailRequest.notification(); + NotificationType notificationType = notificationRequest.notificationType(); - @Transactional - public Long addScenarioWithoutNotification( - final Long memberId, final ScenarioNoNotificationRequest scenarioNoNotificationRequest - ) { - return addScenarioInternal( - memberId, - scenarioNoNotificationRequest.scenarioName(), - scenarioNoNotificationRequest.memo(), - scenarioNoNotificationRequest.basicMissions(), - scenarioNoNotificationRequest.notificationType(), - () -> notificationService.addWithoutNotification(scenarioNoNotificationRequest.notificationType()) - ); + List orders = + scenarioRepository.findOrdersByMemberIdAndNotificationType(memberId, notificationType); + scenarioValidator.validateMaxScenarioCount(orders); + + int order = orders.isEmpty() + ? OrderCalculator.START_ORDER + : getValidScenarioOrder(Collections.max(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); + List missions = missionService.addBasicMission(scenario, scenarioDetailRequest.basicMissions()); + + List basicMissions = missionTypeGroupSorter.groupAndSortByType(missions, MissionType.BASIC); + + return MissionGroupResponse.from(scenario.getId(), basicMissions, null); } @Transactional - public void updateScenario( + public MissionGroupResponse updateScenario( final Long memberId, final Long scenarioId, final ScenarioDetailRequest scenarioDetailRequest ) { - updateScenarioInternal( - memberId, - scenarioId, - scenarioDetailRequest.scenarioName(), - scenarioDetailRequest.memo(), - scenarioDetailRequest.basicMissions(), - notification -> notificationService.updateNotification( - notification, - scenarioDetailRequest.notification(), - scenarioDetailRequest.notificationCondition() - ) + Scenario oldScenario = scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId) + .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_SCENARIO)); + + notificationService.updateNotification( + oldScenario.getNotification(), + scenarioDetailRequest.notification(), + scenarioDetailRequest.notificationCondition() ); - } + missionService.updateBasicMission(oldScenario, scenarioDetailRequest.basicMissions()); - @Transactional - public void updateScenarioWithoutNotification( - final Long memberId, - final Long scenarioId, - final ScenarioNoNotificationRequest scenarioNoNotificationRequest - ) { - updateScenarioInternal( - memberId, - scenarioId, - scenarioNoNotificationRequest.scenarioName(), - scenarioNoNotificationRequest.memo(), - scenarioNoNotificationRequest.basicMissions(), - notificationService::updateWithoutNotification - ); + oldScenario.updateScenarioName(scenarioDetailRequest.scenarioName()); + oldScenario.updateMemo(scenarioDetailRequest.memo()); + + return missionService.findMissionsByScenarioId(memberId, scenarioId, LocalDate.now()); } @@ -200,85 +191,6 @@ public void deleteScenarioWithAllMissions(final Long memberId, final Long scenar } - private Long addScenarioInternal( - final Long memberId, - final String scenarioName, - final String memo, - final List missions, - final NotificationType notificationType, - final Supplier notificationSupplier - ) { - Member member = em.getReference(Member.class, memberId); - - List orders = - scenarioRepository.findOrdersByMemberIdAndNotificationType(memberId, notificationType); - scenarioValidator.validateMaxScenarioCount(orders); - - int order = orders.isEmpty() - ? OrderCalculator.START_ORDER - : getValidScenarioOrder(Collections.max(orders), memberId, notificationType); - - Notification notification = notificationSupplier.get(); - - Scenario scenario = Scenario.builder() - .member(member) - .scenarioName(scenarioName) - .memo(memo) - .scenarioOrder(order) - .notification(notification) - .build(); - - scenarioRepository.save(scenario); - missionService.addBasicMission(scenario, missions); - - return scenario.getId(); - } - - private void updateScenarioInternal( - final Long memberId, - final Long scenarioId, - final String scenarioName, - final String memo, - final List newBasicMissions, - final Consumer notificationUpdater - ) { - Scenario oldScenario = scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId) - .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_SCENARIO)); - - notificationUpdater.accept(oldScenario.getNotification()); - - missionService.updateBasicMission(oldScenario, newBasicMissions); - - oldScenario.updateScenarioName(scenarioName); - oldScenario.updateMemo(memo); - } - - private ScenarioDetailResponse getScenarioDetailResponse( - final Scenario scenario, - final List basicMissions, - final NotificationInfoDto notificationInfo - ) { - Notification notification = scenario.getNotification(); - - NotificationResponse notificationResponse; - NotificationConditionResponse notificationConditionResponse = null; - - if (notificationInfo == null) { - notificationResponse = NotificationResponse.from( - notification, null, null); - } else { - notificationResponse = NotificationResponse.from( - notification, - notificationInfo.isEveryDay(), - notificationInfo.daysOfWeekOrdinal() - ); - notificationConditionResponse = notificationInfo.notificationConditionResponse(); - } - - return ScenarioDetailResponse.from( - scenario, basicMissions, notificationResponse, notificationConditionResponse); - } - private int getValidScenarioOrder( final int maxScenarioOrder, final Long memberId, diff --git a/src/main/java/com/und/server/scenario/util/OrderCalculator.java b/src/main/java/com/und/server/scenario/util/OrderCalculator.java index f872e3ac..ce0d7eee 100644 --- a/src/main/java/com/und/server/scenario/util/OrderCalculator.java +++ b/src/main/java/com/und/server/scenario/util/OrderCalculator.java @@ -11,7 +11,7 @@ @Component public class OrderCalculator { - public static final int START_ORDER = 1000; + 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; 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/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 index 94587590..b35c4277 100644 --- a/src/test/java/com/und/server/notification/dto/request/TimeNotificationRequestTest.java +++ b/src/test/java/com/und/server/notification/dto/request/TimeNotificationRequestTest.java @@ -2,8 +2,6 @@ import static org.assertj.core.api.Assertions.assertThat; -import java.time.DayOfWeek; - import org.junit.jupiter.api.Test; import com.und.server.notification.constants.NotificationType; @@ -33,10 +31,9 @@ void toEntity_mapsFields() { .startMinute(45) .build(); - TimeNotification entity = req.toEntity(notification, DayOfWeek.MONDAY); + TimeNotification entity = req.toEntity(notification); assertThat(entity.getNotification()).isEqualTo(notification); - assertThat(entity.getDayOfWeek()).isEqualTo(DayOfWeek.MONDAY); assertThat(entity.getStartHour()).isEqualTo(8); assertThat(entity.getStartMinute()).isEqualTo(45); } diff --git a/src/test/java/com/und/server/notification/service/NotificationConditionSelectorTest.java b/src/test/java/com/und/server/notification/service/NotificationConditionSelectorTest.java index dc608507..6c57a441 100644 --- a/src/test/java/com/und/server/notification/service/NotificationConditionSelectorTest.java +++ b/src/test/java/com/und/server/notification/service/NotificationConditionSelectorTest.java @@ -18,9 +18,10 @@ import com.und.server.common.exception.ServerException; import com.und.server.notification.constants.NotificationType; -import com.und.server.notification.dto.NotificationInfoDto; 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; @@ -56,15 +57,17 @@ void setUp() { void Given_SupportedNotificationType_When_FindNotificationInfoByType_Then_ReturnNotificationInfoDto() { // given NotificationType notifType = NotificationType.TIME; - NotificationInfoDto expectedDto = - new NotificationInfoDto(true, List.of(0, 1, 2), null); + 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 - NotificationInfoDto result = selector.findNotificationCondition(notification); + NotificationConditionResponse result = selector.findNotificationCondition(notification); // then assertThat(result).isEqualTo(expectedDto); @@ -93,7 +96,6 @@ void Given_UnsupportedNotificationType_When_FindNotificationInfoByType_Then_Thro void Given_SupportedNotificationType_When_AddNotificationCondition_Then_InvokeService() { // given NotificationType notifType = NotificationType.TIME; - List dayOfWeekList = List.of(0, 1, 2); TimeNotificationRequest timeRequest = TimeNotificationRequest.builder() .startHour(9) .startMinute(0) @@ -103,11 +105,11 @@ void Given_SupportedNotificationType_When_AddNotificationCondition_Then_InvokeSe when(timeNotificationService.supports(notifType)).thenReturn(true); // when - selector.addNotificationCondition(notification, dayOfWeekList, timeRequest); + selector.addNotificationCondition(notification, timeRequest); // then verify(timeNotificationService).supports(notifType); - verify(timeNotificationService).addNotificationCondition(notification, dayOfWeekList, timeRequest); + verify(timeNotificationService).addNotificationCondition(notification, timeRequest); } @@ -115,14 +117,13 @@ void Given_SupportedNotificationType_When_AddNotificationCondition_Then_InvokeSe void Given_UnsupportedNotificationType_When_AddNotificationCondition_Then_ThrowServerException() { // given NotificationType notifType = NotificationType.LOCATION; - List dayOfWeekList = List.of(0, 1, 2); when(notification.getNotificationType()).thenReturn(notifType); when(timeNotificationService.supports(notifType)).thenReturn(false); when(locationNotificationService.supports(notifType)).thenReturn(false); // when & then - assertThatThrownBy(() -> selector.addNotificationCondition(notification, dayOfWeekList, conditionRequest)) + assertThatThrownBy(() -> selector.addNotificationCondition(notification, conditionRequest)) .isInstanceOf(ServerException.class) .hasFieldOrPropertyWithValue("errorResult", NotificationErrorResult.UNSUPPORTED_NOTIFICATION); } @@ -165,7 +166,6 @@ void Given_UnsupportedNotificationType_When_DeleteNotificationCondition_Then_Thr void Given_SupportedNotificationType_When_UpdateNotificationCondition_Then_InvokeService() { // given NotificationType notifType = NotificationType.TIME; - List dayOfWeekList = List.of(0, 1, 2); TimeNotificationRequest timeRequest = TimeNotificationRequest.builder() .startHour(9) .startMinute(0) @@ -175,11 +175,11 @@ void Given_SupportedNotificationType_When_UpdateNotificationCondition_Then_Invok when(timeNotificationService.supports(notifType)).thenReturn(true); // when - selector.updateNotificationCondition(notification, dayOfWeekList, timeRequest); + selector.updateNotificationCondition(notification, timeRequest); // then verify(timeNotificationService).supports(notifType); - verify(timeNotificationService).updateNotificationCondition(notification, dayOfWeekList, timeRequest); + verify(timeNotificationService).updateNotificationCondition(notification, timeRequest); } @@ -187,14 +187,13 @@ void Given_SupportedNotificationType_When_UpdateNotificationCondition_Then_Invok void Given_UnsupportedNotificationType_When_UpdateNotificationCondition_Then_ThrowServerException() { // given NotificationType notifType = NotificationType.LOCATION; - List dayOfWeekList = List.of(0, 1, 2); when(notification.getNotificationType()).thenReturn(notifType); when(timeNotificationService.supports(notifType)).thenReturn(false); when(locationNotificationService.supports(notifType)).thenReturn(false); // when & then - assertThatThrownBy(() -> selector.updateNotificationCondition(notification, dayOfWeekList, conditionRequest)) + assertThatThrownBy(() -> selector.updateNotificationCondition(notification, conditionRequest)) .isInstanceOf(ServerException.class) .hasFieldOrPropertyWithValue("errorResult", NotificationErrorResult.UNSUPPORTED_NOTIFICATION); } @@ -204,15 +203,17 @@ void Given_UnsupportedNotificationType_When_UpdateNotificationCondition_Then_Thr void Given_MultipleServices_When_FirstServiceSupports_Then_UseFirstService() { // given NotificationType notifType = NotificationType.TIME; - NotificationInfoDto expectedDto = - new NotificationInfoDto(true, List.of(0, 1, 2), null); + 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 - NotificationInfoDto result = selector.findNotificationCondition(notification); + NotificationConditionResponse result = selector.findNotificationCondition(notification); // then assertThat(result).isEqualTo(expectedDto); @@ -226,8 +227,10 @@ void Given_MultipleServices_When_FirstServiceSupports_Then_UseFirstService() { void Given_MultipleServices_When_FirstServiceNotSupports_Then_UseSecondService() { // given NotificationType notifType = NotificationType.LOCATION; - NotificationInfoDto expectedDto = - new NotificationInfoDto(false, List.of(0, 1), null); + NotificationConditionResponse expectedDto = TimeNotificationResponse.builder() + .startHour(9) + .startMinute(0) + .build(); when(notification.getNotificationType()).thenReturn(notifType); when(timeNotificationService.supports(notifType)).thenReturn(false); @@ -235,7 +238,7 @@ void Given_MultipleServices_When_FirstServiceNotSupports_Then_UseSecondService() when(locationNotificationService.findNotificationInfoByType(notification)).thenReturn(expectedDto); // when - NotificationInfoDto result = selector.findNotificationCondition(notification); + NotificationConditionResponse result = selector.findNotificationCondition(notification); // then assertThat(result).isEqualTo(expectedDto); diff --git a/src/test/java/com/und/server/notification/service/NotificationServiceTest.java b/src/test/java/com/und/server/notification/service/NotificationServiceTest.java index 43804822..8448f8dc 100644 --- a/src/test/java/com/und/server/notification/service/NotificationServiceTest.java +++ b/src/test/java/com/und/server/notification/service/NotificationServiceTest.java @@ -16,9 +16,10 @@ 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.NotificationConditionResponse; +import com.und.server.notification.dto.response.TimeNotificationResponse; import com.und.server.notification.entity.Notification; import com.und.server.notification.repository.NotificationRepository; @@ -36,7 +37,7 @@ class NotificationServiceTest { @Test - void Given_Notification_When_FindNotificationDetails_Then_ReturnNotificationInfo() { + void Given_ActiveNotification_When_FindNotificationDetails_Then_ReturnNotificationInfoDto() { // given Notification notification = Notification.builder() .id(1L) @@ -44,13 +45,15 @@ void Given_Notification_When_FindNotificationDetails_Then_ReturnNotificationInfo .notificationType(NotificationType.TIME) .build(); - NotificationInfoDto expectedInfo = - new NotificationInfoDto(true, List.of(0, 1, 2), null); + NotificationConditionResponse expectedInfo = TimeNotificationResponse.builder() + .startHour(9) + .startMinute(0) + .build(); when(notificationConditionSelector.findNotificationCondition(notification)) .thenReturn(expectedInfo); // when - NotificationInfoDto result = notificationService.findNotificationDetails(notification); + NotificationConditionResponse result = notificationService.findNotificationDetails(notification); // then assertThat(result).isEqualTo(expectedInfo); @@ -62,6 +65,7 @@ void Given_Notification_When_FindNotificationDetails_Then_ReturnNotificationInfo 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)) @@ -92,7 +96,7 @@ void Given_NotificationRequestAndCondition_When_AddNotification_Then_SaveNotific assertThat(result.getIsActive()).isTrue(); verify(notificationRepository).save(any(Notification.class)); verify(notificationConditionSelector) - .addNotificationCondition(any(Notification.class), eq(List.of(0, 1, 2)), eq(conditionInfo)); + .addNotificationCondition(any(Notification.class), eq(conditionInfo)); } @@ -107,6 +111,7 @@ void Given_ActiveNotificationAndSameType_When_UpdateNotification_Then_UpdateNoti .build(); NotificationRequest notificationInfo = NotificationRequest.builder() + .isActive(true) .notificationType(NotificationType.TIME) .notificationMethodType(NotificationMethodType.ALARM) .daysOfWeekOrdinal(List.of(0, 1, 2, 3)) @@ -125,7 +130,7 @@ void Given_ActiveNotificationAndSameType_When_UpdateNotification_Then_UpdateNoti assertThat(oldNotification.getNotificationMethodType()).isEqualTo(NotificationMethodType.ALARM); assertThat(oldNotification.isActive()).isTrue(); verify(notificationConditionSelector) - .updateNotificationCondition(oldNotification, List.of(0, 1, 2, 3), conditionInfo); + .updateNotificationCondition(oldNotification, conditionInfo); } @@ -140,6 +145,7 @@ void Given_ActiveNotificationAndDifferentType_When_UpdateNotification_Then_Delet .build(); NotificationRequest notificationInfo = NotificationRequest.builder() + .isActive(true) .notificationType(NotificationType.LOCATION) .notificationMethodType(NotificationMethodType.ALARM) .daysOfWeekOrdinal(List.of(0, 1, 2)) @@ -156,11 +162,11 @@ void Given_ActiveNotificationAndDifferentType_When_UpdateNotification_Then_Delet // then assertThat(oldNotification.getNotificationType()).isEqualTo(NotificationType.LOCATION); assertThat(oldNotification.getNotificationMethodType()).isEqualTo(NotificationMethodType.ALARM); - assertThat(oldNotification.getIsActive()).isTrue(); - verify(notificationConditionSelector) - .deleteNotificationCondition(NotificationType.TIME, oldNotification.getId()); + assertThat(oldNotification.isActive()).isTrue(); + verify(notificationConditionSelector).deleteNotificationCondition( + NotificationType.TIME, oldNotification.getId()); verify(notificationConditionSelector) - .addNotificationCondition(oldNotification, List.of(0, 1, 2), conditionInfo); + .addNotificationCondition(oldNotification, conditionInfo); } @@ -174,8 +180,13 @@ void Given_ActiveNotificationAndInactive_When_UpdateNotification_Then_DeleteCond .isActive(true) .build(); + NotificationRequest notificationRequest = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .build(); + // when - notificationService.updateWithoutNotification(oldNotification); + notificationService.updateNotification(oldNotification, notificationRequest, null); // then assertThat(oldNotification.isActive()).isFalse(); @@ -197,7 +208,12 @@ void Given_NotificationType_When_AddWithoutNotification_Then_CreateInactiveNotif when(notificationRepository.save(any(Notification.class))).thenReturn(saved); // when - Notification result = notificationService.addWithoutNotification(type); + NotificationRequest request = NotificationRequest.builder() + .isActive(false) + .notificationType(type) + .build(); + + Notification result = notificationService.addNotification(request, null); // then assertThat(result.getNotificationType()).isEqualTo(type); @@ -222,4 +238,236 @@ void Given_Notification_When_DeleteNotification_Then_DeletesCondition() { .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.isEveryDay()).isTrue(); + assertThat(notification.getDaysOfWeekOrdinalList()).hasSize(7); + assertThat(notification.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.isEveryDay()).isFalse(); + assertThat(notification.getDaysOfWeekOrdinalList()).hasSize(3); + assertThat(notification.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.isEveryDay()).isFalse(); + assertThat(notification.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.isEveryDay()).isFalse(); + assertThat(notification.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.getDaysOfWeekOrdinalList()).hasSize(3); + assertThat(notification.getDaysOfWeekOrdinalList()).containsExactlyInAnyOrder(1, 3, 5); + assertThat(notification.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.getDaysOfWeekOrdinalList()).isEmpty(); + assertThat(notification.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.getDaysOfWeekOrdinalList()).isEmpty(); + assertThat(notification.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.getDaysOfWeekOrdinalList()).isEmpty(); + assertThat(notification.isEveryDay()).isFalse(); + } + + + @Test + void Given_Notification_When_DeleteDaysOfWeekOrdinal_Then_SetToNull() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .daysOfWeek("0,1,2") + .build(); + + // when + notification.deleteDaysOfWeekOrdinal(); + + // then + assertThat(notification.getDaysOfWeekOrdinalList()).isEmpty(); + assertThat(notification.isEveryDay()).isFalse(); + } + + + @Test + void Given_Notification_When_DeleteNotificationMethodType_Then_SetToNull() { + // given + Notification notification = Notification.builder() + .id(1L) + .isActive(true) + .notificationType(NotificationType.TIME) + .notificationMethodType(NotificationMethodType.PUSH) + .build(); + + // when + notification.deleteNotificationMethodType(); + + // then + assertThat(notification.getNotificationMethodType()).isNull(); + } + } diff --git a/src/test/java/com/und/server/notification/service/TimeNotificationServiceTest.java b/src/test/java/com/und/server/notification/service/TimeNotificationServiceTest.java index 82f2ebfa..78f7e6c4 100644 --- a/src/test/java/com/und/server/notification/service/TimeNotificationServiceTest.java +++ b/src/test/java/com/und/server/notification/service/TimeNotificationServiceTest.java @@ -1,16 +1,10 @@ package com.und.server.notification.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.time.DayOfWeek; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -18,8 +12,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.und.server.notification.constants.NotificationType; -import com.und.server.notification.dto.NotificationInfoDto; 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; @@ -68,63 +62,27 @@ void Given_EverydayNotification_When_FindNotificationInfoByType_Then_ReturnEvery .id(1L) .isActive(true) .notificationType(NotificationType.TIME) + .daysOfWeek("0,1,2,3,4,5,6") .build(); - TimeNotification monday = TimeNotification.builder() + TimeNotification timeNotification = TimeNotification.builder() .id(10L) - .dayOfWeek(DayOfWeek.MONDAY) - .startHour(9) - .startMinute(0) - .build(); - TimeNotification tuesday = TimeNotification.builder() - .id(11L) - .dayOfWeek(DayOfWeek.TUESDAY) - .startHour(9) - .startMinute(0) - .build(); - TimeNotification wednesday = TimeNotification.builder() - .id(12L) - .dayOfWeek(DayOfWeek.WEDNESDAY) - .startHour(9) - .startMinute(0) - .build(); - TimeNotification thursday = TimeNotification.builder() - .id(13L) - .dayOfWeek(DayOfWeek.THURSDAY) - .startHour(9) - .startMinute(0) - .build(); - TimeNotification friday = TimeNotification.builder() - .id(14L) - .dayOfWeek(DayOfWeek.FRIDAY) - .startHour(9) - .startMinute(0) - .build(); - TimeNotification saturday = TimeNotification.builder() - .id(15L) - .dayOfWeek(DayOfWeek.SATURDAY) - .startHour(9) - .startMinute(0) - .build(); - TimeNotification sunday = TimeNotification.builder() - .id(16L) - .dayOfWeek(DayOfWeek.SUNDAY) .startHour(9) .startMinute(0) .build(); when(timeNotifRepository.findByNotificationId(notification.getId())) - .thenReturn(List.of(monday, tuesday, wednesday, thursday, friday, saturday, sunday)); + .thenReturn(timeNotification); // when - NotificationInfoDto result = timeNotificationService.findNotificationInfoByType(notification); + NotificationConditionResponse result = timeNotificationService.findNotificationInfoByType(notification); // then assertThat(result).isNotNull(); - assertThat(result.isEveryDay()).isTrue(); - assertThat(result.daysOfWeekOrdinal()).hasSize(7); - assertThat(result.daysOfWeekOrdinal()).containsExactlyInAnyOrder(0, 1, 2, 3, 4, 5, 6); - assertThat(result.notificationConditionResponse()).isInstanceOf(TimeNotificationResponse.class); + assertThat(result).isInstanceOf(TimeNotificationResponse.class); + TimeNotificationResponse timeResponse = (TimeNotificationResponse) result; + assertThat(timeResponse.startHour()).isEqualTo(9); + assertThat(timeResponse.startMinute()).isEqualTo(0); } @@ -135,73 +93,49 @@ void Given_SpecificDaysNotification_When_FindNotificationInfoByType_Then_ReturnD .id(1L) .isActive(true) .notificationType(NotificationType.TIME) + .daysOfWeek("0,2") .build(); - TimeNotification monday = TimeNotification.builder() + TimeNotification timeNotification = TimeNotification.builder() .id(10L) - .dayOfWeek(DayOfWeek.MONDAY) - .startHour(10) - .startMinute(30) - .build(); - - TimeNotification wednesday = TimeNotification.builder() - .id(11L) - .dayOfWeek(DayOfWeek.WEDNESDAY) .startHour(10) .startMinute(30) .build(); when(timeNotifRepository.findByNotificationId(notification.getId())) - .thenReturn(List.of(monday, wednesday)); + .thenReturn(timeNotification); // when - NotificationInfoDto result = timeNotificationService.findNotificationInfoByType(notification); + NotificationConditionResponse result = timeNotificationService.findNotificationInfoByType(notification); // then assertThat(result).isNotNull(); - assertThat(result.isEveryDay()).isFalse(); - assertThat(result.daysOfWeekOrdinal()).hasSize(2); - assertThat(result.daysOfWeekOrdinal()).containsExactlyInAnyOrder(0, 2); - assertThat(result.notificationConditionResponse()).isInstanceOf(TimeNotificationResponse.class); + assertThat(result).isInstanceOf(TimeNotificationResponse.class); + TimeNotificationResponse timeResponse = (TimeNotificationResponse) result; + assertThat(timeResponse.startHour()).isEqualTo(10); + assertThat(timeResponse.startMinute()).isEqualTo(30); } @Test - void Given_7DaysAndActiveNotification_When_AddNotificationCondition_Then_SaveAll7Days() { + void Given_InactiveNotification_When_FindNotificationInfoByType_Then_ReturnNull() { // given Notification notification = Notification.builder() .id(1L) - .isActive(true) + .isActive(false) .notificationType(NotificationType.TIME) .build(); - TimeNotificationRequest request = TimeNotificationRequest.builder() - .startHour(8) - .startMinute(30) - .build(); - List allDays = List.of(0, 1, 2, 3, 4, 5, 6); - // when - timeNotificationService.addNotificationCondition(notification, allDays, request); + NotificationConditionResponse result = timeNotificationService.findNotificationInfoByType(notification); // then - verify(timeNotifRepository).saveAll(anyList()); - verify(timeNotifRepository).saveAll(argThat(list -> { - assertThat(list).hasSize(7); - Set savedDays = ((List) list).stream() - .map(TimeNotification::getDayOfWeek) - .collect(Collectors.toSet()); - assertThat(savedDays).containsExactlyInAnyOrder( - DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, - DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY, DayOfWeek.SUNDAY - ); - return true; - })); + assertThat(result).isNull(); } @Test - void Given_SomeDaysAndActiveNotification_When_AddNotificationCondition_Then_SaveAll() { + void Given_ActiveNotification_When_AddNotificationCondition_Then_SaveTimeNotification() { // given Notification notification = Notification.builder() .id(1L) @@ -210,126 +144,52 @@ void Given_SomeDaysAndActiveNotification_When_AddNotificationCondition_Then_Save .build(); TimeNotificationRequest request = TimeNotificationRequest.builder() - .startHour(7) - .startMinute(45) + .startHour(9) + .startMinute(0) .build(); - List days = List.of(1, 3, 5); // TUESDAY, THURSDAY, SATURDAY - - // when - timeNotificationService.addNotificationCondition(notification, days, request); - - // then - verify(timeNotifRepository).saveAll(anyList()); - } + TimeNotification savedTimeNotification = TimeNotification.builder() + .id(1L) + .notification(notification) + .startHour(9) + .startMinute(0) + .build(); - @Test - void Given_NotificationId_When_DeleteNotificationCondition_Then_DeleteAll() { - // given - Long notificationId = 1L; - List timeNotifications = List.of( - TimeNotification.builder().id(1L).build(), - TimeNotification.builder().id(2L).build() - ); - when(timeNotifRepository.findByNotificationId(notificationId)) - .thenReturn(timeNotifications); + when(timeNotifRepository.save(any(TimeNotification.class))) + .thenReturn(savedTimeNotification); // when - timeNotificationService.deleteNotificationCondition(notificationId); + timeNotificationService.addNotificationCondition(notification, request); // then - verify(timeNotifRepository).findByNotificationId(notificationId); - verify(timeNotifRepository).deleteAll(timeNotifications); + verify(timeNotifRepository).save(any(TimeNotification.class)); } @Test - void Given_EverydayNotificationAnd7Days_When_UpdateNotificationCondition_Then_UpdateAll7Days() { + void Given_InactiveNotification_When_AddNotificationCondition_Then_DoNothing() { // given Notification notification = Notification.builder() .id(1L) - .isActive(true) + .isActive(false) .notificationType(NotificationType.TIME) .build(); TimeNotificationRequest request = TimeNotificationRequest.builder() - .startHour(10) - .startMinute(30) - .build(); - List allDays = List.of(0, 1, 2, 3, 4, 5, 6); - - TimeNotification existingMonday = TimeNotification.builder() - .id(10L) - .dayOfWeek(DayOfWeek.MONDAY) - .startHour(9) - .startMinute(0) - .build(); - TimeNotification existingTuesday = TimeNotification.builder() - .id(11L) - .dayOfWeek(DayOfWeek.TUESDAY) .startHour(9) .startMinute(0) .build(); - TimeNotification existingWednesday = TimeNotification.builder() - .id(12L) - .dayOfWeek(DayOfWeek.WEDNESDAY) - .startHour(9) - .startMinute(0) - .build(); - TimeNotification existingThursday = TimeNotification.builder() - .id(13L) - .dayOfWeek(DayOfWeek.THURSDAY) - .startHour(9) - .startMinute(0) - .build(); - TimeNotification existingFriday = TimeNotification.builder() - .id(14L) - .dayOfWeek(DayOfWeek.FRIDAY) - .startHour(9) - .startMinute(0) - .build(); - TimeNotification existingSaturday = TimeNotification.builder() - .id(15L) - .dayOfWeek(DayOfWeek.SATURDAY) - .startHour(9) - .startMinute(0) - .build(); - TimeNotification existingSunday = TimeNotification.builder() - .id(16L) - .dayOfWeek(DayOfWeek.SUNDAY) - .startHour(9) - .startMinute(0) - .build(); - - when(timeNotifRepository.findByNotificationId(notification.getId())) - .thenReturn(List.of(existingMonday, existingTuesday, existingWednesday, - existingThursday, existingFriday, existingSaturday, existingSunday)); // when - timeNotificationService.updateNotificationCondition(notification, allDays, request); + timeNotificationService.addNotificationCondition(notification, request); // then - verify(timeNotifRepository).findByNotificationId(notification.getId()); - verify(timeNotifRepository).saveAll(anyList()); - assertThat(existingMonday.getStartHour()).isEqualTo(10); - assertThat(existingMonday.getStartMinute()).isEqualTo(30); - assertThat(existingTuesday.getStartHour()).isEqualTo(10); - assertThat(existingTuesday.getStartMinute()).isEqualTo(30); - assertThat(existingWednesday.getStartHour()).isEqualTo(10); - assertThat(existingWednesday.getStartMinute()).isEqualTo(30); - assertThat(existingThursday.getStartHour()).isEqualTo(10); - assertThat(existingThursday.getStartMinute()).isEqualTo(30); - assertThat(existingFriday.getStartHour()).isEqualTo(10); - assertThat(existingFriday.getStartMinute()).isEqualTo(30); - assertThat(existingSaturday.getStartHour()).isEqualTo(10); - assertThat(existingSaturday.getStartMinute()).isEqualTo(30); - assertThat(existingSunday.getStartHour()).isEqualTo(10); - assertThat(existingSunday.getStartMinute()).isEqualTo(30); + // verify no interaction with repository } @Test - void Given_SpecificDaysNotificationAndSameDays_When_UpdateNotificationCondition_Then_UpdateExistingDays() { + void Given_ExistingTimeNotification_When_UpdateNotificationCondition_Then_UpdateTimeCondition() { // given Notification notification = Notification.builder() .id(1L) @@ -341,39 +201,28 @@ void Given_SpecificDaysNotificationAndSameDays_When_UpdateNotificationCondition_ .startHour(10) .startMinute(30) .build(); - List sameDays = List.of(0, 2); // MONDAY, WEDNESDAY - TimeNotification existingMonday = TimeNotification.builder() - .id(10L) - .dayOfWeek(DayOfWeek.MONDAY) - .startHour(9) - .startMinute(0) - .build(); - TimeNotification existingWednesday = TimeNotification.builder() - .id(11L) - .dayOfWeek(DayOfWeek.WEDNESDAY) + TimeNotification existingTimeNotification = TimeNotification.builder() + .id(1L) + .notification(notification) .startHour(9) .startMinute(0) .build(); when(timeNotifRepository.findByNotificationId(notification.getId())) - .thenReturn(List.of(existingMonday, existingWednesday)); + .thenReturn(existingTimeNotification); // when - timeNotificationService.updateNotificationCondition(notification, sameDays, request); + timeNotificationService.updateNotificationCondition(notification, request); // then - verify(timeNotifRepository).findByNotificationId(notification.getId()); - verify(timeNotifRepository).saveAll(anyList()); - assertThat(existingMonday.getStartHour()).isEqualTo(10); - assertThat(existingMonday.getStartMinute()).isEqualTo(30); - assertThat(existingWednesday.getStartHour()).isEqualTo(10); - assertThat(existingWednesday.getStartMinute()).isEqualTo(30); + assertThat(existingTimeNotification.getStartHour()).isEqualTo(10); + assertThat(existingTimeNotification.getStartMinute()).isEqualTo(30); } @Test - void Given_SpecificDaysNotificationAndDifferentDays_When_UpdateNotificationCondition_Then_DeleteOldAndAddNew() { + void Given_NoExistingTimeNotification_When_UpdateNotificationCondition_Then_AddNewTimeNotification() { // given Notification notification = Notification.builder() .id(1L) @@ -385,74 +234,28 @@ void Given_SpecificDaysNotificationAndDifferentDays_When_UpdateNotificationCondi .startHour(10) .startMinute(30) .build(); - List newDays = List.of(1, 3); // TUESDAY, THURSDAY - - TimeNotification existingMonday = TimeNotification.builder() - .id(10L) - .dayOfWeek(DayOfWeek.MONDAY) - .startHour(9) - .startMinute(0) - .build(); - TimeNotification existingWednesday = TimeNotification.builder() - .id(11L) - .dayOfWeek(DayOfWeek.WEDNESDAY) - .startHour(9) - .startMinute(0) - .build(); when(timeNotifRepository.findByNotificationId(notification.getId())) - .thenReturn(List.of(existingMonday, existingWednesday)); + .thenReturn(null); // when - timeNotificationService.updateNotificationCondition(notification, newDays, request); + timeNotificationService.updateNotificationCondition(notification, request); // then - verify(timeNotifRepository).findByNotificationId(notification.getId()); - verify(timeNotifRepository).deleteAll(anyList()); - verify(timeNotifRepository).saveAll(anyList()); + verify(timeNotifRepository).save(any(TimeNotification.class)); } @Test - void Given_SpecificDaysNotificationAndMixedDays_When_UpdateNotificationCondition_Then_DeleteAddAndUpdate() { + void Given_NotificationId_When_DeleteNotificationCondition_Then_DeleteByNotificationId() { // given - Notification notification = Notification.builder() - .id(1L) - .isActive(true) - .notificationType(NotificationType.TIME) - .build(); - - TimeNotificationRequest request = TimeNotificationRequest.builder() - .startHour(10) - .startMinute(30) - .build(); - List mixedDays = List.of(0, 1, 3); // MONDAY, TUESDAY, THURSDAY - - TimeNotification existingMonday = TimeNotification.builder() - .id(10L) - .dayOfWeek(DayOfWeek.MONDAY) - .startHour(9) - .startMinute(0) - .build(); - TimeNotification existingWednesday = TimeNotification.builder() - .id(11L) - .dayOfWeek(DayOfWeek.WEDNESDAY) - .startHour(9) - .startMinute(0) - .build(); - - when(timeNotifRepository.findByNotificationId(notification.getId())) - .thenReturn(List.of(existingMonday, existingWednesday)); + Long notificationId = 1L; // when - timeNotificationService.updateNotificationCondition(notification, mixedDays, request); + timeNotificationService.deleteNotificationCondition(notificationId); // then - verify(timeNotifRepository).findByNotificationId(notification.getId()); - verify(timeNotifRepository).deleteAll(anyList()); - verify(timeNotifRepository, org.mockito.Mockito.times(2)).saveAll(anyList()); - assertThat(existingMonday.getStartHour()).isEqualTo(10); - assertThat(existingMonday.getStartMinute()).isEqualTo(30); + verify(timeNotifRepository).deleteByNotificationId(notificationId); } } diff --git a/src/test/java/com/und/server/scenario/controller/MissionControllerTest.java b/src/test/java/com/und/server/scenario/controller/MissionControllerTest.java index 39cfa3c5..a2e0a574 100644 --- a/src/test/java/com/und/server/scenario/controller/MissionControllerTest.java +++ b/src/test/java/com/und/server/scenario/controller/MissionControllerTest.java @@ -15,6 +15,7 @@ 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; @@ -41,9 +42,11 @@ void Given_ValidMemberIdAndScenarioId_When_GetMissionsByScenarioId_Then_ReturnMi Long scenarioId = 1L; LocalDate date = LocalDate.now(); - MissionGroupResponse expectedResponse = new MissionGroupResponse( - List.of(), List.of() - ); + MissionGroupResponse expectedResponse = MissionGroupResponse.builder() + .scenarioId(scenarioId) + .basicMissions(List.of()) + .todayMissions(List.of()) + .build(); when(missionService.findMissionsByScenarioId(memberId, scenarioId, date)) .thenReturn(expectedResponse); @@ -60,58 +63,88 @@ void Given_ValidMemberIdAndScenarioId_When_GetMissionsByScenarioId_Then_ReturnMi @Test - void Given_ValidRequest_When_AddTodayMissionToScenario_Then_ReturnNoContent() { + 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()).isNull(); + assertThat(response.getBody()).isEqualTo(expectedResponse); verify(scenarioService).addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, date); } @Test - void Given_EmptyContentRequest_When_AddTodayMissionToScenario_Then_ReturnNoContent() { + 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()).isNull(); + assertThat(response.getBody()).isEqualTo(expectedResponse); verify(scenarioService).addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, date); } @Test - void Given_LongContentRequest_When_AddTodayMissionToScenario_Then_ReturnNoContent() { + 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()).isNull(); + assertThat(response.getBody()).isEqualTo(expectedResponse); verify(scenarioService).addTodayMissionToScenario(memberId, scenarioId, missionAddRequest, 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 index f674806f..552d5859 100644 --- a/src/test/java/com/und/server/scenario/controller/ScenarioControllerTest.java +++ b/src/test/java/com/und/server/scenario/controller/ScenarioControllerTest.java @@ -16,9 +16,10 @@ 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.ScenarioNoNotificationRequest; 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; @@ -100,15 +101,21 @@ void Given_ValidMemberIdAndScenarioRequest_When_AddScenario_Then_ReturnCreated() .memo("새 시나리오 설명") .build(); + MissionGroupResponse expectedResponse = MissionGroupResponse.builder() + .scenarioId(expectedScenarioId) + .basicMissions(List.of()) + .todayMissions(null) + .build(); + when(scenarioService.addScenario(memberId, scenarioRequest)) - .thenReturn(expectedScenarioId); + .thenReturn(expectedResponse); // when - ResponseEntity response = scenarioController.addScenario(memberId, scenarioRequest); + ResponseEntity response = scenarioController.addScenario(memberId, scenarioRequest); // then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(response.getBody()).isEqualTo(expectedScenarioId); + assertThat(response.getBody()).isEqualTo(expectedResponse); verify(scenarioService).addScenario(memberId, scenarioRequest); } @@ -117,44 +124,74 @@ void Given_ValidMemberIdAndScenarioRequest_When_AddScenarioWithoutNotification_T // given Long memberId = 1L; Long expectedScenarioId = 456L; - ScenarioNoNotificationRequest request = new ScenarioNoNotificationRequest( - "새 시나리오", - "메모", - null, - NotificationType.TIME - ); - when(scenarioService.addScenarioWithoutNotification(memberId, request)) - .thenReturn(expectedScenarioId); + NotificationRequest notification = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.TIME) + .build(); + + ScenarioDetailRequest request = ScenarioDetailRequest.builder() + .scenarioName("새 시나리오") + .memo("메모") + .basicMissions(List.of()) + .notification(notification) + .notificationCondition(null) + .build(); + + MissionGroupResponse expectedResponse = MissionGroupResponse.builder() + .scenarioId(expectedScenarioId) + .basicMissions(List.of()) + .todayMissions(null) + .build(); + + when(scenarioService.addScenario(memberId, request)) + .thenReturn(expectedResponse); // when - ResponseEntity response = scenarioController.addScenarioWithoutNotification(memberId, request); + ResponseEntity response = scenarioController.addScenario(memberId, request); // then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(response.getBody()).isEqualTo(expectedScenarioId); - verify(scenarioService).addScenarioWithoutNotification(memberId, request); + assertThat(response.getBody()).isEqualTo(expectedResponse); + verify(scenarioService).addScenario(memberId, request); } @Test - void Given_ValidMemberIdAndScenarioId_When_UpdateScenarioWithoutNotification_Then_ReturnNoContent() { + void Given_ValidMemberIdAndScenarioId_When_UpdateScenarioWithoutNotification_Then_ReturnOk() { // given Long memberId = 1L; Long scenarioId = 2L; - ScenarioNoNotificationRequest request = new ScenarioNoNotificationRequest( - "수정 시나리오", - "메모", - null, - NotificationType.LOCATION - ); + + NotificationRequest notification = NotificationRequest.builder() + .isActive(false) + .notificationType(NotificationType.LOCATION) + .build(); + + ScenarioDetailRequest request = ScenarioDetailRequest.builder() + .scenarioName("수정 시나리오") + .memo("메모") + .basicMissions(List.of()) + .notification(notification) + .notificationCondition(null) + .build(); + + MissionGroupResponse expectedResponse = MissionGroupResponse.builder() + .scenarioId(scenarioId) + .basicMissions(List.of()) + .todayMissions(null) + .build(); + + when(scenarioService.updateScenario(memberId, scenarioId, request)) + .thenReturn(expectedResponse); // when - ResponseEntity response = scenarioController - .updateScenarioWithoutNotification(memberId, scenarioId, request); + ResponseEntity response = scenarioController + .updateScenario(memberId, scenarioId, request); // then - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - verify(scenarioService).updateScenarioWithoutNotification(memberId, scenarioId, request); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedResponse); + verify(scenarioService).updateScenario(memberId, scenarioId, request); } @@ -168,21 +205,27 @@ void Given_EmptyTitleRequest_When_AddScenario_Then_ReturnCreated() { .memo("빈 제목 시나리오") .build(); + MissionGroupResponse expectedResponse = MissionGroupResponse.builder() + .scenarioId(expectedScenarioId) + .basicMissions(List.of()) + .todayMissions(null) + .build(); + when(scenarioService.addScenario(memberId, scenarioRequest)) - .thenReturn(expectedScenarioId); + .thenReturn(expectedResponse); // when - ResponseEntity response = scenarioController.addScenario(memberId, scenarioRequest); + ResponseEntity response = scenarioController.addScenario(memberId, scenarioRequest); // then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(response.getBody()).isEqualTo(expectedScenarioId); + assertThat(response.getBody()).isEqualTo(expectedResponse); verify(scenarioService).addScenario(memberId, scenarioRequest); } @Test - void Given_ValidMemberIdAndScenarioIdAndRequest_When_UpdateScenario_Then_ReturnNoContent() { + void Given_ValidMemberIdAndScenarioIdAndRequest_When_UpdateScenario_Then_ReturnOk() { // given Long memberId = 1L; Long scenarioId = 1L; @@ -191,18 +234,28 @@ void Given_ValidMemberIdAndScenarioIdAndRequest_When_UpdateScenario_Then_ReturnN .memo("수정된 시나리오 설명") .build(); + MissionGroupResponse expectedResponse = MissionGroupResponse.builder() + .scenarioId(scenarioId) + .basicMissions(List.of()) + .todayMissions(null) + .build(); + + when(scenarioService.updateScenario(memberId, scenarioId, scenarioRequest)) + .thenReturn(expectedResponse); + // when - ResponseEntity response = scenarioController.updateScenario(memberId, scenarioId, scenarioRequest); + ResponseEntity response = + scenarioController.updateScenario(memberId, scenarioId, scenarioRequest); // then - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - assertThat(response.getBody()).isNull(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedResponse); verify(scenarioService).updateScenario(memberId, scenarioId, scenarioRequest); } @Test - void Given_EmptyTitleRequest_When_UpdateScenario_Then_ReturnNoContent() { + void Given_EmptyTitleRequest_When_UpdateScenario_Then_ReturnOk() { // given Long memberId = 1L; Long scenarioId = 1L; @@ -211,12 +264,22 @@ void Given_EmptyTitleRequest_When_UpdateScenario_Then_ReturnNoContent() { .memo("수정된 빈 제목 시나리오") .build(); + MissionGroupResponse expectedResponse = MissionGroupResponse.builder() + .scenarioId(scenarioId) + .basicMissions(List.of()) + .todayMissions(null) + .build(); + + when(scenarioService.updateScenario(memberId, scenarioId, scenarioRequest)) + .thenReturn(expectedResponse); + // when - ResponseEntity response = scenarioController.updateScenario(memberId, scenarioId, scenarioRequest); + ResponseEntity response = + scenarioController.updateScenario(memberId, scenarioId, scenarioRequest); // then - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - assertThat(response.getBody()).isNull(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedResponse); verify(scenarioService).updateScenario(memberId, scenarioId, scenarioRequest); } @@ -231,21 +294,27 @@ void Given_LongTitleRequest_When_AddScenario_Then_ReturnCreated() { .memo("긴 제목 시나리오") .build(); + MissionGroupResponse expectedResponse = MissionGroupResponse.builder() + .scenarioId(expectedScenarioId) + .basicMissions(List.of()) + .todayMissions(null) + .build(); + when(scenarioService.addScenario(memberId, scenarioRequest)) - .thenReturn(expectedScenarioId); + .thenReturn(expectedResponse); // when - ResponseEntity response = scenarioController.addScenario(memberId, scenarioRequest); + ResponseEntity response = scenarioController.addScenario(memberId, scenarioRequest); // then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(response.getBody()).isEqualTo(expectedScenarioId); + assertThat(response.getBody()).isEqualTo(expectedResponse); verify(scenarioService).addScenario(memberId, scenarioRequest); } @Test - void Given_LongTitleRequest_When_UpdateScenario_Then_ReturnNoContent() { + void Given_LongTitleRequest_When_UpdateScenario_Then_ReturnOk() { // given Long memberId = 1L; Long scenarioId = 1L; @@ -254,12 +323,22 @@ void Given_LongTitleRequest_When_UpdateScenario_Then_ReturnNoContent() { .memo("긴 제목 수정 시나리오") .build(); + MissionGroupResponse expectedResponse = MissionGroupResponse.builder() + .scenarioId(scenarioId) + .basicMissions(List.of()) + .todayMissions(null) + .build(); + + when(scenarioService.updateScenario(memberId, scenarioId, scenarioRequest)) + .thenReturn(expectedResponse); + // when - ResponseEntity response = scenarioController.updateScenario(memberId, scenarioId, scenarioRequest); + ResponseEntity response = + scenarioController.updateScenario(memberId, scenarioId, scenarioRequest); // then - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - assertThat(response.getBody()).isNull(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedResponse); verify(scenarioService).updateScenario(memberId, scenarioId, scenarioRequest); } 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/dto/request/ScenarioNoNotificationRequestTest.java b/src/test/java/com/und/server/scenario/dto/request/ScenarioNoNotificationRequestTest.java deleted file mode 100644 index 46b31092..00000000 --- a/src/test/java/com/und/server/scenario/dto/request/ScenarioNoNotificationRequestTest.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.und.server.scenario.dto.request; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.List; - -import org.junit.jupiter.api.Test; - -import com.und.server.notification.constants.NotificationType; - -class ScenarioNoNotificationRequestTest { - - @Test - void construct_holdsValues() { - ScenarioNoNotificationRequest req = new ScenarioNoNotificationRequest( - "name", - "memo", - List.of(BasicMissionRequest.builder().content("A").build()), - NotificationType.TIME - ); - - assertThat(req.scenarioName()).isEqualTo("name"); - assertThat(req.memo()).isEqualTo("memo"); - assertThat(req.basicMissions()).hasSize(1); - assertThat(req.notificationType()).isEqualTo(NotificationType.TIME); - } - -} - - diff --git a/src/test/java/com/und/server/scenario/service/MissionServiceTest.java b/src/test/java/com/und/server/scenario/service/MissionServiceTest.java index f7bdbd33..1e55a493 100644 --- a/src/test/java/com/und/server/scenario/service/MissionServiceTest.java +++ b/src/test/java/com/und/server/scenario/service/MissionServiceTest.java @@ -4,7 +4,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; diff --git a/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java b/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java index 9dfab423..86d9e35d 100644 --- a/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java +++ b/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java @@ -37,12 +37,13 @@ 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.ScenarioNoNotificationRequest; import com.und.server.scenario.dto.request.ScenarioOrderUpdateRequest; import com.und.server.scenario.dto.request.TodayMissionRequest; +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 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; @@ -174,7 +175,7 @@ void Given_validScenario_When_findScenarioByScenarioId_Then_returnResponse() { // mock Mockito.when(scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId)) .thenReturn(Optional.of(scenario)); - Mockito.when(notificationService.findNotificationDetails(notification)).thenReturn(notifInfoDto); + Mockito.when(notificationService.findNotificationDetails(notification)).thenReturn(notifDetail); Mockito.when(missionTypeGrouper.groupAndSortByType(scenario.getMissions(), MissionType.BASIC)) .thenReturn(List.of()); @@ -271,7 +272,7 @@ void Given_OtherUserScenario_When_AddTodayMissionToScenario_Then_ThrowNotFoundEx void Given_ValidRequest_When_AddScenario_Then_SaveScenarioAndAddMissions() { //given Long memberId = 1L; - int calculatedOrder = 1000; + int calculatedOrder = 100000; Member member = Member.builder().id(memberId).build(); given(em.getReference(Member.class, memberId)).willReturn(member); @@ -287,6 +288,7 @@ void Given_ValidRequest_When_AddScenario_Then_SaveScenarioAndAddMissions() { List missionList = List.of(mission1, mission2); NotificationRequest notifRequest = NotificationRequest.builder() + .isActive(true) .notificationType(NotificationType.TIME) .notificationMethodType(NotificationMethodType.ALARM) .daysOfWeekOrdinal(List.of(1, 2)) @@ -319,13 +321,34 @@ void Given_ValidRequest_When_AddScenario_Then_SaveScenarioAndAddMissions() { 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); + given(missionTypeGrouper.groupAndSortByType(savedMissions, MissionType.BASIC)) + .willReturn(groupedBasicMissions); + // when - scenarioService.addScenario(memberId, scenarioRequest); + MissionGroupResponse 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(missionTypeGrouper).groupAndSortByType(savedMissions, MissionType.BASIC); Scenario saved = scenarioCaptor.getValue(); @@ -334,6 +357,11 @@ void Given_ValidRequest_When_AddScenario_Then_SaveScenarioAndAddMissions() { assertThat(saved.getScenarioOrder()).isEqualTo(calculatedOrder); assertThat(saved.getNotification()).isEqualTo(savedNotification); assertThat(saved.getMember().getId()).isEqualTo(member.getId()); + + assertThat(result).isNotNull(); + assertThat(result.scenarioId()).isEqualTo(saved.getId()); + assertThat(result.basicMissions()).hasSize(2); + assertThat(result.todayMissions()).isEmpty(); } @@ -347,8 +375,10 @@ void Given_ReorderRequired_When_AddScenario_Then_ReorderAndRetry() { 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() @@ -388,14 +418,24 @@ void Given_ReorderRequired_When_AddScenario_Then_ReorderAndRetry() { ArgumentCaptor captor = ArgumentCaptor.forClass(Scenario.class); + given(missionService.addBasicMission(any(Scenario.class), eq(List.of()))) + .willReturn(List.of()); + given(missionTypeGrouper.groupAndSortByType(List.of(), MissionType.BASIC)) + .willReturn(List.of()); + // when - scenarioService.addScenario(memberId, scenarioRequest); + MissionGroupResponse result = scenarioService.addScenario(memberId, scenarioRequest); // then verify(scenarioRepository).save(captor.capture()); + verify(missionTypeGrouper).groupAndSortByType(List.of(), MissionType.BASIC); Scenario saved = captor.getValue(); assertThat(saved.getScenarioOrder()).isEqualTo(reorderedOrder); + assertThat(result).isNotNull(); + assertThat(result.scenarioId()).isEqualTo(saved.getId()); + assertThat(result.basicMissions()).isEmpty(); + assertThat(result.todayMissions()).isEmpty(); } @Test @@ -447,6 +487,7 @@ void Given_ValidRequest_When_UpdateScenario_Then_UpdateScenarioAndNotification() .build(); NotificationRequest notifRequest = NotificationRequest.builder() + .isActive(true) .notificationType(NotificationType.TIME) .notificationMethodType(NotificationMethodType.ALARM) .daysOfWeekOrdinal(List.of(1, 2)) @@ -474,8 +515,17 @@ void Given_ValidRequest_When_UpdateScenario_Then_UpdateScenarioAndNotification() return null; }).when(notificationService).updateNotification(oldNotification, notifRequest, condition); + MissionGroupResponse expectedResponse = MissionGroupResponse.builder() + .scenarioId(scenarioId) + .basicMissions(List.of()) + .todayMissions(List.of()) + .build(); + + given(missionService.findMissionsByScenarioId(eq(memberId), eq(scenarioId), any(LocalDate.class))) + .willReturn(expectedResponse); + // when - scenarioService.updateScenario(memberId, scenarioId, scenarioRequest); + MissionGroupResponse result = scenarioService.updateScenario(memberId, scenarioId, scenarioRequest); // then assertThat(oldScenario.getScenarioName()).isEqualTo("수정된 시나리오"); @@ -486,6 +536,11 @@ void Given_ValidRequest_When_UpdateScenario_Then_UpdateScenarioAndNotification() assertThat(oldScenario.getNotification().isActive()).isTrue(); verify(notificationService).updateNotification(oldNotification, notifRequest, condition); verify(missionService).updateBasicMission(oldScenario, List.of()); + + assertThat(result).isNotNull(); + assertThat(result.scenarioId()).isEqualTo(scenarioId); + assertThat(result.basicMissions()).isEmpty(); + assertThat(result.todayMissions()).isEmpty(); } @@ -583,23 +638,30 @@ void Given_ReorderRequired_When_UpdateScenarioOrder_Then_ReorderScenarios() { void Given_ValidRequest_When_AddScenarioWithoutNotification_Then_CreateInactiveNotificationAndSave() { // given Long memberId = 1L; - ScenarioNoNotificationRequest request = new ScenarioNoNotificationRequest( - "시나리오", - "메모", - List.of(), - NotificationType.TIME - ); + + 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).notificationType(NotificationType.TIME).build(); - given(notificationService.addWithoutNotification(NotificationType.TIME)).willReturn(saved); + given(notificationService.addNotification(notificationRequest, null)).willReturn(saved); // when - scenarioService.addScenarioWithoutNotification(memberId, request); + scenarioService.addScenario(memberId, request); // then - verify(notificationService).addWithoutNotification(NotificationType.TIME); + verify(notificationService).addNotification(notificationRequest, null); verify(scenarioRepository).save(any(Scenario.class)); verify(missionService).addBasicMission(any(Scenario.class), eq(List.of())); } @@ -641,9 +703,17 @@ void Given_NotExistScenario_When_UpdateScenario_Then_ThrowNotFoundException() { 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.findFetchByIdAndMemberId(memberId, scenarioId)) @@ -714,24 +784,44 @@ void Given_ValidRequest_When_UpdateScenarioWithoutNotification_Then_UpdateScenar .missions(new java.util.ArrayList<>()) .build(); - ScenarioNoNotificationRequest scenarioRequest = new ScenarioNoNotificationRequest( - "수정된 시나리오", - "수정된 메모", - List.of(), - NotificationType.TIME - ); + 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.findFetchByIdAndMemberId(memberId, scenarioId)) .thenReturn(Optional.of(oldScenario)); + MissionGroupResponse expectedResponse = MissionGroupResponse.builder() + .scenarioId(scenarioId) + .basicMissions(List.of()) + .todayMissions(List.of()) + .build(); + + given(missionService.findMissionsByScenarioId(eq(memberId), eq(scenarioId), any(LocalDate.class))) + .willReturn(expectedResponse); + // when - scenarioService.updateScenarioWithoutNotification(memberId, scenarioId, scenarioRequest); + MissionGroupResponse result = scenarioService.updateScenario(memberId, scenarioId, scenarioRequest); // then assertThat(oldScenario.getScenarioName()).isEqualTo("수정된 시나리오"); assertThat(oldScenario.getMemo()).isEqualTo("수정된 메모"); - verify(notificationService).updateWithoutNotification(oldNotification); + verify(notificationService).updateNotification(oldNotification, notificationRequest, null); verify(missionService).updateBasicMission(oldScenario, List.of()); + + assertThat(result).isNotNull(); + assertThat(result.scenarioId()).isEqualTo(scenarioId); + assertThat(result.basicMissions()).isEmpty(); + assertThat(result.todayMissions()).isEmpty(); } @@ -741,19 +831,25 @@ void Given_NotExistScenario_When_UpdateScenarioWithoutNotification_Then_ThrowNot Long memberId = 1L; Long scenarioId = 99L; - ScenarioNoNotificationRequest scenarioRequest = new ScenarioNoNotificationRequest( - "수정할 시나리오", - "수정할 메모", - List.of(), - NotificationType.TIME - ); + 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.findFetchByIdAndMemberId(memberId, scenarioId)) .thenReturn(Optional.empty()); // when & then assertThatThrownBy(() -> - scenarioService.updateScenarioWithoutNotification(memberId, scenarioId, scenarioRequest)) + scenarioService.updateScenario(memberId, scenarioId, scenarioRequest)) .isInstanceOf(ServerException.class) .hasMessageContaining(ScenarioErrorResult.NOT_FOUND_SCENARIO.getMessage()); } @@ -765,8 +861,10 @@ void Given_MaxScenarioCountExceeded_When_AddScenario_Then_ThrowMaxCountExceededE 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() @@ -805,12 +903,18 @@ void Given_MaxScenarioCountExceeded_When_AddScenarioWithoutNotification_Then_Thr // given Long memberId = 1L; - ScenarioNoNotificationRequest request = new ScenarioNoNotificationRequest( - "시나리오", - "메모", - List.of(), - NotificationType.TIME - ); + 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<>(); @@ -824,7 +928,7 @@ void Given_MaxScenarioCountExceeded_When_AddScenarioWithoutNotification_Then_Thr .when(scenarioValidator).validateMaxScenarioCount(orderList); // when & then - assertThatThrownBy(() -> scenarioService.addScenarioWithoutNotification(memberId, request)) + assertThatThrownBy(() -> scenarioService.addScenario(memberId, request)) .isInstanceOf(ServerException.class) .hasMessageContaining(ScenarioErrorResult.MAX_SCENARIO_COUNT_EXCEEDED.getMessage()); } @@ -884,8 +988,10 @@ void Given_EmptyOrderList_When_AddScenario_Then_CreateScenarioWithStartOrder() { 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() From 8153f2ac9ff97f45bd4556514df0db76b3cbbf38 Mon Sep 17 00:00:00 2001 From: Chori <105255517+choridev@users.noreply.github.com> Date: Wed, 27 Aug 2025 21:27:32 +0900 Subject: [PATCH 19/26] Feat/#87-terms-of-service (#91) --- .../auth/controller/AuthController.java | 20 +- .../auth/dto/{ => request}/AuthRequest.java | 2 +- .../auth/dto/{ => request}/NonceRequest.java | 2 +- .../{ => request}/RefreshTokenRequest.java | 2 +- .../auth/dto/{ => response}/AuthResponse.java | 2 +- .../dto/{ => response}/NonceResponse.java | 2 +- .../filter/SecurityErrorResponseWriter.java | 2 +- .../und/server/auth/service/AuthService.java | 14 +- .../common/controller/TestController.java | 24 +- .../dto/{ => request}/TestAuthRequest.java | 2 +- .../dto/{ => response}/ErrorResponse.java | 2 +- .../dto/{ => response}/TestHelloResponse.java | 2 +- .../exception/GlobalExceptionHandler.java | 2 +- .../member/controller/MemberController.java | 4 +- .../dto/{ => request}/NicknameRequest.java | 2 +- .../dto/{ => response}/MemberResponse.java | 6 +- .../com/und/server/member/entity/Member.java | 20 +- .../server/member/service/MemberService.java | 4 +- .../terms/controller/TermsController.java | 62 +++++ .../request/EventPushAgreementRequest.java | 10 + .../dto/request/TermsAgreementRequest.java | 27 +++ .../dto/response/TermsAgreementResponse.java | 37 +++ .../com/und/server/terms/entity/Terms.java | 57 +++++ .../terms/exception/TermsErrorResult.java | 20 ++ .../terms/repository/TermsRepository.java | 21 ++ .../server/terms/service/TermsService.java | 83 +++++++ .../db/migration/V7__create_terms_table.sql | 12 + .../auth/controller/AuthControllerTest.java | 40 ++-- .../CustomAuthenticationEntryPointTest.java | 2 +- .../filter/JwtAuthenticationFilterTest.java | 2 +- .../server/auth/service/AuthServiceTest.java | 30 +-- .../common/controller/TestControllerTest.java | 47 +++- .../controller/MemberControllerTest.java | 4 +- .../server/member/dto/MemberResponseTest.java | 1 + .../member/service/MemberServiceTest.java | 4 +- .../terms/controller/TermsControllerTest.java | 166 ++++++++++++++ .../terms/repository/TermsRepositoryTest.java | 109 +++++++++ .../terms/service/TermsServiceTest.java | 212 ++++++++++++++++++ 38 files changed, 960 insertions(+), 100 deletions(-) rename src/main/java/com/und/server/auth/dto/{ => request}/AuthRequest.java (92%) rename src/main/java/com/und/server/auth/dto/{ => request}/NonceRequest.java (88%) rename src/main/java/com/und/server/auth/dto/{ => request}/RefreshTokenRequest.java (92%) rename src/main/java/com/und/server/auth/dto/{ => response}/AuthResponse.java (94%) rename src/main/java/com/und/server/auth/dto/{ => response}/NonceResponse.java (85%) rename src/main/java/com/und/server/common/dto/{ => request}/TestAuthRequest.java (91%) rename src/main/java/com/und/server/common/dto/{ => response}/ErrorResponse.java (87%) rename src/main/java/com/und/server/common/dto/{ => response}/TestHelloResponse.java (84%) rename src/main/java/com/und/server/member/dto/{ => request}/NicknameRequest.java (87%) rename src/main/java/com/und/server/member/dto/{ => response}/MemberResponse.java (89%) create mode 100644 src/main/java/com/und/server/terms/controller/TermsController.java create mode 100644 src/main/java/com/und/server/terms/dto/request/EventPushAgreementRequest.java create mode 100644 src/main/java/com/und/server/terms/dto/request/TermsAgreementRequest.java create mode 100644 src/main/java/com/und/server/terms/dto/response/TermsAgreementResponse.java create mode 100644 src/main/java/com/und/server/terms/entity/Terms.java create mode 100644 src/main/java/com/und/server/terms/exception/TermsErrorResult.java create mode 100644 src/main/java/com/und/server/terms/repository/TermsRepository.java create mode 100644 src/main/java/com/und/server/terms/service/TermsService.java create mode 100644 src/main/resources/db/migration/V7__create_terms_table.sql create mode 100644 src/test/java/com/und/server/terms/controller/TermsControllerTest.java create mode 100644 src/test/java/com/und/server/terms/repository/TermsRepositoryTest.java create mode 100644 src/test/java/com/und/server/terms/service/TermsServiceTest.java diff --git a/src/main/java/com/und/server/auth/controller/AuthController.java b/src/main/java/com/und/server/auth/controller/AuthController.java index 1c6fdbe6..4b1f4cff 100644 --- a/src/main/java/com/und/server/auth/controller/AuthController.java +++ b/src/main/java/com/und/server/auth/controller/AuthController.java @@ -8,11 +8,11 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.und.server.auth.dto.AuthRequest; -import com.und.server.auth.dto.AuthResponse; -import com.und.server.auth.dto.NonceRequest; -import com.und.server.auth.dto.NonceResponse; -import com.und.server.auth.dto.RefreshTokenRequest; +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; @@ -29,10 +29,11 @@ public class AuthController { private final AuthService authService; @PostMapping("/nonce") - public ResponseEntity handshake(@RequestBody @Valid final NonceRequest nonceRequest) { - final NonceResponse nonceResponse = authService.handshake(nonceRequest); + @ApiResponse(responseCode = "201", description = "Nonce created") + public ResponseEntity generateNonce(@RequestBody @Valid final NonceRequest nonceRequest) { + final NonceResponse nonceResponse = authService.generateNonce(nonceRequest); - return ResponseEntity.status(HttpStatus.OK).body(nonceResponse); + return ResponseEntity.status(HttpStatus.CREATED).body(nonceResponse); } @PostMapping("/login") @@ -43,12 +44,13 @@ public ResponseEntity login(@RequestBody @Valid final AuthRequest } @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.OK).body(authResponse); + return ResponseEntity.status(HttpStatus.CREATED).body(authResponse); } @DeleteMapping("/logout") diff --git a/src/main/java/com/und/server/auth/dto/AuthRequest.java b/src/main/java/com/und/server/auth/dto/request/AuthRequest.java similarity index 92% rename from src/main/java/com/und/server/auth/dto/AuthRequest.java rename to src/main/java/com/und/server/auth/dto/request/AuthRequest.java index 25a73bf5..b43b11a5 100644 --- a/src/main/java/com/und/server/auth/dto/AuthRequest.java +++ b/src/main/java/com/und/server/auth/dto/request/AuthRequest.java @@ -1,4 +1,4 @@ -package com.und.server.auth.dto; +package com.und.server.auth.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/und/server/auth/dto/NonceRequest.java b/src/main/java/com/und/server/auth/dto/request/NonceRequest.java similarity index 88% rename from src/main/java/com/und/server/auth/dto/NonceRequest.java rename to src/main/java/com/und/server/auth/dto/request/NonceRequest.java index 922f4a52..423d01ea 100644 --- a/src/main/java/com/und/server/auth/dto/NonceRequest.java +++ b/src/main/java/com/und/server/auth/dto/request/NonceRequest.java @@ -1,4 +1,4 @@ -package com.und.server.auth.dto; +package com.und.server.auth.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/und/server/auth/dto/RefreshTokenRequest.java b/src/main/java/com/und/server/auth/dto/request/RefreshTokenRequest.java similarity index 92% rename from src/main/java/com/und/server/auth/dto/RefreshTokenRequest.java rename to src/main/java/com/und/server/auth/dto/request/RefreshTokenRequest.java index a472a42c..8db3d3e2 100644 --- a/src/main/java/com/und/server/auth/dto/RefreshTokenRequest.java +++ b/src/main/java/com/und/server/auth/dto/request/RefreshTokenRequest.java @@ -1,4 +1,4 @@ -package com.und.server.auth.dto; +package com.und.server.auth.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/und/server/auth/dto/AuthResponse.java b/src/main/java/com/und/server/auth/dto/response/AuthResponse.java similarity index 94% rename from src/main/java/com/und/server/auth/dto/AuthResponse.java rename to src/main/java/com/und/server/auth/dto/response/AuthResponse.java index 6372b0b9..3e6cf4a3 100644 --- a/src/main/java/com/und/server/auth/dto/AuthResponse.java +++ b/src/main/java/com/und/server/auth/dto/response/AuthResponse.java @@ -1,4 +1,4 @@ -package com.und.server.auth.dto; +package com.und.server.auth.dto.response; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/und/server/auth/dto/NonceResponse.java b/src/main/java/com/und/server/auth/dto/response/NonceResponse.java similarity index 85% rename from src/main/java/com/und/server/auth/dto/NonceResponse.java rename to src/main/java/com/und/server/auth/dto/response/NonceResponse.java index 88f4a843..06bcaca9 100644 --- a/src/main/java/com/und/server/auth/dto/NonceResponse.java +++ b/src/main/java/com/und/server/auth/dto/response/NonceResponse.java @@ -1,4 +1,4 @@ -package com.und.server.auth.dto; +package com.und.server.auth.dto.response; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/und/server/auth/filter/SecurityErrorResponseWriter.java b/src/main/java/com/und/server/auth/filter/SecurityErrorResponseWriter.java index 8dff6e3e..47a5829c 100644 --- a/src/main/java/com/und/server/auth/filter/SecurityErrorResponseWriter.java +++ b/src/main/java/com/und/server/auth/filter/SecurityErrorResponseWriter.java @@ -6,7 +6,7 @@ import org.springframework.stereotype.Component; import com.fasterxml.jackson.databind.ObjectMapper; -import com.und.server.common.dto.ErrorResponse; +import com.und.server.common.dto.response.ErrorResponse; import com.und.server.common.exception.ErrorResult; import jakarta.servlet.http.HttpServletResponse; diff --git a/src/main/java/com/und/server/auth/service/AuthService.java b/src/main/java/com/und/server/auth/service/AuthService.java index aeb27580..d268448e 100644 --- a/src/main/java/com/und/server/auth/service/AuthService.java +++ b/src/main/java/com/und/server/auth/service/AuthService.java @@ -3,12 +3,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.und.server.auth.dto.AuthRequest; -import com.und.server.auth.dto.AuthResponse; -import com.und.server.auth.dto.NonceRequest; -import com.und.server.auth.dto.NonceResponse; import com.und.server.auth.dto.OidcPublicKeys; -import com.und.server.auth.dto.RefreshTokenRequest; +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; @@ -17,7 +17,7 @@ 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.TestAuthRequest; +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; @@ -50,7 +50,7 @@ public AuthResponse issueTokensForTest(final TestAuthRequest request) { } @Transactional - public NonceResponse handshake(final NonceRequest nonceRequest) { + public NonceResponse generateNonce(final NonceRequest nonceRequest) { final String nonce = nonceService.generateNonceValue(); final Provider provider = convertToProvider(nonceRequest.provider()); diff --git a/src/main/java/com/und/server/common/controller/TestController.java b/src/main/java/com/und/server/common/controller/TestController.java index 6ae6b502..de8928dd 100644 --- a/src/main/java/com/und/server/common/controller/TestController.java +++ b/src/main/java/com/und/server/common/controller/TestController.java @@ -12,16 +12,19 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.und.server.auth.dto.AuthResponse; +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.TestAuthRequest; -import com.und.server.common.dto.TestHelloResponse; -import com.und.server.member.dto.MemberResponse; +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; @@ -34,11 +37,13 @@ public class TestController { private final AuthService authService; private final MemberService memberService; + private final TermsService termsService; @PostMapping("/access") - public ResponseEntity requireAccessToken(@RequestBody @Valid final TestAuthRequest request) { + @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.OK).body(response); + return ResponseEntity.status(HttpStatus.CREATED).body(response); } @GetMapping("/hello") @@ -57,4 +62,11 @@ public ResponseEntity> 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/TestAuthRequest.java b/src/main/java/com/und/server/common/dto/request/TestAuthRequest.java similarity index 91% rename from src/main/java/com/und/server/common/dto/TestAuthRequest.java rename to src/main/java/com/und/server/common/dto/request/TestAuthRequest.java index e57c1daa..525a3f3b 100644 --- a/src/main/java/com/und/server/common/dto/TestAuthRequest.java +++ b/src/main/java/com/und/server/common/dto/request/TestAuthRequest.java @@ -1,4 +1,4 @@ -package com.und.server.common.dto; +package com.und.server.common.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/und/server/common/dto/ErrorResponse.java b/src/main/java/com/und/server/common/dto/response/ErrorResponse.java similarity index 87% rename from src/main/java/com/und/server/common/dto/ErrorResponse.java rename to src/main/java/com/und/server/common/dto/response/ErrorResponse.java index f3839e49..6b71a23a 100644 --- a/src/main/java/com/und/server/common/dto/ErrorResponse.java +++ b/src/main/java/com/und/server/common/dto/response/ErrorResponse.java @@ -1,4 +1,4 @@ -package com.und.server.common.dto; +package com.und.server.common.dto.response; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/und/server/common/dto/TestHelloResponse.java b/src/main/java/com/und/server/common/dto/response/TestHelloResponse.java similarity index 84% rename from src/main/java/com/und/server/common/dto/TestHelloResponse.java rename to src/main/java/com/und/server/common/dto/response/TestHelloResponse.java index 3fd4837b..0bff920c 100644 --- a/src/main/java/com/und/server/common/dto/TestHelloResponse.java +++ b/src/main/java/com/und/server/common/dto/response/TestHelloResponse.java @@ -1,4 +1,4 @@ -package com.und.server.common.dto; +package com.und.server.common.dto.response; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java b/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java index cd5310a2..0434f5aa 100644 --- a/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java @@ -14,7 +14,7 @@ import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; -import com.und.server.common.dto.ErrorResponse; +import com.und.server.common.dto.response.ErrorResponse; import com.und.server.member.exception.MemberErrorResult; import io.swagger.v3.oas.annotations.Hidden; diff --git a/src/main/java/com/und/server/member/controller/MemberController.java b/src/main/java/com/und/server/member/controller/MemberController.java index e2ded8b5..6f83cc81 100644 --- a/src/main/java/com/und/server/member/controller/MemberController.java +++ b/src/main/java/com/und/server/member/controller/MemberController.java @@ -9,8 +9,8 @@ import org.springframework.web.bind.annotation.RestController; import com.und.server.auth.filter.AuthMember; -import com.und.server.member.dto.MemberResponse; -import com.und.server.member.dto.NicknameRequest; +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; diff --git a/src/main/java/com/und/server/member/dto/NicknameRequest.java b/src/main/java/com/und/server/member/dto/request/NicknameRequest.java similarity index 87% rename from src/main/java/com/und/server/member/dto/NicknameRequest.java rename to src/main/java/com/und/server/member/dto/request/NicknameRequest.java index 841abebe..a5196aac 100644 --- a/src/main/java/com/und/server/member/dto/NicknameRequest.java +++ b/src/main/java/com/und/server/member/dto/request/NicknameRequest.java @@ -1,4 +1,4 @@ -package com.und.server.member.dto; +package com.und.server.member.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/und/server/member/dto/MemberResponse.java b/src/main/java/com/und/server/member/dto/response/MemberResponse.java similarity index 89% rename from src/main/java/com/und/server/member/dto/MemberResponse.java rename to src/main/java/com/und/server/member/dto/response/MemberResponse.java index 8a42ea7f..9b85f117 100644 --- a/src/main/java/com/und/server/member/dto/MemberResponse.java +++ b/src/main/java/com/und/server/member/dto/response/MemberResponse.java @@ -1,4 +1,4 @@ -package com.und.server.member.dto; +package com.und.server.member.dto.response; import java.time.LocalDateTime; @@ -20,10 +20,10 @@ public record MemberResponse( @Schema(description = "Apple ID", example = "1234567890") String appleId, - @Schema(description = "Creation timestamp of the member", example = "2025-07-31T22:27:36.037717") + @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:36.037744") + @Schema(description = "Last update timestamp of the member", example = "2025-07-31T22:27:36Z") LocalDateTime updatedAt ) { public static MemberResponse from(final Member member) { diff --git a/src/main/java/com/und/server/member/entity/Member.java b/src/main/java/com/und/server/member/entity/Member.java index 3e23747f..d7fd2dd2 100644 --- a/src/main/java/com/und/server/member/entity/Member.java +++ b/src/main/java/com/und/server/member/entity/Member.java @@ -1,15 +1,16 @@ package com.und.server.member.entity; -import java.time.LocalDateTime; - -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; +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; @@ -23,7 +24,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder -public class Member { +public class Member extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -39,13 +40,8 @@ public class Member { @Column(nullable = true, unique = true) private String appleId; - @CreationTimestamp - @Column(nullable = false, updatable = false) - private LocalDateTime createdAt; - - @UpdateTimestamp - @Column(nullable = false) - private LocalDateTime updatedAt; + @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/service/MemberService.java b/src/main/java/com/und/server/member/service/MemberService.java index 50397a84..a61aaaf4 100644 --- a/src/main/java/com/und/server/member/service/MemberService.java +++ b/src/main/java/com/und/server/member/service/MemberService.java @@ -10,8 +10,8 @@ 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.MemberResponse; -import com.und.server.member.dto.NicknameRequest; +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; 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..5b08e0e9 --- /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(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/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/test/java/com/und/server/auth/controller/AuthControllerTest.java b/src/test/java/com/und/server/auth/controller/AuthControllerTest.java index 1d716ab3..37a96c21 100644 --- a/src/test/java/com/und/server/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/und/server/auth/controller/AuthControllerTest.java @@ -24,11 +24,11 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import com.fasterxml.jackson.databind.ObjectMapper; -import com.und.server.auth.dto.AuthRequest; -import com.und.server.auth.dto.AuthResponse; -import com.und.server.auth.dto.NonceRequest; -import com.und.server.auth.dto.NonceResponse; -import com.und.server.auth.dto.RefreshTokenRequest; +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; @@ -60,8 +60,8 @@ void init() { } @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 NonceRequest request = new NonceRequest(null); @@ -81,8 +81,8 @@ void Given_HandshakeRequestWithNullProvider_When_Handshake_Then_ReturnsBadReques } @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 NonceRequest request = new NonceRequest("facebook"); @@ -90,7 +90,7 @@ void Given_HandshakeRequestWithUnknownProvider_When_Handshake_Then_ReturnsErrorR final AuthErrorResult errorResult = AuthErrorResult.INVALID_PROVIDER; doThrow(new ServerException(errorResult)) - .when(authService).handshake(request); + .when(authService).generateNonce(request); // when final ResultActions resultActions = mockMvc.perform( @@ -106,14 +106,14 @@ void Given_HandshakeRequestWithUnknownProvider_When_Handshake_Then_ReturnsErrorR } @Test - @DisplayName("Succeeds handshake and returns nonce for a valid Kakao request") - void Given_ValidKakaoHandshakeRequest_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 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( @@ -123,19 +123,19 @@ void Given_ValidKakaoHandshakeRequest_When_Handshake_Then_ReturnsOkWithNonce() t ); // then - resultActions.andExpect(status().isOk()) + resultActions.andExpect(status().isCreated()) .andExpect(jsonPath("$.nonce").value("generated-nonce")); } @Test - @DisplayName("Succeeds handshake and returns nonce for a valid Apple request") - void Given_ValidAppleHandshakeRequest_When_Handshake_Then_ReturnsOkWithNonce() throws Exception { + @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).handshake(request); + doReturn(response).when(authService).generateNonce(request); // when final ResultActions resultActions = mockMvc.perform( @@ -145,7 +145,7 @@ void Given_ValidAppleHandshakeRequest_When_Handshake_Then_ReturnsOkWithNonce() t ); // then - resultActions.andExpect(status().isOk()) + resultActions.andExpect(status().isCreated()) .andExpect(jsonPath("$.nonce").value("generated-nonce-for-apple")); } @@ -361,7 +361,7 @@ void Given_RefreshTokenRequestWithNullRefreshToken_When_ReissueTokens_Then_Retur @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( @@ -392,7 +392,7 @@ 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"); diff --git a/src/test/java/com/und/server/auth/filter/CustomAuthenticationEntryPointTest.java b/src/test/java/com/und/server/auth/filter/CustomAuthenticationEntryPointTest.java index d082b5aa..a31173c4 100644 --- a/src/test/java/com/und/server/auth/filter/CustomAuthenticationEntryPointTest.java +++ b/src/test/java/com/und/server/auth/filter/CustomAuthenticationEntryPointTest.java @@ -11,7 +11,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.und.server.auth.exception.AuthErrorResult; -import com.und.server.common.dto.ErrorResponse; +import com.und.server.common.dto.response.ErrorResponse; class CustomAuthenticationEntryPointTest { diff --git a/src/test/java/com/und/server/auth/filter/JwtAuthenticationFilterTest.java b/src/test/java/com/und/server/auth/filter/JwtAuthenticationFilterTest.java index f71b51d2..4549cb6d 100644 --- a/src/test/java/com/und/server/auth/filter/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/und/server/auth/filter/JwtAuthenticationFilterTest.java @@ -24,7 +24,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.und.server.auth.exception.AuthErrorResult; import com.und.server.auth.jwt.JwtProvider; -import com.und.server.common.dto.ErrorResponse; +import com.und.server.common.dto.response.ErrorResponse; import com.und.server.common.exception.ServerException; import jakarta.servlet.FilterChain; diff --git a/src/test/java/com/und/server/auth/service/AuthServiceTest.java b/src/test/java/com/und/server/auth/service/AuthServiceTest.java index 529d2603..4551f4a0 100644 --- a/src/test/java/com/und/server/auth/service/AuthServiceTest.java +++ b/src/test/java/com/und/server/auth/service/AuthServiceTest.java @@ -17,12 +17,12 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.und.server.auth.dto.AuthRequest; -import com.und.server.auth.dto.AuthResponse; -import com.und.server.auth.dto.NonceRequest; -import com.und.server.auth.dto.NonceResponse; import com.und.server.auth.dto.OidcPublicKeys; -import com.und.server.auth.dto.RefreshTokenRequest; +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; @@ -31,7 +31,7 @@ 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.TestAuthRequest; +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; @@ -116,21 +116,21 @@ void Given_NewKakaoMemberForTest_When_IssueTokensForTest_Then_CreatesMemberAndSu } @Test - @DisplayName("Throws an exception on handshake with an invalid provider") - void Given_InvalidProvider_When_Handshake_Then_ThrowsException() { + @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.handshake(nonceRequest)); + () -> authService.generateNonce(nonceRequest)); assertThat(exception.getErrorResult()).isEqualTo(AuthErrorResult.INVALID_PROVIDER); } @Test - @DisplayName("Returns a nonce on a successful handshake for Kakao") - void Given_KakaoProvider_When_Handshake_Then_ReturnsNonce() { + @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"; @@ -140,7 +140,7 @@ void Given_KakaoProvider_When_Handshake_Then_ReturnsNonce() { doNothing().when(nonceService).saveNonce(nonce, Provider.KAKAO); // when - final NonceResponse response = authService.handshake(nonceRequest); + final NonceResponse response = authService.generateNonce(nonceRequest); // then verify(nonceService).generateNonceValue(); @@ -149,8 +149,8 @@ void Given_KakaoProvider_When_Handshake_Then_ReturnsNonce() { } @Test - @DisplayName("Returns a nonce on a successful handshake for Apple") - void Given_AppleProvider_When_Handshake_Then_ReturnsNonce() { + @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"; @@ -160,7 +160,7 @@ void Given_AppleProvider_When_Handshake_Then_ReturnsNonce() { doNothing().when(nonceService).saveNonce(nonce, Provider.APPLE); // when - final NonceResponse response = authService.handshake(nonceRequest); + final NonceResponse response = authService.generateNonce(nonceRequest); // then verify(nonceService).generateNonceValue(); diff --git a/src/test/java/com/und/server/common/controller/TestControllerTest.java b/src/test/java/com/und/server/common/controller/TestControllerTest.java index 98e67a67..93d7f5ca 100644 --- a/src/test/java/com/und/server/common/controller/TestControllerTest.java +++ b/src/test/java/com/und/server/common/controller/TestControllerTest.java @@ -24,17 +24,19 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import com.fasterxml.jackson.databind.ObjectMapper; -import com.und.server.auth.dto.AuthResponse; +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.TestAuthRequest; +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.MemberResponse; +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 { @@ -48,6 +50,9 @@ class TestControllerTest { @Mock private AuthService authService; + @Mock + private TermsService termsService; + @Mock private AuthMemberArgumentResolver authMemberArgumentResolver; @@ -64,7 +69,7 @@ void init() { @Test @DisplayName("Issues tokens for an existing member") - void Given_ExistingMember_When_RequestAccessToken_Then_ReturnsOkWithTokens() throws Exception { + 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"); @@ -92,13 +97,13 @@ void Given_ExistingMember_When_RequestAccessToken_Then_ReturnsOkWithTokens() thr .getContentAsString(StandardCharsets.UTF_8), AuthResponse.class ); - resultActions.andExpect(status().isOk()); + 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_RequestAccessToken_Then_CreatesMemberAndReturnsOkWithTokens() throws Exception { + 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"); @@ -127,7 +132,7 @@ void Given_NonExistingMember_When_RequestAccessToken_Then_CreatesMemberAndReturn .getContentAsString(StandardCharsets.UTF_8), AuthResponse.class ); - resultActions.andExpect(status().isOk()); + resultActions.andExpect(status().isCreated()); assertThat(actualResponse).usingRecursiveComparison().isEqualTo(expectedResponse); } @@ -222,4 +227,32 @@ void Given_ExistingMembers_When_GetMemberList_Then_ReturnsListOfMemberResponses( .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/member/controller/MemberControllerTest.java b/src/test/java/com/und/server/member/controller/MemberControllerTest.java index aa798d91..03f46ab1 100644 --- a/src/test/java/com/und/server/member/controller/MemberControllerTest.java +++ b/src/test/java/com/und/server/member/controller/MemberControllerTest.java @@ -26,8 +26,8 @@ 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.MemberResponse; -import com.und.server.member.dto.NicknameRequest; +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) diff --git a/src/test/java/com/und/server/member/dto/MemberResponseTest.java b/src/test/java/com/und/server/member/dto/MemberResponseTest.java index f4255e85..f4dd4a01 100644 --- a/src/test/java/com/und/server/member/dto/MemberResponseTest.java +++ b/src/test/java/com/und/server/member/dto/MemberResponseTest.java @@ -5,6 +5,7 @@ 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 { diff --git a/src/test/java/com/und/server/member/service/MemberServiceTest.java b/src/test/java/com/und/server/member/service/MemberServiceTest.java index 6b558b51..cfddd446 100644 --- a/src/test/java/com/und/server/member/service/MemberServiceTest.java +++ b/src/test/java/com/und/server/member/service/MemberServiceTest.java @@ -22,8 +22,8 @@ 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.MemberResponse; -import com.und.server.member.dto.NicknameRequest; +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; 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(); + } + +} From ca18da7adc68f95b6d16614c709e863a68246dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?sukipi=20=EC=88=98=ED=82=A4=ED=94=BC?= <108535526+sotogito@users.noreply.github.com> Date: Sat, 30 Aug 2025 13:45:41 +0900 Subject: [PATCH 20/26] Feat/#74 notification (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Add Scenario notification cache update eventlistener * 🔧 Add AsyncConfig * ✨ Add Scenario notification cache feat * 🎨 Refactor NotificationCacheService * 📝 Add new api memo * 🔧 Add Redis Object value map config * 🎨 Modify Scenario update cache update event processor * 🎨 Add error log to event listener * 🚚 Move NotificationEventPublisher * 📝 Update java docs * 🎨 Refactor NotificationCacheService * 🎨 Refactor SceanrioNotificationService * ✨ Add Single Scenario notification api feat * 🎨 Refactor Notification Cache process exception * 🎨 Modify NotificationConditionResponse Schema * 🎨 Modify ScenarioResponse days of week filed name * 📝 Add swagger schema to ScenarioNotificationResponse * 📝 Add swagger schema to NotificationController * 🎨 Add final to parameter * 🐛 Resolve ETag synchronization issue in single notification retrieval * 🎨 Add Transactional annotation * 📝 Add example Schema to ScenarioNotificationListResponse etag filed * 🎨 Modify parameters name * ✅ Update ScenarioService test * ✅ Add NotificationController test * ✅ Add Notification Cache util test * ✅ Add Notification Cache event listener test * 🔥 Delete NotificationCacheException exception method * ✅ Add ScenarioNotificationService test * ✅ Add SceanrioReponsitorycustomImpl test * ✅ Add NotificationCachesService test * 🎨 Modify line * 🎨 Modify ScenarioUpdateEventListener cache process with notification * ✅ Update ScenarioUpdateEventListener test * 🎨 Modify NotificationCacheService EventListener exception * ✅ Update NotificationController test * ✅ Update NotificationCacheService test --- .../und/server/common/config/AsyncConfig.java | 9 + .../und/server/common/config/RedisConfig.java | 16 + .../controller/NotificationController.java | 88 ++++ .../dto/cache/NotificationCacheData.java | 61 +++ .../NotificationConditionResponse.java | 6 +- .../ScenarioNotificationListResponse.java | 24 + .../ScenarioNotificationResponse.java | 84 ++++ .../event/NotificationEventPublisher.java | 41 ++ .../event/ScenarioCreateEvent.java | 10 + .../event/ScenarioCreateEventListener.java | 48 ++ .../event/ScenarioDeleteEvent.java | 9 + .../event/ScenarioDeleteEventListener.java | 47 ++ .../event/ScenarioUpdateEvent.java | 11 + .../event/ScenarioUpdateEventListener.java | 57 +++ .../NotificationCacheErrorResult.java | 36 ++ .../exception/NotificationCacheException.java | 12 + .../service/NotificationCacheService.java | 212 +++++++++ .../util/NotificationCacheKeyGenerator.java | 19 + .../util/NotificationCacheSerializer.java | 55 +++ .../dto/response/ScenarioDetailResponse.java | 12 +- .../repository/ScenarioRepository.java | 3 +- .../repository/ScenarioRepositoryCustom.java | 11 + .../ScenarioRepositoryCustomImpl.java | 97 ++++ .../service/ScenarioNotificationService.java | 34 ++ .../scenario/service/ScenarioService.java | 18 +- .../NotificationControllerTest.java | 217 +++++++++ .../ScenarioNotificationResponseTest.java | 195 ++++++++ .../event/NotificationEventPublisherTest.java | 166 +++++++ .../ScenarioCreateEventListenerTest.java | 157 ++++++ .../ScenarioDeleteEventListenerTest.java | 101 ++++ .../ScenarioUpdateEventListenerTest.java | 209 ++++++++ .../service/NotificationCacheServiceTest.java | 447 ++++++++++++++++++ .../NotificationCacheKeyGeneratorTest.java | 129 +++++ .../util/NotificationCacheSerializerTest.java | 235 +++++++++ .../ScenarioRepositoryCustomImplTest.java | 260 ++++++++++ .../ScenarioNotificationServiceTest.java | 209 ++++++++ .../scenario/service/ScenarioServiceTest.java | 19 +- 37 files changed, 3346 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/und/server/common/config/AsyncConfig.java create mode 100644 src/main/java/com/und/server/notification/controller/NotificationController.java create mode 100644 src/main/java/com/und/server/notification/dto/cache/NotificationCacheData.java create mode 100644 src/main/java/com/und/server/notification/dto/response/ScenarioNotificationListResponse.java create mode 100644 src/main/java/com/und/server/notification/dto/response/ScenarioNotificationResponse.java create mode 100644 src/main/java/com/und/server/notification/event/NotificationEventPublisher.java create mode 100644 src/main/java/com/und/server/notification/event/ScenarioCreateEvent.java create mode 100644 src/main/java/com/und/server/notification/event/ScenarioCreateEventListener.java create mode 100644 src/main/java/com/und/server/notification/event/ScenarioDeleteEvent.java create mode 100644 src/main/java/com/und/server/notification/event/ScenarioDeleteEventListener.java create mode 100644 src/main/java/com/und/server/notification/event/ScenarioUpdateEvent.java create mode 100644 src/main/java/com/und/server/notification/event/ScenarioUpdateEventListener.java create mode 100644 src/main/java/com/und/server/notification/exception/NotificationCacheErrorResult.java create mode 100644 src/main/java/com/und/server/notification/exception/NotificationCacheException.java create mode 100644 src/main/java/com/und/server/notification/service/NotificationCacheService.java create mode 100644 src/main/java/com/und/server/notification/util/NotificationCacheKeyGenerator.java create mode 100644 src/main/java/com/und/server/notification/util/NotificationCacheSerializer.java create mode 100644 src/main/java/com/und/server/scenario/repository/ScenarioRepositoryCustom.java create mode 100644 src/main/java/com/und/server/scenario/repository/ScenarioRepositoryCustomImpl.java create mode 100644 src/main/java/com/und/server/scenario/service/ScenarioNotificationService.java create mode 100644 src/test/java/com/und/server/notification/controller/NotificationControllerTest.java create mode 100644 src/test/java/com/und/server/notification/dto/response/ScenarioNotificationResponseTest.java create mode 100644 src/test/java/com/und/server/notification/event/NotificationEventPublisherTest.java create mode 100644 src/test/java/com/und/server/notification/event/ScenarioCreateEventListenerTest.java create mode 100644 src/test/java/com/und/server/notification/event/ScenarioDeleteEventListenerTest.java create mode 100644 src/test/java/com/und/server/notification/event/ScenarioUpdateEventListenerTest.java create mode 100644 src/test/java/com/und/server/notification/service/NotificationCacheServiceTest.java create mode 100644 src/test/java/com/und/server/notification/util/NotificationCacheKeyGeneratorTest.java create mode 100644 src/test/java/com/und/server/notification/util/NotificationCacheSerializerTest.java create mode 100644 src/test/java/com/und/server/scenario/repository/ScenarioRepositoryCustomImplTest.java create mode 100644 src/test/java/com/und/server/scenario/service/ScenarioNotificationServiceTest.java 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..6f37705b --- /dev/null +++ b/src/main/java/com/und/server/common/config/AsyncConfig.java @@ -0,0 +1,9 @@ +package com.und.server.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@Configuration +@EnableAsync +public class AsyncConfig { +} diff --git a/src/main/java/com/und/server/common/config/RedisConfig.java b/src/main/java/com/und/server/common/config/RedisConfig.java index 27a839ff..0ab297f2 100644 --- a/src/main/java/com/und/server/common/config/RedisConfig.java +++ b/src/main/java/com/und/server/common/config/RedisConfig.java @@ -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; @@ -37,4 +38,19 @@ public CacheManager oidcCacheManager(final RedisConnectionFactory redisConnectio 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/notification/controller/NotificationController.java b/src/main/java/com/und/server/notification/controller/NotificationController.java new file mode 100644 index 00000000..6d030822 --- /dev/null +++ b/src/main/java/com/und/server/notification/controller/NotificationController.java @@ -0,0 +1,88 @@ +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.PathVariable; +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 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.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/notifications") +public class NotificationController { + + private final NotificationCacheService notificationCacheService; + + + @Operation( + summary = "Get scenario notification list", + description = "Retrieve the list of scenario notifications for the user." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "Successfully retrieved scenario notification list", + content = @Content(schema = @Schema(implementation = ScenarioNotificationListResponse.class))), + @ApiResponse(responseCode = "304", description = "Not modified - data has not changed since last request"), + @ApiResponse(responseCode = "401", description = "Unauthorized - authentication required"), + @ApiResponse( + responseCode = "500", description = "Internal server error - failed to retrieve notification cache") + }) + @GetMapping("/scenarios") + 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); + } + + + @Operation( + summary = "Get single scenario notification", + description = "Retrieve notification data for a specific scenario." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "Successfully retrieved scenario notification data", + content = @Content(schema = @Schema(implementation = ScenarioNotificationResponse.class))), + @ApiResponse(responseCode = "401", description = "Unauthorized - authentication required"), + @ApiResponse(responseCode = "404", description = "Scenario not found or scenario has no notification"), + @ApiResponse( + responseCode = "500", description = "Internal server error - failed to retrieve notification cache") + }) + @GetMapping("/scenarios/{scenarioId}") + 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/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/response/NotificationConditionResponse.java b/src/main/java/com/und/server/notification/dto/response/NotificationConditionResponse.java index 4072f769..531e752e 100644 --- a/src/main/java/com/und/server/notification/dto/response/NotificationConditionResponse.java +++ b/src/main/java/com/und/server/notification/dto/response/NotificationConditionResponse.java @@ -14,8 +14,8 @@ @JsonSubTypes.Type(value = TimeNotificationResponse.class, name = "TIME") }) @Schema( - description = - "Notification condition request. The request body structure changes depending on the 'notificationType'.", - discriminatorProperty = "notificationType" + 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/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/event/NotificationEventPublisher.java b/src/main/java/com/und/server/notification/event/NotificationEventPublisher.java new file mode 100644 index 00000000..864428b4 --- /dev/null +++ b/src/main/java/com/und/server/notification/event/NotificationEventPublisher.java @@ -0,0 +1,41 @@ +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); + } + +} 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/service/NotificationCacheService.java b/src/main/java/com/und/server/notification/service/NotificationCacheService.java new file mode 100644 index 00000000..014e385c --- /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; + } + + private 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/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/scenario/dto/response/ScenarioDetailResponse.java b/src/main/java/com/und/server/scenario/dto/response/ScenarioDetailResponse.java index 9eea0b0a..69e77f64 100644 --- a/src/main/java/com/und/server/scenario/dto/response/ScenarioDetailResponse.java +++ b/src/main/java/com/und/server/scenario/dto/response/ScenarioDetailResponse.java @@ -5,12 +5,10 @@ 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.notification.dto.response.TimeNotificationResponse; 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.DiscriminatorMapping; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @@ -40,15 +38,7 @@ public record ScenarioDetailResponse( ) NotificationResponse notification, - @Schema( - description = "Notification details condition that are included only when the notification is active", - discriminatorProperty = "notificationType", - discriminatorMapping = { - @DiscriminatorMapping(value = "TIME", schema = TimeNotificationResponse.class) - }, - requiredMode = Schema.RequiredMode.NOT_REQUIRED, - nullable = true - ) + @Schema(description = "Notification condition, present only when active") NotificationConditionResponse notificationCondition ) { diff --git a/src/main/java/com/und/server/scenario/repository/ScenarioRepository.java b/src/main/java/com/und/server/scenario/repository/ScenarioRepository.java index 64d5d596..41c0734a 100644 --- a/src/main/java/com/und/server/scenario/repository/ScenarioRepository.java +++ b/src/main/java/com/und/server/scenario/repository/ScenarioRepository.java @@ -3,6 +3,7 @@ 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; @@ -11,7 +12,7 @@ import jakarta.validation.constraints.NotNull; -public interface ScenarioRepository extends JpaRepository { +public interface ScenarioRepository extends JpaRepository, ScenarioRepositoryCustom { Optional findByIdAndMemberId(@NotNull Long id, @NotNull Long memberId); 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/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 index 8cb984e8..0069c705 100644 --- a/src/main/java/com/und/server/scenario/service/ScenarioService.java +++ b/src/main/java/com/und/server/scenario/service/ScenarioService.java @@ -14,6 +14,7 @@ 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; @@ -47,6 +48,7 @@ public class ScenarioService { private final OrderCalculator orderCalculator; private final ScenarioValidator scenarioValidator; private final EntityManager em; + private final NotificationEventPublisher notificationEventPublisher; @Transactional(readOnly = true) @@ -124,6 +126,8 @@ public MissionGroupResponse addScenario(final Long memberId, final ScenarioDetai List basicMissions = missionTypeGroupSorter.groupAndSortByType(missions, MissionType.BASIC); + notificationEventPublisher.publishCreateEvent(memberId, scenario); + return MissionGroupResponse.from(scenario.getId(), basicMissions, null); } @@ -136,9 +140,12 @@ public MissionGroupResponse updateScenario( ) { Scenario oldScenario = scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId) .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_SCENARIO)); + Notification oldNotification = oldScenario.getNotification(); + + Boolean isOldScenarioNotificationActive = oldNotification.isActive(); notificationService.updateNotification( - oldScenario.getNotification(), + oldNotification, scenarioDetailRequest.notification(), scenarioDetailRequest.notificationCondition() ); @@ -148,6 +155,8 @@ public MissionGroupResponse updateScenario( oldScenario.updateScenarioName(scenarioDetailRequest.scenarioName()); oldScenario.updateMemo(scenarioDetailRequest.memo()); + notificationEventPublisher.publishUpdateEvent(memberId, oldScenario, isOldScenarioNotificationActive); + return missionService.findMissionsByScenarioId(memberId, scenarioId, LocalDate.now()); } @@ -186,8 +195,13 @@ public void deleteScenarioWithAllMissions(final Long memberId, final Long scenar Scenario scenario = scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId) .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_SCENARIO)); - notificationService.deleteNotification(scenario.getNotification()); + Notification notification = scenario.getNotification(); + boolean isNotificationActive = notification.isActive(); + + notificationService.deleteNotification(notification); scenarioRepository.delete(scenario); + + notificationEventPublisher.publishDeleteEvent(memberId, scenarioId, isNotificationActive); } 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..b0406a8a --- /dev/null +++ b/src/test/java/com/und/server/notification/controller/NotificationControllerTest.java @@ -0,0 +1,217 @@ +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; + +@ExtendWith(MockitoExtension.class) +class NotificationControllerTest { + + @InjectMocks + private NotificationController notificationController; + + @Mock + private NotificationCacheService notificationCacheService; + + 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); + } + +} 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/NotificationEventPublisherTest.java b/src/test/java/com/und/server/notification/event/NotificationEventPublisherTest.java new file mode 100644 index 00000000..bdf63578 --- /dev/null +++ b/src/test/java/com/und/server/notification/event/NotificationEventPublisherTest.java @@ -0,0 +1,166 @@ +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)); + } + +} 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/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/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/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 index 86d9e35d..a748d7e8 100644 --- a/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java +++ b/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java @@ -33,6 +33,7 @@ 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; @@ -80,6 +81,9 @@ class ScenarioServiceTest { @Mock private com.und.server.scenario.util.ScenarioValidator scenarioValidator; + @Mock + private NotificationEventPublisher notificationEventPublisher; + @Test void Given_memberId_When_FindScenarios_Then_ReturnScenarios() { @@ -349,6 +353,7 @@ void Given_ValidRequest_When_AddScenario_Then_SaveScenarioAndAddMissions() { verify(missionService).addBasicMission(any(Scenario.class), eq(missionList)); verify(scenarioRepository).save(scenarioCaptor.capture()); verify(missionTypeGrouper).groupAndSortByType(savedMissions, MissionType.BASIC); + verify(notificationEventPublisher).publishCreateEvent(eq(memberId), any(Scenario.class)); Scenario saved = scenarioCaptor.getValue(); @@ -429,6 +434,7 @@ void Given_ReorderRequired_When_AddScenario_Then_ReorderAndRetry() { // then verify(scenarioRepository).save(captor.capture()); verify(missionTypeGrouper).groupAndSortByType(List.of(), MissionType.BASIC); + verify(notificationEventPublisher).publishCreateEvent(eq(memberId), any(Scenario.class)); Scenario saved = captor.getValue(); assertThat(saved.getScenarioOrder()).isEqualTo(reorderedOrder); @@ -473,6 +479,7 @@ void Given_ValidRequest_When_UpdateScenario_Then_UpdateScenarioAndNotification() Member member = Member.builder().id(memberId).build(); Notification oldNotification = Notification.builder() .id(1L) + .isActive(true) .notificationType(NotificationType.TIME) .build(); // removed unused newNotification @@ -536,6 +543,7 @@ void Given_ValidRequest_When_UpdateScenario_Then_UpdateScenarioAndNotification() assertThat(oldScenario.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(); assertThat(result.scenarioId()).isEqualTo(scenarioId); @@ -554,6 +562,7 @@ void Given_ValidRequest_When_UpdateScenarioOrder_Then_UpdateOrder() { Member member = Member.builder().id(memberId).build(); Notification notification = Notification.builder() .id(1L) + .isActive(true) .notificationType(NotificationType.TIME) .build(); @@ -597,6 +606,7 @@ void Given_ReorderRequired_When_UpdateScenarioOrder_Then_ReorderScenarios() { Member member = Member.builder().id(memberId).build(); Notification notification = Notification.builder() .id(1L) + .isActive(true) .notificationType(NotificationType.TIME) .build(); @@ -654,7 +664,8 @@ void Given_ValidRequest_When_AddScenarioWithoutNotification_Then_CreateInactiveN given(scenarioRepository.findOrdersByMemberIdAndNotificationType(memberId, NotificationType.TIME)) .willReturn(List.of()); - Notification saved = Notification.builder().id(1L).notificationType(NotificationType.TIME).build(); + Notification saved = + Notification.builder().id(1L).isActive(true).notificationType(NotificationType.TIME).build(); given(notificationService.addNotification(notificationRequest, null)).willReturn(saved); // when @@ -664,6 +675,7 @@ void Given_ValidRequest_When_AddScenarioWithoutNotification_Then_CreateInactiveN 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)); } @@ -676,6 +688,7 @@ void Given_ValidRequest_When_DeleteScenarioWithAllMissions_Then_DeleteScenarioAn Member member = Member.builder().id(memberId).build(); Notification notification = Notification.builder() .id(1L) + .isActive(true) .notificationType(NotificationType.TIME) .build(); @@ -694,6 +707,7 @@ void Given_ValidRequest_When_DeleteScenarioWithAllMissions_Then_DeleteScenarioAn // then verify(notificationService).deleteNotification(notification); verify(scenarioRepository).delete(scenario); + verify(notificationEventPublisher).publishDeleteEvent(eq(memberId), eq(scenarioId), eq(true)); } @@ -772,6 +786,7 @@ void Given_ValidRequest_When_UpdateScenarioWithoutNotification_Then_UpdateScenar Member member = Member.builder().id(memberId).build(); Notification oldNotification = Notification.builder() .id(1L) + .isActive(false) .notificationType(NotificationType.TIME) .build(); @@ -817,6 +832,7 @@ void Given_ValidRequest_When_UpdateScenarioWithoutNotification_Then_UpdateScenar assertThat(oldScenario.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(); assertThat(result.scenarioId()).isEqualTo(scenarioId); @@ -1026,6 +1042,7 @@ void Given_EmptyOrderList_When_AddScenario_Then_CreateScenarioWithStartOrder() { // 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); From 33e9540b7d7cfc95a58e315f5a099825afeeb707 Mon Sep 17 00:00:00 2001 From: Chori <105255517+choridev@users.noreply.github.com> Date: Sat, 30 Aug 2025 21:55:09 +0900 Subject: [PATCH 21/26] =?UTF-8?q?=F0=9F=94=80=20Merge=20branch=20'dev'=20i?= =?UTF-8?q?nto=20bugfix/#95-terms-is-over-14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🐛 Fix column annotation for isOver14 --- src/main/java/com/und/server/terms/entity/Terms.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/und/server/terms/entity/Terms.java b/src/main/java/com/und/server/terms/entity/Terms.java index 5b08e0e9..d23c6056 100644 --- a/src/main/java/com/und/server/terms/entity/Terms.java +++ b/src/main/java/com/und/server/terms/entity/Terms.java @@ -42,7 +42,7 @@ public class Terms extends BaseTimeEntity { @Builder.Default private Boolean privacyPolicyAgreed = false; - @Column(nullable = false) + @Column(name = "is_over_14", nullable = false) @Builder.Default private Boolean isOver14 = false; From cd690fe3271cc087cc430efdeb5573799241bfec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?sukipi=20=EC=88=98=ED=82=A4=ED=94=BC?= <108535526+sotogito@users.noreply.github.com> Date: Mon, 1 Sep 2025 13:57:36 +0900 Subject: [PATCH 22/26] Feat/#72 weather (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 Add WeatherProperties * ✨ Add weather feat * 🎨 Add Schema to dto * 🎨 Modify Enum getWorst method parameter type * 🎨 Refactor Extractors * 🎨 Refactor Weather Enums * 🐛 Fix future weather basetime * 🎨 Refactor reducing api accesses * 📝 Add api result log * 🎨 Refactor WeatherController * 🚧 Process Weather Cache * 🎨 Refactor Redis Cache Data * 🎨 Refactor api response dto * 🎨 Modify Weather Enum * 🔥 Delete HourlyWeatherInfo * 🚧 Process Weather Cache * 🎨 Modify FutureWeatherDecisionSelector * 🎨 Refactor future wether * 🎨 Refactor WeatherService * 🎨 Modify Get Date from RequestParam * 🎨 Refactor GridConvertor * 🎨 Refactor WeatherTtlCalculator * 🎨 Refactor Cache grip calculate method * 🎨 Refactor WetherCacheService * 🎨 Refactor Weather api processor * 🎨 Refactor exrtractor * 🔧 Update application.yml to use environment variables for API keys * 🎨 Modify Redis Cache key value dto * 🚚 Move package WeatherKeyGenerator to util * 📝 Update api interface java docs * 🔧 Update WeatherProperties to unchangeable * 🎨 Modify Used WeatherProperties * 🎨 Modify for editorconfigFormat * 🎨 Refactor WeatherCacheData return method * 🎨 Refactor Weather service layer * 🎨 Refactor cache serialize * 🎨 Add retuning default weather response * 🎨 Add Cache data dto returning defult * 🎨 Modify KmaWeatherResponse POJO to record * 🎨 Modify OpenMeteoResponse POJO to record * 🎨 Modify Cache data dto POJO to record * 🎨 Refactor WeatherDecisionService exception * 🎨 Modify WeatherResponse type to Enum * 🎨 Modify WeatherType Default type * 🎨 Modify WeatherErrorResult * 🎨 Refactor Enum constants * 🎨 Add final to parameter * 🎨 Modify TimeSlot range * 🎨 Modify Today weather cache type * 🔥 Delete TimeSlotWeatherCacheData * 🔧 Add WeatherConfig Executor * 🎨 Modify WeatherApiProcessor exception * 🎨 Refactor Extractor * ✨ Add weather fallback process * 🔧 Add open-meteo-kma base url to application * 🎨 Modify fallback process exception * 🎨 Modify open-meteo request variable * 🔊 Modify WeatherCacheService log * 🔊 Modify CacheSerializer log * 📝 Modify annotation order * 📝 Modify parameter * 🔥 Remove WeatherService try-catch * 🎨 Modify WeatherCacheService try-catch * 🎨 Modify CacheData and Response dto from method * 🎨 Modify WeatherService Default returning * 🎨 Modify WeatherService exception * 🐛 Add JsonIgnore to WeatherCacheData * 🎨 Refactor Api service * 🚚 Rename Api service * 🚚 Move to infrastructure package api connection class * 🎨 Modify WeatherTtlCalculator parameter * ✅ Add Weather constants * ✅ Add Weather util * ✅ Add Weather service * ✅ Add UvIndexExtractor test * ✅ Add WeatherCacheService test * ✅ Add Unchecked annotation to WeatherCacheServiceTest * ✅ Add Weather Facade test * ✅ Add WeatherController test * ✅ Add WeatherResponse test * ✅ Update Weather Facade * ✅ Add KmaApiException test * ✅ Add WeatherProperties test * ✅ Add Weather api response test * ✅ Update WeatherApiService * 🎨 Refactor KmaWeatherExtractor * 🔥 Delete calculate AverageValue from UVType * ✅ Update used UvType Average test * 🔥 Delete calculate Average value from FindDustType * ✅ Update used FineDust Average test * 🎨 Refactor FineDustExtractor * 🎨 Refactor UvIndexExtractor * ✏️ Fix FineDust typos * ✅ Modify FindDust typos * ✅ Update FineDustType test * ✅ Update TimeSlot test * ✅ Modify UvType test * ✅ Modify WeatherType test * ✅ Update WeatherController test * ✅ Modify FineDustExtractor test * ✅ Modify KmaWeatherExtractor test * ✅ Modify OpenMeteoWeatherExtractor test * ✅ Modify UvIndexExtractor test * ✅ Modify WeatherApiService test * ✅ Modify WeatherCacheService test * ✅ Modify WeatherDecisionService test * ✅ Modify CacheSerializer test * 👽️ Modify Scenario Notification api --------- Co-authored-by: Chori <105255517+choridev@users.noreply.github.com> --- .../controller/NotificationController.java | 6 +- .../server/weather/config/WeatherConfig.java | 28 ++ .../weather/config/WeatherProperties.java | 27 ++ .../weather/constants/FineDustType.java | 60 +++ .../server/weather/constants/TimeSlot.java | 58 +++ .../und/server/weather/constants/UvType.java | 48 ++ .../server/weather/constants/WeatherType.java | 97 ++++ .../weather/controller/WeatherController.java | 39 ++ .../com/und/server/weather/dto/GridPoint.java | 17 + .../dto/OpenMeteoWeatherApiResultDto.java | 26 + .../weather/dto/WeatherApiResultDto.java | 26 + .../weather/dto/cache/WeatherCacheData.java | 59 +++ .../weather/dto/cache/WeatherCacheKey.java | 72 +++ .../weather/dto/request/WeatherRequest.java | 28 ++ .../weather/dto/response/WeatherResponse.java | 46 ++ .../weather/exception/KmaApiException.java | 18 + .../weather/exception/WeatherErrorResult.java | 52 ++ .../weather/exception/WeatherException.java | 19 + .../weather/infrastructure/KmaApiFacade.java | 80 ++++ .../infrastructure/OpenMeteoApiFacade.java | 102 ++++ .../client/KmaWeatherClient.java | 42 ++ .../client/OpenMeteoClient.java | 38 ++ .../client/OpenMeteoKmaClient.java | 38 ++ .../dto/KmaWeatherResponse.java | 51 ++ .../infrastructure/dto/OpenMeteoResponse.java | 35 ++ .../dto/OpenMeteoWeatherResponse.java | 31 ++ .../weather/service/FineDustExtractor.java | 116 +++++ .../FutureWeatherDecisionSelector.java | 38 ++ .../weather/service/KmaWeatherExtractor.java | 130 +++++ .../service/OpenMeteoWeatherExtractor.java | 87 ++++ .../weather/service/UvIndexExtractor.java | 105 ++++ .../weather/service/WeatherApiService.java | 173 +++++++ .../weather/service/WeatherCacheService.java | 200 ++++++++ .../service/WeatherDecisionService.java | 152 ++++++ .../weather/service/WeatherService.java | 96 ++++ .../server/weather/util/CacheSerializer.java | 66 +++ .../server/weather/util/GridConverter.java | 69 +++ .../weather/util/WeatherKeyGenerator.java | 47 ++ .../weather/util/WeatherTtlCalculator.java | 40 ++ src/main/resources/application.yml | 10 + .../weather/config/WeatherPropertiesTest.java | 116 +++++ .../weather/constants/FineDustTypeTest.java | 208 ++++++++ .../weather/constants/TimeSlotTest.java | 219 +++++++++ .../server/weather/constants/UvTypeTest.java | 176 +++++++ .../weather/constants/WeatherTypeTest.java | 269 +++++++++++ .../controller/WeatherControllerTest.java | 222 +++++++++ .../dto/response/WeatherResponseTest.java | 298 ++++++++++++ .../exception/KmaApiExceptionTest.java | 102 ++++ .../infrastructure/KmaApiFacadeTest.java | 162 +++++++ .../OpenMeteoApiFacadeTest.java | 184 +++++++ .../dto/KmaWeatherResponseTest.java | 55 +++ .../dto/OpenMeteoResponseTest.java | 60 +++ .../dto/OpenMeteoWeatherResponseTest.java | 51 ++ .../service/FineDustExtractorTest.java | 156 ++++++ .../FutureWeatherDecisionSelectorTest.java | 167 +++++++ .../service/KmaWeatherExtractorTest.java | 277 +++++++++++ .../OpenMeteoWeatherExtractorTest.java | 152 ++++++ .../weather/service/UvIndexExtractorTest.java | 160 +++++++ .../service/WeatherApiServiceTest.java | 337 +++++++++++++ .../service/WeatherCacheServiceTest.java | 325 +++++++++++++ .../service/WeatherDecisionServiceTest.java | 365 ++++++++++++++ .../weather/service/WeatherServiceTest.java | 321 +++++++++++++ .../weather/util/CacheSerializerTest.java | 386 +++++++++++++++ .../weather/util/GridConverterTest.java | 207 ++++++++ .../weather/util/WeatherKeyGeneratorTest.java | 190 ++++++++ .../util/WeatherTtlCalculatorTest.java | 452 ++++++++++++++++++ 66 files changed, 8086 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/und/server/weather/config/WeatherConfig.java create mode 100644 src/main/java/com/und/server/weather/config/WeatherProperties.java create mode 100644 src/main/java/com/und/server/weather/constants/FineDustType.java create mode 100644 src/main/java/com/und/server/weather/constants/TimeSlot.java create mode 100644 src/main/java/com/und/server/weather/constants/UvType.java create mode 100644 src/main/java/com/und/server/weather/constants/WeatherType.java create mode 100644 src/main/java/com/und/server/weather/controller/WeatherController.java create mode 100644 src/main/java/com/und/server/weather/dto/GridPoint.java create mode 100644 src/main/java/com/und/server/weather/dto/OpenMeteoWeatherApiResultDto.java create mode 100644 src/main/java/com/und/server/weather/dto/WeatherApiResultDto.java create mode 100644 src/main/java/com/und/server/weather/dto/cache/WeatherCacheData.java create mode 100644 src/main/java/com/und/server/weather/dto/cache/WeatherCacheKey.java create mode 100644 src/main/java/com/und/server/weather/dto/request/WeatherRequest.java create mode 100644 src/main/java/com/und/server/weather/dto/response/WeatherResponse.java create mode 100644 src/main/java/com/und/server/weather/exception/KmaApiException.java create mode 100644 src/main/java/com/und/server/weather/exception/WeatherErrorResult.java create mode 100644 src/main/java/com/und/server/weather/exception/WeatherException.java create mode 100644 src/main/java/com/und/server/weather/infrastructure/KmaApiFacade.java create mode 100644 src/main/java/com/und/server/weather/infrastructure/OpenMeteoApiFacade.java create mode 100644 src/main/java/com/und/server/weather/infrastructure/client/KmaWeatherClient.java create mode 100644 src/main/java/com/und/server/weather/infrastructure/client/OpenMeteoClient.java create mode 100644 src/main/java/com/und/server/weather/infrastructure/client/OpenMeteoKmaClient.java create mode 100644 src/main/java/com/und/server/weather/infrastructure/dto/KmaWeatherResponse.java create mode 100644 src/main/java/com/und/server/weather/infrastructure/dto/OpenMeteoResponse.java create mode 100644 src/main/java/com/und/server/weather/infrastructure/dto/OpenMeteoWeatherResponse.java create mode 100644 src/main/java/com/und/server/weather/service/FineDustExtractor.java create mode 100644 src/main/java/com/und/server/weather/service/FutureWeatherDecisionSelector.java create mode 100644 src/main/java/com/und/server/weather/service/KmaWeatherExtractor.java create mode 100644 src/main/java/com/und/server/weather/service/OpenMeteoWeatherExtractor.java create mode 100644 src/main/java/com/und/server/weather/service/UvIndexExtractor.java create mode 100644 src/main/java/com/und/server/weather/service/WeatherApiService.java create mode 100644 src/main/java/com/und/server/weather/service/WeatherCacheService.java create mode 100644 src/main/java/com/und/server/weather/service/WeatherDecisionService.java create mode 100644 src/main/java/com/und/server/weather/service/WeatherService.java create mode 100644 src/main/java/com/und/server/weather/util/CacheSerializer.java create mode 100644 src/main/java/com/und/server/weather/util/GridConverter.java create mode 100644 src/main/java/com/und/server/weather/util/WeatherKeyGenerator.java create mode 100644 src/main/java/com/und/server/weather/util/WeatherTtlCalculator.java create mode 100644 src/test/java/com/und/server/weather/config/WeatherPropertiesTest.java create mode 100644 src/test/java/com/und/server/weather/constants/FineDustTypeTest.java create mode 100644 src/test/java/com/und/server/weather/constants/TimeSlotTest.java create mode 100644 src/test/java/com/und/server/weather/constants/UvTypeTest.java create mode 100644 src/test/java/com/und/server/weather/constants/WeatherTypeTest.java create mode 100644 src/test/java/com/und/server/weather/controller/WeatherControllerTest.java create mode 100644 src/test/java/com/und/server/weather/dto/response/WeatherResponseTest.java create mode 100644 src/test/java/com/und/server/weather/exception/KmaApiExceptionTest.java create mode 100644 src/test/java/com/und/server/weather/infrastructure/KmaApiFacadeTest.java create mode 100644 src/test/java/com/und/server/weather/infrastructure/OpenMeteoApiFacadeTest.java create mode 100644 src/test/java/com/und/server/weather/infrastructure/dto/KmaWeatherResponseTest.java create mode 100644 src/test/java/com/und/server/weather/infrastructure/dto/OpenMeteoResponseTest.java create mode 100644 src/test/java/com/und/server/weather/infrastructure/dto/OpenMeteoWeatherResponseTest.java create mode 100644 src/test/java/com/und/server/weather/service/FineDustExtractorTest.java create mode 100644 src/test/java/com/und/server/weather/service/FutureWeatherDecisionSelectorTest.java create mode 100644 src/test/java/com/und/server/weather/service/KmaWeatherExtractorTest.java create mode 100644 src/test/java/com/und/server/weather/service/OpenMeteoWeatherExtractorTest.java create mode 100644 src/test/java/com/und/server/weather/service/UvIndexExtractorTest.java create mode 100644 src/test/java/com/und/server/weather/service/WeatherApiServiceTest.java create mode 100644 src/test/java/com/und/server/weather/service/WeatherCacheServiceTest.java create mode 100644 src/test/java/com/und/server/weather/service/WeatherDecisionServiceTest.java create mode 100644 src/test/java/com/und/server/weather/service/WeatherServiceTest.java create mode 100644 src/test/java/com/und/server/weather/util/CacheSerializerTest.java create mode 100644 src/test/java/com/und/server/weather/util/GridConverterTest.java create mode 100644 src/test/java/com/und/server/weather/util/WeatherKeyGeneratorTest.java create mode 100644 src/test/java/com/und/server/weather/util/WeatherTtlCalculatorTest.java diff --git a/src/main/java/com/und/server/notification/controller/NotificationController.java b/src/main/java/com/und/server/notification/controller/NotificationController.java index 6d030822..5d4ec94c 100644 --- a/src/main/java/com/und/server/notification/controller/NotificationController.java +++ b/src/main/java/com/und/server/notification/controller/NotificationController.java @@ -23,7 +23,7 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/api/v1/notifications") +@RequestMapping("/v1") public class NotificationController { private final NotificationCacheService notificationCacheService; @@ -42,7 +42,7 @@ public class NotificationController { @ApiResponse( responseCode = "500", description = "Internal server error - failed to retrieve notification cache") }) - @GetMapping("/scenarios") + @GetMapping("/scenarios/notifications") public ResponseEntity getScenarioNotifications( @AuthMember final Long memberId, @Parameter(description = "ETag for client caching") @@ -74,7 +74,7 @@ public ResponseEntity getScenarioNotifications @ApiResponse( responseCode = "500", description = "Internal server error - failed to retrieve notification cache") }) - @GetMapping("/scenarios/{scenarioId}") + @GetMapping("/scenarios/{scenarioId}/notifications") public ResponseEntity getSingleScenarioNotification( @AuthMember final Long memberId, @PathVariable final Long scenarioId 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/WeatherController.java b/src/main/java/com/und/server/weather/controller/WeatherController.java new file mode 100644 index 00000000..80a0c99c --- /dev/null +++ b/src/main/java/com/und/server/weather/controller/WeatherController.java @@ -0,0 +1,39 @@ +package com.und.server.weather.controller; + +import java.time.LocalDate; + +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 { + + private final WeatherService weatherService; + + @PostMapping + public ResponseEntity getWeather( + @RequestBody @Valid final WeatherRequest request, + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") final LocalDate date + ) { + final WeatherResponse response = weatherService.getWeatherInfo(request, date); + + 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..d0075892 --- /dev/null +++ b/src/main/java/com/und/server/weather/service/WeatherService.java @@ -0,0 +1,96 @@ +package com.und.server.weather.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +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; + + + public WeatherResponse getWeatherInfo( + final WeatherRequest weatherRequest, final LocalDate date + ) { + LocalDateTime nowDateTime = LocalDateTime.now(); + LocalDate today = nowDateTime.toLocalDate(); + + validateLocation(weatherRequest); + validateDate(date, today); + + boolean isToday = date.equals(today); + if (isToday) { + return getTodayWeather(weatherRequest, nowDateTime); + } else { + return getFutureWeather(weatherRequest, nowDateTime, date); + } + } + + + 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.yml b/src/main/resources/application.yml index 19ae1e5d..d197ec44 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -33,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/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..3c061b2c --- /dev/null +++ b/src/test/java/com/und/server/weather/controller/WeatherControllerTest.java @@ -0,0 +1,222 @@ +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 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))) + .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))) + .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))) + .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))) + .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))) + .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))) + .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))) + .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..2af20df2 --- /dev/null +++ b/src/test/java/com/und/server/weather/service/WeatherServiceTest.java @@ -0,0 +1,321 @@ +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.LocalDate; +import java.time.LocalDateTime; + +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; + + @InjectMocks + private WeatherService weatherService; + + + @BeforeEach + void setUp() { + // 기본 설정 + } + + + @Test + @DisplayName("오늘 날씨 정보를 정상적으로 조회한다") + void Given_TodayWeatherRequest_When_GetWeatherInfo_Then_ReturnsTodayWeather() { + // given + WeatherRequest request = new WeatherRequest(37.5665, 126.9780); + LocalDate today = LocalDate.now(); + LocalDateTime nowDateTime = LocalDateTime.now(); + 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); + + // 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.now().plusDays(1); + LocalDateTime nowDateTime = LocalDateTime.now(); + 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); + + // 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.now(); + + when(weatherCacheService.getTodayWeatherCache(eq(request), any(LocalDateTime.class))) + .thenReturn(null); + + // when + WeatherResponse response = weatherService.getWeatherInfo(request, today); + + // 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.now(); + 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); + + // 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.now().plusDays(1); + + when(weatherCacheService.getFutureWeatherCache(eq(request), any(LocalDateTime.class), eq(futureDate))) + .thenReturn(null); + + // when + WeatherResponse response = weatherService.getWeatherInfo(request, futureDate); + + // 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.now().plusDays(1); + 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); + + // 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.now(); + + // when & then + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today)) + .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.now(); + + // when & then + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today)) + .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.now(); + + // when & then + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today)) + .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.now(); + + // when & then + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today)) + .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.now().minusDays(1); + + // when & then + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, yesterday)) + .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.now().plusDays(4); // MAX_FUTURE_DATE + 1 + + // when & then + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, maxDatePlusOne)) + .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.now(); + 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); + + // 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.now().plusDays(3); // 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); + + // 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); + } + } + } + +} From 727956ca7ad9bac9ba61193f8f1cd1bdf7ae91c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?sukipi=20=EC=88=98=ED=82=A4=ED=94=BC?= <108535526+sotogito@users.noreply.github.com> Date: Wed, 3 Sep 2025 17:25:34 +0900 Subject: [PATCH 23/26] Feat/#73 scenario backup (#97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Add EnableScheduling annotation to ServerApplication * ✨ Add Mission backup feat * 🐛 Fix find Scenario detail and Scenario update query * 🐛 Fix Add Today Mission find Scenario fetch query * ⚡️Modify delete Scenario update query * ⚡️ Modify Scenario find query * ⚡️Modify delete expired mission query * 🎨 Modify indentation tab * 🎨 Refactor SenarioMissionDailyJob * 📝 Add Java docs to ScenarioMissionDailyJob Scheduler * ✅ Modify ScenarioService test * ✅ Add ScenarioMissionDailyJob test * 🎨 Modify line ScenarioService * 🔥 Delete Query from ScenarioRepository * 📝 Modify ScnarioMissionDaliyJob delete expired mission log * 🔧 Add Timeconfig * 🎨 Modify calculate today LocalDate method * ✅ Modify ScenarioDaliyJob test * 🎨 Modify Future Mission find query * ✅ Modify MissionService test * 🔧 Modify TimeConfig TimeZone from KST to UTC * 🐛 Modify today TimeZone * 🐛 Modify timeZone to Asia * 🐛 Add TimeZone to Weather * ✅ Update WearherController test * ✅ Update Scenario util test * ✅ Modify MossionsearchType test * ✅ Modify clock TimeZone --- .../com/und/server/ServerApplication.java | 2 + .../und/server/common/config/TimeConfig.java | 16 +++ .../repository/MissionRepository.java | 42 ++++++- .../repository/ScenarioRepository.java | 27 +++- .../scheduler/ScenarioMissionDailyJob.java | 73 +++++++++++ .../scenario/service/MissionService.java | 21 +++- .../scenario/service/ScenarioService.java | 16 ++- .../weather/controller/WeatherController.java | 5 +- .../weather/service/WeatherService.java | 7 +- .../constants/MissionSearchTypeTest.java | 24 ++-- .../ScenarioMissionDailyJobTest.java | 116 ++++++++++++++++++ .../scenario/service/MissionServiceTest.java | 52 +++++--- .../scenario/service/ScenarioServiceTest.java | 47 ++++--- .../scenario/util/MissionTypeGrouperTest.java | 2 +- .../scenario/util/MissionValidatorTest.java | 12 +- .../controller/WeatherControllerTest.java | 14 +-- .../weather/service/WeatherServiceTest.java | 71 ++++++----- 17 files changed, 439 insertions(+), 108 deletions(-) create mode 100644 src/main/java/com/und/server/common/config/TimeConfig.java create mode 100644 src/main/java/com/und/server/scenario/scheduler/ScenarioMissionDailyJob.java create mode 100644 src/test/java/com/und/server/scenario/scheduler/ScenarioMissionDailyJobTest.java 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/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/scenario/repository/MissionRepository.java b/src/main/java/com/und/server/scenario/repository/MissionRepository.java index 4683eb2c..9e30aa3d 100644 --- a/src/main/java/com/und/server/scenario/repository/MissionRepository.java +++ b/src/main/java/com/und/server/scenario/repository/MissionRepository.java @@ -6,6 +6,7 @@ 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; @@ -25,7 +26,8 @@ public interface MissionRepository extends JpaRepository { AND (m.useDate IS NULL OR m.useDate = :date) """) @NotNull - List findDefaultMissions(@NotNull Long memberId, @NotNull Long scenarioId, @NotNull LocalDate date); + List findTodayAndFutureMissions( + @NotNull Long memberId, @NotNull Long scenarioId, @NotNull LocalDate date); @Query(""" SELECT m FROM Mission m @@ -35,6 +37,42 @@ public interface MissionRepository extends JpaRepository { AND m.useDate = :date """) @NotNull - List findMissionsByDate(@NotNull Long memberId, @NotNull Long scenarioId, @NotNull LocalDate date); + 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(""" + UPDATE Mission m + SET m.isChecked = false + WHERE m.useDate IS NULL + AND m.missionType = 'BASIC' + """) + int bulkResetBasicIsChecked(); + + @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 index 41c0734a..9153e91c 100644 --- a/src/main/java/com/und/server/scenario/repository/ScenarioRepository.java +++ b/src/main/java/com/und/server/scenario/repository/ScenarioRepository.java @@ -1,9 +1,9 @@ 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; @@ -19,11 +19,32 @@ public interface ScenarioRepository extends JpaRepository, Scena @Query(""" SELECT s FROM Scenario s LEFT JOIN FETCH s.notification - LEFT JOIN FETCH s.missions + 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 findFetchByIdAndMemberId(@NotNull Long memberId, @NotNull Long id); + 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 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..180f051a --- /dev/null +++ b/src/main/java/com/und/server/scenario/scheduler/ScenarioMissionDailyJob.java @@ -0,0 +1,73 @@ +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(); + + log.info("[MISSION DAILY] Daily Mission Job: cloned={}, reset={}", cloned, reset); + } 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 index 29e6e674..f78a6118 100644 --- a/src/main/java/com/und/server/scenario/service/MissionService.java +++ b/src/main/java/com/und/server/scenario/service/MissionService.java @@ -1,6 +1,8 @@ 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; @@ -36,6 +38,7 @@ public class MissionService { private final MissionTypeGroupSorter missionTypeGroupSorter; private final ScenarioValidator scenarioValidator; private final MissionValidator missionValidator; + private final Clock clock; @Transactional(readOnly = true) @@ -84,7 +87,7 @@ public MissionResponse addTodayMission( final TodayMissionRequest todayMissionRequest, final LocalDate date ) { - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul"))); missionValidator.validateTodayMissionDateRange(today, date); List todayMissions = missionTypeGroupSorter.groupAndSortByType( @@ -159,6 +162,12 @@ public void updateMissionCheck( } + @Transactional + public void deleteMissions(final Long scenarioId) { + missionRepository.deleteByScenarioId(scenarioId); + } + + @Transactional public void deleteTodayMission(final Long memberId, final Long missionId) { Mission mission = missionRepository.findById(missionId) @@ -172,15 +181,15 @@ public void deleteTodayMission(final Long memberId, final Long missionId) { private List getMissionsByDate( final Long memberId, final Long scenarioId, final LocalDate date ) { - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul"))); MissionSearchType missionSearchType = MissionSearchType.getMissionSearchType(today, date); switch (missionSearchType) { - case TODAY -> { - return missionRepository.findDefaultMissions(memberId, scenarioId, date); + case TODAY, FUTURE -> { + return missionRepository.findTodayAndFutureMissions(memberId, scenarioId, date); } - case PAST, FUTURE -> { - return missionRepository.findMissionsByDate(memberId, scenarioId, date); + case PAST -> { + return missionRepository.findPastMissionsByDate(memberId, scenarioId, date); } } throw new ServerException(ScenarioErrorResult.INVALID_MISSION_FOUND_DATE); diff --git a/src/main/java/com/und/server/scenario/service/ScenarioService.java b/src/main/java/com/und/server/scenario/service/ScenarioService.java index 0069c705..b55c2147 100644 --- a/src/main/java/com/und/server/scenario/service/ScenarioService.java +++ b/src/main/java/com/und/server/scenario/service/ScenarioService.java @@ -1,6 +1,8 @@ package com.und.server.scenario.service; +import java.time.Clock; import java.time.LocalDate; +import java.time.ZoneId; import java.util.Collections; import java.util.List; @@ -49,6 +51,7 @@ public class ScenarioService { private final ScenarioValidator scenarioValidator; private final EntityManager em; private final NotificationEventPublisher notificationEventPublisher; + private final Clock clock; @Transactional(readOnly = true) @@ -64,7 +67,7 @@ public List findScenariosByMemberId( @Transactional(readOnly = true) public ScenarioDetailResponse findScenarioDetailByScenarioId(final Long memberId, final Long scenarioId) { - Scenario scenario = scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId) + Scenario scenario = scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId) .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_SCENARIO)); List basicMissions = @@ -88,7 +91,7 @@ public MissionResponse addTodayMissionToScenario( final TodayMissionRequest todayMissionRequest, final LocalDate date ) { - Scenario scenario = scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId) + Scenario scenario = scenarioRepository.findTodayScenarioFetchByIdAndMemberId(memberId, scenarioId, date) .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_SCENARIO)); return missionService.addTodayMission(scenario, todayMissionRequest, date); @@ -138,7 +141,7 @@ public MissionGroupResponse updateScenario( final Long scenarioId, final ScenarioDetailRequest scenarioDetailRequest ) { - Scenario oldScenario = scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId) + Scenario oldScenario = scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId) .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_SCENARIO)); Notification oldNotification = oldScenario.getNotification(); @@ -157,7 +160,8 @@ public MissionGroupResponse updateScenario( notificationEventPublisher.publishUpdateEvent(memberId, oldScenario, isOldScenarioNotificationActive); - return missionService.findMissionsByScenarioId(memberId, scenarioId, LocalDate.now()); + return missionService.findMissionsByScenarioId( + memberId, scenarioId, LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul")))); } @@ -192,12 +196,14 @@ public OrderUpdateResponse updateScenarioOrder( @Transactional public void deleteScenarioWithAllMissions(final Long memberId, final Long scenarioId) { - Scenario scenario = scenarioRepository.findFetchByIdAndMemberId(memberId, 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); diff --git a/src/main/java/com/und/server/weather/controller/WeatherController.java b/src/main/java/com/und/server/weather/controller/WeatherController.java index 80a0c99c..2b4da109 100644 --- a/src/main/java/com/und/server/weather/controller/WeatherController.java +++ b/src/main/java/com/und/server/weather/controller/WeatherController.java @@ -29,9 +29,10 @@ public class WeatherController { @PostMapping public ResponseEntity getWeather( @RequestBody @Valid final WeatherRequest request, - @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") final LocalDate date + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") final LocalDate date, + @RequestParam(defaultValue = "Asia/Seoul") final String timezone ) { - final WeatherResponse response = weatherService.getWeatherInfo(request, date); + final WeatherResponse response = weatherService.getWeatherInfo(request, date, timezone); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/und/server/weather/service/WeatherService.java b/src/main/java/com/und/server/weather/service/WeatherService.java index d0075892..7d1efe9d 100644 --- a/src/main/java/com/und/server/weather/service/WeatherService.java +++ b/src/main/java/com/und/server/weather/service/WeatherService.java @@ -1,7 +1,9 @@ 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; @@ -21,12 +23,13 @@ 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 WeatherRequest weatherRequest, final LocalDate date, final String timezone ) { - LocalDateTime nowDateTime = LocalDateTime.now(); + LocalDateTime nowDateTime = LocalDateTime.now(clock.withZone(ZoneId.of(timezone))); LocalDate today = nowDateTime.toLocalDate(); validateLocation(weatherRequest); diff --git a/src/test/java/com/und/server/scenario/constants/MissionSearchTypeTest.java b/src/test/java/com/und/server/scenario/constants/MissionSearchTypeTest.java index 5ce84289..87bc689f 100644 --- a/src/test/java/com/und/server/scenario/constants/MissionSearchTypeTest.java +++ b/src/test/java/com/und/server/scenario/constants/MissionSearchTypeTest.java @@ -48,7 +48,7 @@ void Given_FutureType_When_GetRangeDays_Then_ReturnSeven() { @DisplayName("오늘 날짜로 요청하면 TODAY 타입을 반환") void Given_TodayDate_When_GetMissionSearchType_Then_ReturnToday() { // given - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); LocalDate requestDate = today; // when @@ -62,7 +62,7 @@ void Given_TodayDate_When_GetMissionSearchType_Then_ReturnToday() { @DisplayName("null 날짜로 요청하면 TODAY 타입을 반환") void Given_NullDate_When_GetMissionSearchType_Then_ReturnToday() { // given - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); LocalDate requestDate = null; // when @@ -76,7 +76,7 @@ void Given_NullDate_When_GetMissionSearchType_Then_ReturnToday() { @DisplayName("어제 날짜로 요청하면 PAST 타입을 반환") void Given_YesterdayDate_When_GetMissionSearchType_Then_ReturnPast() { // given - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); LocalDate requestDate = today.minusDays(1); // when @@ -90,7 +90,7 @@ void Given_YesterdayDate_When_GetMissionSearchType_Then_ReturnPast() { @DisplayName("7일 전 날짜로 요청하면 PAST 타입을 반환") void Given_SevenDaysAgoDate_When_GetMissionSearchType_Then_ReturnPast() { // given - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); LocalDate requestDate = today.minusDays(7); // when @@ -104,7 +104,7 @@ void Given_SevenDaysAgoDate_When_GetMissionSearchType_Then_ReturnPast() { @DisplayName("8일 전 날짜로 요청하면 예외 발생") void Given_EightDaysAgoDate_When_GetMissionSearchType_Then_ThrowException() { // given - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); LocalDate requestDate = today.minusDays(40); // when & then @@ -117,7 +117,7 @@ void Given_EightDaysAgoDate_When_GetMissionSearchType_Then_ThrowException() { @DisplayName("내일 날짜로 요청하면 FUTURE 타입을 반환") void Given_TomorrowDate_When_GetMissionSearchType_Then_ReturnFuture() { // given - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); LocalDate requestDate = today.plusDays(1); // when @@ -131,7 +131,7 @@ void Given_TomorrowDate_When_GetMissionSearchType_Then_ReturnFuture() { @DisplayName("7일 후 날짜로 요청하면 FUTURE 타입을 반환") void Given_SevenDaysLaterDate_When_GetMissionSearchType_Then_ReturnFuture() { // given - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); LocalDate requestDate = today.plusDays(7); // when @@ -145,7 +145,7 @@ void Given_SevenDaysLaterDate_When_GetMissionSearchType_Then_ReturnFuture() { @DisplayName("8일 후 날짜로 요청하면 예외 발생") void Given_EightDaysLaterDate_When_GetMissionSearchType_Then_ThrowException() { // given - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); LocalDate requestDate = today.plusDays(40); // when & then @@ -158,7 +158,7 @@ void Given_EightDaysLaterDate_When_GetMissionSearchType_Then_ThrowException() { @DisplayName("범위 내 과거 날짜들로 요청하면 모두 PAST 타입을 반환") void Given_PastDatesInRange_When_GetMissionSearchType_Then_ReturnPast() { // given - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); // when & then for (int i = 1; i <= 7; i++) { @@ -172,7 +172,7 @@ void Given_PastDatesInRange_When_GetMissionSearchType_Then_ReturnPast() { @DisplayName("범위 내 미래 날짜들로 요청하면 모두 FUTURE 타입을 반환") void Given_FutureDatesInRange_When_GetMissionSearchType_Then_ReturnFuture() { // given - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); // when & then for (int i = 1; i <= 7; i++) { @@ -186,7 +186,7 @@ void Given_FutureDatesInRange_When_GetMissionSearchType_Then_ReturnFuture() { @DisplayName("범위를 벗어난 과거 날짜들로 요청하면 모두 예외 발생") void Given_PastDatesOutOfRange_When_GetMissionSearchType_Then_ThrowException() { // given - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); // when & then for (int i = 15; i <= 17; i++) { @@ -201,7 +201,7 @@ void Given_PastDatesOutOfRange_When_GetMissionSearchType_Then_ThrowException() { @DisplayName("범위를 벗어난 미래 날짜들로 요청하면 모두 예외 발생") void Given_FutureDatesOutOfRange_When_GetMissionSearchType_Then_ThrowException() { // given - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); // when & then for (int i = 15; i <= 17; i++) { 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..d07120f6 --- /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()).thenReturn(100); + + // when + job.runDailyBackupJob(); + + // then + ArgumentCaptor dateCaptor = ArgumentCaptor.forClass(LocalDate.class); + verify(missionRepository).bulkCloneBasicToYesterday(dateCaptor.capture()); + verify(missionRepository).bulkResetBasicIsChecked(); + + 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 index 1e55a493..426ba1e2 100644 --- a/src/test/java/com/und/server/scenario/service/MissionServiceTest.java +++ b/src/test/java/com/und/server/scenario/service/MissionServiceTest.java @@ -8,16 +8,21 @@ 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; @@ -32,6 +37,7 @@ import com.und.server.scenario.util.MissionTypeGroupSorter; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class MissionServiceTest { @Mock @@ -46,16 +52,27 @@ class MissionServiceTest { @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.now(); + LocalDate date = LocalDate.of(2024, 1, 15); Member member = Member.builder() .id(memberId) @@ -84,7 +101,7 @@ void Given_ValidScenarioId_When_FindMissionsByScenarioId_Then_ReturnMissionGroup List groupedBasicMissions = Arrays.asList(basicMission); List groupedTodayMissions = Arrays.asList(todayMission); - when(missionRepository.findDefaultMissions(memberId, scenarioId, date)).thenReturn( + when(missionRepository.findTodayAndFutureMissions(memberId, scenarioId, date)).thenReturn( missionList); when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.BASIC)) .thenReturn(groupedBasicMissions); @@ -98,7 +115,7 @@ void Given_ValidScenarioId_When_FindMissionsByScenarioId_Then_ReturnMissionGroup assertThat(result).isNotNull(); assertThat(result.basicMissions()).isNotEmpty(); assertThat(result.todayMissions()).isNotEmpty(); - verify(missionRepository).findDefaultMissions(memberId, scenarioId, date); + verify(missionRepository).findTodayAndFutureMissions(memberId, scenarioId, date); verify(missionTypeGrouper).groupAndSortByType(missionList, MissionType.BASIC); verify(missionTypeGrouper).groupAndSortByType(missionList, MissionType.TODAY); } @@ -109,9 +126,9 @@ void Given_EmptyMissionList_When_FindMissionsByScenarioId_Then_ReturnEmptyRespon // given Long memberId = 1L; Long scenarioId = 1L; - LocalDate date = LocalDate.now(); + LocalDate date = LocalDate.of(2024, 1, 15); - when(missionRepository.findDefaultMissions(memberId, scenarioId, date)).thenReturn( + when(missionRepository.findTodayAndFutureMissions(memberId, scenarioId, date)).thenReturn( List.of()); // when @@ -121,7 +138,7 @@ void Given_EmptyMissionList_When_FindMissionsByScenarioId_Then_ReturnEmptyRespon assertThat(result).isNotNull(); assertThat(result.basicMissions()).isEmpty(); assertThat(result.todayMissions()).isEmpty(); - verify(missionRepository).findDefaultMissions(memberId, scenarioId, date); + verify(missionRepository).findTodayAndFutureMissions(memberId, scenarioId, date); } @@ -130,9 +147,9 @@ void Given_UnauthorizedMember_When_FindMissionsByScenarioId_Then_ThrowServerExce // given Long memberId = 1L; Long scenarioId = 1L; - LocalDate date = LocalDate.now(); + LocalDate date = LocalDate.of(2024, 1, 15); - when(missionRepository.findDefaultMissions(memberId, scenarioId, date)).thenReturn( + when(missionRepository.findTodayAndFutureMissions(memberId, scenarioId, date)).thenReturn( List.of()); // when & then @@ -152,7 +169,7 @@ void Given_ScenarioAndTodayMissionRequest_When_AddTodayMission_Then_SaveMission( .build(); TodayMissionRequest missionAddInfo = new TodayMissionRequest("오늘 미션"); - LocalDate date = LocalDate.now(); + LocalDate date = LocalDate.of(2024, 1, 15); // when missionService.addTodayMission(scenario, missionAddInfo, date); @@ -534,7 +551,8 @@ void Given_NullDate_When_FindMissionsByScenarioId_Then_UseCurrentDate() { List groupedBasicMissions = List.of(mission); List groupedTodayMissions = List.of(); - when(missionRepository.findDefaultMissions(any(Long.class), any(Long.class), any())).thenReturn(missionList); + 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)) @@ -547,7 +565,7 @@ void Given_NullDate_When_FindMissionsByScenarioId_Then_UseCurrentDate() { assertThat(result).isNotNull(); assertThat(result.basicMissions()).isNotEmpty(); assertThat(result.todayMissions()).isEmpty(); - verify(missionRepository).findDefaultMissions(any(Long.class), any(Long.class), any()); + verify(missionRepository).findTodayAndFutureMissions(any(Long.class), any(Long.class), any()); } @@ -556,7 +574,7 @@ void Given_PastDate_When_FindMissionsByScenarioId_Then_UsePastDate() { // given Long memberId = 1L; Long scenarioId = 1L; - LocalDate pastDate = LocalDate.now().minusDays(1); + LocalDate pastDate = LocalDate.of(2024, 1, 14); Mission mission = Mission.builder() .id(1L) @@ -569,7 +587,7 @@ void Given_PastDate_When_FindMissionsByScenarioId_Then_UsePastDate() { List groupedBasicMissions = List.of(); List groupedTodayMissions = List.of(mission); - when(missionRepository.findMissionsByDate(memberId, scenarioId, pastDate)).thenReturn(missionList); + when(missionRepository.findPastMissionsByDate(memberId, scenarioId, pastDate)).thenReturn(missionList); when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.BASIC)) .thenReturn(groupedBasicMissions); when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.TODAY)) @@ -582,7 +600,7 @@ void Given_PastDate_When_FindMissionsByScenarioId_Then_UsePastDate() { assertThat(result).isNotNull(); assertThat(result.basicMissions()).isEmpty(); assertThat(result.todayMissions()).isNotEmpty(); - verify(missionRepository).findMissionsByDate(memberId, scenarioId, pastDate); + verify(missionRepository).findPastMissionsByDate(memberId, scenarioId, pastDate); } @@ -591,7 +609,7 @@ void Given_FutureDate_When_FindMissionsByScenarioId_Then_UseFutureDate() { // given Long memberId = 1L; Long scenarioId = 1L; - LocalDate futureDate = LocalDate.now().plusDays(1); + LocalDate futureDate = LocalDate.of(2024, 1, 16); Mission mission = Mission.builder() .id(1L) @@ -604,7 +622,7 @@ void Given_FutureDate_When_FindMissionsByScenarioId_Then_UseFutureDate() { List groupedBasicMissions = List.of(); List groupedTodayMissions = List.of(mission); - when(missionRepository.findMissionsByDate(memberId, scenarioId, futureDate)).thenReturn(missionList); + when(missionRepository.findTodayAndFutureMissions(memberId, scenarioId, futureDate)).thenReturn(missionList); when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.BASIC)) .thenReturn(groupedBasicMissions); when(missionTypeGrouper.groupAndSortByType(missionList, MissionType.TODAY)) @@ -617,7 +635,7 @@ void Given_FutureDate_When_FindMissionsByScenarioId_Then_UseFutureDate() { assertThat(result).isNotNull(); assertThat(result.basicMissions()).isEmpty(); assertThat(result.todayMissions()).isNotEmpty(); - verify(missionRepository).findMissionsByDate(memberId, scenarioId, futureDate); + verify(missionRepository).findTodayAndFutureMissions(memberId, scenarioId, futureDate); } diff --git a/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java b/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java index a748d7e8..19b3a183 100644 --- a/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java +++ b/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java @@ -11,11 +11,15 @@ 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; @@ -23,6 +27,8 @@ 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; @@ -55,6 +61,7 @@ import jakarta.persistence.EntityManager; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class ScenarioServiceTest { @InjectMocks @@ -84,6 +91,17 @@ class ScenarioServiceTest { @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() { @@ -177,7 +195,7 @@ void Given_validScenario_When_findScenarioByScenarioId_Then_returnResponse() { ); // mock - Mockito.when(scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId)) + Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) .thenReturn(Optional.of(scenario)); Mockito.when(notificationService.findNotificationDetails(notification)).thenReturn(notifDetail); Mockito.when(missionTypeGrouper.groupAndSortByType(scenario.getMissions(), MissionType.BASIC)) @@ -202,7 +220,7 @@ void Given_notExistScenario_When_findScenarioByScenarioId_Then_throwNotFoundExce final Long memberId = 1L; final Long scenarioId = 99L; - Mockito.when(scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId)) + Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) .thenReturn(Optional.empty()); // when & then @@ -219,7 +237,7 @@ void Given_otherUserScenario_When_findScenarioByScenarioId_Then_throwNotFoundExc final Long scenarioId = 10L; // 다른 사용자의 시나리오는 존재하지 않음 (권한 검증으로 인해) - Mockito.when(scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId)) + Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) .thenReturn(Optional.empty()); // when & then @@ -245,7 +263,7 @@ void Given_ValidMemberAndScenario_When_AddTodayMissionToScenario_Then_InvokeMiss TodayMissionRequest request = new TodayMissionRequest("Stretch"); - Mockito.when(scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId)) + Mockito.when(scenarioRepository.findTodayScenarioFetchByIdAndMemberId(memberId, scenarioId, date)) .thenReturn(Optional.of(scenario)); scenarioService.addTodayMissionToScenario(memberId, scenarioId, request, date); @@ -262,7 +280,7 @@ void Given_OtherUserScenario_When_AddTodayMissionToScenario_Then_ThrowNotFoundEx TodayMissionRequest request = new TodayMissionRequest("Stretch"); - Mockito.when(scenarioRepository.findFetchByIdAndMemberId(requestMemberId, scenarioId)) + Mockito.when(scenarioRepository.findTodayScenarioFetchByIdAndMemberId(requestMemberId, scenarioId, date)) .thenReturn(Optional.empty()); assertThatThrownBy(() -> @@ -457,7 +475,7 @@ void Given_PastDate_When_AddTodayMissionToScenario_Then_ThrowException() { TodayMissionRequest request = new TodayMissionRequest("Past Mission"); - given(scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId)) + given(scenarioRepository.findTodayScenarioFetchByIdAndMemberId(memberId, scenarioId, pastDate)) .willReturn(Optional.of(scenario)); doThrow(new ServerException(ScenarioErrorResult.INVALID_TODAY_MISSION_DATE)) .when(missionService).addTodayMission(scenario, request, pastDate); @@ -513,7 +531,7 @@ void Given_ValidRequest_When_UpdateScenario_Then_UpdateScenarioAndNotification() .notificationCondition(condition) .build(); - Mockito.when(scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId)) + Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) .thenReturn(Optional.of(oldScenario)); Mockito.doAnswer(invocation -> { Notification target = invocation.getArgument(0); @@ -698,13 +716,14 @@ void Given_ValidRequest_When_DeleteScenarioWithAllMissions_Then_DeleteScenarioAn .notification(notification) .build(); - Mockito.when(scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId)) + 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)); @@ -730,7 +749,7 @@ void Given_NotExistScenario_When_UpdateScenario_Then_ThrowNotFoundException() { .notificationCondition(null) .build(); - Mockito.when(scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId)) + Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) .thenReturn(Optional.empty()); // when & then @@ -767,7 +786,7 @@ void Given_NotExistScenario_When_DeleteScenarioWithAllMissions_Then_ThrowNotFoun Long memberId = 1L; Long scenarioId = 99L; - Mockito.when(scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId)) + Mockito.when(scenarioRepository.findNotificationFetchByIdAndMemberId(memberId, scenarioId)) .thenReturn(Optional.empty()); // when & then @@ -812,7 +831,7 @@ void Given_ValidRequest_When_UpdateScenarioWithoutNotification_Then_UpdateScenar .notificationCondition(null) .build(); - Mockito.when(scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId)) + Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) .thenReturn(Optional.of(oldScenario)); MissionGroupResponse expectedResponse = MissionGroupResponse.builder() @@ -860,7 +879,7 @@ void Given_NotExistScenario_When_UpdateScenarioWithoutNotification_Then_ThrowNot .notificationCondition(null) .build(); - Mockito.when(scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId)) + Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) .thenReturn(Optional.empty()); // when & then @@ -977,7 +996,7 @@ void Given_notificationInfoIsNull_When_findScenarioByScenarioId_Then_returnRespo .build(); // mock - notificationInfo가 null인 경우 - Mockito.when(scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId)) + Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) .thenReturn(Optional.of(scenario)); Mockito.when(notificationService.findNotificationDetails(notification)).thenReturn(null); Mockito.when(missionTypeGrouper.groupAndSortByType(scenario.getMissions(), MissionType.BASIC)) @@ -1066,7 +1085,7 @@ void Given_FutureDate_When_AddTodayMissionToScenario_Then_InvokeMissionService() TodayMissionRequest request = new TodayMissionRequest("Future Mission"); - Mockito.when(scenarioRepository.findFetchByIdAndMemberId(memberId, scenarioId)) + Mockito.when(scenarioRepository.findTodayScenarioFetchByIdAndMemberId(memberId, scenarioId, futureDate)) .thenReturn(Optional.of(scenario)); // when diff --git a/src/test/java/com/und/server/scenario/util/MissionTypeGrouperTest.java b/src/test/java/com/und/server/scenario/util/MissionTypeGrouperTest.java index 6154e52e..834ed1d1 100644 --- a/src/test/java/com/und/server/scenario/util/MissionTypeGrouperTest.java +++ b/src/test/java/com/und/server/scenario/util/MissionTypeGrouperTest.java @@ -51,7 +51,7 @@ void Given_BasicMissions_When_GroupAndSort_Then_ReturnSortedList() { @Test void Given_TodayMissions_When_GroupAndSort_Then_ReturnReverseSortedList() { // given - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.of(2024, 1, 15, 12, 0); Mission m1 = Mission.builder() .missionOrder(null) diff --git a/src/test/java/com/und/server/scenario/util/MissionValidatorTest.java b/src/test/java/com/und/server/scenario/util/MissionValidatorTest.java index 79bd9cee..56fba212 100644 --- a/src/test/java/com/und/server/scenario/util/MissionValidatorTest.java +++ b/src/test/java/com/und/server/scenario/util/MissionValidatorTest.java @@ -27,8 +27,8 @@ class MissionValidatorTest { @Test void Given_TodayDate_When_ValidateTodayMissionDateRange_Then_NoException() { // given - LocalDate today = LocalDate.now(); - LocalDate requestDate = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); + LocalDate requestDate = LocalDate.of(2024, 1, 15); // when & then assertDoesNotThrow(() -> missionValidator.validateTodayMissionDateRange(today, requestDate)); @@ -37,8 +37,8 @@ void Given_TodayDate_When_ValidateTodayMissionDateRange_Then_NoException() { @Test void Given_FutureDate_When_ValidateTodayMissionDateRange_Then_NoException() { // given - LocalDate today = LocalDate.now(); - LocalDate futureDate = LocalDate.now().plusDays(1); + LocalDate today = LocalDate.of(2024, 1, 15); + LocalDate futureDate = LocalDate.of(2024, 1, 16); // when & then assertDoesNotThrow(() -> missionValidator.validateTodayMissionDateRange(today, futureDate)); @@ -47,8 +47,8 @@ void Given_FutureDate_When_ValidateTodayMissionDateRange_Then_NoException() { @Test void Given_PastDate_When_ValidateTodayMissionDateRange_Then_ThrowException() { // given - LocalDate today = LocalDate.now(); - LocalDate pastDate = LocalDate.now().minusDays(1); + LocalDate today = LocalDate.of(2024, 1, 15); + LocalDate pastDate = LocalDate.of(2024, 1, 14); // when & then assertThatThrownBy(() -> missionValidator.validateTodayMissionDateRange(today, pastDate)) diff --git a/src/test/java/com/und/server/weather/controller/WeatherControllerTest.java b/src/test/java/com/und/server/weather/controller/WeatherControllerTest.java index 3c061b2c..cdf22f07 100644 --- a/src/test/java/com/und/server/weather/controller/WeatherControllerTest.java +++ b/src/test/java/com/und/server/weather/controller/WeatherControllerTest.java @@ -56,7 +56,7 @@ void Given_ValidRequest_When_GetWeather_Then_ReturnsWeatherResponse() throws Exc WeatherType.SUNNY, FineDustType.GOOD, UvType.LOW ); - given(weatherService.getWeatherInfo((request), (date))) + given(weatherService.getWeatherInfo((request), (date), "Asia/Seoul")) .willReturn(expectedResponse); // when & then @@ -81,7 +81,7 @@ void Given_RainyWeather_When_GetWeather_Then_ReturnsRainyWeatherResponse() throw WeatherType.RAIN, FineDustType.NORMAL, UvType.NORMAL ); - given(weatherService.getWeatherInfo((request), (date))) + given(weatherService.getWeatherInfo((request), (date), "Asia/Seoul")) .willReturn(expectedResponse); // when & then @@ -106,7 +106,7 @@ void Given_BadFineDust_When_GetWeather_Then_ReturnsBadFineDustResponse() throws WeatherType.CLOUDY, FineDustType.BAD, UvType.HIGH ); - given(weatherService.getWeatherInfo((request), (date))) + given(weatherService.getWeatherInfo((request), (date), "Asia/Seoul")) .willReturn(expectedResponse); // when & then @@ -130,7 +130,7 @@ void Given_SnowyWeather_When_GetWeather_Then_ReturnsSnowyWeatherResponse() throw WeatherType.SNOW, FineDustType.GOOD, UvType.VERY_LOW ); - given(weatherService.getWeatherInfo((request), (date))) + given(weatherService.getWeatherInfo((request), (date), "Asia/Seoul")) .willReturn(expectedResponse); // when & then @@ -155,7 +155,7 @@ void Given_DifferentCoordinates_When_GetWeather_Then_ReturnsWeatherResponse() th WeatherType.SUNNY, FineDustType.GOOD, UvType.VERY_HIGH ); - given(weatherService.getWeatherInfo((request), (date))) + given(weatherService.getWeatherInfo((request), (date), "Asia/Seoul")) .willReturn(expectedResponse); // when & then @@ -180,7 +180,7 @@ void Given_DifferentDate_When_GetWeather_Then_ReturnsWeatherResponse() throws Ex WeatherType.CLOUDY, FineDustType.NORMAL, UvType.LOW ); - given(weatherService.getWeatherInfo((request), (date))) + given(weatherService.getWeatherInfo((request), (date), "Asia/Seoul")) .willReturn(expectedResponse); // when & then @@ -205,7 +205,7 @@ void Given_WorstWeatherConditions_When_GetWeather_Then_ReturnsWorstWeatherRespon WeatherType.SNOW, FineDustType.VERY_BAD, UvType.VERY_HIGH ); - given(weatherService.getWeatherInfo((request), (date))) + given(weatherService.getWeatherInfo((request), (date), "Asia/Seoul")) .willReturn(expectedResponse); // when & then diff --git a/src/test/java/com/und/server/weather/service/WeatherServiceTest.java b/src/test/java/com/und/server/weather/service/WeatherServiceTest.java index 2af20df2..c51fb853 100644 --- a/src/test/java/com/und/server/weather/service/WeatherServiceTest.java +++ b/src/test/java/com/und/server/weather/service/WeatherServiceTest.java @@ -6,8 +6,10 @@ 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; @@ -33,13 +35,20 @@ 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") + )); } @@ -48,8 +57,8 @@ void setUp() { void Given_TodayWeatherRequest_When_GetWeatherInfo_Then_ReturnsTodayWeather() { // given WeatherRequest request = new WeatherRequest(37.5665, 126.9780); - LocalDate today = LocalDate.now(); - LocalDateTime nowDateTime = LocalDateTime.now(); + 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) @@ -60,7 +69,7 @@ void Given_TodayWeatherRequest_When_GetWeatherInfo_Then_ReturnsTodayWeather() { .thenReturn(mockCacheData); // when - WeatherResponse response = weatherService.getWeatherInfo(request, today); + WeatherResponse response = weatherService.getWeatherInfo(request, today, "Asia/Seoul"); // then assertThat(response).isNotNull(); @@ -74,8 +83,8 @@ void Given_TodayWeatherRequest_When_GetWeatherInfo_Then_ReturnsTodayWeather() { void Given_FutureWeatherRequest_When_GetWeatherInfo_Then_ReturnsFutureWeather() { // given WeatherRequest request = new WeatherRequest(37.5665, 126.9780); - LocalDate futureDate = LocalDate.now().plusDays(1); - LocalDateTime nowDateTime = LocalDateTime.now(); + 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) @@ -86,7 +95,7 @@ void Given_FutureWeatherRequest_When_GetWeatherInfo_Then_ReturnsFutureWeather() .thenReturn(mockCacheData); // when - WeatherResponse response = weatherService.getWeatherInfo(request, futureDate); + WeatherResponse response = weatherService.getWeatherInfo(request, futureDate, "Asia/Seoul"); // then assertThat(response).isNotNull(); @@ -100,13 +109,13 @@ void Given_FutureWeatherRequest_When_GetWeatherInfo_Then_ReturnsFutureWeather() void Given_TodayWeatherCacheIsNull_When_GetWeatherInfo_Then_ReturnsDefault() { // given WeatherRequest request = new WeatherRequest(37.5665, 126.9780); - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); when(weatherCacheService.getTodayWeatherCache(eq(request), any(LocalDateTime.class))) .thenReturn(null); // when - WeatherResponse response = weatherService.getWeatherInfo(request, today); + WeatherResponse response = weatherService.getWeatherInfo(request, today, "Asia/Seoul"); // then assertThat(response).isNotNull(); @@ -120,7 +129,7 @@ void Given_TodayWeatherCacheIsNull_When_GetWeatherInfo_Then_ReturnsDefault() { void Given_TodayWeatherCacheIsInvalid_When_GetWeatherInfo_Then_ReturnsValidDefault() { // given WeatherRequest request = new WeatherRequest(37.5665, 126.9780); - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); WeatherCacheData invalidCacheData = WeatherCacheData.builder() .weather(null) .fineDust(FineDustType.GOOD) @@ -131,7 +140,7 @@ void Given_TodayWeatherCacheIsInvalid_When_GetWeatherInfo_Then_ReturnsValidDefau .thenReturn(invalidCacheData); // when - WeatherResponse response = weatherService.getWeatherInfo(request, today); + WeatherResponse response = weatherService.getWeatherInfo(request, today, "Asia/Seoul"); // then assertThat(response).isNotNull(); @@ -145,13 +154,13 @@ void Given_TodayWeatherCacheIsInvalid_When_GetWeatherInfo_Then_ReturnsValidDefau void Given_FutureWeatherCacheIsNull_When_GetWeatherInfo_Then_ReturnsDefault() { // given WeatherRequest request = new WeatherRequest(37.5665, 126.9780); - LocalDate futureDate = LocalDate.now().plusDays(1); + 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); + WeatherResponse response = weatherService.getWeatherInfo(request, futureDate, "Asia/Seoul"); // then assertThat(response).isNotNull(); @@ -165,7 +174,7 @@ void Given_FutureWeatherCacheIsNull_When_GetWeatherInfo_Then_ReturnsDefault() { void Given_FutureWeatherCacheIsInvalid_When_GetWeatherInfo_Then_ReturnsValidDefault() { // given WeatherRequest request = new WeatherRequest(37.5665, 126.9780); - LocalDate futureDate = LocalDate.now().plusDays(1); + LocalDate futureDate = LocalDate.of(2024, 1, 16); WeatherCacheData invalidCacheData = WeatherCacheData.builder() .weather(WeatherType.CLOUDY) .fineDust(null) @@ -176,7 +185,7 @@ void Given_FutureWeatherCacheIsInvalid_When_GetWeatherInfo_Then_ReturnsValidDefa .thenReturn(invalidCacheData); // when - WeatherResponse response = weatherService.getWeatherInfo(request, futureDate); + WeatherResponse response = weatherService.getWeatherInfo(request, futureDate, "Asia/Seoul"); // then assertThat(response).isNotNull(); @@ -190,10 +199,10 @@ void Given_FutureWeatherCacheIsInvalid_When_GetWeatherInfo_Then_ReturnsValidDefa void Given_LatitudeLessThanMinus90_When_GetWeatherInfo_Then_ThrowsException() { // given WeatherRequest request = new WeatherRequest(-91.0, 126.9780); - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); // when & then - assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today)) + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today, "Asia/Seoul")) .isInstanceOf(WeatherException.class) .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.INVALID_COORDINATES); } @@ -204,10 +213,10 @@ void Given_LatitudeLessThanMinus90_When_GetWeatherInfo_Then_ThrowsException() { void Given_LatitudeGreaterThan90_When_GetWeatherInfo_Then_ThrowsException() { // given WeatherRequest request = new WeatherRequest(91.0, 126.9780); - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); // when & then - assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today)) + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today, "Asia/Seoul")) .isInstanceOf(WeatherException.class) .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.INVALID_COORDINATES); } @@ -218,10 +227,10 @@ void Given_LatitudeGreaterThan90_When_GetWeatherInfo_Then_ThrowsException() { void Given_LongitudeLessThanMinus180_When_GetWeatherInfo_Then_ThrowsException() { // given WeatherRequest request = new WeatherRequest(37.5665, -181.0); - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); // when & then - assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today)) + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today, "Asia/Seoul")) .isInstanceOf(WeatherException.class) .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.INVALID_COORDINATES); } @@ -232,10 +241,10 @@ void Given_LongitudeLessThanMinus180_When_GetWeatherInfo_Then_ThrowsException() void Given_LongitudeGreaterThan180_When_GetWeatherInfo_Then_ThrowsException() { // given WeatherRequest request = new WeatherRequest(37.5665, 181.0); - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); // when & then - assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today)) + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today, "Asia/Seoul")) .isInstanceOf(WeatherException.class) .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.INVALID_COORDINATES); } @@ -246,10 +255,10 @@ void Given_LongitudeGreaterThan180_When_GetWeatherInfo_Then_ThrowsException() { void Given_DateBeforeToday_When_GetWeatherInfo_Then_ThrowsException() { // given WeatherRequest request = new WeatherRequest(37.5665, 126.9780); - LocalDate yesterday = LocalDate.now().minusDays(1); + LocalDate yesterday = LocalDate.of(2024, 1, 14); // when & then - assertThatThrownBy(() -> weatherService.getWeatherInfo(request, yesterday)) + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, yesterday, "Asia/Seoul")) .isInstanceOf(WeatherException.class) .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.DATE_OUT_OF_RANGE); } @@ -260,10 +269,10 @@ void Given_DateBeforeToday_When_GetWeatherInfo_Then_ThrowsException() { void Given_DateAfterMaxDate_When_GetWeatherInfo_Then_ThrowsException() { // given WeatherRequest request = new WeatherRequest(37.5665, 126.9780); - LocalDate maxDatePlusOne = LocalDate.now().plusDays(4); // MAX_FUTURE_DATE + 1 + LocalDate maxDatePlusOne = LocalDate.of(2024, 1, 19); // MAX_FUTURE_DATE + 1 // when & then - assertThatThrownBy(() -> weatherService.getWeatherInfo(request, maxDatePlusOne)) + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, maxDatePlusOne, "Asia/Seoul")) .isInstanceOf(WeatherException.class) .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.DATE_OUT_OF_RANGE); } @@ -274,7 +283,7 @@ void Given_DateAfterMaxDate_When_GetWeatherInfo_Then_ThrowsException() { void Given_ValidCoordinatesAndDate_When_GetWeatherInfo_Then_ReturnsWeatherInfo() { // given WeatherRequest request = new WeatherRequest(37.5665, 126.9780); - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 15); WeatherCacheData mockCacheData = WeatherCacheData.builder() .weather(WeatherType.RAIN) .fineDust(FineDustType.BAD) @@ -285,7 +294,7 @@ void Given_ValidCoordinatesAndDate_When_GetWeatherInfo_Then_ReturnsWeatherInfo() .thenReturn(mockCacheData); // when - WeatherResponse response = weatherService.getWeatherInfo(request, today); + WeatherResponse response = weatherService.getWeatherInfo(request, today, "Asia/Seoul"); // then assertThat(response).isNotNull(); @@ -299,7 +308,7 @@ void Given_ValidCoordinatesAndDate_When_GetWeatherInfo_Then_ReturnsWeatherInfo() void Given_MaxAllowedDate_When_GetWeatherInfo_Then_ReturnsWeatherInfo() { // given WeatherRequest request = new WeatherRequest(37.5665, 126.9780); - LocalDate maxDate = LocalDate.now().plusDays(3); // MAX_FUTURE_DATE + LocalDate maxDate = LocalDate.of(2024, 1, 18); // MAX_FUTURE_DATE WeatherCacheData mockCacheData = WeatherCacheData.builder() .weather(WeatherType.SNOW) .fineDust(FineDustType.VERY_BAD) @@ -310,7 +319,7 @@ void Given_MaxAllowedDate_When_GetWeatherInfo_Then_ReturnsWeatherInfo() { .thenReturn(mockCacheData); // when - WeatherResponse response = weatherService.getWeatherInfo(request, maxDate); + WeatherResponse response = weatherService.getWeatherInfo(request, maxDate, "Asia/Seoul"); // then assertThat(response).isNotNull(); From f85210fce161114d479509cc93d3e68e977b0318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?sukipi=20=EC=88=98=ED=82=A4=ED=94=BC?= <108535526+sotogito@users.noreply.github.com> Date: Sat, 6 Sep 2025 14:26:44 +0900 Subject: [PATCH 24/26] Feat/#100 future basic missio check (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Add parentMissionId column to Mission entity * 🔧 Add Mission parent_mission_id Alert DDL * ✨ Add Future Basic mission check feat * 🎨 Refactor mission check update * 🔧 Modify common AsyncConfig * 🐛 Fix Returning empty mission list * ⚡️ Modify returning future mission list check update * ⚡️Modify future missions change method * ⚡️Modify future mission check * 🐛 Fix LocalDate TimeZone * 🎨 Modify MissionRepository indentation tab * ✅ Modify ScenarioMissionDailyJob test * ✅ Modify MissionController test * ✅ Update MissionService test * ✅ Update MissionService test * 🔥 Remove missionId parameter from MissionService * ✅ Update MissionService test --- .../und/server/common/config/AsyncConfig.java | 23 + .../controller/MissionController.java | 5 +- .../dto/response/MissionGroupResponse.java | 10 + .../dto/response/MissionResponse.java | 9 + .../und/server/scenario/entity/Mission.java | 14 + .../repository/MissionRepository.java | 30 +- .../scheduler/ScenarioMissionDailyJob.java | 6 +- .../scenario/service/MissionService.java | 70 +- .../scenario/util/MissionTypeGroupSorter.java | 3 +- .../migration/V8__add_parent_mission_id.sql | 6 + .../controller/MissionControllerTest.java | 10 +- .../ScenarioMissionDailyJobTest.java | 4 +- .../scenario/service/MissionServiceTest.java | 698 +++++++++++++++++- 13 files changed, 844 insertions(+), 44 deletions(-) create mode 100644 src/main/resources/db/migration/V8__add_parent_mission_id.sql diff --git a/src/main/java/com/und/server/common/config/AsyncConfig.java b/src/main/java/com/und/server/common/config/AsyncConfig.java index 6f37705b..490e15a0 100644 --- a/src/main/java/com/und/server/common/config/AsyncConfig.java +++ b/src/main/java/com/und/server/common/config/AsyncConfig.java @@ -1,9 +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/scenario/controller/MissionController.java b/src/main/java/com/und/server/scenario/controller/MissionController.java index bea07afc..9a8190e5 100644 --- a/src/main/java/com/und/server/scenario/controller/MissionController.java +++ b/src/main/java/com/und/server/scenario/controller/MissionController.java @@ -86,9 +86,10 @@ public ResponseEntity addTodayMissionToScenario( public ResponseEntity updateMissionCheck( @AuthMember final Long memberId, @PathVariable final Long missionId, - @RequestBody @NotNull final Boolean isChecked + @RequestBody @NotNull final Boolean isChecked, + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") final LocalDate date ) { - missionService.updateMissionCheck(memberId, missionId, isChecked); + missionService.updateMissionCheck(memberId, missionId, isChecked, date); return ResponseEntity.status(HttpStatus.NO_CONTENT).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 index 00387549..bbd75836 100644 --- a/src/main/java/com/und/server/scenario/dto/response/MissionGroupResponse.java +++ b/src/main/java/com/und/server/scenario/dto/response/MissionGroupResponse.java @@ -46,4 +46,14 @@ public static MissionGroupResponse from( .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 index e3f64443..2ae6d2ee 100644 --- a/src/main/java/com/und/server/scenario/dto/response/MissionResponse.java +++ b/src/main/java/com/und/server/scenario/dto/response/MissionResponse.java @@ -36,6 +36,15 @@ public static MissionResponse from(final Mission mission) { .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<>(); diff --git a/src/main/java/com/und/server/scenario/entity/Mission.java b/src/main/java/com/und/server/scenario/entity/Mission.java index 5bac82a8..494b2bc8 100644 --- a/src/main/java/com/und/server/scenario/entity/Mission.java +++ b/src/main/java/com/und/server/scenario/entity/Mission.java @@ -51,6 +51,9 @@ public class Mission extends BaseTimeEntity { @Max(10_000_000) private Integer missionOrder; + @Column + private Long parentMissionId; + @Column private LocalDate useDate; @@ -66,4 +69,15 @@ public void updateMissionOrder(final Integer missionOrder) { this.missionOrder = missionOrder; } + public Mission createFutureChildMission(final boolean isChecked, 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/repository/MissionRepository.java b/src/main/java/com/und/server/scenario/repository/MissionRepository.java index 9e30aa3d..8ad374b5 100644 --- a/src/main/java/com/und/server/scenario/repository/MissionRepository.java +++ b/src/main/java/com/und/server/scenario/repository/MissionRepository.java @@ -18,6 +18,8 @@ public interface MissionRepository extends JpaRepository { @EntityGraph(attributePaths = {"scenario", "scenario.member"}) Optional findById(@NotNull Long id); + Optional findByParentMissionIdAndUseDate(Long parentMissionId, LocalDate useDate); + @Query(""" SELECT m FROM Mission m LEFT JOIN m.scenario s @@ -45,13 +47,33 @@ List findPastMissionsByDate( 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(""" - UPDATE Mission m - SET m.isChecked = false - WHERE m.useDate IS NULL + DELETE FROM Mission m + WHERE m.useDate = :today + AND m.parentMissionId IS NOT NULL AND m.missionType = 'BASIC' """) - int bulkResetBasicIsChecked(); + 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 = """ diff --git a/src/main/java/com/und/server/scenario/scheduler/ScenarioMissionDailyJob.java b/src/main/java/com/und/server/scenario/scheduler/ScenarioMissionDailyJob.java index 180f051a..006acc8d 100644 --- a/src/main/java/com/und/server/scenario/scheduler/ScenarioMissionDailyJob.java +++ b/src/main/java/com/und/server/scenario/scheduler/ScenarioMissionDailyJob.java @@ -35,9 +35,11 @@ public void runDailyBackupJob() { try { int cloned = missionRepository.bulkCloneBasicToYesterday(yesterday); - int reset = missionRepository.bulkResetBasicIsChecked(); + int reset = missionRepository.bulkResetBasicIsChecked(today); + int deleteChildBasic = missionRepository.deleteTodayChildBasics(today); - log.info("[MISSION DAILY] Daily Mission Job: cloned={}, reset={}", cloned, reset); + 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; diff --git a/src/main/java/com/und/server/scenario/service/MissionService.java b/src/main/java/com/und/server/scenario/service/MissionService.java index f78a6118..c7cb4534 100644 --- a/src/main/java/com/und/server/scenario/service/MissionService.java +++ b/src/main/java/com/und/server/scenario/service/MissionService.java @@ -47,10 +47,13 @@ public MissionGroupResponse findMissionsByScenarioId( ) { scenarioValidator.validateScenarioExists(scenarioId); - List missions = getMissionsByDate(memberId, scenarioId, date); + 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(List.of(), List.of()); + return MissionGroupResponse.from(scenarioId, List.of(), List.of()); } List groupedBasicMissions = @@ -58,6 +61,10 @@ public MissionGroupResponse findMissionsByScenarioId( List groupedTodayMissions = missionTypeGroupSorter.groupAndSortByType(missions, MissionType.TODAY); + if (missionSearchType == MissionSearchType.FUTURE) { + return MissionGroupResponse.futureFrom( + scenarioId, getFutureCheckStatusMissions(groupedBasicMissions), groupedTodayMissions); + } return MissionGroupResponse.from(scenarioId, groupedBasicMissions, groupedTodayMissions); } @@ -146,18 +153,30 @@ public void updateBasicMission(final Scenario oldSCenario, final List new ServerException(ScenarioErrorResult.NOT_FOUND_MISSION)); missionValidator.validateMissionAccessibleMember(mission, memberId); + 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); } @@ -179,11 +198,11 @@ public void deleteTodayMission(final Long memberId, final Long missionId) { private List getMissionsByDate( - final Long memberId, final Long scenarioId, final LocalDate date + final MissionSearchType missionSearchType, + final Long memberId, + final Long scenarioId, + final LocalDate date ) { - LocalDate today = LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul"))); - MissionSearchType missionSearchType = MissionSearchType.getMissionSearchType(today, date); - switch (missionSearchType) { case TODAY, FUTURE -> { return missionRepository.findTodayAndFutureMissions(memberId, scenarioId, date); @@ -195,4 +214,41 @@ private List getMissionsByDate( 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/util/MissionTypeGroupSorter.java b/src/main/java/com/und/server/scenario/util/MissionTypeGroupSorter.java index eded0d75..328d1a48 100644 --- a/src/main/java/com/und/server/scenario/util/MissionTypeGroupSorter.java +++ b/src/main/java/com/und/server/scenario/util/MissionTypeGroupSorter.java @@ -30,7 +30,8 @@ public List groupAndSortByType(final List missions, final Miss private Comparator getComparatorByType(final MissionType type) { return switch (type) { - case BASIC -> Comparator.comparing(Mission::getMissionOrder); + 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/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/scenario/controller/MissionControllerTest.java b/src/test/java/com/und/server/scenario/controller/MissionControllerTest.java index a2e0a574..513088e5 100644 --- a/src/test/java/com/und/server/scenario/controller/MissionControllerTest.java +++ b/src/test/java/com/und/server/scenario/controller/MissionControllerTest.java @@ -170,14 +170,15 @@ void Given_ValidMissionIdAndIsChecked_When_UpdateMissionCheck_Then_ReturnNoConte Long memberId = 1L; Long missionId = 1L; Boolean isChecked = true; + LocalDate date = LocalDate.of(2024, 1, 15); // when - ResponseEntity response = missionController.updateMissionCheck(memberId, missionId, isChecked); + 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); + verify(missionService).updateMissionCheck(memberId, missionId, isChecked, date); } @@ -187,14 +188,15 @@ void Given_ValidMissionIdAndIsUnchecked_When_UpdateMissionCheck_Then_ReturnNoCon Long memberId = 1L; Long missionId = 1L; Boolean isChecked = false; + LocalDate date = LocalDate.of(2024, 1, 15); // when - ResponseEntity response = missionController.updateMissionCheck(memberId, missionId, isChecked); + 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); + verify(missionService).updateMissionCheck(memberId, missionId, isChecked, date); } } diff --git a/src/test/java/com/und/server/scenario/scheduler/ScenarioMissionDailyJobTest.java b/src/test/java/com/und/server/scenario/scheduler/ScenarioMissionDailyJobTest.java index d07120f6..682bf950 100644 --- a/src/test/java/com/und/server/scenario/scheduler/ScenarioMissionDailyJobTest.java +++ b/src/test/java/com/und/server/scenario/scheduler/ScenarioMissionDailyJobTest.java @@ -49,7 +49,7 @@ void setUp() { void Given_NormalCase_When_RunDailyBackupJob_Then_CloneAndResetCalled() { // given when(missionRepository.bulkCloneBasicToYesterday(any(LocalDate.class))).thenReturn(7); - when(missionRepository.bulkResetBasicIsChecked()).thenReturn(100); + when(missionRepository.bulkResetBasicIsChecked(any(LocalDate.class))).thenReturn(100); // when job.runDailyBackupJob(); @@ -57,7 +57,7 @@ void Given_NormalCase_When_RunDailyBackupJob_Then_CloneAndResetCalled() { // then ArgumentCaptor dateCaptor = ArgumentCaptor.forClass(LocalDate.class); verify(missionRepository).bulkCloneBasicToYesterday(dateCaptor.capture()); - verify(missionRepository).bulkResetBasicIsChecked(); + verify(missionRepository).bulkResetBasicIsChecked(any(LocalDate.class)); LocalDate captured = dateCaptor.getValue(); LocalDate expectedYesterday = LocalDate.of(2025, 8, 31); diff --git a/src/test/java/com/und/server/scenario/service/MissionServiceTest.java b/src/test/java/com/und/server/scenario/service/MissionServiceTest.java index 426ba1e2..dc15e7d9 100644 --- a/src/test/java/com/und/server/scenario/service/MissionServiceTest.java +++ b/src/test/java/com/und/server/scenario/service/MissionServiceTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -30,6 +31,7 @@ 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; @@ -112,9 +114,10 @@ void Given_ValidScenarioId_When_FindMissionsByScenarioId_Then_ReturnMissionGroup MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, date); // then - assertThat(result).isNotNull(); - assertThat(result.basicMissions()).isNotEmpty(); - assertThat(result.todayMissions()).isNotEmpty(); + 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); @@ -135,9 +138,10 @@ void Given_EmptyMissionList_When_FindMissionsByScenarioId_Then_ReturnEmptyRespon MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, date); // then - assertThat(result).isNotNull(); - assertThat(result.basicMissions()).isEmpty(); - assertThat(result.todayMissions()).isEmpty(); + assertThat(result) + .isNotNull() + .satisfies(r -> assertThat(r.basicMissions()).isEmpty()) + .satisfies(r -> assertThat(r.todayMissions()).isEmpty()); verify(missionRepository).findTodayAndFutureMissions(memberId, scenarioId, date); } @@ -155,9 +159,10 @@ void Given_UnauthorizedMember_When_FindMissionsByScenarioId_Then_ThrowServerExce // when & then MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, date); - assertThat(result).isNotNull(); - assertThat(result.basicMissions()).isEmpty(); - assertThat(result.todayMissions()).isEmpty(); + assertThat(result) + .isNotNull() + .satisfies(r -> assertThat(r.basicMissions()).isEmpty()) + .satisfies(r -> assertThat(r.todayMissions()).isEmpty()); } @@ -418,6 +423,7 @@ void Given_ValidMissionIdAndAuthorizedMember_When_UpdateMissionCheck_Then_Update Long memberId = 1L; Long missionId = 1L; Boolean isChecked = true; + LocalDate date = LocalDate.of(2024, 1, 15); Member member = Member.builder() .id(memberId) @@ -439,7 +445,7 @@ void Given_ValidMissionIdAndAuthorizedMember_When_UpdateMissionCheck_Then_Update when(missionRepository.findById(missionId)).thenReturn(java.util.Optional.of(mission)); // when - missionService.updateMissionCheck(memberId, missionId, isChecked); + missionService.updateMissionCheck(memberId, missionId, isChecked, date); // then verify(missionRepository).findById(missionId); @@ -452,11 +458,12 @@ void Given_NonExistentMissionId_When_UpdateMissionCheck_Then_ThrowNotFoundExcept Long memberId = 1L; Long missionId = 999L; Boolean isChecked = true; + LocalDate date = LocalDate.of(2024, 1, 15); when(missionRepository.findById(missionId)).thenReturn(java.util.Optional.empty()); // when & then - assertThatThrownBy(() -> missionService.updateMissionCheck(memberId, missionId, isChecked)) + assertThatThrownBy(() -> missionService.updateMissionCheck(memberId, missionId, isChecked, date)) .isInstanceOf(ServerException.class) .hasFieldOrPropertyWithValue("errorResult", ScenarioErrorResult.NOT_FOUND_MISSION); verify(missionRepository).findById(missionId); @@ -469,6 +476,7 @@ void Given_UnauthorizedMember_When_UpdateMissionCheck_Then_ThrowUnauthorizedExce Long unauthorizedMemberId = 2L; Long missionId = 1L; Boolean isChecked = true; + LocalDate date = LocalDate.of(2024, 1, 15); Member authorizedMember = Member.builder() .id(authorizedMemberId) @@ -492,7 +500,7 @@ void Given_UnauthorizedMember_When_UpdateMissionCheck_Then_ThrowUnauthorizedExce .when(missionValidator).validateMissionAccessibleMember(mission, unauthorizedMemberId); // when & then - assertThatThrownBy(() -> missionService.updateMissionCheck(unauthorizedMemberId, missionId, isChecked)) + assertThatThrownBy(() -> missionService.updateMissionCheck(unauthorizedMemberId, missionId, isChecked, date)) .isInstanceOf(ServerException.class) .hasFieldOrPropertyWithValue("errorResult", ScenarioErrorResult.UNAUTHORIZED_ACCESS); verify(missionRepository).findById(missionId); @@ -505,6 +513,7 @@ void Given_ValidMissionIdAndUncheck_When_UpdateMissionCheck_Then_UpdateMissionTo Long memberId = 1L; Long missionId = 1L; Boolean isChecked = false; + LocalDate date = LocalDate.of(2024, 1, 15); Member member = Member.builder() .id(memberId) @@ -526,7 +535,7 @@ void Given_ValidMissionIdAndUncheck_When_UpdateMissionCheck_Then_UpdateMissionTo .thenReturn(Optional.of(mission)); // when - missionService.updateMissionCheck(memberId, missionId, isChecked); + missionService.updateMissionCheck(memberId, missionId, isChecked, date); // then assertThat(mission.getIsChecked()).isFalse(); @@ -562,9 +571,10 @@ void Given_NullDate_When_FindMissionsByScenarioId_Then_UseCurrentDate() { MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, nullDate); // then - assertThat(result).isNotNull(); - assertThat(result.basicMissions()).isNotEmpty(); - assertThat(result.todayMissions()).isEmpty(); + 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()); } @@ -597,9 +607,10 @@ void Given_PastDate_When_FindMissionsByScenarioId_Then_UsePastDate() { MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, pastDate); // then - assertThat(result).isNotNull(); - assertThat(result.basicMissions()).isEmpty(); - assertThat(result.todayMissions()).isNotEmpty(); + assertThat(result) + .isNotNull() + .satisfies(r -> assertThat(r.basicMissions()).isEmpty()) + .satisfies(r -> assertThat(r.todayMissions()).isNotEmpty()); verify(missionRepository).findPastMissionsByDate(memberId, scenarioId, pastDate); } @@ -632,9 +643,10 @@ void Given_FutureDate_When_FindMissionsByScenarioId_Then_UseFutureDate() { MissionGroupResponse result = missionService.findMissionsByScenarioId(memberId, scenarioId, futureDate); // then - assertThat(result).isNotNull(); - assertThat(result.basicMissions()).isEmpty(); - assertThat(result.todayMissions()).isNotEmpty(); + assertThat(result) + .isNotNull() + .satisfies(r -> assertThat(r.basicMissions()).isEmpty()) + .satisfies(r -> assertThat(r.todayMissions()).isNotEmpty()); verify(missionRepository).findTodayAndFutureMissions(memberId, scenarioId, futureDate); } @@ -696,4 +708,646 @@ void Given_ScenarioAndMissionRequestWithNonExistentMissionId_When_UpdateBasicMis 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.findById(missionId)).thenReturn(Optional.of(mission)); + when(missionRepository.findByParentMissionIdAndUseDate(missionId, futureDate)) + .thenReturn(Optional.empty()); + + // when + missionService.updateMissionCheck(memberId, missionId, isChecked, futureDate); + + // then + verify(missionRepository).findById(missionId); + 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.findById(missionId)).thenReturn(Optional.of(mission)); + when(missionRepository.findByParentMissionIdAndUseDate(missionId, futureDate)) + .thenReturn(Optional.of(childMission)); + + // when + missionService.updateMissionCheck(memberId, missionId, isChecked, futureDate); + + // then + verify(missionRepository).findById(missionId); + 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.findById(missionId)).thenReturn(Optional.of(mission)); + when(missionRepository.findByParentMissionIdAndUseDate(missionId, futureDate)) + .thenReturn(Optional.of(childMission)); + + // when + missionService.updateMissionCheck(memberId, missionId, isChecked, futureDate); + + // then + verify(missionRepository).findById(missionId); + 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())); + } + } From fdae09dde6ea5432e488a847b5815cb830f15b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?sukipi=20=EC=88=98=ED=82=A4=ED=94=BC?= <108535526+sotogito@users.noreply.github.com> Date: Tue, 9 Sep 2025 14:17:33 +0900 Subject: [PATCH 25/26] Refactor/#98 api docs (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ⚡️ Modify getWeather TimeZone Parameter to ZoneId * ✅ Modify Weather TimeZone to ZoneId * ⚡️ Add Weather Api Fail common Exception * 📝 Add NotificationApiDocs * 📝 Add WeatherApiDocs * 📝 Add MissionApiDocs * 📝 Add ScenarioApiDocs * ⚡️ Modify Mission find query * 🎨 Refactor Scenario notification active status update * 🎨 Add Mission entity parameter final * 🔥 Delete unused find mission code * 🔥 Delete unused import from MissionValidator * 🔧 Add ApiDocd to BeforeExecutionExclusionFileFilter * ✅ Modify ScenarioService test * ✅ Modify MissionService test * ✅ Modify NotificationService test * ✅ Modify MissionValidator test --- checkstyle/naver-checkstyle-rules.xml | 4 +- .../controller/NotificationApiDocs.java | 185 +++++ .../controller/NotificationController.java | 35 +- .../notification/entity/Notification.java | 39 +- .../service/NotificationService.java | 11 +- .../scenario/controller/MissionApiDocs.java | 308 ++++++++ .../controller/MissionController.java | 29 +- .../scenario/controller/ScenarioApiDocs.java | 719 ++++++++++++++++++ .../controller/ScenarioController.java | 37 +- .../und/server/scenario/entity/Mission.java | 2 +- .../exception/ScenarioErrorResult.java | 2 - .../repository/MissionRepository.java | 4 +- .../scenario/service/MissionService.java | 6 +- .../scenario/util/MissionValidator.java | 8 - .../weather/controller/WeatherApiDocs.java | 123 +++ .../weather/controller/WeatherController.java | 6 +- .../weather/service/WeatherService.java | 16 +- .../service/NotificationServiceTest.java | 9 +- .../scenario/service/MissionServiceTest.java | 53 +- .../scenario/service/ScenarioServiceTest.java | 5 +- .../scenario/util/MissionValidatorTest.java | 28 - .../controller/WeatherControllerTest.java | 15 +- .../weather/service/WeatherServiceTest.java | 28 +- 23 files changed, 1449 insertions(+), 223 deletions(-) create mode 100644 src/main/java/com/und/server/notification/controller/NotificationApiDocs.java create mode 100644 src/main/java/com/und/server/scenario/controller/MissionApiDocs.java create mode 100644 src/main/java/com/und/server/scenario/controller/ScenarioApiDocs.java create mode 100644 src/main/java/com/und/server/weather/controller/WeatherApiDocs.java 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/notification/controller/NotificationApiDocs.java b/src/main/java/com/und/server/notification/controller/NotificationApiDocs.java new file mode 100644 index 00000000..15505fec --- /dev/null +++ b/src/main/java/com/und/server/notification/controller/NotificationApiDocs.java @@ -0,0 +1,185 @@ +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; + +public interface NotificationApiDocs { + + @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 index 5d4ec94c..8ba581c1 100644 --- a/src/main/java/com/und/server/notification/controller/NotificationController.java +++ b/src/main/java/com/und/server/notification/controller/NotificationController.java @@ -13,35 +13,18 @@ import com.und.server.notification.dto.response.ScenarioNotificationResponse; import com.und.server.notification.service.NotificationCacheService; -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.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor @RequestMapping("/v1") -public class NotificationController { +public class NotificationController implements NotificationApiDocs { private final NotificationCacheService notificationCacheService; - @Operation( - summary = "Get scenario notification list", - description = "Retrieve the list of scenario notifications for the user." - ) - @ApiResponses({ - @ApiResponse( - responseCode = "200", description = "Successfully retrieved scenario notification list", - content = @Content(schema = @Schema(implementation = ScenarioNotificationListResponse.class))), - @ApiResponse(responseCode = "304", description = "Not modified - data has not changed since last request"), - @ApiResponse(responseCode = "401", description = "Unauthorized - authentication required"), - @ApiResponse( - responseCode = "500", description = "Internal server error - failed to retrieve notification cache") - }) + @Override @GetMapping("/scenarios/notifications") public ResponseEntity getScenarioNotifications( @AuthMember final Long memberId, @@ -61,19 +44,7 @@ public ResponseEntity getScenarioNotifications } - @Operation( - summary = "Get single scenario notification", - description = "Retrieve notification data for a specific scenario." - ) - @ApiResponses({ - @ApiResponse( - responseCode = "200", description = "Successfully retrieved scenario notification data", - content = @Content(schema = @Schema(implementation = ScenarioNotificationResponse.class))), - @ApiResponse(responseCode = "401", description = "Unauthorized - authentication required"), - @ApiResponse(responseCode = "404", description = "Scenario not found or scenario has no notification"), - @ApiResponse( - responseCode = "500", description = "Internal server error - failed to retrieve notification cache") - }) + @Override @GetMapping("/scenarios/{scenarioId}/notifications") public ResponseEntity getSingleScenarioNotification( @AuthMember final Long memberId, diff --git a/src/main/java/com/und/server/notification/entity/Notification.java b/src/main/java/com/und/server/notification/entity/Notification.java index 50768f76..cf59875c 100644 --- a/src/main/java/com/und/server/notification/entity/Notification.java +++ b/src/main/java/com/und/server/notification/entity/Notification.java @@ -56,6 +56,23 @@ public boolean isEveryDay() { return days.size() == 7; } + 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(); @@ -66,19 +83,7 @@ public List getDaysOfWeekOrdinalList() { .collect(Collectors.toList()); } - public void updateActiveStatus(final Boolean isActive) { - this.isActive = isActive; - } - - public void updateNotification( - final NotificationType notificationType, - final NotificationMethodType notificationMethodType - ) { - this.notificationType = notificationType; - this.notificationMethodType = notificationMethodType; - } - - public void updateDaysOfWeekOrdinal(List daysOfWeekOrdinal) { + public void updateDaysOfWeekOrdinal(final List daysOfWeekOrdinal) { if (!isActive || daysOfWeekOrdinal == null || daysOfWeekOrdinal.isEmpty()) { this.daysOfWeek = null; return; @@ -88,12 +93,4 @@ public void updateDaysOfWeekOrdinal(List daysOfWeekOrdinal) { .collect(Collectors.joining(",")); } - public void deleteNotificationMethodType() { - this.notificationMethodType = null; - } - - public void deleteDaysOfWeekOrdinal() { - this.daysOfWeek = null; - } - } diff --git a/src/main/java/com/und/server/notification/service/NotificationService.java b/src/main/java/com/und/server/notification/service/NotificationService.java index 44d30509..46e5d297 100644 --- a/src/main/java/com/und/server/notification/service/NotificationService.java +++ b/src/main/java/com/und/server/notification/service/NotificationService.java @@ -93,12 +93,11 @@ private void updateWithNotification( NotificationType newNotificationtype = notificationRequest.notificationType(); boolean isChangeNotificationType = oldNotificationType != newNotificationtype; - notification.updateNotification( + notification.activate( newNotificationtype, - notificationRequest.notificationMethodType() + notificationRequest.notificationMethodType(), + notificationRequest.daysOfWeekOrdinal() ); - notification.updateActiveStatus(true); - notification.updateDaysOfWeekOrdinal(notificationRequest.daysOfWeekOrdinal()); if (isChangeNotificationType) { notificationConditionSelector.deleteNotificationCondition(oldNotificationType, notification.getId()); @@ -115,9 +114,7 @@ private void updateWithoutNotification(final Notification oldNotification) { notificationConditionSelector.deleteNotificationCondition( oldNotification.getNotificationType(), oldNotification.getId()); - oldNotification.updateActiveStatus(false); - oldNotification.deleteNotificationMethodType(); - oldNotification.deleteDaysOfWeekOrdinal(); + oldNotification.deactivate(); } } 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 index 9a8190e5..03eaf92b 100644 --- a/src/main/java/com/und/server/scenario/controller/MissionController.java +++ b/src/main/java/com/und/server/scenario/controller/MissionController.java @@ -23,8 +23,6 @@ import com.und.server.scenario.service.MissionService; import com.und.server.scenario.service.ScenarioService; -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; import lombok.RequiredArgsConstructor; @@ -33,18 +31,14 @@ @RequiredArgsConstructor @PreAuthorize("isAuthenticated()") @RequestMapping("/v1") -public class MissionController { +public class MissionController implements MissionApiDocs { private final ScenarioService scenarioService; private final MissionService missionService; + @Override @GetMapping("/scenarios/{scenarioId}/missions") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "Get missions successful"), - @ApiResponse(responseCode = "400", description = "Invalid parameter"), - @ApiResponse(responseCode = "404", description = "Scenario not found") - }) public ResponseEntity getMissionsByScenarioId( @AuthMember final Long memberId, @PathVariable final Long scenarioId, @@ -57,12 +51,8 @@ public ResponseEntity getMissionsByScenarioId( } + @Override @PostMapping("/scenarios/{scenarioId}/missions/today") - @ApiResponses({ - @ApiResponse(responseCode = "201", description = "Create Today Mission successful"), - @ApiResponse(responseCode = "400", description = "Invalid parameter"), - @ApiResponse(responseCode = "404", description = "Scenario not found") - }) public ResponseEntity addTodayMissionToScenario( @AuthMember final Long memberId, @PathVariable final Long scenarioId, @@ -76,13 +66,8 @@ public ResponseEntity addTodayMissionToScenario( } + @Override @PatchMapping("/missions/{missionId}/check") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "Update check status successful"), - @ApiResponse(responseCode = "400", description = "Invalid parameter"), - @ApiResponse(responseCode = "401", description = "Unauthorized access"), - @ApiResponse(responseCode = "404", description = "Mission not found") - }) public ResponseEntity updateMissionCheck( @AuthMember final Long memberId, @PathVariable final Long missionId, @@ -95,12 +80,8 @@ public ResponseEntity updateMissionCheck( } + @Override @DeleteMapping("/missions/{missionId}") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "Delete Today Mission successful"), - @ApiResponse(responseCode = "401", description = "Unauthorized access"), - @ApiResponse(responseCode = "404", description = "Mission not found") - }) public ResponseEntity deleteTodayMissionById( @AuthMember final Long memberId, @PathVariable final Long missionId 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..aff1949a --- /dev/null +++ b/src/main/java/com/und/server/scenario/controller/ScenarioApiDocs.java @@ -0,0 +1,719 @@ +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", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = MissionGroupResponse.class), + examples = @ExampleObject( + name = "Basic missions only", + value = """ + { + "scenarioId": 2, + "basicMissions": [ + { + "missionId": 3, + "content": "Lock door", + "isChecked": false, + "missionType": "BASIC" + }, + { + "missionId": 4, + "content": "Open door", + "isChecked": false, + "missionType": "BASIC" + } + ], + "todayMissions": [] + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Bad request - invalid parameters", + 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", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = MissionGroupResponse.class) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Bad request - invalid parameters", + 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 index 187807fd..ba2e0091 100644 --- a/src/main/java/com/und/server/scenario/controller/ScenarioController.java +++ b/src/main/java/com/und/server/scenario/controller/ScenarioController.java @@ -26,8 +26,6 @@ import com.und.server.scenario.dto.response.ScenarioResponse; import com.und.server.scenario.service.ScenarioService; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -35,16 +33,13 @@ @RequiredArgsConstructor @PreAuthorize("isAuthenticated()") @RequestMapping("/v1") -public class ScenarioController { +public class ScenarioController implements ScenarioApiDocs { private final ScenarioService scenarioService; + @Override @GetMapping("/scenarios") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "Get scenarios successful"), - @ApiResponse(responseCode = "400", description = "Invalid parameter") - }) public ResponseEntity> getScenarios( @AuthMember final Long memberId, @RequestParam(defaultValue = "TIME") final NotificationType notificationType @@ -56,12 +51,8 @@ public ResponseEntity> getScenarios( } + @Override @GetMapping("/scenarios/{scenarioId}") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "Get scenario detail successful"), - @ApiResponse(responseCode = "400", description = "Invalid parameter"), - @ApiResponse(responseCode = "404", description = "Scenario not found") - }) public ResponseEntity getScenarioDetail( @AuthMember final Long memberId, @PathVariable final Long scenarioId @@ -73,11 +64,8 @@ public ResponseEntity getScenarioDetail( } + @Override @PostMapping("/scenarios") - @ApiResponses({ - @ApiResponse(responseCode = "201", description = "Create Scenario successful"), - @ApiResponse(responseCode = "400", description = "Invalid parameter") - }) public ResponseEntity addScenario( @AuthMember final Long memberId, @RequestBody @Valid final ScenarioDetailRequest scenarioRequest @@ -89,12 +77,8 @@ public ResponseEntity addScenario( } + @Override @PutMapping("/scenarios/{scenarioId}") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "Update Scenario successful"), - @ApiResponse(responseCode = "400", description = "Invalid parameter"), - @ApiResponse(responseCode = "404", description = "Scenario not found") - }) public ResponseEntity updateScenario( @AuthMember final Long memberId, @PathVariable final Long scenarioId, @@ -107,12 +91,8 @@ public ResponseEntity updateScenario( } + @Override @PatchMapping("/scenarios/{scenarioId}/order") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "Update Scenario order successful"), - @ApiResponse(responseCode = "400", description = "Invalid parameter"), - @ApiResponse(responseCode = "404", description = "Scenario not found") - }) public ResponseEntity updateScenarioOrder( @AuthMember final Long memberId, @PathVariable final Long scenarioId, @@ -125,11 +105,8 @@ public ResponseEntity updateScenarioOrder( } + @Override @DeleteMapping("/scenarios/{scenarioId}") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "Delete Scenario successful"), - @ApiResponse(responseCode = "404", description = "Scenario not found") - }) public ResponseEntity deleteScenario( @AuthMember final Long memberId, @PathVariable final Long scenarioId diff --git a/src/main/java/com/und/server/scenario/entity/Mission.java b/src/main/java/com/und/server/scenario/entity/Mission.java index 494b2bc8..80202cc2 100644 --- a/src/main/java/com/und/server/scenario/entity/Mission.java +++ b/src/main/java/com/und/server/scenario/entity/Mission.java @@ -69,7 +69,7 @@ public void updateMissionOrder(final Integer missionOrder) { this.missionOrder = missionOrder; } - public Mission createFutureChildMission(final boolean isChecked, LocalDate future) { + public Mission createFutureChildMission(final boolean isChecked, final LocalDate future) { return Mission.builder() .scenario(this.scenario) .content(this.content) diff --git a/src/main/java/com/und/server/scenario/exception/ScenarioErrorResult.java b/src/main/java/com/und/server/scenario/exception/ScenarioErrorResult.java index 446c8f55..d0e4af2c 100644 --- a/src/main/java/com/und/server/scenario/exception/ScenarioErrorResult.java +++ b/src/main/java/com/und/server/scenario/exception/ScenarioErrorResult.java @@ -15,8 +15,6 @@ public enum ScenarioErrorResult implements ErrorResult { HttpStatus.NOT_FOUND, "Scenario not found"), NOT_FOUND_MISSION( HttpStatus.NOT_FOUND, "Mission not found"), - UNAUTHORIZED_ACCESS( - HttpStatus.UNAUTHORIZED, "Unauthorized access"), UNSUPPORTED_MISSION_TYPE( HttpStatus.BAD_REQUEST, "Unsupported mission type"), REORDER_REQUIRED( diff --git a/src/main/java/com/und/server/scenario/repository/MissionRepository.java b/src/main/java/com/und/server/scenario/repository/MissionRepository.java index 8ad374b5..4b5efc96 100644 --- a/src/main/java/com/und/server/scenario/repository/MissionRepository.java +++ b/src/main/java/com/und/server/scenario/repository/MissionRepository.java @@ -15,8 +15,8 @@ public interface MissionRepository extends JpaRepository { - @EntityGraph(attributePaths = {"scenario", "scenario.member"}) - Optional findById(@NotNull Long id); + @EntityGraph(attributePaths = {"scenario"}) + Optional findByIdAndScenarioMemberId(Long missionId, Long memberId); Optional findByParentMissionIdAndUseDate(Long parentMissionId, LocalDate useDate); diff --git a/src/main/java/com/und/server/scenario/service/MissionService.java b/src/main/java/com/und/server/scenario/service/MissionService.java index c7cb4534..56eb4c8d 100644 --- a/src/main/java/com/und/server/scenario/service/MissionService.java +++ b/src/main/java/com/und/server/scenario/service/MissionService.java @@ -166,9 +166,8 @@ public void updateMissionCheck( final Boolean isChecked, final LocalDate date ) { - Mission mission = missionRepository.findById(missionId) + Mission mission = missionRepository.findByIdAndScenarioMemberId(missionId, memberId) .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_MISSION)); - missionValidator.validateMissionAccessibleMember(mission, memberId); MissionSearchType missionSearchType = MissionSearchType.getMissionSearchType( LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul"))), date); @@ -189,9 +188,8 @@ public void deleteMissions(final Long scenarioId) { @Transactional public void deleteTodayMission(final Long memberId, final Long missionId) { - Mission mission = missionRepository.findById(missionId) + Mission mission = missionRepository.findByIdAndScenarioMemberId(missionId, memberId) .orElseThrow(() -> new ServerException(ScenarioErrorResult.NOT_FOUND_MISSION)); - missionValidator.validateMissionAccessibleMember(mission, memberId); missionRepository.delete(mission); } diff --git a/src/main/java/com/und/server/scenario/util/MissionValidator.java b/src/main/java/com/und/server/scenario/util/MissionValidator.java index 738083ee..49a741ba 100644 --- a/src/main/java/com/und/server/scenario/util/MissionValidator.java +++ b/src/main/java/com/und/server/scenario/util/MissionValidator.java @@ -6,7 +6,6 @@ import org.springframework.stereotype.Component; import com.und.server.common.exception.ServerException; -import com.und.server.member.entity.Member; import com.und.server.scenario.constants.MissionSearchType; import com.und.server.scenario.entity.Mission; import com.und.server.scenario.exception.ScenarioErrorResult; @@ -20,13 +19,6 @@ public class MissionValidator { private static final int BASIC_MISSION_MAX_COUNT = 20; private static final int TODAY_MISSION_MAX_COUNT = 20; - public void validateMissionAccessibleMember(final Mission mission, final Long memberId) { - Member member = mission.getScenario().getMember(); - if (!memberId.equals(member.getId())) { - throw new ServerException(ScenarioErrorResult.UNAUTHORIZED_ACCESS); - } - } - public void validateMaxBasicMissionCount(final List missions) { if (missions.size() >= BASIC_MISSION_MAX_COUNT) { throw new ServerException(ScenarioErrorResult.MAX_MISSION_COUNT_EXCEEDED); 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 index 2b4da109..296bc6e1 100644 --- a/src/main/java/com/und/server/weather/controller/WeatherController.java +++ b/src/main/java/com/und/server/weather/controller/WeatherController.java @@ -1,6 +1,7 @@ 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; @@ -22,15 +23,16 @@ @RequiredArgsConstructor @PreAuthorize("isAuthenticated()") @RequestMapping("/v1/weather") -public class WeatherController { +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 String timezone + @RequestParam(defaultValue = "Asia/Seoul") final ZoneId timezone ) { final WeatherResponse response = weatherService.getWeatherInfo(request, date, timezone); diff --git a/src/main/java/com/und/server/weather/service/WeatherService.java b/src/main/java/com/und/server/weather/service/WeatherService.java index 7d1efe9d..8ab7d68c 100644 --- a/src/main/java/com/und/server/weather/service/WeatherService.java +++ b/src/main/java/com/und/server/weather/service/WeatherService.java @@ -27,19 +27,23 @@ public class WeatherService { public WeatherResponse getWeatherInfo( - final WeatherRequest weatherRequest, final LocalDate date, final String timezone + final WeatherRequest weatherRequest, final LocalDate date, final ZoneId timezone ) { - LocalDateTime nowDateTime = LocalDateTime.now(clock.withZone(ZoneId.of(timezone))); + LocalDateTime nowDateTime = LocalDateTime.now(clock.withZone(timezone)); LocalDate today = nowDateTime.toLocalDate(); validateLocation(weatherRequest); validateDate(date, today); boolean isToday = date.equals(today); - if (isToday) { - return getTodayWeather(weatherRequest, nowDateTime); - } else { - return getFutureWeather(weatherRequest, nowDateTime, date); + try { + if (isToday) { + return getTodayWeather(weatherRequest, nowDateTime); + } else { + return getFutureWeather(weatherRequest, nowDateTime, date); + } + } catch (WeatherException e) { + throw new WeatherException(WeatherErrorResult.WEATHER_SERVICE_ERROR); } } diff --git a/src/test/java/com/und/server/notification/service/NotificationServiceTest.java b/src/test/java/com/und/server/notification/service/NotificationServiceTest.java index 8448f8dc..149f80ea 100644 --- a/src/test/java/com/und/server/notification/service/NotificationServiceTest.java +++ b/src/test/java/com/und/server/notification/service/NotificationServiceTest.java @@ -435,7 +435,7 @@ void Given_ActiveNotification_When_UpdateDaysOfWeekOrdinalWithEmpty_Then_SetToNu @Test - void Given_Notification_When_DeleteDaysOfWeekOrdinal_Then_SetToNull() { + void Given_Notification_When_UpdateDaysOfWeekOrdinalWithEmptyList_Then_SetToNull() { // given Notification notification = Notification.builder() .id(1L) @@ -445,7 +445,7 @@ void Given_Notification_When_DeleteDaysOfWeekOrdinal_Then_SetToNull() { .build(); // when - notification.deleteDaysOfWeekOrdinal(); + notification.updateDaysOfWeekOrdinal(List.of()); // then assertThat(notification.getDaysOfWeekOrdinalList()).isEmpty(); @@ -454,7 +454,7 @@ void Given_Notification_When_DeleteDaysOfWeekOrdinal_Then_SetToNull() { @Test - void Given_Notification_When_DeleteNotificationMethodType_Then_SetToNull() { + void Given_Notification_When_DeactivateNotification_Then_SetMethodTypeToNull() { // given Notification notification = Notification.builder() .id(1L) @@ -464,10 +464,11 @@ void Given_Notification_When_DeleteNotificationMethodType_Then_SetToNull() { .build(); // when - notification.deleteNotificationMethodType(); + notification.deactivate(); // then assertThat(notification.getNotificationMethodType()).isNull(); + assertThat(notification.isActive()).isFalse(); } } diff --git a/src/test/java/com/und/server/scenario/service/MissionServiceTest.java b/src/test/java/com/und/server/scenario/service/MissionServiceTest.java index dc15e7d9..32270457 100644 --- a/src/test/java/com/und/server/scenario/service/MissionServiceTest.java +++ b/src/test/java/com/und/server/scenario/service/MissionServiceTest.java @@ -4,7 +4,6 @@ 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.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -356,13 +355,14 @@ void Given_ValidMissionIdAndAuthorizedMember_When_DeleteTodayMission_Then_Delete .missionType(MissionType.TODAY) .build(); - when(missionRepository.findById(missionId)).thenReturn(java.util.Optional.of(mission)); + when(missionRepository.findByIdAndScenarioMemberId(missionId, memberId)).thenReturn( + java.util.Optional.of(mission)); // when missionService.deleteTodayMission(memberId, missionId); // then - verify(missionRepository).findById(missionId); + verify(missionRepository).findByIdAndScenarioMemberId(missionId, memberId); verify(missionRepository).delete(mission); } @@ -372,13 +372,13 @@ void Given_NonExistentMissionId_When_DeleteTodayMission_Then_ThrowNotFoundExcept Long memberId = 1L; Long missionId = 999L; - when(missionRepository.findById(missionId)).thenReturn(java.util.Optional.empty()); + 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).findById(missionId); + verify(missionRepository).findByIdAndScenarioMemberId(missionId, memberId); verify(missionRepository, org.mockito.Mockito.never()).delete(any()); } @@ -405,15 +405,14 @@ void Given_UnauthorizedMember_When_DeleteTodayMission_Then_ThrowUnauthorizedExce .missionType(MissionType.TODAY) .build(); - when(missionRepository.findById(missionId)).thenReturn(java.util.Optional.of(mission)); - doThrow(new ServerException(ScenarioErrorResult.UNAUTHORIZED_ACCESS)) - .when(missionValidator).validateMissionAccessibleMember(mission, unauthorizedMemberId); + when(missionRepository.findByIdAndScenarioMemberId(missionId, unauthorizedMemberId)) + .thenReturn(java.util.Optional.empty()); // when & then assertThatThrownBy(() -> missionService.deleteTodayMission(unauthorizedMemberId, missionId)) .isInstanceOf(ServerException.class) - .hasFieldOrPropertyWithValue("errorResult", ScenarioErrorResult.UNAUTHORIZED_ACCESS); - verify(missionRepository).findById(missionId); + .hasFieldOrPropertyWithValue("errorResult", ScenarioErrorResult.NOT_FOUND_MISSION); + verify(missionRepository).findByIdAndScenarioMemberId(missionId, unauthorizedMemberId); verify(missionRepository, org.mockito.Mockito.never()).delete(any()); } @@ -442,13 +441,14 @@ void Given_ValidMissionIdAndAuthorizedMember_When_UpdateMissionCheck_Then_Update .missionType(MissionType.TODAY) .build(); - when(missionRepository.findById(missionId)).thenReturn(java.util.Optional.of(mission)); + when(missionRepository.findByIdAndScenarioMemberId(missionId, memberId)).thenReturn( + java.util.Optional.of(mission)); // when missionService.updateMissionCheck(memberId, missionId, isChecked, date); // then - verify(missionRepository).findById(missionId); + verify(missionRepository).findByIdAndScenarioMemberId(missionId, memberId); assertThat(mission.getIsChecked()).isEqualTo(isChecked); } @@ -460,13 +460,13 @@ void Given_NonExistentMissionId_When_UpdateMissionCheck_Then_ThrowNotFoundExcept Boolean isChecked = true; LocalDate date = LocalDate.of(2024, 1, 15); - when(missionRepository.findById(missionId)).thenReturn(java.util.Optional.empty()); + 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).findById(missionId); + verify(missionRepository).findByIdAndScenarioMemberId(missionId, memberId); } @Test @@ -495,15 +495,14 @@ void Given_UnauthorizedMember_When_UpdateMissionCheck_Then_ThrowUnauthorizedExce .missionType(MissionType.TODAY) .build(); - when(missionRepository.findById(missionId)).thenReturn(java.util.Optional.of(mission)); - doThrow(new ServerException(ScenarioErrorResult.UNAUTHORIZED_ACCESS)) - .when(missionValidator).validateMissionAccessibleMember(mission, unauthorizedMemberId); + 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.UNAUTHORIZED_ACCESS); - verify(missionRepository).findById(missionId); + .hasFieldOrPropertyWithValue("errorResult", ScenarioErrorResult.NOT_FOUND_MISSION); + verify(missionRepository).findByIdAndScenarioMemberId(missionId, unauthorizedMemberId); } @@ -531,7 +530,7 @@ void Given_ValidMissionIdAndUncheck_When_UpdateMissionCheck_Then_UpdateMissionTo .isChecked(true) .build(); - when(missionRepository.findById(missionId)) + when(missionRepository.findByIdAndScenarioMemberId(missionId, memberId)) .thenReturn(Optional.of(mission)); // when @@ -539,7 +538,7 @@ void Given_ValidMissionIdAndUncheck_When_UpdateMissionCheck_Then_UpdateMissionTo // then assertThat(mission.getIsChecked()).isFalse(); - verify(missionRepository).findById(missionId); + verify(missionRepository).findByIdAndScenarioMemberId(missionId, memberId); } @@ -873,7 +872,7 @@ void Given_BasicMissionAndFutureDate_When_UpdateMissionCheck_Then_UpdateFutureBa .missionType(MissionType.BASIC) .build(); - when(missionRepository.findById(missionId)).thenReturn(Optional.of(mission)); + when(missionRepository.findByIdAndScenarioMemberId(missionId, memberId)).thenReturn(Optional.of(mission)); when(missionRepository.findByParentMissionIdAndUseDate(missionId, futureDate)) .thenReturn(Optional.empty()); @@ -881,7 +880,7 @@ void Given_BasicMissionAndFutureDate_When_UpdateMissionCheck_Then_UpdateFutureBa missionService.updateMissionCheck(memberId, missionId, isChecked, futureDate); // then - verify(missionRepository).findById(missionId); + verify(missionRepository).findByIdAndScenarioMemberId(missionId, memberId); verify(missionRepository).findByParentMissionIdAndUseDate(missionId, futureDate); verify(missionRepository).save(any(Mission.class)); } @@ -919,7 +918,7 @@ void Given_BasicMissionAndFutureDateWithExistingChild_When_UpdateMissionCheckToT .missionType(MissionType.BASIC) .build(); - when(missionRepository.findById(missionId)).thenReturn(Optional.of(mission)); + when(missionRepository.findByIdAndScenarioMemberId(missionId, memberId)).thenReturn(Optional.of(mission)); when(missionRepository.findByParentMissionIdAndUseDate(missionId, futureDate)) .thenReturn(Optional.of(childMission)); @@ -927,7 +926,7 @@ void Given_BasicMissionAndFutureDateWithExistingChild_When_UpdateMissionCheckToT missionService.updateMissionCheck(memberId, missionId, isChecked, futureDate); // then - verify(missionRepository).findById(missionId); + verify(missionRepository).findByIdAndScenarioMemberId(missionId, memberId); verify(missionRepository).findByParentMissionIdAndUseDate(missionId, futureDate); assertThat(childMission.getIsChecked()).isTrue(); } @@ -965,7 +964,7 @@ void Given_BasicMissionAndFutureDateWithExistingChild_When_UpdateMissionCheckToF .missionType(MissionType.BASIC) .build(); - when(missionRepository.findById(missionId)).thenReturn(Optional.of(mission)); + when(missionRepository.findByIdAndScenarioMemberId(missionId, memberId)).thenReturn(Optional.of(mission)); when(missionRepository.findByParentMissionIdAndUseDate(missionId, futureDate)) .thenReturn(Optional.of(childMission)); @@ -973,7 +972,7 @@ void Given_BasicMissionAndFutureDateWithExistingChild_When_UpdateMissionCheckToF missionService.updateMissionCheck(memberId, missionId, isChecked, futureDate); // then - verify(missionRepository).findById(missionId); + verify(missionRepository).findByIdAndScenarioMemberId(missionId, memberId); verify(missionRepository).findByParentMissionIdAndUseDate(missionId, futureDate); verify(missionRepository).delete(childMission); } diff --git a/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java b/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java index 19b3a183..4cde304d 100644 --- a/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java +++ b/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java @@ -535,8 +535,9 @@ void Given_ValidRequest_When_UpdateScenario_Then_UpdateScenarioAndNotification() .thenReturn(Optional.of(oldScenario)); Mockito.doAnswer(invocation -> { Notification target = invocation.getArgument(0); - target.updateNotification(notifRequest.notificationType(), notifRequest.notificationMethodType()); - target.updateActiveStatus(true); + target.activate(notifRequest.notificationType(), + notifRequest.notificationMethodType(), + notifRequest.daysOfWeekOrdinal()); return null; }).when(notificationService).updateNotification(oldNotification, notifRequest, condition); diff --git a/src/test/java/com/und/server/scenario/util/MissionValidatorTest.java b/src/test/java/com/und/server/scenario/util/MissionValidatorTest.java index 56fba212..42ac84f9 100644 --- a/src/test/java/com/und/server/scenario/util/MissionValidatorTest.java +++ b/src/test/java/com/und/server/scenario/util/MissionValidatorTest.java @@ -13,9 +13,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.und.server.common.exception.ServerException; -import com.und.server.member.entity.Member; import com.und.server.scenario.entity.Mission; -import com.und.server.scenario.entity.Scenario; import com.und.server.scenario.exception.ScenarioErrorResult; @ExtendWith(MockitoExtension.class) @@ -56,32 +54,6 @@ void Given_PastDate_When_ValidateTodayMissionDateRange_Then_ThrowException() { .hasMessageContaining(ScenarioErrorResult.INVALID_TODAY_MISSION_DATE.getMessage()); } - @Test - void Given_SameMemberId_When_ValidateMissionAccessibleMember_Then_NoException() { - // given - Long memberId = 1L; - Member member = Member.builder().id(memberId).build(); - Scenario scenario = Scenario.builder().member(member).build(); - Mission mission = Mission.builder().scenario(scenario).build(); - - // when & then - assertDoesNotThrow(() -> missionValidator.validateMissionAccessibleMember(mission, memberId)); - } - - @Test - void Given_DifferentMemberId_When_ValidateMissionAccessibleMember_Then_ThrowException() { - // given - Long memberId = 1L; - Long otherMemberId = 2L; - Member member = Member.builder().id(memberId).build(); - Scenario scenario = Scenario.builder().member(member).build(); - Mission mission = Mission.builder().scenario(scenario).build(); - - // when & then - assertThatThrownBy(() -> missionValidator.validateMissionAccessibleMember(mission, otherMemberId)) - .isInstanceOf(ServerException.class) - .hasMessageContaining(ScenarioErrorResult.UNAUTHORIZED_ACCESS.getMessage()); - } @Test void Given_BasicMissionListBelowMaxCount_When_ValidateMaxBasicMissionCount_Then_NoException() { diff --git a/src/test/java/com/und/server/weather/controller/WeatherControllerTest.java b/src/test/java/com/und/server/weather/controller/WeatherControllerTest.java index cdf22f07..5fbede2c 100644 --- a/src/test/java/com/und/server/weather/controller/WeatherControllerTest.java +++ b/src/test/java/com/und/server/weather/controller/WeatherControllerTest.java @@ -6,6 +6,7 @@ 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; @@ -56,7 +57,7 @@ void Given_ValidRequest_When_GetWeather_Then_ReturnsWeatherResponse() throws Exc WeatherType.SUNNY, FineDustType.GOOD, UvType.LOW ); - given(weatherService.getWeatherInfo((request), (date), "Asia/Seoul")) + given(weatherService.getWeatherInfo((request), (date), ZoneId.of("Asia/Seoul"))) .willReturn(expectedResponse); // when & then @@ -81,7 +82,7 @@ void Given_RainyWeather_When_GetWeather_Then_ReturnsRainyWeatherResponse() throw WeatherType.RAIN, FineDustType.NORMAL, UvType.NORMAL ); - given(weatherService.getWeatherInfo((request), (date), "Asia/Seoul")) + given(weatherService.getWeatherInfo((request), (date), ZoneId.of("Asia/Seoul"))) .willReturn(expectedResponse); // when & then @@ -106,7 +107,7 @@ void Given_BadFineDust_When_GetWeather_Then_ReturnsBadFineDustResponse() throws WeatherType.CLOUDY, FineDustType.BAD, UvType.HIGH ); - given(weatherService.getWeatherInfo((request), (date), "Asia/Seoul")) + given(weatherService.getWeatherInfo((request), (date), ZoneId.of("Asia/Seoul"))) .willReturn(expectedResponse); // when & then @@ -130,7 +131,7 @@ void Given_SnowyWeather_When_GetWeather_Then_ReturnsSnowyWeatherResponse() throw WeatherType.SNOW, FineDustType.GOOD, UvType.VERY_LOW ); - given(weatherService.getWeatherInfo((request), (date), "Asia/Seoul")) + given(weatherService.getWeatherInfo((request), (date), ZoneId.of("Asia/Seoul"))) .willReturn(expectedResponse); // when & then @@ -155,7 +156,7 @@ void Given_DifferentCoordinates_When_GetWeather_Then_ReturnsWeatherResponse() th WeatherType.SUNNY, FineDustType.GOOD, UvType.VERY_HIGH ); - given(weatherService.getWeatherInfo((request), (date), "Asia/Seoul")) + given(weatherService.getWeatherInfo((request), (date), ZoneId.of("Asia/Seoul"))) .willReturn(expectedResponse); // when & then @@ -180,7 +181,7 @@ void Given_DifferentDate_When_GetWeather_Then_ReturnsWeatherResponse() throws Ex WeatherType.CLOUDY, FineDustType.NORMAL, UvType.LOW ); - given(weatherService.getWeatherInfo((request), (date), "Asia/Seoul")) + given(weatherService.getWeatherInfo((request), (date), ZoneId.of("Asia/Seoul"))) .willReturn(expectedResponse); // when & then @@ -205,7 +206,7 @@ void Given_WorstWeatherConditions_When_GetWeather_Then_ReturnsWorstWeatherRespon WeatherType.SNOW, FineDustType.VERY_BAD, UvType.VERY_HIGH ); - given(weatherService.getWeatherInfo((request), (date), "Asia/Seoul")) + given(weatherService.getWeatherInfo((request), (date), ZoneId.of("Asia/Seoul"))) .willReturn(expectedResponse); // when & then diff --git a/src/test/java/com/und/server/weather/service/WeatherServiceTest.java b/src/test/java/com/und/server/weather/service/WeatherServiceTest.java index c51fb853..d602cb48 100644 --- a/src/test/java/com/und/server/weather/service/WeatherServiceTest.java +++ b/src/test/java/com/und/server/weather/service/WeatherServiceTest.java @@ -69,7 +69,7 @@ void Given_TodayWeatherRequest_When_GetWeatherInfo_Then_ReturnsTodayWeather() { .thenReturn(mockCacheData); // when - WeatherResponse response = weatherService.getWeatherInfo(request, today, "Asia/Seoul"); + WeatherResponse response = weatherService.getWeatherInfo(request, today, ZoneId.of("Asia/Seoul")); // then assertThat(response).isNotNull(); @@ -95,7 +95,7 @@ void Given_FutureWeatherRequest_When_GetWeatherInfo_Then_ReturnsFutureWeather() .thenReturn(mockCacheData); // when - WeatherResponse response = weatherService.getWeatherInfo(request, futureDate, "Asia/Seoul"); + WeatherResponse response = weatherService.getWeatherInfo(request, futureDate, ZoneId.of("Asia/Seoul")); // then assertThat(response).isNotNull(); @@ -115,7 +115,7 @@ void Given_TodayWeatherCacheIsNull_When_GetWeatherInfo_Then_ReturnsDefault() { .thenReturn(null); // when - WeatherResponse response = weatherService.getWeatherInfo(request, today, "Asia/Seoul"); + WeatherResponse response = weatherService.getWeatherInfo(request, today, ZoneId.of("Asia/Seoul")); // then assertThat(response).isNotNull(); @@ -140,7 +140,7 @@ void Given_TodayWeatherCacheIsInvalid_When_GetWeatherInfo_Then_ReturnsValidDefau .thenReturn(invalidCacheData); // when - WeatherResponse response = weatherService.getWeatherInfo(request, today, "Asia/Seoul"); + WeatherResponse response = weatherService.getWeatherInfo(request, today, ZoneId.of("Asia/Seoul")); // then assertThat(response).isNotNull(); @@ -160,7 +160,7 @@ void Given_FutureWeatherCacheIsNull_When_GetWeatherInfo_Then_ReturnsDefault() { .thenReturn(null); // when - WeatherResponse response = weatherService.getWeatherInfo(request, futureDate, "Asia/Seoul"); + WeatherResponse response = weatherService.getWeatherInfo(request, futureDate, ZoneId.of("Asia/Seoul")); // then assertThat(response).isNotNull(); @@ -185,7 +185,7 @@ void Given_FutureWeatherCacheIsInvalid_When_GetWeatherInfo_Then_ReturnsValidDefa .thenReturn(invalidCacheData); // when - WeatherResponse response = weatherService.getWeatherInfo(request, futureDate, "Asia/Seoul"); + WeatherResponse response = weatherService.getWeatherInfo(request, futureDate, ZoneId.of("Asia/Seoul")); // then assertThat(response).isNotNull(); @@ -202,7 +202,7 @@ void Given_LatitudeLessThanMinus90_When_GetWeatherInfo_Then_ThrowsException() { LocalDate today = LocalDate.of(2024, 1, 15); // when & then - assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today, "Asia/Seoul")) + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today, ZoneId.of("Asia/Seoul"))) .isInstanceOf(WeatherException.class) .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.INVALID_COORDINATES); } @@ -216,7 +216,7 @@ void Given_LatitudeGreaterThan90_When_GetWeatherInfo_Then_ThrowsException() { LocalDate today = LocalDate.of(2024, 1, 15); // when & then - assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today, "Asia/Seoul")) + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today, ZoneId.of("Asia/Seoul"))) .isInstanceOf(WeatherException.class) .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.INVALID_COORDINATES); } @@ -230,7 +230,7 @@ void Given_LongitudeLessThanMinus180_When_GetWeatherInfo_Then_ThrowsException() LocalDate today = LocalDate.of(2024, 1, 15); // when & then - assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today, "Asia/Seoul")) + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today, ZoneId.of("Asia/Seoul"))) .isInstanceOf(WeatherException.class) .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.INVALID_COORDINATES); } @@ -244,7 +244,7 @@ void Given_LongitudeGreaterThan180_When_GetWeatherInfo_Then_ThrowsException() { LocalDate today = LocalDate.of(2024, 1, 15); // when & then - assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today, "Asia/Seoul")) + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, today, ZoneId.of("Asia/Seoul"))) .isInstanceOf(WeatherException.class) .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.INVALID_COORDINATES); } @@ -258,7 +258,7 @@ void Given_DateBeforeToday_When_GetWeatherInfo_Then_ThrowsException() { LocalDate yesterday = LocalDate.of(2024, 1, 14); // when & then - assertThatThrownBy(() -> weatherService.getWeatherInfo(request, yesterday, "Asia/Seoul")) + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, yesterday, ZoneId.of("Asia/Seoul"))) .isInstanceOf(WeatherException.class) .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.DATE_OUT_OF_RANGE); } @@ -272,7 +272,7 @@ void Given_DateAfterMaxDate_When_GetWeatherInfo_Then_ThrowsException() { LocalDate maxDatePlusOne = LocalDate.of(2024, 1, 19); // MAX_FUTURE_DATE + 1 // when & then - assertThatThrownBy(() -> weatherService.getWeatherInfo(request, maxDatePlusOne, "Asia/Seoul")) + assertThatThrownBy(() -> weatherService.getWeatherInfo(request, maxDatePlusOne, ZoneId.of("Asia/Seoul"))) .isInstanceOf(WeatherException.class) .hasFieldOrPropertyWithValue("errorResult", WeatherErrorResult.DATE_OUT_OF_RANGE); } @@ -294,7 +294,7 @@ void Given_ValidCoordinatesAndDate_When_GetWeatherInfo_Then_ReturnsWeatherInfo() .thenReturn(mockCacheData); // when - WeatherResponse response = weatherService.getWeatherInfo(request, today, "Asia/Seoul"); + WeatherResponse response = weatherService.getWeatherInfo(request, today, ZoneId.of("Asia/Seoul")); // then assertThat(response).isNotNull(); @@ -319,7 +319,7 @@ void Given_MaxAllowedDate_When_GetWeatherInfo_Then_ReturnsWeatherInfo() { .thenReturn(mockCacheData); // when - WeatherResponse response = weatherService.getWeatherInfo(request, maxDate, "Asia/Seoul"); + WeatherResponse response = weatherService.getWeatherInfo(request, maxDate, ZoneId.of("Asia/Seoul")); // then assertThat(response).isNotNull(); From 58191988136cb46c0e5c7db5f974d0704e0225a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?sukipi=20=EC=88=98=ED=82=A4=ED=94=BC?= <108535526+sotogito@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:44:20 +0900 Subject: [PATCH 26/26] Bug/#104 scenario (#105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 Fix Scenario order when creating scenario * 🐛 Fix calculate reorder * 🐛 Fix Response body when creating and updating Scenario * ✨ Add Notification Active update feat * ✨ Add Notification active update eventListener * ✅ Modify ScenarioController test * ✅ Modify SceenarioService test * ✅ Modify OrderCalculator test * ✅ Add Notification status update test * ✅ Add Notification Active status update eventListener test * ✅ Modify Notification and Scenario service assertThat --- .../controller/NotificationApiDocs.java | 32 ++ .../controller/NotificationController.java | 17 + .../notification/entity/Notification.java | 8 + .../notification/event/ActiveUpdateEvent.java | 8 + .../event/ActiveUpdateEventListener.java | 45 +++ .../event/NotificationEventPublisher.java | 6 + .../service/NotificationCacheService.java | 2 +- .../service/NotificationService.java | 31 ++ .../scenario/controller/ScenarioApiDocs.java | 43 +-- .../controller/ScenarioController.java | 13 +- .../repository/ScenarioRepository.java | 4 + .../scenario/service/ScenarioService.java | 25 +- .../server/scenario/util/OrderCalculator.java | 6 +- .../NotificationControllerTest.java | 50 +++ .../event/ActiveUpdateEventListenerTest.java | 116 +++++++ .../event/ActiveUpdateEventTest.java | 101 ++++++ .../event/NotificationEventPublisherTest.java | 40 +++ .../service/NotificationServiceTest.java | 311 +++++++++++++++--- .../controller/ScenarioControllerTest.java | 113 ++++--- .../scenario/service/ScenarioServiceTest.java | 196 ++++++----- .../scenario/util/OrderCalculatorTest.java | 13 +- 21 files changed, 944 insertions(+), 236 deletions(-) create mode 100644 src/main/java/com/und/server/notification/event/ActiveUpdateEvent.java create mode 100644 src/main/java/com/und/server/notification/event/ActiveUpdateEventListener.java create mode 100644 src/test/java/com/und/server/notification/event/ActiveUpdateEventListenerTest.java create mode 100644 src/test/java/com/und/server/notification/event/ActiveUpdateEventTest.java diff --git a/src/main/java/com/und/server/notification/controller/NotificationApiDocs.java b/src/main/java/com/und/server/notification/controller/NotificationApiDocs.java index 15505fec..ff22174c 100644 --- a/src/main/java/com/und/server/notification/controller/NotificationApiDocs.java +++ b/src/main/java/com/und/server/notification/controller/NotificationApiDocs.java @@ -13,9 +13,41 @@ 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( diff --git a/src/main/java/com/und/server/notification/controller/NotificationController.java b/src/main/java/com/und/server/notification/controller/NotificationController.java index 8ba581c1..fadbed38 100644 --- a/src/main/java/com/und/server/notification/controller/NotificationController.java +++ b/src/main/java/com/und/server/notification/controller/NotificationController.java @@ -3,7 +3,9 @@ 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; @@ -12,8 +14,10 @@ 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 @@ -21,9 +25,22 @@ @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( diff --git a/src/main/java/com/und/server/notification/entity/Notification.java b/src/main/java/com/und/server/notification/entity/Notification.java index cf59875c..a497f96e 100644 --- a/src/main/java/com/und/server/notification/entity/Notification.java +++ b/src/main/java/com/und/server/notification/entity/Notification.java @@ -56,6 +56,14 @@ public boolean isEveryDay() { 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, 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 index 864428b4..68a01122 100644 --- a/src/main/java/com/und/server/notification/event/NotificationEventPublisher.java +++ b/src/main/java/com/und/server/notification/event/NotificationEventPublisher.java @@ -38,4 +38,10 @@ public void publishDeleteEvent( 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/service/NotificationCacheService.java b/src/main/java/com/und/server/notification/service/NotificationCacheService.java index 014e385c..7c030f8b 100644 --- a/src/main/java/com/und/server/notification/service/NotificationCacheService.java +++ b/src/main/java/com/und/server/notification/service/NotificationCacheService.java @@ -154,7 +154,7 @@ private boolean handleRefreshCacheFromDatabase(Long memberId) { return false; } - private void refreshCacheFromDatabase(Long memberId) { + public void refreshCacheFromDatabase(Long memberId) { List scenarioNotifications = scenarioNotificationService.getScenarioNotifications(memberId); diff --git a/src/main/java/com/und/server/notification/service/NotificationService.java b/src/main/java/com/und/server/notification/service/NotificationService.java index 46e5d297..795ef55d 100644 --- a/src/main/java/com/und/server/notification/service/NotificationService.java +++ b/src/main/java/com/und/server/notification/service/NotificationService.java @@ -1,5 +1,8 @@ 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; @@ -8,7 +11,10 @@ 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; @@ -17,7 +23,9 @@ public class NotificationService { private final NotificationRepository notificationRepository; + private final ScenarioRepository scenarioRepository; private final NotificationConditionSelector notificationConditionSelector; + private final NotificationEventPublisher notificationEventPublisher; @Transactional(readOnly = true) @@ -55,6 +63,29 @@ public void updateNotification( } + @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( diff --git a/src/main/java/com/und/server/scenario/controller/ScenarioApiDocs.java b/src/main/java/com/und/server/scenario/controller/ScenarioApiDocs.java index aff1949a..56a2572d 100644 --- a/src/main/java/com/und/server/scenario/controller/ScenarioApiDocs.java +++ b/src/main/java/com/und/server/scenario/controller/ScenarioApiDocs.java @@ -199,38 +199,11 @@ ResponseEntity getScenarioDetail( @ApiResponses({ @ApiResponse( responseCode = "201", - description = "Create Scenario successful", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = MissionGroupResponse.class), - examples = @ExampleObject( - name = "Basic missions only", - value = """ - { - "scenarioId": 2, - "basicMissions": [ - { - "missionId": 3, - "content": "Lock door", - "isChecked": false, - "missionType": "BASIC" - }, - { - "missionId": 4, - "content": "Open door", - "isChecked": false, - "missionType": "BASIC" - } - ], - "todayMissions": [] - } - """ - ) - ) + description = "Create Scenario successful" ), @ApiResponse( responseCode = "400", - description = "Bad request - invalid parameters", + description = "Bad request", content = @Content( mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), @@ -319,7 +292,7 @@ ResponseEntity getScenarioDetail( ) ) }) - ResponseEntity addScenario( + ResponseEntity> addScenario( @Parameter(hidden = true) Long memberId, @RequestBody( description = "Scenario detail request", @@ -380,15 +353,11 @@ ResponseEntity addScenario( @ApiResponses({ @ApiResponse( responseCode = "200", - description = "Update Scenario successful", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = MissionGroupResponse.class) - ) + description = "Update Scenario successful" ), @ApiResponse( responseCode = "400", - description = "Bad request - invalid parameters", + description = "Bad request", content = @Content( mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), @@ -494,7 +463,7 @@ ResponseEntity addScenario( ) ) }) - ResponseEntity updateScenario( + ResponseEntity> updateScenario( @Parameter(hidden = true) Long memberId, @Parameter(description = "Scenario ID") Long scenarioId, @RequestBody( diff --git a/src/main/java/com/und/server/scenario/controller/ScenarioController.java b/src/main/java/com/und/server/scenario/controller/ScenarioController.java index ba2e0091..a0c610a5 100644 --- a/src/main/java/com/und/server/scenario/controller/ScenarioController.java +++ b/src/main/java/com/und/server/scenario/controller/ScenarioController.java @@ -20,7 +20,6 @@ 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; @@ -66,28 +65,28 @@ public ResponseEntity getScenarioDetail( @Override @PostMapping("/scenarios") - public ResponseEntity addScenario( + public ResponseEntity> addScenario( @AuthMember final Long memberId, @RequestBody @Valid final ScenarioDetailRequest scenarioRequest ) { - final MissionGroupResponse missionGroupResponse = + final List scenarios = scenarioService.addScenario(memberId, scenarioRequest); - return ResponseEntity.status(HttpStatus.CREATED).body(missionGroupResponse); + return ResponseEntity.status(HttpStatus.CREATED).body(scenarios); } @Override @PutMapping("/scenarios/{scenarioId}") - public ResponseEntity updateScenario( + public ResponseEntity> updateScenario( @AuthMember final Long memberId, @PathVariable final Long scenarioId, @RequestBody @Valid final ScenarioDetailRequest scenarioRequest ) { - MissionGroupResponse missionGroupResponse = + final List scenarios = scenarioService.updateScenario(memberId, scenarioId, scenarioRequest); - return ResponseEntity.ok().body(missionGroupResponse); + return ResponseEntity.ok().body(scenarios); } diff --git a/src/main/java/com/und/server/scenario/repository/ScenarioRepository.java b/src/main/java/com/und/server/scenario/repository/ScenarioRepository.java index 9153e91c..26f6affd 100644 --- a/src/main/java/com/und/server/scenario/repository/ScenarioRepository.java +++ b/src/main/java/com/und/server/scenario/repository/ScenarioRepository.java @@ -4,6 +4,7 @@ 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; @@ -16,6 +17,9 @@ public interface ScenarioRepository extends JpaRepository, Scena 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 diff --git a/src/main/java/com/und/server/scenario/service/ScenarioService.java b/src/main/java/com/und/server/scenario/service/ScenarioService.java index b55c2147..6c26d55d 100644 --- a/src/main/java/com/und/server/scenario/service/ScenarioService.java +++ b/src/main/java/com/und/server/scenario/service/ScenarioService.java @@ -2,7 +2,6 @@ import java.time.Clock; import java.time.LocalDate; -import java.time.ZoneId; import java.util.Collections; import java.util.List; @@ -22,7 +21,6 @@ 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.MissionGroupResponse; import com.und.server.scenario.dto.response.MissionResponse; import com.und.server.scenario.dto.response.OrderUpdateResponse; import com.und.server.scenario.dto.response.ScenarioDetailResponse; @@ -99,7 +97,7 @@ public MissionResponse addTodayMissionToScenario( @Transactional - public MissionGroupResponse addScenario(final Long memberId, final ScenarioDetailRequest scenarioDetailRequest) { + public List addScenario(final Long memberId, final ScenarioDetailRequest scenarioDetailRequest) { Member member = em.getReference(Member.class, memberId); NotificationRequest notificationRequest = scenarioDetailRequest.notification(); @@ -111,7 +109,7 @@ public MissionGroupResponse addScenario(final Long memberId, final ScenarioDetai int order = orders.isEmpty() ? OrderCalculator.START_ORDER - : getValidScenarioOrder(Collections.max(orders), memberId, notificationType); + : getValidScenarioOrder(Collections.min(orders), memberId, notificationType); Notification notification = notificationService.addNotification( notificationRequest, scenarioDetailRequest.notificationCondition()); @@ -125,18 +123,15 @@ public MissionGroupResponse addScenario(final Long memberId, final ScenarioDetai .build(); scenarioRepository.save(scenario); - List missions = missionService.addBasicMission(scenario, scenarioDetailRequest.basicMissions()); - - List basicMissions = missionTypeGroupSorter.groupAndSortByType(missions, MissionType.BASIC); + missionService.addBasicMission(scenario, scenarioDetailRequest.basicMissions()); notificationEventPublisher.publishCreateEvent(memberId, scenario); - - return MissionGroupResponse.from(scenario.getId(), basicMissions, null); + return findScenariosByMemberId(memberId, notificationType); } @Transactional - public MissionGroupResponse updateScenario( + public List updateScenario( final Long memberId, final Long scenarioId, final ScenarioDetailRequest scenarioDetailRequest @@ -159,9 +154,7 @@ public MissionGroupResponse updateScenario( oldScenario.updateMemo(scenarioDetailRequest.memo()); notificationEventPublisher.publishUpdateEvent(memberId, oldScenario, isOldScenarioNotificationActive); - - return missionService.findMissionsByScenarioId( - memberId, scenarioId, LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul")))); + return findScenariosByMemberId(memberId, scenarioDetailRequest.notification().notificationType()); } @@ -212,17 +205,17 @@ public void deleteScenarioWithAllMissions(final Long memberId, final Long scenar private int getValidScenarioOrder( - final int maxScenarioOrder, + final int minScenarioOrder, final Long memberId, final NotificationType notificationType ) { try { - return orderCalculator.getOrder(maxScenarioOrder, null); + return orderCalculator.getOrder(null, minScenarioOrder); } catch (ReorderRequiredException e) { List scenarios = scenarioRepository.findByMemberIdAndNotificationType(memberId, notificationType); - return orderCalculator.getMaxOrderAfterReorder(scenarios); + return orderCalculator.getMinOrderAfterReorder(scenarios); } } diff --git a/src/main/java/com/und/server/scenario/util/OrderCalculator.java b/src/main/java/com/und/server/scenario/util/OrderCalculator.java index ce0d7eee..607b21cf 100644 --- a/src/main/java/com/und/server/scenario/util/OrderCalculator.java +++ b/src/main/java/com/und/server/scenario/util/OrderCalculator.java @@ -57,16 +57,16 @@ public List reorder( } - public Integer getMaxOrderAfterReorder(final List scenarios) { + public Integer getMinOrderAfterReorder(final List scenarios) { if (scenarios.isEmpty()) { return START_ORDER; } scenarios.sort(Comparator.comparing(Scenario::getScenarioOrder)); assignSequentialOrders(scenarios); - Scenario lastScenario = scenarios.get(scenarios.size() - 1); + Scenario firstScenario = scenarios.get(0); - return calculateLastOrder(lastScenario.getScenarioOrder()); + return calculateStartOrder(firstScenario.getScenarioOrder()); } diff --git a/src/test/java/com/und/server/notification/controller/NotificationControllerTest.java b/src/test/java/com/und/server/notification/controller/NotificationControllerTest.java index b0406a8a..8928cd9a 100644 --- a/src/test/java/com/und/server/notification/controller/NotificationControllerTest.java +++ b/src/test/java/com/und/server/notification/controller/NotificationControllerTest.java @@ -20,6 +20,7 @@ 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 { @@ -30,6 +31,9 @@ class NotificationControllerTest { @Mock private NotificationCacheService notificationCacheService; + @Mock + private NotificationService notificationService; + private final Long memberId = 1L; private final Long scenarioId = 10L; @@ -214,4 +218,50 @@ void Given_DifferentScenarioId_When_GetSingleScenarioNotification_Then_ReturnCor 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/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 index bdf63578..c291f1c7 100644 --- a/src/test/java/com/und/server/notification/event/NotificationEventPublisherTest.java +++ b/src/test/java/com/und/server/notification/event/NotificationEventPublisherTest.java @@ -163,4 +163,44 @@ void Given_DifferentMemberIdAndScenarioId_When_PublishDeleteEvent_Then_PublishSc 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/service/NotificationServiceTest.java b/src/test/java/com/und/server/notification/service/NotificationServiceTest.java index 149f80ea..46ce066a 100644 --- a/src/test/java/com/und/server/notification/service/NotificationServiceTest.java +++ b/src/test/java/com/und/server/notification/service/NotificationServiceTest.java @@ -2,7 +2,10 @@ 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; @@ -21,7 +24,10 @@ 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 { @@ -32,6 +38,12 @@ class NotificationServiceTest { @Mock private NotificationConditionSelector notificationConditionSelector; + @Mock + private ScenarioRepository scenarioRepository; + + @Mock + private NotificationEventPublisher notificationEventPublisher; + @InjectMocks private NotificationService notificationService; @@ -90,10 +102,11 @@ void Given_NotificationRequestAndCondition_When_AddNotification_Then_SaveNotific Notification result = notificationService.addNotification(notificationInfo, conditionInfo); // then - assertThat(result).isNotNull(); - assertThat(result.getNotificationType()).isEqualTo(NotificationType.TIME); - assertThat(result.getNotificationMethodType()).isEqualTo(NotificationMethodType.PUSH); - assertThat(result.getIsActive()).isTrue(); + 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)); @@ -126,9 +139,10 @@ void Given_ActiveNotificationAndSameType_When_UpdateNotification_Then_UpdateNoti notificationService.updateNotification(oldNotification, notificationInfo, conditionInfo); // then - assertThat(oldNotification.getNotificationType()).isEqualTo(NotificationType.TIME); - assertThat(oldNotification.getNotificationMethodType()).isEqualTo(NotificationMethodType.ALARM); - assertThat(oldNotification.isActive()).isTrue(); + 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); } @@ -160,9 +174,10 @@ void Given_ActiveNotificationAndDifferentType_When_UpdateNotification_Then_Delet notificationService.updateNotification(oldNotification, notificationInfo, conditionInfo); // then - assertThat(oldNotification.getNotificationType()).isEqualTo(NotificationType.LOCATION); - assertThat(oldNotification.getNotificationMethodType()).isEqualTo(NotificationMethodType.ALARM); - assertThat(oldNotification.isActive()).isTrue(); + 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) @@ -189,8 +204,9 @@ void Given_ActiveNotificationAndInactive_When_UpdateNotification_Then_DeleteCond notificationService.updateNotification(oldNotification, notificationRequest, null); // then - assertThat(oldNotification.isActive()).isFalse(); - assertThat(oldNotification.getNotificationMethodType()).isNull(); + assertThat(oldNotification) + .satisfies(n -> assertThat(n.isActive()).isFalse()) + .satisfies(n -> assertThat(n.getNotificationMethodType()).isNull()); verify(notificationConditionSelector) .deleteNotificationCondition(NotificationType.TIME, oldNotification.getId()); } @@ -216,8 +232,9 @@ void Given_NotificationType_When_AddWithoutNotification_Then_CreateInactiveNotif Notification result = notificationService.addNotification(request, null); // then - assertThat(result.getNotificationType()).isEqualTo(type); - assertThat(result.getIsActive()).isFalse(); + assertThat(result) + .satisfies(r -> assertThat(r.getNotificationType()).isEqualTo(type)) + .satisfies(r -> assertThat(r.getIsActive()).isFalse()); verify(notificationRepository).save(any(Notification.class)); } @@ -261,9 +278,10 @@ void Given_NotificationWithDaysOfWeek_When_FindNotificationDetails_Then_ReturnNo // then assertThat(result).isEqualTo(expectedInfo); - assertThat(notification.isEveryDay()).isTrue(); - assertThat(notification.getDaysOfWeekOrdinalList()).hasSize(7); - assertThat(notification.getDaysOfWeekOrdinalList()).containsExactlyInAnyOrder(0, 1, 2, 3, 4, 5, 6); + 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); } @@ -290,9 +308,10 @@ void Given_NotificationWithSpecificDays_When_FindNotificationDetails_Then_Return // then assertThat(result).isEqualTo(expectedInfo); - assertThat(notification.isEveryDay()).isFalse(); - assertThat(notification.getDaysOfWeekOrdinalList()).hasSize(3); - assertThat(notification.getDaysOfWeekOrdinalList()).containsExactlyInAnyOrder(0, 2, 4); + 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); } @@ -319,8 +338,9 @@ void Given_NotificationWithEmptyDays_When_FindNotificationDetails_Then_ReturnNot // then assertThat(result).isEqualTo(expectedInfo); - assertThat(notification.isEveryDay()).isFalse(); - assertThat(notification.getDaysOfWeekOrdinalList()).isEmpty(); + assertThat(notification) + .satisfies(n -> assertThat(n.isEveryDay()).isFalse()) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).isEmpty()); verify(notificationConditionSelector).findNotificationCondition(notification); } @@ -347,8 +367,9 @@ void Given_NotificationWithNullDays_When_FindNotificationDetails_Then_ReturnNoti // then assertThat(result).isEqualTo(expectedInfo); - assertThat(notification.isEveryDay()).isFalse(); - assertThat(notification.getDaysOfWeekOrdinalList()).isEmpty(); + assertThat(notification) + .satisfies(n -> assertThat(n.isEveryDay()).isFalse()) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).isEmpty()); verify(notificationConditionSelector).findNotificationCondition(notification); } @@ -369,9 +390,10 @@ void Given_ActiveNotification_When_UpdateDaysOfWeekOrdinal_Then_UpdateSuccessful notification.updateDaysOfWeekOrdinal(newDays); // then - assertThat(notification.getDaysOfWeekOrdinalList()).hasSize(3); - assertThat(notification.getDaysOfWeekOrdinalList()).containsExactlyInAnyOrder(1, 3, 5); - assertThat(notification.isEveryDay()).isFalse(); + assertThat(notification) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).hasSize(3)) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).containsExactlyInAnyOrder(1, 3, 5)) + .satisfies(n -> assertThat(n.isEveryDay()).isFalse()); } @@ -391,8 +413,9 @@ void Given_InactiveNotification_When_UpdateDaysOfWeekOrdinal_Then_SetToNull() { notification.updateDaysOfWeekOrdinal(newDays); // then - assertThat(notification.getDaysOfWeekOrdinalList()).isEmpty(); - assertThat(notification.isEveryDay()).isFalse(); + assertThat(notification) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).isEmpty()) + .satisfies(n -> assertThat(n.isEveryDay()).isFalse()); } @@ -410,8 +433,9 @@ void Given_ActiveNotification_When_UpdateDaysOfWeekOrdinalWithNull_Then_SetToNul notification.updateDaysOfWeekOrdinal(null); // then - assertThat(notification.getDaysOfWeekOrdinalList()).isEmpty(); - assertThat(notification.isEveryDay()).isFalse(); + assertThat(notification) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).isEmpty()) + .satisfies(n -> assertThat(n.isEveryDay()).isFalse()); } @@ -429,8 +453,9 @@ void Given_ActiveNotification_When_UpdateDaysOfWeekOrdinalWithEmpty_Then_SetToNu notification.updateDaysOfWeekOrdinal(List.of()); // then - assertThat(notification.getDaysOfWeekOrdinalList()).isEmpty(); - assertThat(notification.isEveryDay()).isFalse(); + assertThat(notification) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).isEmpty()) + .satisfies(n -> assertThat(n.isEveryDay()).isFalse()); } @@ -448,8 +473,9 @@ void Given_Notification_When_UpdateDaysOfWeekOrdinalWithEmptyList_Then_SetToNull notification.updateDaysOfWeekOrdinal(List.of()); // then - assertThat(notification.getDaysOfWeekOrdinalList()).isEmpty(); - assertThat(notification.isEveryDay()).isFalse(); + assertThat(notification) + .satisfies(n -> assertThat(n.getDaysOfWeekOrdinalList()).isEmpty()) + .satisfies(n -> assertThat(n.isEveryDay()).isFalse()); } @@ -467,8 +493,219 @@ void Given_Notification_When_DeactivateNotification_Then_SetMethodTypeToNull() { notification.deactivate(); // then - assertThat(notification.getNotificationMethodType()).isNull(); - assertThat(notification.isActive()).isFalse(); + 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/scenario/controller/ScenarioControllerTest.java b/src/test/java/com/und/server/scenario/controller/ScenarioControllerTest.java index 552d5859..007f3605 100644 --- a/src/test/java/com/und/server/scenario/controller/ScenarioControllerTest.java +++ b/src/test/java/com/und/server/scenario/controller/ScenarioControllerTest.java @@ -19,7 +19,6 @@ 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.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; @@ -101,17 +100,19 @@ void Given_ValidMemberIdAndScenarioRequest_When_AddScenario_Then_ReturnCreated() .memo("새 시나리오 설명") .build(); - MissionGroupResponse expectedResponse = MissionGroupResponse.builder() - .scenarioId(expectedScenarioId) - .basicMissions(List.of()) - .todayMissions(null) - .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); + ResponseEntity> response = scenarioController.addScenario(memberId, scenarioRequest); // then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); @@ -138,17 +139,19 @@ void Given_ValidMemberIdAndScenarioRequest_When_AddScenarioWithoutNotification_T .notificationCondition(null) .build(); - MissionGroupResponse expectedResponse = MissionGroupResponse.builder() - .scenarioId(expectedScenarioId) - .basicMissions(List.of()) - .todayMissions(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); + ResponseEntity> response = scenarioController.addScenario(memberId, request); // then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); @@ -175,17 +178,19 @@ void Given_ValidMemberIdAndScenarioId_When_UpdateScenarioWithoutNotification_The .notificationCondition(null) .build(); - MissionGroupResponse expectedResponse = MissionGroupResponse.builder() - .scenarioId(scenarioId) - .basicMissions(List.of()) - .todayMissions(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 + ResponseEntity> response = scenarioController .updateScenario(memberId, scenarioId, request); // then @@ -205,17 +210,19 @@ void Given_EmptyTitleRequest_When_AddScenario_Then_ReturnCreated() { .memo("빈 제목 시나리오") .build(); - MissionGroupResponse expectedResponse = MissionGroupResponse.builder() - .scenarioId(expectedScenarioId) - .basicMissions(List.of()) - .todayMissions(null) - .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); + ResponseEntity> response = scenarioController.addScenario(memberId, scenarioRequest); // then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); @@ -234,17 +241,19 @@ void Given_ValidMemberIdAndScenarioIdAndRequest_When_UpdateScenario_Then_ReturnO .memo("수정된 시나리오 설명") .build(); - MissionGroupResponse expectedResponse = MissionGroupResponse.builder() - .scenarioId(scenarioId) - .basicMissions(List.of()) - .todayMissions(null) - .build(); + List expectedResponse = List.of( + ScenarioResponse.builder() + .scenarioId(scenarioId) + .scenarioName("수정된 시나리오") + .memo("수정된 시나리오 설명") + .build() + ); when(scenarioService.updateScenario(memberId, scenarioId, scenarioRequest)) .thenReturn(expectedResponse); // when - ResponseEntity response = + ResponseEntity> response = scenarioController.updateScenario(memberId, scenarioId, scenarioRequest); // then @@ -264,17 +273,19 @@ void Given_EmptyTitleRequest_When_UpdateScenario_Then_ReturnOk() { .memo("수정된 빈 제목 시나리오") .build(); - MissionGroupResponse expectedResponse = MissionGroupResponse.builder() - .scenarioId(scenarioId) - .basicMissions(List.of()) - .todayMissions(null) - .build(); + List expectedResponse = List.of( + ScenarioResponse.builder() + .scenarioId(scenarioId) + .scenarioName("") + .memo("수정된 빈 제목 시나리오") + .build() + ); when(scenarioService.updateScenario(memberId, scenarioId, scenarioRequest)) .thenReturn(expectedResponse); // when - ResponseEntity response = + ResponseEntity> response = scenarioController.updateScenario(memberId, scenarioId, scenarioRequest); // then @@ -294,17 +305,19 @@ void Given_LongTitleRequest_When_AddScenario_Then_ReturnCreated() { .memo("긴 제목 시나리오") .build(); - MissionGroupResponse expectedResponse = MissionGroupResponse.builder() - .scenarioId(expectedScenarioId) - .basicMissions(List.of()) - .todayMissions(null) - .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); + ResponseEntity> response = scenarioController.addScenario(memberId, scenarioRequest); // then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); @@ -323,17 +336,19 @@ void Given_LongTitleRequest_When_UpdateScenario_Then_ReturnOk() { .memo("긴 제목 수정 시나리오") .build(); - MissionGroupResponse expectedResponse = MissionGroupResponse.builder() - .scenarioId(scenarioId) - .basicMissions(List.of()) - .todayMissions(null) - .build(); + List expectedResponse = List.of( + ScenarioResponse.builder() + .scenarioId(scenarioId) + .scenarioName("매우 긴 수정된 시나리오 제목입니다") + .memo("긴 제목 수정 시나리오") + .build() + ); when(scenarioService.updateScenario(memberId, scenarioId, scenarioRequest)) .thenReturn(expectedResponse); // when - ResponseEntity response = + ResponseEntity> response = scenarioController.updateScenario(memberId, scenarioId, scenarioRequest); // then diff --git a/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java b/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java index 4cde304d..82542384 100644 --- a/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java +++ b/src/test/java/com/und/server/scenario/service/ScenarioServiceTest.java @@ -46,7 +46,6 @@ 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.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; @@ -77,7 +76,7 @@ class ScenarioServiceTest { private ScenarioRepository scenarioRepository; @Mock - private MissionTypeGroupSorter missionTypeGrouper; + private MissionTypeGroupSorter missionTypeGroupSorter; @Mock private OrderCalculator orderCalculator; @@ -151,9 +150,10 @@ void Given_memberId_When_FindScenarios_Then_ReturnScenarios() { //then assertNotNull(result); - assertThat(result.size()).isEqualTo(2); - assertThat(result.get(0).scenarioName()).isEqualTo("시나리오A"); - assertThat(result.get(1).scenarioName()).isEqualTo("시나리오B"); + assertThat(result) + .hasSize(2) + .satisfies(r -> assertThat(r.get(0).scenarioName()).isEqualTo("시나리오A")) + .satisfies(r -> assertThat(r.get(1).scenarioName()).isEqualTo("시나리오B")); } @@ -198,7 +198,7 @@ void Given_validScenario_When_findScenarioByScenarioId_Then_returnResponse() { Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) .thenReturn(Optional.of(scenario)); Mockito.when(notificationService.findNotificationDetails(notification)).thenReturn(notifDetail); - Mockito.when(missionTypeGrouper.groupAndSortByType(scenario.getMissions(), MissionType.BASIC)) + Mockito.when(missionTypeGroupSorter.groupAndSortByType(scenario.getMissions(), MissionType.BASIC)) .thenReturn(List.of()); // when @@ -206,11 +206,14 @@ void Given_validScenario_When_findScenarioByScenarioId_Then_returnResponse() { // then assertNotNull(response); - assertThat(response.scenarioId()).isEqualTo(scenarioId); - assertThat(response.notificationCondition()).isInstanceOf(TimeNotificationResponse.class); - TimeNotificationResponse detail = (TimeNotificationResponse) response.notificationCondition(); - assertThat(detail.startHour()).isEqualTo(8); - assertThat(detail.startMinute()).isEqualTo(30); + 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); + }); } @@ -360,31 +363,44 @@ void Given_ValidRequest_When_AddScenario_Then_SaveScenarioAndAddMissions() { given(missionService.addBasicMission(any(Scenario.class), eq(missionList))) .willReturn(savedMissions); - given(missionTypeGrouper.groupAndSortByType(savedMissions, MissionType.BASIC)) - .willReturn(groupedBasicMissions); + + // 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 - MissionGroupResponse result = scenarioService.addScenario(memberId, scenarioRequest); + 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(missionTypeGrouper).groupAndSortByType(savedMissions, MissionType.BASIC); verify(notificationEventPublisher).publishCreateEvent(eq(memberId), any(Scenario.class)); Scenario saved = scenarioCaptor.getValue(); - assertThat(saved.getScenarioName()).isEqualTo("Morning"); - assertThat(saved.getMemo()).isEqualTo("Routine"); - assertThat(saved.getScenarioOrder()).isEqualTo(calculatedOrder); - assertThat(saved.getNotification()).isEqualTo(savedNotification); - assertThat(saved.getMember().getId()).isEqualTo(member.getId()); - - assertThat(result).isNotNull(); - assertThat(result.scenarioId()).isEqualTo(saved.getId()); - assertThat(result.basicMissions()).hasSize(2); - assertThat(result.todayMissions()).isEmpty(); + 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")); } @@ -436,30 +452,40 @@ void Given_ReorderRequired_When_AddScenario_Then_ReorderAndRetry() { Scenario s1 = Scenario.builder().id(1L).scenarioOrder(10_000_000).build(); given(scenarioRepository.findByMemberIdAndNotificationType(memberId, NotificationType.TIME)) .willReturn(List.of(s1)); - given(orderCalculator.getMaxOrderAfterReorder(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()); - given(missionTypeGrouper.groupAndSortByType(List.of(), MissionType.BASIC)) - .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 - MissionGroupResponse result = scenarioService.addScenario(memberId, scenarioRequest); + List result = scenarioService.addScenario(memberId, scenarioRequest); // then verify(scenarioRepository).save(captor.capture()); - verify(missionTypeGrouper).groupAndSortByType(List.of(), MissionType.BASIC); verify(notificationEventPublisher).publishCreateEvent(eq(memberId), any(Scenario.class)); Scenario saved = captor.getValue(); - assertThat(saved.getScenarioOrder()).isEqualTo(reorderedOrder); - assertThat(result).isNotNull(); - assertThat(result.scenarioId()).isEqualTo(saved.getId()); - assertThat(result.basicMissions()).isEmpty(); - assertThat(result.todayMissions()).isEmpty(); + 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 @@ -541,33 +567,39 @@ void Given_ValidRequest_When_UpdateScenario_Then_UpdateScenarioAndNotification() return null; }).when(notificationService).updateNotification(oldNotification, notifRequest, condition); - MissionGroupResponse expectedResponse = MissionGroupResponse.builder() - .scenarioId(scenarioId) - .basicMissions(List.of()) - .todayMissions(List.of()) + // Mock findScenariosByMemberId to return the updated scenario + Scenario updatedScenario = Scenario.builder() + .id(scenarioId) + .scenarioName("수정할 시나리오") + .memo("수정할 메모") + .notification(oldNotification) + .member(member) .build(); - - given(missionService.findMissionsByScenarioId(eq(memberId), eq(scenarioId), any(LocalDate.class))) - .willReturn(expectedResponse); + given(scenarioRepository.findByMemberIdAndNotificationType(memberId, NotificationType.TIME)) + .willReturn(List.of(updatedScenario)); // when - MissionGroupResponse result = scenarioService.updateScenario(memberId, scenarioId, scenarioRequest); + List result = scenarioService.updateScenario(memberId, scenarioId, scenarioRequest); // then - assertThat(oldScenario.getScenarioName()).isEqualTo("수정된 시나리오"); - assertThat(oldScenario.getMemo()).isEqualTo("수정된 메모"); - assertThat(oldScenario.getNotification().getNotificationType()).isEqualTo(notifRequest.notificationType()); - assertThat(oldScenario.getNotification().getNotificationMethodType()) - .isEqualTo(notifRequest.notificationMethodType()); - assertThat(oldScenario.getNotification().isActive()).isTrue(); + 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(); - assertThat(result.scenarioId()).isEqualTo(scenarioId); - assertThat(result.basicMissions()).isEmpty(); - assertThat(result.todayMissions()).isEmpty(); + 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("수정할 메모")); } @@ -607,10 +639,11 @@ void Given_ValidRequest_When_UpdateScenarioOrder_Then_UpdateOrder() { // then assertThat(scenario.getScenarioOrder()).isEqualTo(newOrder); - assertThat(response.isReorder()).isFalse(); - assertThat(response.orderUpdates()).hasSize(1); - assertThat(response.orderUpdates().get(0).id()).isEqualTo(scenarioId); - assertThat(response.orderUpdates().get(0).newOrder()).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); } @@ -658,8 +691,9 @@ void Given_ReorderRequired_When_UpdateScenarioOrder_Then_ReorderScenarios() { OrderUpdateResponse response = scenarioService.updateScenarioOrder(memberId, scenarioId, orderRequest); // then - assertThat(response.isReorder()).isTrue(); - assertThat(response.orderUpdates()).hasSize(2); + assertThat(response) + .satisfies(r -> assertThat(r.isReorder()).isTrue()) + .satisfies(r -> assertThat(r.orderUpdates()).hasSize(2)); verify(orderCalculator).reorder(anyList(), eq(scenarioId), eq(errorOrder)); } @@ -835,29 +869,34 @@ void Given_ValidRequest_When_UpdateScenarioWithoutNotification_Then_UpdateScenar Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) .thenReturn(Optional.of(oldScenario)); - MissionGroupResponse expectedResponse = MissionGroupResponse.builder() - .scenarioId(scenarioId) - .basicMissions(List.of()) - .todayMissions(List.of()) + // Mock findScenariosByMemberId to return the updated scenario + Scenario updatedScenario = Scenario.builder() + .id(scenarioId) + .scenarioName("수정할 시나리오") + .memo("수정할 메모") + .notification(oldNotification) + .member(member) .build(); - - given(missionService.findMissionsByScenarioId(eq(memberId), eq(scenarioId), any(LocalDate.class))) - .willReturn(expectedResponse); + given(scenarioRepository.findByMemberIdAndNotificationType(memberId, NotificationType.TIME)) + .willReturn(List.of(updatedScenario)); // when - MissionGroupResponse result = scenarioService.updateScenario(memberId, scenarioId, scenarioRequest); + List result = scenarioService.updateScenario(memberId, scenarioId, scenarioRequest); // then - assertThat(oldScenario.getScenarioName()).isEqualTo("수정된 시나리오"); - assertThat(oldScenario.getMemo()).isEqualTo("수정된 메모"); + 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(); - assertThat(result.scenarioId()).isEqualTo(scenarioId); - assertThat(result.basicMissions()).isEmpty(); - assertThat(result.todayMissions()).isEmpty(); + 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("수정할 메모")); } @@ -1000,7 +1039,7 @@ void Given_notificationInfoIsNull_When_findScenarioByScenarioId_Then_returnRespo Mockito.when(scenarioRepository.findScenarioDetailFetchByIdAndMemberId(memberId, scenarioId)) .thenReturn(Optional.of(scenario)); Mockito.when(notificationService.findNotificationDetails(notification)).thenReturn(null); - Mockito.when(missionTypeGrouper.groupAndSortByType(scenario.getMissions(), MissionType.BASIC)) + Mockito.when(missionTypeGroupSorter.groupAndSortByType(scenario.getMissions(), MissionType.BASIC)) .thenReturn(List.of()); // when @@ -1008,10 +1047,11 @@ void Given_notificationInfoIsNull_When_findScenarioByScenarioId_Then_returnRespo // then assertNotNull(response); - assertThat(response.scenarioId()).isEqualTo(scenarioId); - assertThat(response.notification().isEveryDay()).isNull(); - assertThat(response.notification().daysOfWeekOrdinal()).isNull(); - assertThat(response.notificationCondition()).isNull(); + 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()); } diff --git a/src/test/java/com/und/server/scenario/util/OrderCalculatorTest.java b/src/test/java/com/und/server/scenario/util/OrderCalculatorTest.java index 9365c5cb..32bb4461 100644 --- a/src/test/java/com/und/server/scenario/util/OrderCalculatorTest.java +++ b/src/test/java/com/und/server/scenario/util/OrderCalculatorTest.java @@ -84,7 +84,6 @@ void Given_ScenariosAndTargetId_When_Reorder_Then_ReturnReorderedScenarios() { // 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); @@ -93,19 +92,19 @@ void Given_ScenariosAndTargetId_When_Reorder_Then_ReturnReorderedScenarios() { } @Test - void Given_EmptyScenarioList_When_GetMaxOrderAfterReorder_Then_ReturnStartOrder() { + void Given_EmptyScenarioList_When_GetMinOrderAfterReorder_Then_ReturnStartOrder() { // given List emptyScenarios = List.of(); // when - Integer result = orderCalculator.getMaxOrderAfterReorder(emptyScenarios); + Integer result = orderCalculator.getMinOrderAfterReorder(emptyScenarios); // then assertThat(result).isEqualTo(OrderCalculator.START_ORDER); } @Test - void Given_ScenarioList_When_GetMaxOrderAfterReorder_Then_ReturnMaxOrderPlusDefault() { + void Given_ScenarioList_When_GetMinOrderAfterReorder_Then_ReturnMinOrderMinusDefault() { // given Scenario scenario1 = Scenario.builder() .id(1L) @@ -120,12 +119,10 @@ void Given_ScenarioList_When_GetMaxOrderAfterReorder_Then_ReturnMaxOrderPlusDefa List scenarios = new java.util.ArrayList<>(List.of(scenario1, scenario2)); // when - Integer result = orderCalculator.getMaxOrderAfterReorder(scenarios); + Integer result = orderCalculator.getMinOrderAfterReorder(scenarios); // then - // 리오더링 후 마지막 시나리오의 order + DEFAULT_ORDER - assertThat(result).isEqualTo( - OrderCalculator.START_ORDER + OrderCalculator.DEFAULT_ORDER + OrderCalculator.DEFAULT_ORDER); + assertThat(result).isEqualTo(OrderCalculator.START_ORDER - OrderCalculator.DEFAULT_ORDER); } }