diff --git a/src/main/java/org/runimo/runimo/auth/controller/request/AuthSignupRequest.java b/src/main/java/org/runimo/runimo/auth/controller/request/AuthSignupRequest.java index 50f3dc04..85b9b0e2 100644 --- a/src/main/java/org/runimo/runimo/auth/controller/request/AuthSignupRequest.java +++ b/src/main/java/org/runimo/runimo/auth/controller/request/AuthSignupRequest.java @@ -2,23 +2,62 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; import org.runimo.runimo.auth.service.dto.UserSignupCommand; +import org.runimo.runimo.user.domain.DevicePlatform; import org.runimo.runimo.user.domain.Gender; import org.springframework.web.multipart.MultipartFile; @Schema(description = "사용자 회원가입 요청 DTO") -public record AuthSignupRequest( +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class AuthSignupRequest { + @Schema(description = "회원가입용 임시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI...") - @NotBlank String registerToken, + @NotBlank(message = "회원가입 토큰은 필수입니다") + private String registerToken; @Schema(description = "사용자 닉네임", example = "RunimoUser") - @NotBlank String nickname, + @NotBlank + String nickname; @Schema(description = "성별", example = "FEMALE") - Gender gender -) { + private Gender gender; + + @Schema(description = "디바이스 토큰", example = "string") + private String deviceToken; + + @Schema(description = "디바이스 플랫폼", example = "FCM / APNS") + private String devicePlatform; + + public AuthSignupRequest(String registerToken, String nickname, Gender gender) { + this.registerToken = registerToken; + this.nickname = nickname; + this.gender = gender; + } public UserSignupCommand toUserSignupCommand(MultipartFile file) { - return new UserSignupCommand(registerToken, nickname, file, gender); + if (hasDeviceToken() && (devicePlatform == null || devicePlatform.trim().isEmpty())) { + throw new IllegalArgumentException("디바이스 토큰이 있으면 플랫폼도 필수입니다."); + } + return new UserSignupCommand( + registerToken, + nickname, + file, + gender, + deviceToken, + devicePlatform != null ? DevicePlatform.fromString(devicePlatform) : null + ); + } + + private boolean hasDeviceToken() { + return deviceToken != null && !deviceToken.trim().isEmpty(); } -} +} \ No newline at end of file diff --git a/src/main/java/org/runimo/runimo/auth/service/SignUpUsecaseImpl.java b/src/main/java/org/runimo/runimo/auth/service/SignUpUsecaseImpl.java index fbc05f73..00b50013 100644 --- a/src/main/java/org/runimo/runimo/auth/service/SignUpUsecaseImpl.java +++ b/src/main/java/org/runimo/runimo/auth/service/SignUpUsecaseImpl.java @@ -19,6 +19,7 @@ import org.runimo.runimo.user.enums.UserHttpResponseCode; import org.runimo.runimo.user.repository.AppleUserTokenRepository; import org.runimo.runimo.user.service.UserRegisterService; +import org.runimo.runimo.user.service.dto.command.DeviceTokenDto; import org.runimo.runimo.user.service.dto.command.UserRegisterCommand; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -43,13 +44,8 @@ public SignupUserResponse register(UserSignupCommand command) { SignupToken signupToken = findUnExpiredSignupToken(payload.token()); userRegisterService.validateExistingUser(payload.providerId(), payload.socialProvider()); String imgUrl = fileStorageService.storeFile(command.profileImage()); - User savedUser = userRegisterService.registerUser(new UserRegisterCommand( - command.nickname(), - imgUrl, - command.gender(), - payload.providerId(), - payload.socialProvider()) - ); + User savedUser = userRegisterService.registerUser( + mapToUserCreateCommand(payload, imgUrl, command)); if (payload.socialProvider() == SocialProvider.APPLE) { createAppleUserToken(savedUser.getId(), signupToken); } @@ -78,4 +74,16 @@ private void createAppleUserToken(Long userId, SignupToken signupToken) { ); appleUserTokenRepository.save(appleUserToken); } + + private UserRegisterCommand mapToUserCreateCommand(SignupTokenPayload payload, String imgUrl, + UserSignupCommand command) { + return new UserRegisterCommand( + command.nickname(), + imgUrl, + command.gender(), + payload.providerId(), + payload.socialProvider(), + DeviceTokenDto.of(command.deviceToken(), command.devicePlatform()) + ); + } } diff --git a/src/main/java/org/runimo/runimo/auth/service/dto/UserSignupCommand.java b/src/main/java/org/runimo/runimo/auth/service/dto/UserSignupCommand.java index 9eefe623..4c1b9a6f 100644 --- a/src/main/java/org/runimo/runimo/auth/service/dto/UserSignupCommand.java +++ b/src/main/java/org/runimo/runimo/auth/service/dto/UserSignupCommand.java @@ -1,6 +1,7 @@ package org.runimo.runimo.auth.service.dto; +import org.runimo.runimo.user.domain.DevicePlatform; import org.runimo.runimo.user.domain.Gender; import org.springframework.web.multipart.MultipartFile; @@ -8,7 +9,9 @@ public record UserSignupCommand( String registerToken, String nickname, MultipartFile profileImage, - Gender gender + Gender gender, + String deviceToken, + DevicePlatform devicePlatform ) { } diff --git a/src/main/java/org/runimo/runimo/user/domain/DevicePlatform.java b/src/main/java/org/runimo/runimo/user/domain/DevicePlatform.java new file mode 100644 index 00000000..0a98fdfc --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/domain/DevicePlatform.java @@ -0,0 +1,12 @@ +package org.runimo.runimo.user.domain; + +public enum DevicePlatform { + FCM, + APNS, + NONE; + + + public static DevicePlatform fromString(String value) { + return DevicePlatform.valueOf(value.toUpperCase()); + } +} diff --git a/src/main/java/org/runimo/runimo/user/domain/UserDeviceToken.java b/src/main/java/org/runimo/runimo/user/domain/UserDeviceToken.java new file mode 100644 index 00000000..fbdc19ba --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/domain/UserDeviceToken.java @@ -0,0 +1,60 @@ +package org.runimo.runimo.user.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.runimo.runimo.common.CreateUpdateAuditEntity; + +@Table(name = "user_token") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class UserDeviceToken extends CreateUpdateAuditEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(name = "user_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private User user; + + @Column(name = "device_token", nullable = false) + private String deviceToken; + + @Column(name = "platform", nullable = false) + private DevicePlatform platform; + + @Column(name = "notification_allowed", nullable = false) + private Boolean notificationAllowed; + + @Column(name = "last_used_at") + private LocalDateTime lastUsedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public static UserDeviceToken from(String deviceToken, DevicePlatform platform, + Boolean notificationAllowed) { + return UserDeviceToken.builder() + .deviceToken(deviceToken) + .notificationAllowed(notificationAllowed) + .platform(platform) + .build(); + } + +} diff --git a/src/main/java/org/runimo/runimo/user/repository/UserDeviceTokenRepository.java b/src/main/java/org/runimo/runimo/user/repository/UserDeviceTokenRepository.java new file mode 100644 index 00000000..e10ff445 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/repository/UserDeviceTokenRepository.java @@ -0,0 +1,10 @@ +package org.runimo.runimo.user.repository; + +import org.runimo.runimo.user.domain.UserDeviceToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserDeviceTokenRepository extends JpaRepository { + +} \ No newline at end of file diff --git a/src/main/java/org/runimo/runimo/user/service/UserCreator.java b/src/main/java/org/runimo/runimo/user/service/UserCreator.java index 9ae53c79..cc22a321 100644 --- a/src/main/java/org/runimo/runimo/user/service/UserCreator.java +++ b/src/main/java/org/runimo/runimo/user/service/UserCreator.java @@ -5,9 +5,12 @@ import org.runimo.runimo.user.domain.OAuthInfo; import org.runimo.runimo.user.domain.SocialProvider; 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.OAuthInfoRepository; +import org.runimo.runimo.user.repository.UserDeviceTokenRepository; import org.runimo.runimo.user.repository.UserRepository; +import org.runimo.runimo.user.service.dto.command.DeviceTokenDto; import org.runimo.runimo.user.service.dto.command.UserCreateCommand; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -19,6 +22,7 @@ public class UserCreator { private final UserRepository userRepository; private final OAuthInfoRepository oAuthInfoRepository; private final LovePointRepository lovePointRepository; + private final UserDeviceTokenRepository userDeviceTokenRepository; @Transactional public User createUser(UserCreateCommand command) { @@ -48,4 +52,18 @@ public LovePoint createLovePoint(Long userId) { .build(); return lovePointRepository.save(lovePoint); } + + @Transactional + public void createUserDeviceToken(User user, DeviceTokenDto deviceTokenDto) { + if (deviceTokenDto.isEmpty()) { + return; + } + UserDeviceToken userDeviceToken = UserDeviceToken.builder() + .user(user) + .deviceToken(deviceTokenDto.token()) + .platform(deviceTokenDto.platform()) + .notificationAllowed(true) + .build(); + userDeviceTokenRepository.save(userDeviceToken); + } } diff --git a/src/main/java/org/runimo/runimo/user/service/UserRegisterService.java b/src/main/java/org/runimo/runimo/user/service/UserRegisterService.java index 2e2e08ef..9cfd1ed1 100644 --- a/src/main/java/org/runimo/runimo/user/service/UserRegisterService.java +++ b/src/main/java/org/runimo/runimo/user/service/UserRegisterService.java @@ -26,6 +26,7 @@ public User registerUser(UserRegisterCommand command) { userCreator.createUserOAuthInfo(savedUser, command.socialProvider(), command.providerId()); userCreator.createLovePoint(savedUser.getId()); userItemCreator.createAll(savedUser.getId()); + userCreator.createUserDeviceToken(savedUser, command.deviceToken()); return savedUser; } diff --git a/src/main/java/org/runimo/runimo/user/service/dto/command/DeviceTokenDto.java b/src/main/java/org/runimo/runimo/user/service/dto/command/DeviceTokenDto.java new file mode 100644 index 00000000..90da6112 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/dto/command/DeviceTokenDto.java @@ -0,0 +1,20 @@ +package org.runimo.runimo.user.service.dto.command; + +import org.runimo.runimo.user.domain.DevicePlatform; + +public record DeviceTokenDto(String token, DevicePlatform platform) { + + public static final DeviceTokenDto EMPTY = new DeviceTokenDto("", DevicePlatform.NONE); + + public static DeviceTokenDto of(String deviceToken, DevicePlatform devicePlatform) { + if (deviceToken == null || deviceToken.isEmpty() || devicePlatform == null + || devicePlatform == DevicePlatform.NONE) { + return EMPTY; + } + return new DeviceTokenDto(deviceToken, devicePlatform); + } + + public boolean isEmpty() { + return this == EMPTY; + } +} diff --git a/src/main/java/org/runimo/runimo/user/service/dto/command/UserRegisterCommand.java b/src/main/java/org/runimo/runimo/user/service/dto/command/UserRegisterCommand.java index 02037fe0..59557170 100644 --- a/src/main/java/org/runimo/runimo/user/service/dto/command/UserRegisterCommand.java +++ b/src/main/java/org/runimo/runimo/user/service/dto/command/UserRegisterCommand.java @@ -9,7 +9,8 @@ public record UserRegisterCommand( String imgUrl, Gender gender, @NotNull String providerId, - @NotNull SocialProvider socialProvider + @NotNull SocialProvider socialProvider, + DeviceTokenDto deviceToken ) { } diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql index eeb23855..f696949a 100644 --- a/src/main/resources/sql/schema.sql +++ b/src/main/resources/sql/schema.sql @@ -37,11 +37,15 @@ CREATE TABLE `users` CREATE TABLE `user_token` ( - `user_id` BIGINT NOT NULL, - `device_token` VARCHAR(255) NOT NULL, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `deleted_at` TIMESTAMP NULL, + `id` BIGINT AUTO_INCREMENT PRIMARY KEY NOT NULL, + `user_id` BIGINT NOT NULL, + `device_token` VARCHAR(255) NOT NULL, + `platform` ENUM ('FCM', 'APNS') NOT NULL DEFAULT 'APNS', + `notification_allowed` BOOLEAN NOT NULL DEFAULT TRUE, + `last_used_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL, FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ); diff --git a/src/test/java/org/runimo/runimo/auth/controller/AuthAcceptanceTest.java b/src/test/java/org/runimo/runimo/auth/controller/AuthAcceptanceTest.java index fe9e4d56..c2399d12 100644 --- a/src/test/java/org/runimo/runimo/auth/controller/AuthAcceptanceTest.java +++ b/src/test/java/org/runimo/runimo/auth/controller/AuthAcceptanceTest.java @@ -3,6 +3,7 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.notNullValue; +import static org.runimo.runimo.user.domain.DevicePlatform.APNS; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -10,6 +11,7 @@ import io.restassured.http.ContentType; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.runimo.runimo.CleanUpUtil; import org.runimo.runimo.auth.controller.request.AuthSignupRequest; @@ -33,127 +35,178 @@ @ActiveProfiles("test") class AuthAcceptanceTest { - @LocalServerPort - private int port; - @MockitoBean - private FileStorageService fileStorageService; - @MockitoBean - private KakaoTokenVerifier kakaoTokenVerifier; - @MockitoBean - private AppleTokenVerifier appleTokenVerifier; - - @Autowired - private SignupTokenRepository signupTokenRepository; - - @Autowired - private KakaoLoginHandler kakaoLoginHandler; - - private String token; - @Autowired - private ObjectMapper objectMapper; - @Autowired - private CleanUpUtil cleanUpUtil; - @Autowired - private JwtTokenFactory jwtTokenFactory; - - @BeforeEach - void setUp() { - RestAssured.port = port; - // Save a valid signup token in the database - SignupToken signupToken = new SignupToken( - "valid-token", - "provider-id", - "refresh-token", - SocialProvider.KAKAO - ); - token = jwtTokenFactory.generateSignupTemporalToken("provider-id", SocialProvider.KAKAO, - "valid-token"); - signupTokenRepository.save(signupToken); - } - - @AfterEach - void tearDown() { - cleanUpUtil.cleanUpUserInfos(); - } - - @Test - void 회원가입_성공_201응답() throws JsonProcessingException { - - AuthSignupRequest request = new AuthSignupRequest(token, "username", Gender.UNKNOWN); - - given() - .contentType(ContentType.MULTIPART) - .multiPart("request", objectMapper.writeValueAsString(request)) - .when() - .post("/api/v1/auth/signup") - .then() - .statusCode(HttpStatus.CREATED.value()) - .log().all() - .body("payload.nickname", equalTo("username")) - .body("payload.token_pair.access_token", notNullValue()) - .body("payload.token_pair.refresh_token", notNullValue()); - } - - @Test - void 토큰_오류_회원가입_실패_401응답() throws JsonProcessingException { - AuthSignupRequest request = new AuthSignupRequest(token, "username", Gender.UNKNOWN); - - given() - .contentType(ContentType.MULTIPART) - .multiPart("request", objectMapper.writeValueAsString(request)) - .when() - .post("/api/v1/auth/signup") - .then() - .statusCode(HttpStatus.CREATED.value()) - .log().all() - .body("payload.nickname", equalTo("username")) - .body("payload.token_pair.access_token", notNullValue()) - .body("payload.token_pair.refresh_token", notNullValue()); - - given() - .contentType(ContentType.MULTIPART) - .multiPart("request", objectMapper.writeValueAsString(request)) - .when() - .post("/api/v1/auth/signup") - .then() - .statusCode(HttpStatus.UNAUTHORIZED.value()); - } - - @Test - void 중복_유저_회원가입_409응답() throws JsonProcessingException { - AuthSignupRequest request = new AuthSignupRequest(token, "username", Gender.UNKNOWN); - - given() - .contentType(ContentType.MULTIPART) - .multiPart("request", objectMapper.writeValueAsString(request)) - .when() - .post("/api/v1/auth/signup") - .then() - .statusCode(HttpStatus.CREATED.value()) - .log().all() - .body("payload.nickname", equalTo("username")) - .body("payload.token_pair.access_token", notNullValue()) - .body("payload.token_pair.refresh_token", notNullValue()); - - SignupToken signupToken = new SignupToken( - "valid-token", - "provider-id", - "refresh-token", - SocialProvider.KAKAO - ); - token = jwtTokenFactory.generateSignupTemporalToken("provider-id", SocialProvider.KAKAO, - "valid-token"); - signupTokenRepository.save(signupToken); - request = new AuthSignupRequest(token, "username", Gender.UNKNOWN); - - given() - .contentType(ContentType.MULTIPART) - .multiPart("request", objectMapper.writeValueAsString(request)) - .when() - .post("/api/v1/auth/signup") - .then() - .statusCode(HttpStatus.CONFLICT.value()); - - signupTokenRepository.delete(signupToken); - } + @LocalServerPort + private int port; + @MockitoBean + private FileStorageService fileStorageService; + @MockitoBean + private KakaoTokenVerifier kakaoTokenVerifier; + @MockitoBean + private AppleTokenVerifier appleTokenVerifier; + + @Autowired + private SignupTokenRepository signupTokenRepository; + + @Autowired + private KakaoLoginHandler kakaoLoginHandler; + + private String token; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private CleanUpUtil cleanUpUtil; + @Autowired + private JwtTokenFactory jwtTokenFactory; + + @BeforeEach + void setUp() { + RestAssured.port = port; + // Save a valid signup token in the database + SignupToken signupToken = new SignupToken( + "valid-token", + "provider-id", + "refresh-token", + SocialProvider.KAKAO + ); + token = jwtTokenFactory.generateSignupTemporalToken("provider-id", SocialProvider.KAKAO, + "valid-token"); + signupTokenRepository.save(signupToken); + } + + @AfterEach + void tearDown() { + cleanUpUtil.cleanUpUserInfos(); + signupTokenRepository.deleteAll(); + + } + + @Test + void 회원가입_성공_201응답() throws JsonProcessingException { + + AuthSignupRequest request = new AuthSignupRequest(token, "username", Gender.UNKNOWN); + + given() + .contentType(ContentType.MULTIPART) + .multiPart("request", objectMapper.writeValueAsString(request)) + .when() + .post("/api/v1/auth/signup") + .then() + .statusCode(HttpStatus.CREATED.value()) + .log().all() + .body("payload.nickname", equalTo("username")) + .body("payload.token_pair.access_token", notNullValue()) + .body("payload.token_pair.refresh_token", notNullValue()); + } + + @Test + void 토큰_오류_회원가입_실패_401응답() throws JsonProcessingException { + AuthSignupRequest request = new AuthSignupRequest(token, "username", Gender.UNKNOWN); + + given() + .contentType(ContentType.MULTIPART) + .multiPart("request", objectMapper.writeValueAsString(request)) + .when() + .post("/api/v1/auth/signup") + .then() + .statusCode(HttpStatus.CREATED.value()) + .log().all() + .body("payload.nickname", equalTo("username")) + .body("payload.token_pair.access_token", notNullValue()) + .body("payload.token_pair.refresh_token", notNullValue()); + + given() + .contentType(ContentType.MULTIPART) + .multiPart("request", objectMapper.writeValueAsString(request)) + .when() + .post("/api/v1/auth/signup") + .then() + .statusCode(HttpStatus.UNAUTHORIZED.value()); + } + + @Test + void 중복_유저_회원가입_409응답() throws JsonProcessingException { + AuthSignupRequest request = new AuthSignupRequest(token, "username", Gender.UNKNOWN); + + given() + .contentType(ContentType.MULTIPART) + .multiPart("request", objectMapper.writeValueAsString(request)) + .when() + .post("/api/v1/auth/signup") + .then() + .statusCode(HttpStatus.CREATED.value()) + .log().all() + .body("payload.nickname", equalTo("username")) + .body("payload.token_pair.access_token", notNullValue()) + .body("payload.token_pair.refresh_token", notNullValue()); + + SignupToken signupToken = new SignupToken( + "valid-token", + "provider-id", + "refresh-token", + SocialProvider.KAKAO + ); + token = jwtTokenFactory.generateSignupTemporalToken("provider-id", SocialProvider.KAKAO, + "valid-token"); + signupTokenRepository.save(signupToken); + request = new AuthSignupRequest(token, "username", Gender.UNKNOWN); + + given() + .contentType(ContentType.MULTIPART) + .multiPart("request", objectMapper.writeValueAsString(request)) + .when() + .post("/api/v1/auth/signup") + .then() + .statusCode(HttpStatus.CONFLICT.value()); + + signupTokenRepository.delete(signupToken); + } + + @Test + @DisplayName("디바이스 토큰을 포함하여 회원가입 성공 201응답") + void 회원가입_디바이스_토큰_포함_성공_201응답() throws JsonProcessingException { + AuthSignupRequest request = buildSignupRequest(token, "username", Gender.UNKNOWN); + given() + .contentType(ContentType.MULTIPART) + .multiPart("request", objectMapper.writeValueAsString(request)) + .when() + .post("/api/v1/auth/signup") + .then() + .statusCode(HttpStatus.CREATED.value()) + .log().all() + .body("payload.nickname", equalTo("username")) + .body("payload.token_pair.access_token", notNullValue()) + .body("payload.token_pair.refresh_token", notNullValue()); + } + + @Test + @DisplayName("디바이스 토큰을 포함했지만 디바이스 플랫폼을 미포함하여 400응답") + void 회원가입_디바이스_토큰_포함_플랫폼_미포함_실패_400응답() throws JsonProcessingException { + AuthSignupRequest request = new AuthSignupRequest(token, "nickname", null, + "example_device_token", null); + given() + .contentType(ContentType.MULTIPART) + .multiPart("request", objectMapper.writeValueAsString(request)) + .when() + .post("/api/v1/auth/signup") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("APNS_플랫폼으로_회원가입_성공_201응답") + void 회원가입_APNS_플랫폼으로_성공_201응답() throws JsonProcessingException { + AuthSignupRequest request = buildSignupRequest(token, "username", Gender.UNKNOWN); + given() + .contentType(ContentType.MULTIPART) + .multiPart("request", objectMapper.writeValueAsString(request)) + .when() + .post("/api/v1/auth/signup") + .then() + .statusCode(HttpStatus.CREATED.value()) + .log().all(); + } + + private AuthSignupRequest buildSignupRequest(String token, String nickname, Gender gender) { + return new AuthSignupRequest(token, nickname, gender, "example_device_token", APNS.name()); + } } diff --git a/src/test/java/org/runimo/runimo/auth/controller/AuthControllerTest.java b/src/test/java/org/runimo/runimo/auth/controller/AuthControllerTest.java index 465b548a..0784ed2f 100644 --- a/src/test/java/org/runimo/runimo/auth/controller/AuthControllerTest.java +++ b/src/test/java/org/runimo/runimo/auth/controller/AuthControllerTest.java @@ -18,6 +18,7 @@ import org.runimo.runimo.auth.service.TokenRefreshService; import org.runimo.runimo.auth.service.dto.AuthResult; import org.runimo.runimo.auth.service.dto.AuthStatus; +import org.runimo.runimo.auth.service.dto.SignupUserResponse; import org.runimo.runimo.auth.service.dto.TokenPair; import org.runimo.runimo.configs.ControllerTest; import org.runimo.runimo.user.UserFixtures; @@ -28,8 +29,8 @@ import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -@ControllerTest(controllers = {AuthController.class}) +@ControllerTest(controllers = {AuthController.class}) class AuthControllerTest { @Autowired @@ -137,12 +138,70 @@ class AuthControllerTest { // when & then mockMvc.perform( - multipart( - "/api/v1/auth/signup") - .param("request", "{\"registerToken\":\"invalid-token\", \"nickname\":\"RunimoUser\"}")) + multipart("/api/v1/auth/signup") + .param("request", + "{\"registerToken\":\"invalid-token\", \"nickname\":\"RunimoUser\"}")) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.code").value(UserHttpResponseCode.TOKEN_INVALID.getCode())) .andExpect( jsonPath("$.message").value(UserHttpResponseCode.TOKEN_INVALID.getClientMessage())); } + + @Test + @DisplayName("디바이스 토큰 없이 회원가입 요청시 201 응답 (구버전 앱 호환)") + void 회원가입_디바이스_토큰_없음_201응답() throws Exception { + // given - 디바이스 토큰 없는 회원가입도 성공해야 함 + given(signUpUsecase.register(any())).willReturn( + new SignupUserResponse( + 1L, "RunimoUser", "profile_url", new TokenPair("access_token", "refresh_token"), + "exmaple_egg_name", + "example_egg_type", + "example_egg_url" + ) + ); + + // when & then + mockMvc.perform( + multipart("/api/v1/auth/signup") + .param("request", + "{\"registerToken\":\"valid-token\", \"nickname\":\"RunimoUser\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.code").value(UserHttpResponseCode.SIGNUP_SUCCESS.getCode())); + } + + @Test + @DisplayName("디바이스 토큰 있으나 플랫폼 없이 회원가입 요청시 400 응답") + void 회원가입_플랫폼_없음_400응답() throws Exception { + //given + given(signUpUsecase.register(any())) + .willThrow(new IllegalArgumentException("디바이스 토큰이 있으면 플랫폼도 필수입니다.")); + + mockMvc.perform( + multipart("/api/v1/auth/signup") + .param("request", + "{\"registerToken\":\"valid-token\", \"nickname\":\"RunimoUser\", \"deviceToken\":\"valid_device_token\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("정상적인 회원가입 요청시 201 응답") + void 회원가입_성공_201응답() throws Exception { + // given + given(signUpUsecase.register(any())).willReturn( + new SignupUserResponse( + 1L, "RunimoUser", "profile_url", new TokenPair("access_token", "refresh_token"), + "exmaple_egg_name", + "example_egg_type", + "example_egg_url" + ) + ); + + // when & then + mockMvc.perform( + multipart("/api/v1/auth/signup") + .param("request", + "{\"registerToken\":\"valid-token\", \"nickname\":\"RunimoUser\", \"deviceToken\":\"valid_device_token\", \"devicePlatform\":\"FCM\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.code").value(UserHttpResponseCode.SIGNUP_SUCCESS.getCode())); + } } diff --git a/src/test/java/org/runimo/runimo/auth/service/SignUpUsecaseTest.java b/src/test/java/org/runimo/runimo/auth/service/SignUpUsecaseTest.java index fdb80bef..c9aadced 100644 --- a/src/test/java/org/runimo/runimo/auth/service/SignUpUsecaseTest.java +++ b/src/test/java/org/runimo/runimo/auth/service/SignUpUsecaseTest.java @@ -25,6 +25,7 @@ import org.runimo.runimo.item.EggFixtures; import org.runimo.runimo.rewards.service.eggs.EggGrantService; import org.runimo.runimo.user.UserFixtures; +import org.runimo.runimo.user.domain.DevicePlatform; import org.runimo.runimo.user.domain.Gender; import org.runimo.runimo.user.domain.SocialProvider; import org.runimo.runimo.user.enums.UserHttpResponseCode; @@ -81,7 +82,8 @@ void setUp() { when(jwtTokenFactory.generateTokenPair(any())).thenReturn(UserFixtures.TEST_TOKEN_PAIR); SignupUserResponse response = sut - .register(new UserSignupCommand(registerToken, "nickname", null, Gender.UNKNOWN)); + .register(new UserSignupCommand(registerToken, "nickname", null, Gender.UNKNOWN, + "device_token", DevicePlatform.APNS)); assertEquals(1L, response.userId()); assertEquals(UserFixtures.TEST_USER_NICKNAME, response.nickname()); @@ -110,7 +112,8 @@ void setUp() { .validateExistingUser(payload.providerId(), payload.socialProvider()); assertThrows(SignUpException.class, () -> { - sut.register(new UserSignupCommand(registerToken, "nickname", null, Gender.UNKNOWN)); + sut.register(new UserSignupCommand(registerToken, "nickname", null, Gender.UNKNOWN, + "device_token", DevicePlatform.APNS)); }); } } diff --git a/src/test/java/org/runimo/runimo/rewards/RewardTest.java b/src/test/java/org/runimo/runimo/rewards/RewardTest.java index 59daf198..e7e36e23 100644 --- a/src/test/java/org/runimo/runimo/rewards/RewardTest.java +++ b/src/test/java/org/runimo/runimo/rewards/RewardTest.java @@ -23,6 +23,7 @@ import org.runimo.runimo.rewards.service.RewardService; import org.runimo.runimo.rewards.service.dto.RewardClaimCommand; import org.runimo.runimo.rewards.service.dto.RewardResponse; +import org.runimo.runimo.user.domain.DevicePlatform; import org.runimo.runimo.user.domain.Gender; import org.runimo.runimo.user.domain.SocialProvider; import org.runimo.runimo.user.domain.User; @@ -74,8 +75,9 @@ void setUp() { null, SocialProvider.KAKAO )); - UserSignupCommand command = new UserSignupCommand(registerToken, "name", null, - Gender.UNKNOWN); + UserSignupCommand command = new UserSignupCommand(registerToken, "nickname", null, + Gender.UNKNOWN, + "device_token", DevicePlatform.APNS); Long useId = signUpUsecaseImpl.register(command).userId(); savedUser = userRepository.findById(useId).orElse(null); } diff --git a/src/test/java/org/runimo/runimo/user/api/UserItemAcceptanceTest.java b/src/test/java/org/runimo/runimo/user/api/UserItemAcceptanceTest.java index 7a3cff68..d9418f2c 100644 --- a/src/test/java/org/runimo/runimo/user/api/UserItemAcceptanceTest.java +++ b/src/test/java/org/runimo/runimo/user/api/UserItemAcceptanceTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.any; import static org.mockito.Mockito.when; import static org.runimo.runimo.TestConsts.TEST_USER_UUID; +import static org.runimo.runimo.user.domain.DevicePlatform.APNS; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -153,7 +154,9 @@ void tearDown() { AuthSignupRequest request = new AuthSignupRequest( registerToken, "test-user", - Gender.FEMALE + Gender.FEMALE, + "device_token", + APNS.name() ); ValidatableResponse res = given() diff --git a/src/test/java/org/runimo/runimo/user/service/usecases/UserRegisterServiceTest.java b/src/test/java/org/runimo/runimo/user/service/usecases/UserRegisterServiceTest.java index 41ec5fbf..8f02fd9d 100644 --- a/src/test/java/org/runimo/runimo/user/service/usecases/UserRegisterServiceTest.java +++ b/src/test/java/org/runimo/runimo/user/service/usecases/UserRegisterServiceTest.java @@ -13,12 +13,14 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.runimo.runimo.user.domain.DevicePlatform; import org.runimo.runimo.user.domain.Gender; import org.runimo.runimo.user.domain.SocialProvider; import org.runimo.runimo.user.domain.User; import org.runimo.runimo.user.service.UserCreator; import org.runimo.runimo.user.service.UserItemCreator; import org.runimo.runimo.user.service.UserRegisterService; +import org.runimo.runimo.user.service.dto.command.DeviceTokenDto; import org.runimo.runimo.user.service.dto.command.UserRegisterCommand; class UserRegisterServiceTest { @@ -47,7 +49,8 @@ void setUp() { "https://test.com", Gender.UNKNOWN, providerId, - SocialProvider.KAKAO + SocialProvider.KAKAO, + DeviceTokenDto.of("example-device-token", DevicePlatform.APNS) ); User mockUser = mock(User.class); when(userCreator.createUser(any())).thenReturn(mockUser); diff --git a/src/test/resources/sql/schema.sql b/src/test/resources/sql/schema.sql index 77cf95e1..8353769d 100644 --- a/src/test/resources/sql/schema.sql +++ b/src/test/resources/sql/schema.sql @@ -35,13 +35,18 @@ CREATE TABLE `users` `deleted_at` TIMESTAMP NULL ); + CREATE TABLE `user_token` ( - `user_id` BIGINT NOT NULL, - `device_token` VARCHAR(255) NOT NULL, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `deleted_at` TIMESTAMP NULL, + `id` BIGINT AUTO_INCREMENT PRIMARY KEY NOT NULL, + `user_id` BIGINT NOT NULL, + `device_token` VARCHAR(255) NOT NULL, + `platform` ENUM ('FCM', 'APNS') NOT NULL DEFAULT 'APNS', + `notification_allowed` BOOLEAN NOT NULL DEFAULT TRUE, + `last_used_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL, FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE );