diff --git a/src/main/java/org/runimo/runimo/user/controller/MyPageController.java b/src/main/java/org/runimo/runimo/user/controller/MyPageController.java index ddc878aa..7d5d476f 100644 --- a/src/main/java/org/runimo/runimo/user/controller/MyPageController.java +++ b/src/main/java/org/runimo/runimo/user/controller/MyPageController.java @@ -1,12 +1,22 @@ package org.runimo.runimo.user.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import lombok.RequiredArgsConstructor; import org.runimo.runimo.common.response.SuccessResponse; +import org.runimo.runimo.user.controller.request.UpdateNotificationAllowedRequst; import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.runimo.runimo.user.service.dto.command.UpdateNotificationAllowedCommand; import org.runimo.runimo.user.service.dto.response.MyPageViewResponse; +import org.runimo.runimo.user.service.dto.response.NotificationAllowedResponse; +import org.runimo.runimo.user.service.usecases.UpdateUserDetailUsecase; import org.runimo.runimo.user.service.usecases.query.MyPageQueryUsecase; +import org.runimo.runimo.user.service.usecases.query.UserInfoQueryUsecase; 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.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -16,6 +26,8 @@ public class MyPageController { private final MyPageQueryUsecase myPageQueryUsecase; + private final UserInfoQueryUsecase userInfoQueryUsecase; + private final UpdateUserDetailUsecase updateUserDetailUsecase; @GetMapping public ResponseEntity> queryMyPageView( @@ -25,4 +37,34 @@ public ResponseEntity> queryMyPageView( SuccessResponse.of(UserHttpResponseCode.MY_PAGE_DATA_FETCHED, response)); } + @Operation(summary = "회원 알림 수신 허용 여부 수정", description = "알림을 수신 여부 변경") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "수신 여부 수정 성공"), + @ApiResponse(responseCode = "404", description = "없는 사용자") + }) + @PatchMapping("/notifications/permission") + public ResponseEntity> updateNotificationPermission( + @UserId Long userId, + @RequestBody UpdateNotificationAllowedRequst request + ) { + updateUserDetailUsecase.updateUserNotificationAllowed( + UpdateNotificationAllowedCommand.of(userId, request)); + return ResponseEntity.ok().body( + SuccessResponse.messageOnly(UserHttpResponseCode.NOTIFICATION_ALLOW_UPDATED)); + } + + @Operation(summary = "회원 알림 수신 허용 여부 확인", description = "알림을 수신하는지 확인") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "수신 여부 확인"), + @ApiResponse(responseCode = "404", description = "없는 사용자 또는 토큰 미등록") + }) + @GetMapping("/notifications/permission") + public ResponseEntity> queryNotificationPermission( + @UserId Long userId + ) { + return ResponseEntity.ok( + SuccessResponse.of(UserHttpResponseCode.NOTIFICATION_ALLOW_FETCHED, + userInfoQueryUsecase.getUserNotificationAllowed(userId))); + } + } diff --git a/src/main/java/org/runimo/runimo/user/controller/request/UpdateNotificationAllowedRequst.java b/src/main/java/org/runimo/runimo/user/controller/request/UpdateNotificationAllowedRequst.java new file mode 100644 index 00000000..9d882b87 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/controller/request/UpdateNotificationAllowedRequst.java @@ -0,0 +1,13 @@ +package org.runimo.runimo.user.controller.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "알림 허용 여부 업데이트 요청") +public record UpdateNotificationAllowedRequst( + @Schema(description = "알림 허용 여부", example = "true") + boolean allowed, + String deviceToken, + String platform +) { + +} diff --git a/src/main/java/org/runimo/runimo/user/domain/UserDeviceToken.java b/src/main/java/org/runimo/runimo/user/domain/UserDeviceToken.java index fbdc19ba..e2bb871c 100644 --- a/src/main/java/org/runimo/runimo/user/domain/UserDeviceToken.java +++ b/src/main/java/org/runimo/runimo/user/domain/UserDeviceToken.java @@ -57,4 +57,20 @@ public static UserDeviceToken from(String deviceToken, DevicePlatform platform, .build(); } + public void updateNotificationAllowed(Boolean allowed) { + this.notificationAllowed = allowed; + } + + public void updateDeviceToken(String deviceToken) { + validateDeviceToken(deviceToken, this.platform); + this.deviceToken = deviceToken; + this.lastUsedAt = LocalDateTime.now(); + } + + private void validateDeviceToken(String deviceToken, DevicePlatform platform) { + if (deviceToken == null || deviceToken.trim().isEmpty()) { + throw new IllegalArgumentException("디바이스 토큰은 필수입니다"); + } + } + } diff --git a/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java b/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java index 157ac11d..f5fe713c 100644 --- a/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java +++ b/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java @@ -14,6 +14,8 @@ public enum UserHttpResponseCode implements CustomResponseCode { REGISTER_EGG_SUCCESS(HttpStatus.CREATED, "부화기 등록 성공", "부화기 등록 성공"), USE_LOVE_POINT_SUCCESS(HttpStatus.OK, "애정 사용 성공", "애정 사용 성공"), MY_INCUBATING_EGG_FETCHED(HttpStatus.OK, "부화기중인 알 조회 성공", "부화중인 알 조회 성공"), + NOTIFICATION_ALLOW_UPDATED(HttpStatus.OK, "알림 허용 업데이트 성공", "알림 허용 업데이트 성공"), + NOTIFICATION_ALLOW_FETCHED(HttpStatus.OK, "알림 허용 조회 성공", "알림 허용 조회 성공"), LOGIN_FAIL_NOT_SIGN_IN(HttpStatus.NOT_FOUND , "로그인 실패 - 회원가입하지 않은 사용자", "로그인 실패 - 회원가입하지 않은 사용자"), @@ -27,7 +29,8 @@ public enum UserHttpResponseCode implements CustomResponseCode { "사용자가 유효하지 않습니다. Refresh 토큰 삭제에 실패했습니다"), LOG_OUT_SUCCESS(HttpStatus.OK, "로그아웃 성공", "로그아웃 성공"), ALREADY_LOG_OUT_SUCCESS(HttpStatus.OK, "로그아웃 성공 (이미 로그아웃된 사용자)", "로그아웃 성공 (이미 로그아웃된 사용자)"), - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없음", "사용자를 찾을 수 없음"); + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없음", "사용자를 찾을 수 없음"), + DEVICE_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "디바이스 토큰이 없음", "디바이스 토큰이 없음"); private final HttpStatus code; private final String clientMessage; diff --git a/src/main/java/org/runimo/runimo/user/repository/UserDeviceTokenRepository.java b/src/main/java/org/runimo/runimo/user/repository/UserDeviceTokenRepository.java index e10ff445..cb757463 100644 --- a/src/main/java/org/runimo/runimo/user/repository/UserDeviceTokenRepository.java +++ b/src/main/java/org/runimo/runimo/user/repository/UserDeviceTokenRepository.java @@ -1,5 +1,6 @@ package org.runimo.runimo.user.repository; +import java.util.Optional; import org.runimo.runimo.user.domain.UserDeviceToken; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -7,4 +8,5 @@ @Repository public interface UserDeviceTokenRepository extends JpaRepository { + Optional findByUserId(Long userId); } \ No newline at end of file diff --git a/src/main/java/org/runimo/runimo/user/service/UserFinder.java b/src/main/java/org/runimo/runimo/user/service/UserFinder.java index b3e6a9a4..ca49cf0b 100644 --- a/src/main/java/org/runimo/runimo/user/service/UserFinder.java +++ b/src/main/java/org/runimo/runimo/user/service/UserFinder.java @@ -4,7 +4,9 @@ import lombok.RequiredArgsConstructor; import org.runimo.runimo.user.domain.LovePoint; import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.domain.UserDeviceToken; import org.runimo.runimo.user.repository.LovePointRepository; +import org.runimo.runimo.user.repository.UserDeviceTokenRepository; import org.runimo.runimo.user.repository.UserRepository; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -15,6 +17,7 @@ public class UserFinder { private final UserRepository userRepository; private final LovePointRepository lovePointRepository; + private final UserDeviceTokenRepository userDeviceTokenRepository; @Transactional(readOnly = true) public Optional findUserByPublicId(final String publicId) { @@ -30,4 +33,9 @@ public Optional findUserById(final Long userId) { public Optional findLovePointByUserId(Long userId) { return lovePointRepository.findLovePointByUserId(userId); } + + @Transactional(readOnly = true) + public Optional findUserDeviceTokenByUserId(Long userId) { + return userDeviceTokenRepository.findByUserId(userId); + } } diff --git a/src/main/java/org/runimo/runimo/user/service/dto/command/UpdateNotificationAllowedCommand.java b/src/main/java/org/runimo/runimo/user/service/dto/command/UpdateNotificationAllowedCommand.java new file mode 100644 index 00000000..a9c48a23 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/dto/command/UpdateNotificationAllowedCommand.java @@ -0,0 +1,20 @@ +package org.runimo.runimo.user.service.dto.command; + +import org.runimo.runimo.user.controller.request.UpdateNotificationAllowedRequst; +import org.runimo.runimo.user.domain.DevicePlatform; + +public record UpdateNotificationAllowedCommand( + Long userId, + Boolean allowed, + String deviceToken, + DevicePlatform devicePlatform +) { + + public static UpdateNotificationAllowedCommand of(Long userId, + UpdateNotificationAllowedRequst request) { + return new UpdateNotificationAllowedCommand(userId, request.allowed(), + request.deviceToken(), DevicePlatform.fromString(request.platform())); + } + + +} diff --git a/src/main/java/org/runimo/runimo/user/service/dto/response/NotificationAllowedResponse.java b/src/main/java/org/runimo/runimo/user/service/dto/response/NotificationAllowedResponse.java new file mode 100644 index 00000000..f5ae67be --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/dto/response/NotificationAllowedResponse.java @@ -0,0 +1,13 @@ +package org.runimo.runimo.user.service.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "알림 허용 여부 응답") +public record NotificationAllowedResponse( + @Schema(description = "사용자 ID", example = "1") + Long userId, + @Schema(description = "알림 허용 여부", example = "true") + boolean allowed +) { + +} diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/UpdateUserDetailUsecase.java b/src/main/java/org/runimo/runimo/user/service/usecases/UpdateUserDetailUsecase.java new file mode 100644 index 00000000..879ce6d6 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/usecases/UpdateUserDetailUsecase.java @@ -0,0 +1,8 @@ +package org.runimo.runimo.user.service.usecases; + +import org.runimo.runimo.user.service.dto.command.UpdateNotificationAllowedCommand; + +public interface UpdateUserDetailUsecase { + + void updateUserNotificationAllowed(UpdateNotificationAllowedCommand command); +} diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/UpdateUserDetailUsecaseImpl.java b/src/main/java/org/runimo/runimo/user/service/usecases/UpdateUserDetailUsecaseImpl.java new file mode 100644 index 00000000..6438e852 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/usecases/UpdateUserDetailUsecaseImpl.java @@ -0,0 +1,44 @@ +package org.runimo.runimo.user.service.usecases; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.domain.UserDeviceToken; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.runimo.runimo.user.exception.UserException; +import org.runimo.runimo.user.repository.UserDeviceTokenRepository; +import org.runimo.runimo.user.service.UserFinder; +import org.runimo.runimo.user.service.dto.command.UpdateNotificationAllowedCommand; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UpdateUserDetailUsecaseImpl implements UpdateUserDetailUsecase { + + private final UserFinder userFinder; + private final UserDeviceTokenRepository userDeviceTokenRepository; + + @Override + @Transactional + public void updateUserNotificationAllowed(UpdateNotificationAllowedCommand command) { + UserDeviceToken token = userDeviceTokenRepository + .findByUserId(command.userId()) + .orElseGet(() -> createUserDeviceTokenIfNotExist(command)); + token.updateDeviceToken(command.deviceToken()); + token.updateNotificationAllowed(command.allowed()); + } + + private UserDeviceToken createUserDeviceTokenIfNotExist( + UpdateNotificationAllowedCommand command) { + User user = userFinder.findUserById(command.userId()) + .orElseThrow(() -> UserException.of(UserHttpResponseCode.USER_NOT_FOUND)); + + UserDeviceToken token = UserDeviceToken.builder() + .user(user) + .deviceToken(command.deviceToken()) + .platform(command.devicePlatform()) + .notificationAllowed(command.allowed()) + .build(); + return userDeviceTokenRepository.save(token); + } +} diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/query/UserInfoQueryUsecase.java b/src/main/java/org/runimo/runimo/user/service/usecases/query/UserInfoQueryUsecase.java new file mode 100644 index 00000000..21ad4c32 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/usecases/query/UserInfoQueryUsecase.java @@ -0,0 +1,9 @@ +package org.runimo.runimo.user.service.usecases.query; + +import org.runimo.runimo.user.service.dto.response.NotificationAllowedResponse; + +public interface UserInfoQueryUsecase { + + NotificationAllowedResponse getUserNotificationAllowed(Long userId); + +} diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/query/UserInfoQueryUsecaseImpl.java b/src/main/java/org/runimo/runimo/user/service/usecases/query/UserInfoQueryUsecaseImpl.java new file mode 100644 index 00000000..082a55c3 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/usecases/query/UserInfoQueryUsecaseImpl.java @@ -0,0 +1,25 @@ +package org.runimo.runimo.user.service.usecases.query; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.runimo.runimo.user.exception.UserException; +import org.runimo.runimo.user.service.UserFinder; +import org.runimo.runimo.user.service.dto.response.NotificationAllowedResponse; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserInfoQueryUsecaseImpl implements UserInfoQueryUsecase { + + private final UserFinder userFinder; + + @Override + public NotificationAllowedResponse getUserNotificationAllowed(Long userId) { + if (userFinder.findUserById(userId).isEmpty()) { + throw UserException.of(UserHttpResponseCode.USER_NOT_FOUND); + } + var userDeviceToken = userFinder.findUserDeviceTokenByUserId(userId) + .orElseThrow(() -> UserException.of(UserHttpResponseCode.DEVICE_TOKEN_NOT_FOUND)); + return new NotificationAllowedResponse(userId, userDeviceToken.getNotificationAllowed()); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fa6c9593..3ce932af 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -117,6 +117,11 @@ spring: dialect: org.hibernate.dialect.MySQLDialect show-sql: true + flyway: + enabled: true + locations: classpath:db/migration/mysql + baseline-on-migrate: true + clean-disabled: false jwt: expiration: ${JWT_EXPIRATION:180000} refresh: diff --git a/src/main/resources/db/migration/V202050709__add_unique_constraint_to_device_token.sql b/src/main/resources/db/migration/V202050709__add_unique_constraint_to_device_token.sql new file mode 100644 index 00000000..a6b42aa1 --- /dev/null +++ b/src/main/resources/db/migration/V202050709__add_unique_constraint_to_device_token.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_token + ADD CONSTRAINT uq_user_token_device_token UNIQUE (device_token); \ No newline at end of file diff --git a/src/test/java/org/runimo/runimo/CleanUpUtil.java b/src/test/java/org/runimo/runimo/CleanUpUtil.java index 65cab991..78d0e253 100644 --- a/src/test/java/org/runimo/runimo/CleanUpUtil.java +++ b/src/test/java/org/runimo/runimo/CleanUpUtil.java @@ -15,6 +15,7 @@ public class CleanUpUtil { "user_love_point", "incubating_egg", "runimo", + "user_token" }; @Autowired diff --git a/src/test/java/org/runimo/runimo/user/api/MyPageAcceptanceTest.java b/src/test/java/org/runimo/runimo/user/api/MyPageAcceptanceTest.java index c6bfc378..a1e86417 100644 --- a/src/test/java/org/runimo/runimo/user/api/MyPageAcceptanceTest.java +++ b/src/test/java/org/runimo/runimo/user/api/MyPageAcceptanceTest.java @@ -73,4 +73,157 @@ void tearDown() { equalTo(6700)); } + @Test + @Sql(scripts = "/sql/user_device_token_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 유저_토큰이_저장된_유저가_알림_허용_상태를_변경하는_경우() { + // given + String requestBody = """ + { + "allowed": false, + "device_token": "existing_device_token_12345", + "platform": "FCM" + } + """; + + // when & then + given() + .header("Authorization", token) + .contentType(ContentType.JSON) + .body(requestBody) + .when() + .patch("/api/v1/users/me/notifications/permission") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .body("code", equalTo("NOTIFICATION_ALLOW_UPDATED")); + + // 변경된 상태 확인 + given() + .header("Authorization", token) + .contentType(ContentType.JSON) + .when() + .get("/api/v1/users/me/notifications/permission") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .body("code", equalTo("NOTIFICATION_ALLOW_FETCHED")) + .body("payload.allowed", equalTo(false)); + } + + @Test + @Sql(scripts = "/sql/user_device_token_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 유저_토큰이_없는_사용자가_알림_허용_요청을_보내는_경우_새로_저장된다() { + // given + String requestBody = """ + { + "allowed": true, + "device_token": "new_device_token_67890", + "platform": "APNS" + } + """; + + // when & then + given() + .header("Authorization", token) + .contentType(ContentType.JSON) + .body(requestBody) + .when() + .patch("/api/v1/users/me/notifications/permission") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .body("code", equalTo("NOTIFICATION_ALLOW_UPDATED")); + + // 생성된 토큰 정보 확인 + given() + .header("Authorization", token) + .contentType(ContentType.JSON) + .when() + .get("/api/v1/users/me/notifications/permission") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .body("code", equalTo("NOTIFICATION_ALLOW_FETCHED")) + .body("payload.allowed", equalTo(true)); + } + + @Test + @Sql(scripts = "/sql/user_device_token_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 유저_토큰이_저장된_유저의_토큰정보가_바뀐_경우_새로운_토큰으로_업데이트된다() { + // given - 기존 토큰: "existing_device_token_12345", FCM, true + // 새로운 토큰으로 변경 요청 + String requestBody = """ + { + "allowed": true, + "device_token": "updated_device_token_99999", + "platform": "APNS" + } + """; + + // when & then + given() + .header("Authorization", token) + .contentType(ContentType.JSON) + .body(requestBody) + .when() + .patch("/api/v1/users/me/notifications/permission") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .body("code", equalTo("NOTIFICATION_ALLOW_UPDATED")); + + // 업데이트된 정보 확인 + given() + .header("Authorization", token) + .contentType(ContentType.JSON) + .when() + .get("/api/v1/users/me/notifications/permission") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .body("code", equalTo("NOTIFICATION_ALLOW_FETCHED")) + .body("payload.allowed", equalTo(true)); + } + + @Test + @Sql(scripts = "/sql/user_device_token_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 잘못된_플랫폼으로_요청하는_경우_400_에러가_발생한다() { + // given + String requestBody = """ + { + "allowed": true, + "device_token": "some_device_token", + "platform": "INVALID_PLATFORM" + } + """; + + // when & then - 현재 DevicePlatform.fromString에서 IllegalArgumentException 발생 + given() + .header("Authorization", token) + .contentType(ContentType.JSON) + .body(requestBody) + .when() + .patch("/api/v1/users/me/notifications/permission") + .then() + .log().all() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @Sql(scripts = "/sql/user_mypage_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 디바이스_토큰이_없는_사용자가_알림_허용_조회시_404_에러가_발생한다() { + // given - 토큰이 저장되지 않은 상태 + + // when & then + given() + .header("Authorization", token) + .contentType(ContentType.JSON) + .when() + .get("/api/v1/users/me/notifications/permission") + .then() + .log().all() + .statusCode(HttpStatus.NOT_FOUND.value()) + .body("code", equalTo("DEVICE_TOKEN_NOT_FOUND")); + } + } diff --git a/src/test/java/org/runimo/runimo/user/controller/MyPageControllerTest.java b/src/test/java/org/runimo/runimo/user/controller/MyPageControllerTest.java index 6918b509..65ba91bd 100644 --- a/src/test/java/org/runimo/runimo/user/controller/MyPageControllerTest.java +++ b/src/test/java/org/runimo/runimo/user/controller/MyPageControllerTest.java @@ -18,7 +18,9 @@ import org.runimo.runimo.configs.ControllerTest; import org.runimo.runimo.user.service.dto.LatestRunningRecord; import org.runimo.runimo.user.service.dto.response.MyPageViewResponse; +import org.runimo.runimo.user.service.usecases.UpdateUserDetailUsecase; import org.runimo.runimo.user.service.usecases.query.MyPageQueryUsecase; +import org.runimo.runimo.user.service.usecases.query.UserInfoQueryUsecase; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithAnonymousUser; @@ -36,6 +38,10 @@ class MyPageControllerTest { @MockitoBean private MyPageQueryUsecase myPageQueryUsecase; @MockitoBean + private UserInfoQueryUsecase userInfoQueryUsecase; + @MockitoBean + private UpdateUserDetailUsecase updateUserDetailUsecase; + @MockitoBean private JwtResolver jwtResolver; @MockitoBean private UserIdResolver userIdResolver; diff --git a/src/test/java/org/runimo/runimo/user/service/usecases/UpdateUserDetailUsecaseImplTest.java b/src/test/java/org/runimo/runimo/user/service/usecases/UpdateUserDetailUsecaseImplTest.java new file mode 100644 index 00000000..6aa046af --- /dev/null +++ b/src/test/java/org/runimo/runimo/user/service/usecases/UpdateUserDetailUsecaseImplTest.java @@ -0,0 +1,76 @@ +package org.runimo.runimo.user.service.usecases; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.runimo.runimo.user.UserFixtures; +import org.runimo.runimo.user.controller.request.UpdateNotificationAllowedRequst; +import org.runimo.runimo.user.domain.DevicePlatform; +import org.runimo.runimo.user.domain.UserDeviceToken; +import org.runimo.runimo.user.repository.UserDeviceTokenRepository; +import org.runimo.runimo.user.service.UserFinder; +import org.runimo.runimo.user.service.dto.command.UpdateNotificationAllowedCommand; + +class UpdateUserDetailUsecaseImplTest { + + private UpdateUserDetailUsecase updateUserDetailUsecase; + @Mock + private UserFinder userFinder; + @Mock + private UserDeviceTokenRepository userDeviceTokenRepository; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + updateUserDetailUsecase = new UpdateUserDetailUsecaseImpl(userFinder, + userDeviceTokenRepository); + } + + @Test + void 토큰이_없으면_새로_생성() { + // given + given(userDeviceTokenRepository.findByUserId(any())) + .willReturn(Optional.empty()); + given(userFinder.findUserById(any())) + .willReturn(Optional.ofNullable(UserFixtures.getDefaultUser())); + given(userDeviceTokenRepository.save(any())) + .willReturn(UserDeviceToken.from("token", DevicePlatform.APNS, true)); + var command = UpdateNotificationAllowedCommand.of( + 1L, + new UpdateNotificationAllowedRequst(true, "token", "APNS") + ); + + // when + updateUserDetailUsecase.updateUserNotificationAllowed(command); + + // then + verify(userDeviceTokenRepository, times(1)).save(any()); + } + + @Test + void 토큰_있으면_생성안함() { + given(userDeviceTokenRepository.findByUserId(any())) + .willReturn( + Optional.ofNullable(UserDeviceToken.from("token", DevicePlatform.APNS, true))); + given(userFinder.findUserById(any())) + .willReturn(Optional.ofNullable(UserFixtures.getDefaultUser())); + var command = UpdateNotificationAllowedCommand.of( + 1L, + new UpdateNotificationAllowedRequst(true, "token", "APNS") + ); + + // when + updateUserDetailUsecase.updateUserNotificationAllowed(command); + + // then + verify(userDeviceTokenRepository, times(0)).save(any()); + } + +} \ No newline at end of file diff --git a/src/test/resources/db/migration/h2/V202050709__add_unique_constraint_to_device_token.sql b/src/test/resources/db/migration/h2/V202050709__add_unique_constraint_to_device_token.sql new file mode 100644 index 00000000..a6b42aa1 --- /dev/null +++ b/src/test/resources/db/migration/h2/V202050709__add_unique_constraint_to_device_token.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_token + ADD CONSTRAINT uq_user_token_device_token UNIQUE (device_token); \ No newline at end of file diff --git a/src/test/resources/sql/all_user_notification.sql b/src/test/resources/sql/all_user_notification.sql new file mode 100644 index 00000000..1e187049 --- /dev/null +++ b/src/test/resources/sql/all_user_notification.sql @@ -0,0 +1,36 @@ +-- notification_all_users_test_data.sql +-- ALL_USERS 조건 테스트를 위한 데이터 +-- 알림 허용된 모든 활성 사용자 대상 + +-- 사용자 데이터 (7명 - 활성 5명, 삭제된 사용자 2명) +INSERT INTO users (id, public_id, nickname, created_at, updated_at, deleted_at) +VALUES (1, 'active-user-1', '활성사용자1', '2025-01-01 00:00:00', '2025-01-01 00:00:00', NULL), + (2, 'active-user-2', '활성사용자2', '2025-01-01 00:00:00', '2025-01-01 00:00:00', NULL), + (3, 'active-user-3', '활성사용자3', '2025-01-01 00:00:00', '2025-01-01 00:00:00', NULL), + (4, 'active-user-4', '활성사용자4', '2025-01-01 00:00:00', '2025-01-01 00:00:00', NULL), + (5, 'active-user-5', '활성사용자5', '2025-01-01 00:00:00', '2025-01-01 00:00:00', NULL), +-- 삭제된 사용자들 (deleted_at이 NULL이 아님) + (6, 'deleted-user-1', '삭제된사용자1', '2025-01-01 00:00:00', '2025-01-01 00:00:00', + '2025-01-02 00:00:00'), + (7, 'deleted-user-2', '삭제된사용자2', '2025-01-01 00:00:00', '2025-01-01 00:00:00', + '2025-01-03 00:00:00'); + +INSERT INTO users (id, public_id, nickname, img_url, total_distance_in_meters, + total_time_in_seconds, created_at, + updated_at, role) +VALUES (11, 'admin-user-uuid-1', 'Daniel', 'https://example.com/images/user1.png', 10000, 3600, + NOW(), + NOW(), 'ADMIN'); + +-- 사용자별 device token +-- 활성 사용자 5명은 모두 알림 허용, 삭제된 사용자는 제외 +INSERT INTO user_token (user_id, device_token, platform, notification_allowed, created_at, + updated_at) +VALUES (1, 'active-device-token-1', 'FCM', true, '2025-01-01 00:00:00', '2025-01-01 00:00:00'), + (2, 'active-device-token-2', 'APNS', true, '2025-01-01 00:00:00', '2025-01-01 00:00:00'), + (3, 'active-device-token-3', 'FCM', true, '2025-01-01 00:00:00', '2025-01-01 00:00:00'), + (4, 'active-device-token-4', 'APNS', true, '2025-01-01 00:00:00', '2025-01-01 00:00:00'), + (5, 'active-device-token-5', 'FCM', true, '2025-01-01 00:00:00', '2025-01-01 00:00:00'), +-- 삭제된 사용자들의 토큰 (대상에서 제외되어야 함) + (6, 'deleted-device-token-1', 'FCM', true, '2025-01-01 00:00:00', '2025-01-01 00:00:00'), + (7, 'deleted-device-token-2', 'APNS', true, '2025-01-01 00:00:00', '2025-01-01 00:00:00'); diff --git a/src/test/resources/sql/inactive_user_notification.sql b/src/test/resources/sql/inactive_user_notification.sql new file mode 100644 index 00000000..01d0ff44 --- /dev/null +++ b/src/test/resources/sql/inactive_user_notification.sql @@ -0,0 +1,29 @@ +-- INACTIVE_USERS 조건 테스트를 위한 데이터 +-- 1주일 내 달리기 기록이 없는 사용자 = 비활성 사용자 + +-- 사용자 데이터 (5명) +INSERT INTO users (id, public_id, nickname, created_at, updated_at) +VALUES (1, 'active-user-1', '활성사용자1', '2025-01-01 00:00:00', '2025-01-01 00:00:00'), + (2, 'active-user-2', '활성사용자2', '2025-01-01 00:00:00', '2025-01-01 00:00:00'), + (3, 'active-user-3', '활성사용자3', '2025-01-01 00:00:00', '2025-01-01 00:00:00'), + (4, 'inactive-user-1', '비활성사용자1', '2025-01-01 00:00:00', '2025-01-01 00:00:00'), + (5, 'inactive-user-2', '비활성사용자2', '2025-01-01 00:00:00', '2025-01-01 00:00:00'); + +INSERT INTO users (id, public_id, nickname, img_url, total_distance_in_meters, + total_time_in_seconds, created_at, + updated_at, role) +VALUES (11, 'admin-user-uuid-1', 'Daniel', 'https://example.com/images/user1.png', 10000, 3600, + NOW(), + NOW(), 'ADMIN'); + + +-- 사용자별 device token (알림 허용된 사용자들만) +INSERT INTO user_token (user_id, device_token, platform, notification_allowed, created_at, + updated_at) +VALUES (1, 'active-device-token-1', 'FCM', true, '2025-01-01 00:00:00', '2025-01-01 00:00:00'), + (2, 'active-device-token-2', 'APNS', true, '2025-01-01 00:00:00', '2025-01-01 00:00:00'), + (3, 'active-device-token-3', 'FCM', true, '2025-01-01 00:00:00', '2025-01-01 00:00:00'), + (4, 'inactive-device-token-1', 'FCM', true, '2025-01-01 00:00:00', '2025-01-01 00:00:00'), + (5, 'inactive-device-token-2', 'APNS', true, '2025-01-01 00:00:00', '2025-01-01 00:00:00'); + + diff --git a/src/test/resources/sql/user_device_token_test_data.sql b/src/test/resources/sql/user_device_token_test_data.sql new file mode 100644 index 00000000..d63963ea --- /dev/null +++ b/src/test/resources/sql/user_device_token_test_data.sql @@ -0,0 +1,18 @@ +-- 테스트용 기본 유저 (기존에 있다고 가정) +SET FOREIGN_KEY_CHECKS = 0; +TRUNCATE TABLE users; +INSERT INTO users (id, public_id, nickname, img_url, total_distance_in_meters, + total_time_in_seconds, created_at, + updated_at) +VALUES (1, 'test-user-uuid-1', 'Daniel', 'https://example.com/images/user1.png', 10000, 3600, NOW(), + NOW()); +SET FOREIGN_KEY_CHECKS = 1; + +TRUNCATE TABLE oauth_account; +INSERT INTO oauth_account (id, created_at, deleted_at, updated_at, provider, provider_id, user_id) +VALUES (1, NOW(), null, NOW(), 'KAKAO', 1234, 1); + +-- 기존 디바이스 토큰 데이터 +INSERT INTO user_token (id, user_id, device_token, platform, notification_allowed, created_at, + updated_at) +VALUES (1, 1, 'existing_device_token_12345', 'FCM', true, NOW(), NOW());