From 4a1b54190c5c176091c66a1ebd70905163ccb1c3 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Tue, 26 May 2026 17:30:15 +0900 Subject: [PATCH] Add analytics preference API --- docs/account-deletion-api.md | 2 + .../account-deletion-verification-evidence.md | 1 + docs/analytics-preference-api.md | 91 ++++++++++ .../ontime_back/config/SecurityConfig.java | 4 +- .../ontime_back/config/SwaggerConfig.java | 7 + .../AnalyticsPreferenceController.java | 71 ++++++++ .../dto/AnalyticsPreferenceResponseDto.java | 15 ++ .../dto/AnalyticsPreferenceUpdateDto.java | 40 +++++ .../entity/UserAnalyticsPreference.java | 85 +++++++++ .../global/oauth/apple/AppleLoginService.java | 3 + .../oauth/google/GoogleLoginService.java | 5 + .../global/oauth/kakao/KakaoLoginFilter.java | 7 +- .../UserAnalyticsPreferenceRepository.java | 12 ++ .../service/AnalyticsPreferenceService.java | 71 ++++++++ .../ontime_back/service/UserAuthService.java | 2 + .../main/resources/application-dev.properties | 1 + .../resources/application-local.properties | 1 + .../resources/application-prod.properties | 1 + .../resources/application-test.properties | 1 + .../V15__add_user_analytics_preference.sql | 12 ++ .../ontime_back/ControllerTestSupport.java | 4 + .../ontime_back/config/SwaggerConfigTest.java | 1 + .../AnalyticsPreferenceControllerTest.java | 91 ++++++++++ .../oauth/OAuthLoginFilterValidationTest.java | 9 +- ...thRegistrationAnalyticsPreferenceTest.java | 166 ++++++++++++++++++ .../AnalyticsPreferenceServiceTest.java | 140 +++++++++++++++ .../service/UserAuthServiceTest.java | 13 ++ .../src/test/resources/application.properties | 1 + 28 files changed, 853 insertions(+), 4 deletions(-) create mode 100644 docs/analytics-preference-api.md create mode 100644 ontime-back/src/main/java/devkor/ontime_back/controller/AnalyticsPreferenceController.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/dto/AnalyticsPreferenceResponseDto.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/dto/AnalyticsPreferenceUpdateDto.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/entity/UserAnalyticsPreference.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/repository/UserAnalyticsPreferenceRepository.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/service/AnalyticsPreferenceService.java create mode 100644 ontime-back/src/main/resources/db/migration/V15__add_user_analytics_preference.sql create mode 100644 ontime-back/src/test/java/devkor/ontime_back/controller/AnalyticsPreferenceControllerTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthRegistrationAnalyticsPreferenceTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/service/AnalyticsPreferenceServiceTest.java diff --git a/docs/account-deletion-api.md b/docs/account-deletion-api.md index e9d1763..e15d510 100644 --- a/docs/account-deletion-api.md +++ b/docs/account-deletion-api.md @@ -8,6 +8,8 @@ Account deletion hard-deletes the user from OnTime. The request can optionally i For Google and Apple social accounts, the backend first tries to revoke the social login token, then deletes the local OnTime account. If provider token revocation fails, the backend logs a warning and still deletes the local OnTime account. +Account deletion also deletes account-scoped settings and preferences, including the analytics preference stored in `user_analytics_preference`. Future user-linked Product Usage Events stop after deletion; historical analytics may be retained only in aggregate or de-identified form under the approved privacy policy and analytics provider configuration. + For release/privacy evidence by data category, see `docs/account-deletion-verification-evidence.md`. ## Authentication diff --git a/docs/account-deletion-verification-evidence.md b/docs/account-deletion-verification-evidence.md index ed2a298..e57078b 100644 --- a/docs/account-deletion-verification-evidence.md +++ b/docs/account-deletion-verification-evidence.md @@ -49,6 +49,7 @@ request timestamp, response, owner, and evidence link. | Access and refresh tokens | `user.access_token`, `user.refresh_token`, `user_device.session_access_token`, `user_device.session_refresh_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user and device rows are absent | Backend repo; release env owner TBD | | Device records and FCM tokens | `user.firebase_token`, `user_device`, `user_device.firebase_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user device row is absent | Backend repo; release env owner TBD | | Alarm settings and alarm status | `user_alarm_setting`, `user_alarm_status` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user/device | Test asserts alarm setting and status rows are absent | Backend repo; release env owner TBD | +| Analytics preference | `user_analytics_preference` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts analytics preference row is absent | Backend repo; release env owner TBD | | Default preparation settings | `preparation_user` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts preparation user rows are absent | Backend repo; release env owner TBD | | Schedules | `schedule`, `notification_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user and schedule | Test asserts schedule and notification schedule rows are absent | Backend repo; release env owner TBD | | Schedule preparation steps | `preparation_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from schedule | Test asserts preparation schedule rows are absent | Backend repo; release env owner TBD | diff --git a/docs/analytics-preference-api.md b/docs/analytics-preference-api.md new file mode 100644 index 0000000..513aa24 --- /dev/null +++ b/docs/analytics-preference-api.md @@ -0,0 +1,91 @@ +# Analytics Preference API + +Issue: #318 + +## Summary + +The analytics preference API stores whether the signed-in account allows optional +Product Usage Events. The preference is account-scoped, not device-scoped. + +This API does not define Firebase event names, frontend instrumentation, local +pre-login preference storage, UI copy, marketing analytics, personalization, or +Firebase Remote Config behavior. + +## Default And Release Gate + +Backend default is controlled by: + +```properties +analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false} +``` + +The default remains `false` until the privacy policy and Google Play Data Safety +updates are approved for the Firebase Analytics release. After approval, deploy +owners may set `ANALYTICS_PREFERENCE_DEFAULT_ENABLED=true`. + +Rows created before the default is flipped are marked as not user-overridden. +When the service reads a non-overridden row, it may align that row to the current +deploy default. Once a user explicitly updates the preference, the row is marked +as user-overridden and future default flips do not change that choice. + +## Get Analytics Preference + +```http +GET /users/me/analytics-preference +Authorization: Bearer +``` + +Successful response: + +```json +{ + "status": "success", + "code": 200, + "message": "OK", + "data": { + "enabled": false, + "updatedAt": "2026-05-26T12:00:00Z" + } +} +``` + +## Update Analytics Preference + +```http +PUT /users/me/analytics-preference +Authorization: Bearer +Content-Type: application/json + +{ + "enabled": true +} +``` + +Successful response: + +```json +{ + "status": "success", + "code": 200, + "message": "OK", + "data": { + "enabled": true, + "updatedAt": "2026-05-26T12:00:05Z" + } +} +``` + +`enabled` is required and must be a JSON boolean. Missing, null, non-boolean, or +unknown fields are rejected with the existing validation-style `400` response. + +## Persistence And Deletion + +The preference is stored in `user_analytics_preference` with a unique foreign key +to `user(user_id)`, `enabled`, `updated_at`, and the internal +`user_overridden` flag. + +On account deletion, the local analytics preference row is deleted by foreign-key +cascade. Future user-linked Product Usage Events stop when the account +preference is disabled or the account is deleted. Historical analytics may be +retained only in aggregate or de-identified form, subject to the approved privacy +policy and Firebase/analytics project configuration. diff --git a/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java b/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java index 3986586..b76c78c 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java +++ b/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java @@ -17,6 +17,7 @@ import devkor.ontime_back.global.oauth.google.GoogleLoginFilter; import devkor.ontime_back.repository.UserAlarmSettingRepository; import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.service.AnalyticsPreferenceService; import jakarta.validation.Validator; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -58,6 +59,7 @@ public class SecurityConfig { private final Validator validator; private final AppleLoginService appleLoginService; private final GoogleLoginService googleLoginService; + private final AnalyticsPreferenceService analyticsPreferenceService; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -79,7 +81,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/health").permitAll() // 로드밸런서 연결 확인용 url .anyRequest().authenticated() ) - .addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository), + .addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository, analyticsPreferenceService), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(new GoogleLoginFilter("/oauth2/google/login", objectMapper, validator, googleLoginService, userRepository), UsernamePasswordAuthenticationFilter.class) diff --git a/ontime-back/src/main/java/devkor/ontime_back/config/SwaggerConfig.java b/ontime-back/src/main/java/devkor/ontime_back/config/SwaggerConfig.java index bb4a958..fd7b96f 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/config/SwaggerConfig.java +++ b/ontime-back/src/main/java/devkor/ontime_back/config/SwaggerConfig.java @@ -281,6 +281,11 @@ private Map requestExamples(String operationKey) { examples.put("valid_partial_update", json("Valid partial update", "Only provided fields are updated.", "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10}")); examples.put("invalid_unknown_field", json("Invalid unknown field", "Unknown fields are rejected.", "{\"alarmsEnabled\":\"true\",\"unknown\":1}")); } + case "PUT /users/me/analytics-preference" -> { + examples.put("enabled", json("Enable analytics", "Allows optional Product Usage Events for the signed-in account.", "{\"enabled\":true}")); + examples.put("disabled", json("Disable analytics", "Stops optional Product Usage Events for the signed-in account.", "{\"enabled\":false}")); + examples.put("invalid_unknown_field", json("Invalid unknown field", "Only enabled is accepted.", "{\"enabled\":\"false\",\"unknown\":1}")); + } case "PUT /users/me/devices/current" -> { examples.put("valid_ios_device", json("Valid iOS device", "Registers the current access-token session to the device.", "{\"deviceId\":\"ios-device-000001\",\"platform\":\"ios\",\"appVersion\":\"1.2.3\",\"osVersion\":\"iOS 18.0\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}")); examples.put("invalid_device_id", json("Invalid device ID", "deviceId must be 16-128 allowed characters.", "{\"deviceId\":\"short\",\"platform\":\"ios\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}")); @@ -389,6 +394,8 @@ private String successData(String operationKey) { return switch (operationKey) { case "GET /users/me/alarm-settings" -> "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10,\"updatedAt\":\"2026-05-05T00:00:00Z\"}"; case "PATCH /users/me/alarm-settings" -> "{\"alarmsEnabled\":false,\"defaultAlarmOffsetMinutes\":5,\"updatedAt\":\"2026-05-05T00:00:00Z\"}"; + case "GET /users/me/analytics-preference" -> "{\"enabled\":false,\"updatedAt\":\"2026-05-26T12:00:00Z\"}"; + case "PUT /users/me/analytics-preference" -> "{\"enabled\":true,\"updatedAt\":\"2026-05-26T12:00:05Z\"}"; case "PUT /users/me/devices/current" -> "{\"deviceId\":\"ios-device-000001\",\"active\":true,\"lastSeenAt\":\"2026-05-05T00:00:00Z\"}"; case "DELETE /users/me/devices/current" -> "{\"active\":false}"; case "POST /users/me/alarm-status" -> "{\"received\":true}"; diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/AnalyticsPreferenceController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/AnalyticsPreferenceController.java new file mode 100644 index 0000000..c63f1ad --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/AnalyticsPreferenceController.java @@ -0,0 +1,71 @@ +package devkor.ontime_back.controller; + +import devkor.ontime_back.dto.AnalyticsPreferenceResponseDto; +import devkor.ontime_back.dto.AnalyticsPreferenceUpdateDto; +import devkor.ontime_back.response.ApiResponseForm; +import devkor.ontime_back.service.AnalyticsPreferenceService; +import devkor.ontime_back.service.UserAuthService; +import io.swagger.v3.oas.annotations.Operation; +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 jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class AnalyticsPreferenceController { + + private final UserAuthService userAuthService; + private final AnalyticsPreferenceService analyticsPreferenceService; + + @Operation(summary = "Get current user's analytics preference") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Analytics preference lookup succeeded", content = @Content( + mediaType = "application/json", + schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:00Z\"\n }\n}") + )), + @ApiResponse(responseCode = "4XX", description = "Analytics preference lookup failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message"))) + }) + @GetMapping("/users/me/analytics-preference") + public ResponseEntity> getAnalyticsPreference(HttpServletRequest request) { + Long userId = userAuthService.getUserIdFromToken(request); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponseForm.success(analyticsPreferenceService.getAnalyticsPreference(userId))); + } + + @Operation( + summary = "Update current user's analytics preference", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Account-scoped analytics preference update.", + required = true, + content = @Content(schema = @Schema( + type = "object", + example = "{\"enabled\": false}" + )) + ) + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Analytics preference update succeeded", content = @Content( + mediaType = "application/json", + schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:05Z\"\n }\n}") + )), + @ApiResponse(responseCode = "4XX", description = "Analytics preference update failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message"))) + }) + @PutMapping("/users/me/analytics-preference") + public ResponseEntity> updateAnalyticsPreference( + HttpServletRequest request, + @Valid @RequestBody AnalyticsPreferenceUpdateDto requestDto) { + Long userId = userAuthService.getUserIdFromToken(request); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponseForm.success(analyticsPreferenceService.updateAnalyticsPreference(userId, requestDto))); + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AnalyticsPreferenceResponseDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AnalyticsPreferenceResponseDto.java new file mode 100644 index 0000000..e89d13a --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AnalyticsPreferenceResponseDto.java @@ -0,0 +1,15 @@ +package devkor.ontime_back.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; + +@Getter +@Builder +@AllArgsConstructor +public class AnalyticsPreferenceResponseDto { + private Boolean enabled; + private Instant updatedAt; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AnalyticsPreferenceUpdateDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AnalyticsPreferenceUpdateDto.java new file mode 100644 index 0000000..3116d38 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AnalyticsPreferenceUpdateDto.java @@ -0,0 +1,40 @@ +package devkor.ontime_back.dto; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import jakarta.validation.constraints.AssertTrue; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.HashMap; +import java.util.Map; + +@Getter +@NoArgsConstructor +public class AnalyticsPreferenceUpdateDto { + private Object enabled; + private final Map unknownFields = new HashMap<>(); + + @JsonAnySetter + public void addUnknownField(String name, Object value) { + unknownFields.put(name, value); + } + + @AssertTrue(message = "알 수 없는 분석 설정 필드입니다.") + public boolean isKnownFieldsOnly() { + return unknownFields.isEmpty(); + } + + @AssertTrue(message = "enabled는 필수 값입니다.") + public boolean isEnabledPresent() { + return enabled != null; + } + + @AssertTrue(message = "enabled는 boolean 값이어야 합니다.") + public boolean isEnabledBoolean() { + return enabled == null || enabled instanceof Boolean; + } + + public Boolean getEnabledValue() { + return enabled instanceof Boolean value ? value : null; + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/entity/UserAnalyticsPreference.java b/ontime-back/src/main/java/devkor/ontime_back/entity/UserAnalyticsPreference.java new file mode 100644 index 0000000..43e31bd --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/entity/UserAnalyticsPreference.java @@ -0,0 +1,85 @@ +package devkor.ontime_back.entity; + +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.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.time.Instant; +import java.util.Objects; + +@Getter +@Entity +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table( + uniqueConstraints = { + @UniqueConstraint(name = "uk_user_analytics_preference_user", columnNames = "user_id") + } +) +public class UserAnalyticsPreference { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userAnalyticsPreferenceId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private User user; + + @Column(nullable = false) + private Boolean enabled; + + @Column(nullable = false) + private Instant updatedAt; + + @Column(nullable = false) + private Boolean userOverridden; + + public static UserAnalyticsPreference defaultFor(User user, boolean defaultEnabled) { + return UserAnalyticsPreference.builder() + .user(user) + .enabled(defaultEnabled) + .updatedAt(Instant.now()) + .userOverridden(false) + .build(); + } + + @PrePersist + private void initializeDefaults() { + if (enabled == null) enabled = false; + if (updatedAt == null) updatedAt = Instant.now(); + if (userOverridden == null) userOverridden = false; + } + + public boolean alignToDefault(boolean defaultEnabled) { + if (Boolean.TRUE.equals(userOverridden) || Objects.equals(enabled, defaultEnabled)) { + return false; + } + this.enabled = defaultEnabled; + this.updatedAt = Instant.now(); + return true; + } + + public void update(boolean enabled) { + this.enabled = enabled; + this.userOverridden = true; + this.updatedAt = Instant.now(); + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java index cdd642f..6291c3d 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java @@ -14,6 +14,7 @@ import devkor.ontime_back.repository.UserAlarmSettingRepository; import devkor.ontime_back.repository.UserRepository; import devkor.ontime_back.response.InvalidTokenException; +import devkor.ontime_back.service.AnalyticsPreferenceService; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; @@ -71,6 +72,7 @@ public class AppleLoginService { private final UserRepository userRepository; private final UserAlarmSettingRepository userAlarmSettingRepository; private final JwtTokenProvider jwtTokenProvider; + private final AnalyticsPreferenceService analyticsPreferenceService; private final RestTemplate restTemplate = new RestTemplate(); public Authentication handleLogin(String appleRefreshToken, User user, HttpServletResponse response) throws IOException { @@ -141,6 +143,7 @@ public Authentication handleRegister(String appleRefreshToken, OAuthAppleUserDto savedUser.updateRefreshToken(refreshToken); userRepository.save(savedUser); userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(savedUser)); + analyticsPreferenceService.createDefaultPreference(savedUser); Authentication authentication = new UsernamePasswordAuthenticationToken( savedUser, null, Collections.singletonList(new SimpleGrantedAuthority(savedUser.getRole().name())) diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java index ddc4438..6ac936e 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java @@ -13,6 +13,7 @@ import devkor.ontime_back.global.jwt.JwtTokenProvider; import devkor.ontime_back.repository.UserAlarmSettingRepository; import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.service.AnalyticsPreferenceService; import io.jsonwebtoken.Claims; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -44,6 +45,7 @@ public class GoogleLoginService { private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; private final UserAlarmSettingRepository userAlarmSettingRepository; + private final AnalyticsPreferenceService analyticsPreferenceService; private static final String GOOGLE_USER_INFO_URL = "https://www.googleapis.com/userinfo/v2/me"; private static final String GOOGLE_REVOKE_URL = "https://oauth2.googleapis.com/revoke?token="; @@ -53,12 +55,14 @@ public GoogleLoginService( JwtTokenProvider jwtTokenProvider, UserRepository userRepository, UserAlarmSettingRepository userAlarmSettingRepository, + AnalyticsPreferenceService analyticsPreferenceService, @Value("${google.web.client-id}") String webClientId, @Value("${google.app.client-id}") String appClientId ) { this.jwtTokenProvider = jwtTokenProvider; this.userRepository = userRepository; this.userAlarmSettingRepository = userAlarmSettingRepository; + this.analyticsPreferenceService = analyticsPreferenceService; this.validClientIds = Stream.concat( Stream.of(webClientId), Stream.of(appClientId.split(",")) @@ -138,6 +142,7 @@ public Authentication handleRegister(OAuthGoogleRequestDto oAuthGoogleRequestDto savedUser.updateRefreshToken(refreshToken); userRepository.save(savedUser); userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(savedUser)); + analyticsPreferenceService.createDefaultPreference(savedUser); Authentication authentication = new UsernamePasswordAuthenticationToken( savedUser, null, Collections.singletonList(new SimpleGrantedAuthority(savedUser.getRole().name())) diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java index 43c7847..0db7270 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java @@ -11,6 +11,7 @@ import devkor.ontime_back.repository.UserAlarmSettingRepository; import devkor.ontime_back.repository.UserRepository; import devkor.ontime_back.response.ValidationErrorWriter; +import devkor.ontime_back.service.AnalyticsPreferenceService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -37,6 +38,7 @@ public class KakaoLoginFilter extends AbstractAuthenticationProcessingFilter { private final UserRepository userRepository; private final UserAlarmSettingRepository userAlarmSettingRepository; private final JwtTokenProvider jwtTokenProvider; + private final AnalyticsPreferenceService analyticsPreferenceService; private final ObjectMapper objectMapper; private final Validator validator; @@ -45,13 +47,15 @@ public KakaoLoginFilter(String defaultFilterProcessesUrl, Validator validator, JwtTokenProvider jwtTokenProvider, UserRepository userRepository, - UserAlarmSettingRepository userAlarmSettingRepository) { + UserAlarmSettingRepository userAlarmSettingRepository, + AnalyticsPreferenceService analyticsPreferenceService) { super(defaultFilterProcessesUrl); this.objectMapper = objectMapper; this.validator = validator; this.jwtTokenProvider = jwtTokenProvider; this.userRepository = userRepository; this.userAlarmSettingRepository = userAlarmSettingRepository; + this.analyticsPreferenceService = analyticsPreferenceService; } @@ -126,6 +130,7 @@ private Authentication handleRegister(OAuthKakaoUserDto oAuthKakaoUserDto, HttpS savedUser.updateRefreshToken(refreshToken); userRepository.save(savedUser); userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(savedUser)); + analyticsPreferenceService.createDefaultPreference(savedUser); response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/UserAnalyticsPreferenceRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/UserAnalyticsPreferenceRepository.java new file mode 100644 index 0000000..605af68 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/UserAnalyticsPreferenceRepository.java @@ -0,0 +1,12 @@ +package devkor.ontime_back.repository; + +import devkor.ontime_back.entity.UserAnalyticsPreference; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserAnalyticsPreferenceRepository extends JpaRepository { + Optional findByUserId(Long userId); +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/AnalyticsPreferenceService.java b/ontime-back/src/main/java/devkor/ontime_back/service/AnalyticsPreferenceService.java new file mode 100644 index 0000000..381a6cd --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/service/AnalyticsPreferenceService.java @@ -0,0 +1,71 @@ +package devkor.ontime_back.service; + +import devkor.ontime_back.dto.AnalyticsPreferenceResponseDto; +import devkor.ontime_back.dto.AnalyticsPreferenceUpdateDto; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.entity.UserAnalyticsPreference; +import devkor.ontime_back.repository.UserAnalyticsPreferenceRepository; +import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.response.GeneralException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static devkor.ontime_back.response.ErrorCode.USER_NOT_FOUND; + +@Service +@Transactional(readOnly = true) +public class AnalyticsPreferenceService { + + private final UserRepository userRepository; + private final UserAnalyticsPreferenceRepository userAnalyticsPreferenceRepository; + private final boolean defaultEnabled; + + public AnalyticsPreferenceService( + UserRepository userRepository, + UserAnalyticsPreferenceRepository userAnalyticsPreferenceRepository, + @Value("${analytics.preference.default-enabled:false}") boolean defaultEnabled) { + this.userRepository = userRepository; + this.userAnalyticsPreferenceRepository = userAnalyticsPreferenceRepository; + this.defaultEnabled = defaultEnabled; + } + + @Transactional + public AnalyticsPreferenceResponseDto getAnalyticsPreference(Long userId) { + UserAnalyticsPreference preference = getOrCreatePreference(userId); + preference.alignToDefault(defaultEnabled); + return toResponse(preference); + } + + @Transactional + public AnalyticsPreferenceResponseDto updateAnalyticsPreference(Long userId, AnalyticsPreferenceUpdateDto requestDto) { + UserAnalyticsPreference preference = getOrCreatePreference(userId); + preference.update(requestDto.getEnabledValue()); + return toResponse(preference); + } + + @Transactional + public UserAnalyticsPreference createDefaultPreference(User user) { + if (user.getId() != null) { + return userAnalyticsPreferenceRepository.findByUserId(user.getId()) + .orElseGet(() -> userAnalyticsPreferenceRepository.save(UserAnalyticsPreference.defaultFor(user, defaultEnabled))); + } + return userAnalyticsPreferenceRepository.save(UserAnalyticsPreference.defaultFor(user, defaultEnabled)); + } + + private UserAnalyticsPreference getOrCreatePreference(Long userId) { + return userAnalyticsPreferenceRepository.findByUserId(userId) + .orElseGet(() -> { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(USER_NOT_FOUND)); + return userAnalyticsPreferenceRepository.save(UserAnalyticsPreference.defaultFor(user, defaultEnabled)); + }); + } + + private AnalyticsPreferenceResponseDto toResponse(UserAnalyticsPreference preference) { + return AnalyticsPreferenceResponseDto.builder() + .enabled(preference.getEnabled()) + .updatedAt(preference.getUpdatedAt()) + .build(); + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/UserAuthService.java b/ontime-back/src/main/java/devkor/ontime_back/service/UserAuthService.java index 7a47fb7..c38f0bd 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/UserAuthService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/UserAuthService.java @@ -43,6 +43,7 @@ public class UserAuthService { private final UserSettingRepository userSettingRepository; private final UserAlarmSettingRepository userAlarmSettingRepository; private final AccountDeletionFeedbackRepository accountDeletionFeedbackRepository; + private final AnalyticsPreferenceService analyticsPreferenceService; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; @@ -120,6 +121,7 @@ private User createUserAndUserSetting(UserSignUpDto userSignUpDto) { user.setUserSetting(userSetting); userRepository.save(user); //CASCADE옵션 덕분에 userRepository만 save해주면 됨(userSettingRepository는 save안해줘도 부모인 user를 따라 저장됨) userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(user)); + analyticsPreferenceService.createDefaultPreference(user); return user; } diff --git a/ontime-back/src/main/resources/application-dev.properties b/ontime-back/src/main/resources/application-dev.properties index 7a552c6..15cb83c 100644 --- a/ontime-back/src/main/resources/application-dev.properties +++ b/ontime-back/src/main/resources/application-dev.properties @@ -42,6 +42,7 @@ logging.level.devkor.ontime_back=DEBUG # Feature flags feature.apple-login.enabled=${FEATURE_APPLE_LOGIN_ENABLED:false} +analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false} # Actuator management.endpoint.health.probes.enabled=true diff --git a/ontime-back/src/main/resources/application-local.properties b/ontime-back/src/main/resources/application-local.properties index a8813db..a5a7c9d 100644 --- a/ontime-back/src/main/resources/application-local.properties +++ b/ontime-back/src/main/resources/application-local.properties @@ -42,3 +42,4 @@ logging.level.devkor.ontime_back=DEBUG # Feature flags feature.apple-login.enabled=${FEATURE_APPLE_LOGIN_ENABLED:true} +analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false} diff --git a/ontime-back/src/main/resources/application-prod.properties b/ontime-back/src/main/resources/application-prod.properties index 47b5032..7094d3d 100644 --- a/ontime-back/src/main/resources/application-prod.properties +++ b/ontime-back/src/main/resources/application-prod.properties @@ -31,3 +31,4 @@ management.health.livenessstate.enabled=true server.shutdown=graceful spring.lifecycle.timeout-per-shutdown-phase=30s server.forward-headers-strategy=framework +analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false} diff --git a/ontime-back/src/main/resources/application-test.properties b/ontime-back/src/main/resources/application-test.properties index b2e1081..5d72369 100644 --- a/ontime-back/src/main/resources/application-test.properties +++ b/ontime-back/src/main/resources/application-test.properties @@ -42,6 +42,7 @@ logging.level.devkor.ontime_back=INFO # Feature flags feature.apple-login.enabled=${FEATURE_APPLE_LOGIN_ENABLED:false} +analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false} # Actuator management.endpoints.web.exposure.include=health diff --git a/ontime-back/src/main/resources/db/migration/V15__add_user_analytics_preference.sql b/ontime-back/src/main/resources/db/migration/V15__add_user_analytics_preference.sql new file mode 100644 index 0000000..e354dde --- /dev/null +++ b/ontime-back/src/main/resources/db/migration/V15__add_user_analytics_preference.sql @@ -0,0 +1,12 @@ +CREATE TABLE user_analytics_preference ( + user_analytics_preference_id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + user_overridden BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT uk_user_analytics_preference_user UNIQUE (user_id), + CONSTRAINT fk_user_analytics_preference_user FOREIGN KEY (user_id) REFERENCES user (user_id) ON DELETE CASCADE +); + +INSERT INTO user_analytics_preference (user_id, enabled, updated_at, user_overridden) +SELECT user_id, FALSE, CURRENT_TIMESTAMP, FALSE FROM user; diff --git a/ontime-back/src/test/java/devkor/ontime_back/ControllerTestSupport.java b/ontime-back/src/test/java/devkor/ontime_back/ControllerTestSupport.java index 60cd6e3..2644382 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/ControllerTestSupport.java +++ b/ontime-back/src/test/java/devkor/ontime_back/ControllerTestSupport.java @@ -25,6 +25,7 @@ FeedbackController.class, FirebaseTokenController.class, AlarmController.class, + AnalyticsPreferenceController.class, SocialAuthController.class, AccountDeletionPageController.class, PrivacyPolicyController.class @@ -68,6 +69,9 @@ public abstract class ControllerTestSupport { @MockBean protected AlarmService alarmService; + @MockBean + protected AnalyticsPreferenceService analyticsPreferenceService; + @MockBean protected AppleLoginService appleLoginService; diff --git a/ontime-back/src/test/java/devkor/ontime_back/config/SwaggerConfigTest.java b/ontime-back/src/test/java/devkor/ontime_back/config/SwaggerConfigTest.java index f82a22d..3503bc4 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/config/SwaggerConfigTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/config/SwaggerConfigTest.java @@ -86,6 +86,7 @@ void customizerAddsExamplesForEveryControllerEndpointCase() { .addPathItem("/privacy-policy", new PathItem().get(operation())) .addPathItem("/privacy-policy/en", new PathItem().get(operation())) .addPathItem("/users/me/alarm-settings", new PathItem().get(operation()).patch(operationWithBody())) + .addPathItem("/users/me/analytics-preference", new PathItem().get(operation()).put(operationWithBody())) .addPathItem("/users/me/devices/current", new PathItem().put(operationWithBody()).delete(operationWithBody())) .addPathItem("/users/me/alarm-status", new PathItem().post(operationWithBody()).get(operation())) .addPathItem("/documents/terms", new PathItem().get(operation())) diff --git a/ontime-back/src/test/java/devkor/ontime_back/controller/AnalyticsPreferenceControllerTest.java b/ontime-back/src/test/java/devkor/ontime_back/controller/AnalyticsPreferenceControllerTest.java new file mode 100644 index 0000000..423efec --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/controller/AnalyticsPreferenceControllerTest.java @@ -0,0 +1,91 @@ +package devkor.ontime_back.controller; + +import devkor.ontime_back.ControllerTestSupport; +import devkor.ontime_back.TestSecurityConfig; +import devkor.ontime_back.dto.AnalyticsPreferenceResponseDto; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; + +import java.time.Instant; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Import(TestSecurityConfig.class) +class AnalyticsPreferenceControllerTest extends ControllerTestSupport { + + @Test + @DisplayName("분석 설정 조회는 로그인한 계정의 enabled와 updatedAt을 반환한다") + void getAnalyticsPreference() throws Exception { + Instant updatedAt = Instant.parse("2026-05-26T12:00:00Z"); + when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); + when(analyticsPreferenceService.getAnalyticsPreference(1L)).thenReturn( + AnalyticsPreferenceResponseDto.builder() + .enabled(false) + .updatedAt(updatedAt) + .build() + ); + + mockMvc.perform(get("/users/me/analytics-preference")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.enabled").value(false)) + .andExpect(jsonPath("$.data.updatedAt").value("2026-05-26T12:00:00Z")); + + verify(analyticsPreferenceService).getAnalyticsPreference(1L); + } + + @Test + @DisplayName("분석 설정 업데이트는 boolean enabled만 받아 계정 설정을 변경한다") + void updateAnalyticsPreference() throws Exception { + Instant updatedAt = Instant.parse("2026-05-26T12:00:05Z"); + when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); + when(analyticsPreferenceService.updateAnalyticsPreference(eq(1L), any())).thenReturn( + AnalyticsPreferenceResponseDto.builder() + .enabled(true) + .updatedAt(updatedAt) + .build() + ); + + mockMvc.perform(put("/users/me/analytics-preference") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"enabled\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.enabled").value(true)) + .andExpect(jsonPath("$.data.updatedAt").value("2026-05-26T12:00:05Z")); + + verify(analyticsPreferenceService).updateAnalyticsPreference(eq(1L), any()); + } + + @Test + @DisplayName("분석 설정 업데이트는 누락, null, 문자열, 알 수 없는 필드를 거절한다") + void updateAnalyticsPreferenceValidationFailure() throws Exception { + assertInvalidUpdate("{}"); + assertInvalidUpdate("{\"enabled\":null}"); + assertInvalidUpdate("{\"enabled\":\"false\"}"); + assertInvalidUpdate("{\"enabled\":false,\"unknown\":1}"); + + verify(analyticsPreferenceService, never()).updateAnalyticsPreference(any(), any()); + } + + private void assertInvalidUpdate(String body) throws Exception { + mockMvc.perform(put("/users/me/analytics-preference") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("error")) + .andExpect(jsonPath("$.data.errors").isArray()); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java index d188185..ab3f14f 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java @@ -9,6 +9,7 @@ import devkor.ontime_back.global.oauth.kakao.KakaoLoginFilter; import devkor.ontime_back.repository.UserAlarmSettingRepository; import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.service.AnalyticsPreferenceService; import jakarta.validation.Validation; import jakarta.validation.Validator; import org.junit.jupiter.api.DisplayName; @@ -45,6 +46,9 @@ class OAuthLoginFilterValidationTest { @Mock private UserAlarmSettingRepository userAlarmSettingRepository; + @Mock + private AnalyticsPreferenceService analyticsPreferenceService; + private final ObjectMapper objectMapper = new ObjectMapper(); private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); @@ -100,7 +104,8 @@ void kakaoLoginFilterRejectsInvalidRequest() throws Exception { validator, jwtTokenProvider, userRepository, - userAlarmSettingRepository); + userAlarmSettingRepository, + analyticsPreferenceService); MockHttpServletResponse response = new MockHttpServletResponse(); assertThatThrownBy(() -> filter.attemptAuthentication( @@ -109,7 +114,7 @@ void kakaoLoginFilterRejectsInvalidRequest() throws Exception { .isInstanceOf(AuthenticationException.class); assertValidationResponse(response); - verifyNoInteractions(jwtTokenProvider, userRepository, userAlarmSettingRepository); + verifyNoInteractions(jwtTokenProvider, userRepository, userAlarmSettingRepository, analyticsPreferenceService); } @Test diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthRegistrationAnalyticsPreferenceTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthRegistrationAnalyticsPreferenceTest.java new file mode 100644 index 0000000..b0def58 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthRegistrationAnalyticsPreferenceTest.java @@ -0,0 +1,166 @@ +package devkor.ontime_back.global.oauth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.ontime_back.dto.OAuthAppleUserDto; +import devkor.ontime_back.dto.OAuthGoogleRequestDto; +import devkor.ontime_back.dto.OAuthGoogleUserDto; +import devkor.ontime_back.global.jwt.JwtTokenProvider; +import devkor.ontime_back.global.jwt.JwtUtils; +import devkor.ontime_back.global.oauth.apple.AppleLoginService; +import devkor.ontime_back.global.oauth.apple.ApplePublicKeyGenerator; +import devkor.ontime_back.global.oauth.google.GoogleLoginService; +import devkor.ontime_back.global.oauth.kakao.KakaoLoginFilter; +import devkor.ontime_back.repository.UserAlarmSettingRepository; +import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.service.AnalyticsPreferenceService; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +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 org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OAuthRegistrationAnalyticsPreferenceTest { + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Mock + private UserRepository userRepository; + + @Mock + private UserAlarmSettingRepository userAlarmSettingRepository; + + @Mock + private AnalyticsPreferenceService analyticsPreferenceService; + + @Mock + private ApplePublicKeyGenerator applePublicKeyGenerator; + + @Mock + private JwtUtils jwtUtils; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + @DisplayName("구글 신규 가입은 계정 분석 설정 기본 행을 생성한다") + void googleRegisterCreatesAnalyticsPreference() throws Exception { + GoogleLoginService googleLoginService = new GoogleLoginService( + jwtTokenProvider, + userRepository, + userAlarmSettingRepository, + analyticsPreferenceService, + "123-web.apps.googleusercontent.com", + "123-app.apps.googleusercontent.com" + ); + when(userRepository.save(any())).thenAnswer(invocation -> { + Object user = invocation.getArgument(0); + ReflectionTestUtils.setField(user, "id", 1L); + return user; + }); + when(jwtTokenProvider.createAccessToken(anyString(), any())).thenReturn("access-token"); + when(jwtTokenProvider.createRefreshToken()).thenReturn("refresh-token"); + + OAuthGoogleRequestDto requestDto = new OAuthGoogleRequestDto(); + ReflectionTestUtils.setField(requestDto, "refreshToken", "google-refresh-token"); + OAuthGoogleUserDto userDto = new OAuthGoogleUserDto( + "google-id", + "Google User", + "https://example.com/profile.png", + "user@example.com" + ); + + googleLoginService.handleRegister(requestDto, userDto, new MockHttpServletResponse()); + + verify(analyticsPreferenceService).createDefaultPreference(any()); + } + + @Test + @DisplayName("애플 신규 가입은 계정 분석 설정 기본 행을 생성한다") + void appleRegisterCreatesAnalyticsPreference() throws Exception { + AppleLoginService appleLoginService = new AppleLoginService( + applePublicKeyGenerator, + jwtUtils, + userRepository, + userAlarmSettingRepository, + jwtTokenProvider, + analyticsPreferenceService + ); + when(userRepository.save(any())).thenAnswer(invocation -> { + Object user = invocation.getArgument(0); + ReflectionTestUtils.setField(user, "id", 1L); + return user; + }); + when(jwtTokenProvider.createAccessToken(anyString(), any())).thenReturn("access-token"); + when(jwtTokenProvider.createRefreshToken()).thenReturn("refresh-token"); + + OAuthAppleUserDto userDto = new OAuthAppleUserDto("apple-id", "user@example.com", "Apple User"); + + appleLoginService.handleRegister("apple-refresh-token", userDto, new MockHttpServletResponse()); + + verify(analyticsPreferenceService).createDefaultPreference(any()); + } + + @Test + @DisplayName("카카오 신규 가입은 계정 분석 설정 기본 행을 생성한다") + void kakaoRegisterCreatesAnalyticsPreference() throws Exception { + KakaoLoginFilter filter = new KakaoLoginFilter( + "/oauth2/kakao/login", + objectMapper, + validator, + jwtTokenProvider, + userRepository, + userAlarmSettingRepository, + analyticsPreferenceService + ); + when(userRepository.findBySocialTypeAndSocialId(any(), anyString())).thenReturn(Optional.empty()); + when(userRepository.save(any())).thenAnswer(invocation -> { + Object user = invocation.getArgument(0); + ReflectionTestUtils.setField(user, "id", 1L); + return user; + }); + when(jwtTokenProvider.createAccessToken(isNull(), any())).thenReturn("access-token"); + when(jwtTokenProvider.createRefreshToken()).thenReturn("refresh-token"); + + filter.attemptAuthentication( + request(""" + { + "id": "kakao-id", + "profile": { + "nickname": "Kakao User", + "thumbnailImageUrl": "https://example.com/thumb.png", + "profile_image_url": "https://example.com/profile.png", + "defaultImage": false, + "defaultNickname": false + } + } + """), + new MockHttpServletResponse() + ); + + verify(analyticsPreferenceService).createDefaultPreference(any()); + } + + private MockHttpServletRequest request(String body) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setContentType("application/json"); + request.setMethod("POST"); + request.setRequestURI("/oauth2/kakao/login"); + request.setContent(body.getBytes()); + return request; + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/AnalyticsPreferenceServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/AnalyticsPreferenceServiceTest.java new file mode 100644 index 0000000..3c50139 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/service/AnalyticsPreferenceServiceTest.java @@ -0,0 +1,140 @@ +package devkor.ontime_back.service; + +import devkor.ontime_back.dto.AnalyticsPreferenceResponseDto; +import devkor.ontime_back.dto.AnalyticsPreferenceUpdateDto; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.entity.UserAnalyticsPreference; +import devkor.ontime_back.repository.UserAnalyticsPreferenceRepository; +import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.response.ErrorCode; +import devkor.ontime_back.response.GeneralException; +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 org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.util.Optional; + +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; + +@ExtendWith(MockitoExtension.class) +class AnalyticsPreferenceServiceTest { + + private static final Long USER_ID = 1L; + + @Mock + private UserRepository userRepository; + + @Mock + private UserAnalyticsPreferenceRepository userAnalyticsPreferenceRepository; + + private User user; + + @BeforeEach + void setUp() { + user = User.builder() + .id(USER_ID) + .email("user@example.com") + .build(); + } + + @Test + @DisplayName("설정 행이 없으면 현재 배포 기본값 false로 생성해 반환한다") + void getAnalyticsPreferenceCreatesDefaultDisabledPreference() { + AnalyticsPreferenceService service = serviceWithDefault(false); + when(userAnalyticsPreferenceRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(userAnalyticsPreferenceRepository.save(any(UserAnalyticsPreference.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + AnalyticsPreferenceResponseDto response = service.getAnalyticsPreference(USER_ID); + + assertThat(response.getEnabled()).isFalse(); + assertThat(response.getUpdatedAt()).isNotNull(); + verify(userAnalyticsPreferenceRepository).save(any(UserAnalyticsPreference.class)); + } + + @Test + @DisplayName("사용자가 업데이트하면 값을 저장하고 userOverridden과 updatedAt을 갱신한다") + void updateAnalyticsPreferenceMarksUserOverride() { + AnalyticsPreferenceService service = serviceWithDefault(false); + Instant previousUpdatedAt = Instant.parse("2026-01-01T00:00:00Z"); + UserAnalyticsPreference preference = preference(false, previousUpdatedAt, false); + AnalyticsPreferenceUpdateDto requestDto = new AnalyticsPreferenceUpdateDto(); + ReflectionTestUtils.setField(requestDto, "enabled", true); + + when(userAnalyticsPreferenceRepository.findByUserId(USER_ID)).thenReturn(Optional.of(preference)); + + AnalyticsPreferenceResponseDto response = service.updateAnalyticsPreference(USER_ID, requestDto); + + assertThat(response.getEnabled()).isTrue(); + assertThat(response.getUpdatedAt()).isAfter(previousUpdatedAt); + assertThat(preference.getUserOverridden()).isTrue(); + } + + @Test + @DisplayName("사용자가 건드리지 않은 행은 배포 기본값이 true로 바뀌면 읽을 때 정렬된다") + void getAnalyticsPreferenceAlignsNonOverriddenRowsToCurrentDefault() { + AnalyticsPreferenceService service = serviceWithDefault(true); + Instant previousUpdatedAt = Instant.parse("2026-01-01T00:00:00Z"); + UserAnalyticsPreference preference = preference(false, previousUpdatedAt, false); + + when(userAnalyticsPreferenceRepository.findByUserId(USER_ID)).thenReturn(Optional.of(preference)); + + AnalyticsPreferenceResponseDto response = service.getAnalyticsPreference(USER_ID); + + assertThat(response.getEnabled()).isTrue(); + assertThat(response.getUpdatedAt()).isAfter(previousUpdatedAt); + assertThat(preference.getUserOverridden()).isFalse(); + } + + @Test + @DisplayName("명시적으로 끈 사용자의 선택은 배포 기본값 true에도 보존된다") + void getAnalyticsPreferencePreservesExplicitOptOut() { + AnalyticsPreferenceService service = serviceWithDefault(true); + Instant previousUpdatedAt = Instant.parse("2026-01-01T00:00:00Z"); + UserAnalyticsPreference preference = preference(false, previousUpdatedAt, true); + + when(userAnalyticsPreferenceRepository.findByUserId(USER_ID)).thenReturn(Optional.of(preference)); + + AnalyticsPreferenceResponseDto response = service.getAnalyticsPreference(USER_ID); + + assertThat(response.getEnabled()).isFalse(); + assertThat(response.getUpdatedAt()).isEqualTo(previousUpdatedAt); + assertThat(preference.getUserOverridden()).isTrue(); + } + + @Test + @DisplayName("설정 행도 사용자도 없으면 USER_NOT_FOUND를 반환한다") + void getAnalyticsPreferenceRejectsMissingUser() { + AnalyticsPreferenceService service = serviceWithDefault(false); + when(userAnalyticsPreferenceRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getAnalyticsPreference(USER_ID)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.USER_NOT_FOUND); + } + + private AnalyticsPreferenceService serviceWithDefault(boolean defaultEnabled) { + return new AnalyticsPreferenceService(userRepository, userAnalyticsPreferenceRepository, defaultEnabled); + } + + private UserAnalyticsPreference preference(boolean enabled, Instant updatedAt, boolean userOverridden) { + return UserAnalyticsPreference.builder() + .user(user) + .enabled(enabled) + .updatedAt(updatedAt) + .userOverridden(userOverridden) + .build(); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java index bee95a9..67cb78e 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java @@ -20,6 +20,7 @@ import devkor.ontime_back.entity.User; import devkor.ontime_back.entity.UserAlarmSetting; import devkor.ontime_back.entity.UserAlarmStatus; +import devkor.ontime_back.entity.UserAnalyticsPreference; import devkor.ontime_back.entity.UserDevice; import devkor.ontime_back.entity.UserSetting; import devkor.ontime_back.repository.AccountDeletionFeedbackRepository; @@ -32,6 +33,7 @@ import devkor.ontime_back.repository.ScheduleRepository; import devkor.ontime_back.repository.UserAlarmSettingRepository; import devkor.ontime_back.repository.UserAlarmStatusRepository; +import devkor.ontime_back.repository.UserAnalyticsPreferenceRepository; import devkor.ontime_back.repository.UserDeviceRepository; import devkor.ontime_back.repository.UserRepository; import devkor.ontime_back.repository.UserSettingRepository; @@ -92,6 +94,8 @@ class UserAuthServiceTest { @Autowired private UserAlarmSettingRepository userAlarmSettingRepository; @Autowired + private UserAnalyticsPreferenceRepository userAnalyticsPreferenceRepository; + @Autowired private UserAlarmStatusRepository userAlarmStatusRepository; @Autowired private UserDeviceRepository userDeviceRepository; @@ -105,6 +109,7 @@ class UserAuthServiceTest { @AfterEach void tearDown() { accountDeletionFeedbackRepository.deleteAllInBatch(); + userAnalyticsPreferenceRepository.deleteAllInBatch(); userAlarmStatusRepository.deleteAllInBatch(); userDeviceRepository.deleteAllInBatch(); notificationScheduleRepository.deleteAllInBatch(); @@ -143,6 +148,12 @@ void signUp() throws Exception { assertThat(passwordEncoder.matches("password1234", user.getPassword())).isTrue(); assertThat(user.getRefreshToken()).isNotNull(); assertThat(user.getUserSetting()).isNotNull(); + assertThat(userAnalyticsPreferenceRepository.findByUserId(user.getId())) + .hasValueSatisfying(preference -> { + assertThat(preference.getEnabled()).isFalse(); + assertThat(preference.getUserOverridden()).isFalse(); + assertThat(preference.getUpdatedAt()).isNotNull(); + }); } @DisplayName("이미 존재하는 이메일로 회원가입을 시도하는 경우 예외가 발생한다.") @@ -478,6 +489,7 @@ void deleteSocialUserRemovesAssociatedDataAndRetainsAnonymizedDeletionFeedback(S .receiverId(friendUser.getId()) .acceptStatus("ACCEPTED") .build()); + userAnalyticsPreferenceRepository.save(UserAnalyticsPreference.defaultFor(targetUser, false)); userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(targetUser)); UserDevice userDevice = UserDevice.create(targetUser, "device-" + socialType.name().toLowerCase()); userDevice.activate("ios", "1.0.0", "17.0", true, "native", "fcm", Instant.now()); @@ -522,6 +534,7 @@ void deleteSocialUserRemovesAssociatedDataAndRetainsAnonymizedDeletionFeedback(S assertThat(feedbackRepository.count()).isZero(); assertThat(friendshipRepository.count()).isZero(); assertThat(userSettingRepository.findByUserId(targetUser.getId())).isEmpty(); + assertThat(userAnalyticsPreferenceRepository.findByUserId(targetUser.getId())).isEmpty(); assertThat(userAlarmSettingRepository.findByUserId(targetUser.getId())).isEmpty(); assertThat(userDeviceRepository.findByUserIdAndDeviceId(targetUser.getId(), userDevice.getDeviceId())).isEmpty(); assertThat(userAlarmStatusRepository.findByUserDeviceUserDeviceId(userDevice.getUserDeviceId())).isEmpty(); diff --git a/ontime-back/src/test/resources/application.properties b/ontime-back/src/test/resources/application.properties index 9d0f890..333ac64 100644 --- a/ontime-back/src/test/resources/application.properties +++ b/ontime-back/src/test/resources/application.properties @@ -27,3 +27,4 @@ apple.private-key.base64= firebase.credentials.base64= feature.apple-login.enabled=false +analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false}