From 91c82e46c453c44e8683578fce26634d77e14ef4 Mon Sep 17 00:00:00 2001 From: hoshino-love0102 Date: Wed, 30 Sep 2026 11:05:24 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85?= =?UTF-8?q?=20Swagger=20=EC=9A=94=EC=B2=AD=20=EC=98=88=EC=8B=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#180)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../account/controller/AuthController.java | 46 +++++++++++++++++++ .../account/AuthOpenApiIntegrationTest.java | 36 +++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/main/java/com/heddy/adapter/in/web/account/controller/AuthController.java b/src/main/java/com/heddy/adapter/in/web/account/controller/AuthController.java index 5ba8dd78..4f5d6999 100644 --- a/src/main/java/com/heddy/adapter/in/web/account/controller/AuthController.java +++ b/src/main/java/com/heddy/adapter/in/web/account/controller/AuthController.java @@ -28,6 +28,9 @@ import com.heddy.global.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -37,6 +40,7 @@ import jakarta.validation.constraints.Size; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; @@ -121,6 +125,27 @@ public ApiResponse emailAvailability( @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "201", description = "회원가입 성공") public ResponseEntity> emailSignup( + @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = EmailSignupRequest.class), + examples = @ExampleObject( + name = "이메일 회원가입 요청", + value = """ + { + "email": "exampleeamil1234@example.com", + "password": "Password123!", + "nickname": "gangmin", + "agreements": { + "terms_of_service": true, + "privacy_policy": true, + "ai_training": false, + "service_analytics": true, + "marketing_notification": false + } + } + """))) @Valid @RequestBody EmailSignupRequest request, HttpServletRequest servletRequest ) { @@ -138,6 +163,27 @@ public ResponseEntity> emailSignup( @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "201", description = "회원가입 성공") public ResponseEntity> socialSignup( + @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = SocialSignupRequest.class), + examples = @ExampleObject( + name = "소셜 회원가입 요청", + value = """ + { + "provider": "GOOGLE", + "provider_token": "provider-issued-id-token", + "nickname": "gangmin", + "agreements": { + "terms_of_service": true, + "privacy_policy": true, + "ai_training": false, + "service_analytics": true, + "marketing_notification": false + } + } + """))) @Valid @RequestBody SocialSignupRequest request, HttpServletRequest servletRequest ) { diff --git a/src/test/java/com/heddy/adapter/in/web/account/AuthOpenApiIntegrationTest.java b/src/test/java/com/heddy/adapter/in/web/account/AuthOpenApiIntegrationTest.java index d4498851..8b0e5319 100644 --- a/src/test/java/com/heddy/adapter/in/web/account/AuthOpenApiIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/in/web/account/AuthOpenApiIntegrationTest.java @@ -32,4 +32,40 @@ void loginSchemasDoNotExposeDeviceInformation() throws Exception { .andExpect(jsonPath("$.components.schemas.SocialLoginRequest.required", not(hasItem("device")))); } + + @Test + void signupRequestExamplesMatchTheApiSpecification() throws Exception { + mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$['paths']['/auth/signup/email']['post']" + + "['requestBody']['content']['application/json']['examples']" + + "['이메일 회원가입 요청']['value']['email']") + .value("exampleeamil1234@example.com")) + .andExpect(jsonPath("$['paths']['/auth/signup/email']['post']" + + "['requestBody']['content']['application/json']['examples']" + + "['이메일 회원가입 요청']['value']['password']") + .value("Password123!")) + .andExpect(jsonPath("$['paths']['/auth/signup/email']['post']" + + "['requestBody']['content']['application/json']['examples']" + + "['이메일 회원가입 요청']['value']['agreements']['terms_of_service']") + .value(true)) + .andExpect(jsonPath("$['paths']['/auth/signup/email']['post']" + + "['requestBody']['content']['application/json']['examples']" + + "['이메일 회원가입 요청']['value']['phone_number']").doesNotExist()) + .andExpect(jsonPath("$['paths']['/auth/signup/social']['post']" + + "['requestBody']['content']['application/json']['examples']" + + "['소셜 회원가입 요청']['value']['provider']") + .value("GOOGLE")) + .andExpect(jsonPath("$['paths']['/auth/signup/social']['post']" + + "['requestBody']['content']['application/json']['examples']" + + "['소셜 회원가입 요청']['value']['provider_token']") + .value("provider-issued-id-token")) + .andExpect(jsonPath("$['paths']['/auth/signup/social']['post']" + + "['requestBody']['content']['application/json']['examples']" + + "['소셜 회원가입 요청']['value']['agreements']['privacy_policy']") + .value(true)) + .andExpect(jsonPath("$['paths']['/auth/signup/social']['post']" + + "['requestBody']['content']['application/json']['examples']" + + "['소셜 회원가입 요청']['value']['phone_number']").doesNotExist()); + } } From 29826f595df4f7134849f3f5bcfe66841a70cca1 Mon Sep 17 00:00:00 2001 From: hoshino-love0102 Date: Wed, 30 Sep 2026 23:39:50 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=EB=AA=A8=EB=B0=9C=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=A0=80=EC=9E=A5=20(#183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../account/controller/AuthController.java | 14 +++ .../web/account/dto/EmailSignupRequest.java | 10 +- .../web/account/dto/HairProfileRequest.java | 6 ++ .../web/account/dto/SocialSignupRequest.java | 10 +- .../account/service/EmailSignupService.java | 9 ++ .../account/service/SocialSignupService.java | 9 ++ .../account/port/in/EmailSignupCommand.java | 3 +- .../port/in/SignupHairProfileCommand.java | 15 +++ .../account/port/in/SocialSignupCommand.java | 3 +- .../account/AuthOpenApiIntegrationTest.java | 26 ++++- .../controller/AuthControllerTest.java | 75 ++++++++++++++ .../service/EmailSignupServiceTest.java | 39 +++++++- .../service/SocialSignupServiceTest.java | 98 +++++++++++++++++++ 13 files changed, 306 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/heddy/domain/account/port/in/SignupHairProfileCommand.java create mode 100644 src/test/java/com/heddy/application/account/service/SocialSignupServiceTest.java diff --git a/src/main/java/com/heddy/adapter/in/web/account/controller/AuthController.java b/src/main/java/com/heddy/adapter/in/web/account/controller/AuthController.java index 4f5d6999..95d032a1 100644 --- a/src/main/java/com/heddy/adapter/in/web/account/controller/AuthController.java +++ b/src/main/java/com/heddy/adapter/in/web/account/controller/AuthController.java @@ -143,6 +143,13 @@ public ResponseEntity> emailSignup( "ai_training": false, "service_analytics": true, "marketing_notification": false + }, + "hair_profile": { + "hair_type": "STRAIGHT", + "hair_condition": "HEALTHY", + "hair_length": "SHORT", + "hair_thickness": "THIN", + "available_care_time_minutes": 15 } } """))) @@ -181,6 +188,13 @@ public ResponseEntity> socialSignup( "ai_training": false, "service_analytics": true, "marketing_notification": false + }, + "hair_profile": { + "hair_type": "STRAIGHT", + "hair_condition": "HEALTHY", + "hair_length": "SHORT", + "hair_thickness": "THIN", + "available_care_time_minutes": 15 } } """))) diff --git a/src/main/java/com/heddy/adapter/in/web/account/dto/EmailSignupRequest.java b/src/main/java/com/heddy/adapter/in/web/account/dto/EmailSignupRequest.java index 710f930e..064016ae 100644 --- a/src/main/java/com/heddy/adapter/in/web/account/dto/EmailSignupRequest.java +++ b/src/main/java/com/heddy/adapter/in/web/account/dto/EmailSignupRequest.java @@ -40,11 +40,17 @@ public record EmailSignupRequest( @NotNull @Valid @Schema(description = "약관 동의. 5개 항목을 모두 보내야 한다", requiredMode = Schema.RequiredMode.REQUIRED) - AgreementsRequest agreements + AgreementsRequest agreements, + + @Valid + @Schema(description = "회원가입 온보딩 모발 프로필. 건너뛰는 경우 생략할 수 있으며, " + + "전달할 때는 모발 유형·상태·길이·굵기를 모두 보내야 한다") + @JsonProperty("hair_profile") HairProfileRequest hairProfile ) { public EmailSignupCommand toCommand(String policyVersion) { return new EmailSignupCommand( email.toLowerCase(), password, nickname, phoneNumber, - agreements.toDecisions(policyVersion)); + agreements.toDecisions(policyVersion), + hairProfile == null ? null : hairProfile.toSignupCommand()); } } diff --git a/src/main/java/com/heddy/adapter/in/web/account/dto/HairProfileRequest.java b/src/main/java/com/heddy/adapter/in/web/account/dto/HairProfileRequest.java index ecf67724..817ecb77 100644 --- a/src/main/java/com/heddy/adapter/in/web/account/dto/HairProfileRequest.java +++ b/src/main/java/com/heddy/adapter/in/web/account/dto/HairProfileRequest.java @@ -6,6 +6,7 @@ import com.heddy.domain.account.model.HairProfile.HairThickness; import com.heddy.domain.account.model.HairProfile.HairType; import com.heddy.domain.account.port.in.SaveHairProfileCommand; +import com.heddy.domain.account.port.in.SignupHairProfileCommand; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; @@ -41,4 +42,9 @@ public SaveHairProfileCommand toCommand(UUID userId) { return new SaveHairProfileCommand(userId, hairType, hairCondition, hairLength, hairThickness, availableCareTimeMinutes); } + + public SignupHairProfileCommand toSignupCommand() { + return new SignupHairProfileCommand(hairType, hairCondition, hairLength, + hairThickness, availableCareTimeMinutes); + } } diff --git a/src/main/java/com/heddy/adapter/in/web/account/dto/SocialSignupRequest.java b/src/main/java/com/heddy/adapter/in/web/account/dto/SocialSignupRequest.java index 36d689ce..83365fc6 100644 --- a/src/main/java/com/heddy/adapter/in/web/account/dto/SocialSignupRequest.java +++ b/src/main/java/com/heddy/adapter/in/web/account/dto/SocialSignupRequest.java @@ -35,11 +35,17 @@ public record SocialSignupRequest( @NotNull @Valid @Schema(description = "약관 동의. 5개 항목을 모두 보내야 한다", requiredMode = Schema.RequiredMode.REQUIRED) - AgreementsRequest agreements + AgreementsRequest agreements, + + @Valid + @Schema(description = "회원가입 온보딩 모발 프로필. 건너뛰는 경우 생략할 수 있으며, " + + "전달할 때는 모발 유형·상태·길이·굵기를 모두 보내야 한다") + @JsonProperty("hair_profile") HairProfileRequest hairProfile ) { public SocialSignupCommand toCommand(String policyVersion) { return new SocialSignupCommand( provider, providerToken, nickname, phoneNumber, - agreements.toDecisions(policyVersion)); + agreements.toDecisions(policyVersion), + hairProfile == null ? null : hairProfile.toSignupCommand()); } } diff --git a/src/main/java/com/heddy/application/account/service/EmailSignupService.java b/src/main/java/com/heddy/application/account/service/EmailSignupService.java index e29360a2..ca17eb8c 100644 --- a/src/main/java/com/heddy/application/account/service/EmailSignupService.java +++ b/src/main/java/com/heddy/application/account/service/EmailSignupService.java @@ -4,12 +4,14 @@ import com.heddy.domain.account.exception.AccountException; import com.heddy.domain.account.model.Account; import com.heddy.domain.account.model.ConsentSource; +import com.heddy.domain.account.model.HairProfile; import com.heddy.domain.account.model.UserProfile; import com.heddy.domain.account.port.in.AuthResult; import com.heddy.domain.account.port.in.EmailSignupCommand; import com.heddy.domain.account.port.in.EmailSignupUseCase; import com.heddy.domain.account.port.out.AccountRepositoryPort; import com.heddy.domain.account.port.out.ConsentHistoryRepositoryPort; +import com.heddy.domain.account.port.out.HairProfileRepositoryPort; import com.heddy.domain.account.port.out.PasswordEncoderPort; import com.heddy.domain.account.port.out.UserProfileRepositoryPort; import lombok.RequiredArgsConstructor; @@ -25,6 +27,7 @@ public class EmailSignupService implements EmailSignupUseCase { private final AccountRepositoryPort accountRepositoryPort; private final UserProfileRepositoryPort userProfileRepositoryPort; + private final HairProfileRepositoryPort hairProfileRepositoryPort; private final ConsentHistoryRepositoryPort consentHistoryRepositoryPort; private final PasswordEncoderPort passwordEncoderPort; private final SessionTokenService sessionTokenService; @@ -45,6 +48,12 @@ public AuthResult signup(EmailSignupCommand command) { userId, command.email(), passwordEncoderPort.encode(command.password()))); UserProfile profile = userProfileRepositoryPort.save( UserProfile.signup(userId, command.nickname(), command.phone())); + if (command.hairProfile() != null) { + var hairProfile = command.hairProfile(); + hairProfileRepositoryPort.save(HairProfile.create(userId, hairProfile.hairType(), + hairProfile.hairCondition(), hairProfile.hairLength(), + hairProfile.hairThickness(), hairProfile.availableCareTimeMinutes())); + } consentHistoryRepositoryPort.append( userId, command.agreements(), ConsentSource.SIGNUP, Instant.now()); AuthResult result = sessionTokenService.issue(account, profile); diff --git a/src/main/java/com/heddy/application/account/service/SocialSignupService.java b/src/main/java/com/heddy/application/account/service/SocialSignupService.java index 39dece5c..d7d83a76 100644 --- a/src/main/java/com/heddy/application/account/service/SocialSignupService.java +++ b/src/main/java/com/heddy/application/account/service/SocialSignupService.java @@ -4,12 +4,14 @@ import com.heddy.domain.account.exception.AccountException; import com.heddy.domain.account.model.Account; import com.heddy.domain.account.model.ConsentSource; +import com.heddy.domain.account.model.HairProfile; import com.heddy.domain.account.model.UserProfile; import com.heddy.domain.account.port.in.AuthResult; import com.heddy.domain.account.port.in.SocialSignupCommand; import com.heddy.domain.account.port.in.SocialSignupUseCase; import com.heddy.domain.account.port.out.AccountRepositoryPort; import com.heddy.domain.account.port.out.ConsentHistoryRepositoryPort; +import com.heddy.domain.account.port.out.HairProfileRepositoryPort; import com.heddy.domain.account.port.out.SocialTokenVerifierPort; import com.heddy.domain.account.port.out.UserProfileRepositoryPort; import lombok.RequiredArgsConstructor; @@ -26,6 +28,7 @@ public class SocialSignupService implements SocialSignupUseCase { private final SocialTokenVerifierPort socialTokenVerifierPort; private final AccountRepositoryPort accountRepositoryPort; private final UserProfileRepositoryPort userProfileRepositoryPort; + private final HairProfileRepositoryPort hairProfileRepositoryPort; private final ConsentHistoryRepositoryPort consentHistoryRepositoryPort; private final SessionTokenService sessionTokenService; private final SignupPhoneVerificationService signupPhoneVerificationService; @@ -47,6 +50,12 @@ public AuthResult signup(SocialSignupCommand command) { Account.social(userId, command.provider(), identity.subject())); UserProfile profile = userProfileRepositoryPort.save( UserProfile.signup(userId, command.nickname(), command.phone())); + if (command.hairProfile() != null) { + var hairProfile = command.hairProfile(); + hairProfileRepositoryPort.save(HairProfile.create(userId, hairProfile.hairType(), + hairProfile.hairCondition(), hairProfile.hairLength(), + hairProfile.hairThickness(), hairProfile.availableCareTimeMinutes())); + } consentHistoryRepositoryPort.append( userId, command.agreements(), ConsentSource.SIGNUP, Instant.now()); AuthResult result = sessionTokenService.issue(account, profile); diff --git a/src/main/java/com/heddy/domain/account/port/in/EmailSignupCommand.java b/src/main/java/com/heddy/domain/account/port/in/EmailSignupCommand.java index 0842b103..9702fcc0 100644 --- a/src/main/java/com/heddy/domain/account/port/in/EmailSignupCommand.java +++ b/src/main/java/com/heddy/domain/account/port/in/EmailSignupCommand.java @@ -9,6 +9,7 @@ public record EmailSignupCommand( String password, String nickname, String phone, - List agreements + List agreements, + SignupHairProfileCommand hairProfile ) { } diff --git a/src/main/java/com/heddy/domain/account/port/in/SignupHairProfileCommand.java b/src/main/java/com/heddy/domain/account/port/in/SignupHairProfileCommand.java new file mode 100644 index 00000000..ffd66d9e --- /dev/null +++ b/src/main/java/com/heddy/domain/account/port/in/SignupHairProfileCommand.java @@ -0,0 +1,15 @@ +package com.heddy.domain.account.port.in; + +import com.heddy.domain.account.model.HairProfile.HairCondition; +import com.heddy.domain.account.model.HairProfile.HairLength; +import com.heddy.domain.account.model.HairProfile.HairThickness; +import com.heddy.domain.account.model.HairProfile.HairType; + +public record SignupHairProfileCommand( + HairType hairType, + HairCondition hairCondition, + HairLength hairLength, + HairThickness hairThickness, + Integer availableCareTimeMinutes +) { +} diff --git a/src/main/java/com/heddy/domain/account/port/in/SocialSignupCommand.java b/src/main/java/com/heddy/domain/account/port/in/SocialSignupCommand.java index 7a274cd8..e8bac9bb 100644 --- a/src/main/java/com/heddy/domain/account/port/in/SocialSignupCommand.java +++ b/src/main/java/com/heddy/domain/account/port/in/SocialSignupCommand.java @@ -10,6 +10,7 @@ public record SocialSignupCommand( String providerToken, String nickname, String phone, - List agreements + List agreements, + SignupHairProfileCommand hairProfile ) { } diff --git a/src/test/java/com/heddy/adapter/in/web/account/AuthOpenApiIntegrationTest.java b/src/test/java/com/heddy/adapter/in/web/account/AuthOpenApiIntegrationTest.java index 8b0e5319..f6e6df59 100644 --- a/src/test/java/com/heddy/adapter/in/web/account/AuthOpenApiIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/in/web/account/AuthOpenApiIntegrationTest.java @@ -52,6 +52,14 @@ void signupRequestExamplesMatchTheApiSpecification() throws Exception { .andExpect(jsonPath("$['paths']['/auth/signup/email']['post']" + "['requestBody']['content']['application/json']['examples']" + "['이메일 회원가입 요청']['value']['phone_number']").doesNotExist()) + .andExpect(jsonPath("$['paths']['/auth/signup/email']['post']" + + "['requestBody']['content']['application/json']['examples']" + + "['이메일 회원가입 요청']['value']['hair_profile']['hair_type']") + .value("STRAIGHT")) + .andExpect(jsonPath("$['paths']['/auth/signup/email']['post']" + + "['requestBody']['content']['application/json']['examples']" + + "['이메일 회원가입 요청']['value']['hair_profile']['hair_length']") + .value("SHORT")) .andExpect(jsonPath("$['paths']['/auth/signup/social']['post']" + "['requestBody']['content']['application/json']['examples']" + "['소셜 회원가입 요청']['value']['provider']") @@ -66,6 +74,22 @@ void signupRequestExamplesMatchTheApiSpecification() throws Exception { .value(true)) .andExpect(jsonPath("$['paths']['/auth/signup/social']['post']" + "['requestBody']['content']['application/json']['examples']" - + "['소셜 회원가입 요청']['value']['phone_number']").doesNotExist()); + + "['소셜 회원가입 요청']['value']['phone_number']").doesNotExist()) + .andExpect(jsonPath("$['paths']['/auth/signup/social']['post']" + + "['requestBody']['content']['application/json']['examples']" + + "['소셜 회원가입 요청']['value']['hair_profile']['hair_condition']") + .value("HEALTHY")) + .andExpect(jsonPath("$.components.schemas.EmailSignupRequest.properties" + + ".hair_profile['$ref']").value("#/components/schemas/HairProfileRequest")) + .andExpect(jsonPath("$.components.schemas.SocialSignupRequest.properties" + + ".hair_profile['$ref']").value("#/components/schemas/HairProfileRequest")) + .andExpect(jsonPath("$.components.schemas.HairProfileRequest.required", + hasItem("hair_type"))) + .andExpect(jsonPath("$.components.schemas.HairProfileRequest.required", + hasItem("hair_condition"))) + .andExpect(jsonPath("$.components.schemas.HairProfileRequest.required", + hasItem("hair_length"))) + .andExpect(jsonPath("$.components.schemas.HairProfileRequest.required", + hasItem("hair_thickness"))); } } diff --git a/src/test/java/com/heddy/adapter/in/web/account/controller/AuthControllerTest.java b/src/test/java/com/heddy/adapter/in/web/account/controller/AuthControllerTest.java index 4311dfb7..4d053097 100644 --- a/src/test/java/com/heddy/adapter/in/web/account/controller/AuthControllerTest.java +++ b/src/test/java/com/heddy/adapter/in/web/account/controller/AuthControllerTest.java @@ -99,6 +99,13 @@ void emailSignupReturnsUuidUserAndSnakeCaseTokens() throws Exception { "ai_training":false, "service_analytics":true, "marketing_notification":false + }, + "hair_profile":{ + "hair_type":"STRAIGHT", + "hair_condition":"HEALTHY", + "hair_length":"SHORT", + "hair_thickness":"THIN", + "available_care_time_minutes":15 } } """)) @@ -109,6 +116,74 @@ void emailSignupReturnsUuidUserAndSnakeCaseTokens() throws Exception { .andExpect(jsonPath("$.data.tokens.token_type").value("Bearer")) .andExpect(jsonPath("$.data.tokens.expires_in").value(900)) .andExpect(jsonPath("$.request_id").value("request-2")); + + verify(emailSignupUseCase).signup(org.mockito.ArgumentMatchers.argThat(command -> + command.hairProfile() != null + && command.hairProfile().hairType().name().equals("STRAIGHT") + && command.hairProfile().hairCondition().name().equals("HEALTHY") + && command.hairProfile().hairLength().name().equals("SHORT") + && command.hairProfile().hairThickness().name().equals("THIN") + && command.hairProfile().availableCareTimeMinutes() == 15)); + } + + @Test + void socialSignupMapsHairProfile() throws Exception { + given(socialSignupUseCase.signup(any())).willReturn(authResult()); + + mockMvc.perform(post("/auth/signup/social") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "provider":"GOOGLE", + "provider_token":"provider-token", + "nickname":"헤디", + "agreements":{ + "terms_of_service":true, + "privacy_policy":true, + "ai_training":false, + "service_analytics":true, + "marketing_notification":false + }, + "hair_profile":{ + "hair_type":"WAVY", + "hair_condition":"NORMAL", + "hair_length":"BELOW_SHOULDER", + "hair_thickness":"NORMAL", + "available_care_time_minutes":20 + } + } + """)) + .andExpect(status().isCreated()); + + verify(socialSignupUseCase).signup(org.mockito.ArgumentMatchers.argThat(command -> + command.hairProfile() != null + && command.hairProfile().hairType().name().equals("WAVY") + && command.hairProfile().availableCareTimeMinutes() == 20)); + } + + @Test + void rejectsIncompleteSignupHairProfile() throws Exception { + mockMvc.perform(post("/auth/signup/email") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "email":"user@example.com", + "password":"Password123", + "nickname":"헤디", + "agreements":{ + "terms_of_service":true, + "privacy_policy":true, + "ai_training":false, + "service_analytics":true, + "marketing_notification":false + }, + "hair_profile":{ + "hair_type":"STRAIGHT" + } + } + """)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")); } @Test diff --git a/src/test/java/com/heddy/application/account/service/EmailSignupServiceTest.java b/src/test/java/com/heddy/application/account/service/EmailSignupServiceTest.java index 9946eb92..629c2574 100644 --- a/src/test/java/com/heddy/application/account/service/EmailSignupServiceTest.java +++ b/src/test/java/com/heddy/application/account/service/EmailSignupServiceTest.java @@ -5,10 +5,13 @@ import com.heddy.domain.account.model.Account; import com.heddy.domain.account.model.ConsentDecision; import com.heddy.domain.account.model.ConsentType; +import com.heddy.domain.account.model.HairProfile; import com.heddy.domain.account.model.UserProfile; import com.heddy.domain.account.port.in.EmailSignupCommand; +import com.heddy.domain.account.port.in.SignupHairProfileCommand; import com.heddy.domain.account.port.out.AccountRepositoryPort; import com.heddy.domain.account.port.out.ConsentHistoryRepositoryPort; +import com.heddy.domain.account.port.out.HairProfileRepositoryPort; import com.heddy.domain.account.port.out.PasswordEncoderPort; import com.heddy.domain.account.port.out.UserProfileRepositoryPort; import org.junit.jupiter.api.BeforeEach; @@ -32,6 +35,7 @@ class EmailSignupServiceTest { @Mock AccountRepositoryPort accountRepositoryPort; @Mock UserProfileRepositoryPort userProfileRepositoryPort; + @Mock HairProfileRepositoryPort hairProfileRepositoryPort; @Mock ConsentHistoryRepositoryPort consentHistoryRepositoryPort; @Mock PasswordEncoderPort passwordEncoderPort; @Mock SessionTokenService sessionTokenService; @@ -42,8 +46,8 @@ class EmailSignupServiceTest { @BeforeEach void setUp() { service = new EmailSignupService(accountRepositoryPort, userProfileRepositoryPort, - consentHistoryRepositoryPort, passwordEncoderPort, sessionTokenService, - signupPhoneVerificationService); + hairProfileRepositoryPort, consentHistoryRepositoryPort, passwordEncoderPort, + sessionTokenService, signupPhoneVerificationService); } @Test @@ -67,6 +71,31 @@ void createsUuidAccountProfileAndConsentHistory() { org.mockito.ArgumentMatchers.eq(account.getValue().userId()), org.mockito.ArgumentMatchers.eq(command.agreements()), any(), any()); + verify(hairProfileRepositoryPort, never()).save(any()); + } + + @Test + void savesHairProfileWhenProvided() { + SignupHairProfileCommand hairProfile = new SignupHairProfileCommand( + HairProfile.HairType.STRAIGHT, HairProfile.HairCondition.HEALTHY, + HairProfile.HairLength.SHORT, HairProfile.HairThickness.THIN, 15); + EmailSignupCommand command = new EmailSignupCommand( + "user@example.com", "Password123", "헤디", null, + consents(true, true), hairProfile); + given(passwordEncoderPort.encode("Password123")).willReturn("encoded"); + given(accountRepositoryPort.save(any())).willAnswer(invocation -> invocation.getArgument(0)); + given(userProfileRepositoryPort.save(any())).willAnswer(invocation -> invocation.getArgument(0)); + given(hairProfileRepositoryPort.save(any())).willAnswer(invocation -> invocation.getArgument(0)); + + service.signup(command); + + ArgumentCaptor saved = ArgumentCaptor.forClass(HairProfile.class); + verify(hairProfileRepositoryPort).save(saved.capture()); + assertThat(saved.getValue().hairType()).isEqualTo(HairProfile.HairType.STRAIGHT); + assertThat(saved.getValue().hairCondition()).isEqualTo(HairProfile.HairCondition.HEALTHY); + assertThat(saved.getValue().hairLength()).isEqualTo(HairProfile.HairLength.SHORT); + assertThat(saved.getValue().hairThickness()).isEqualTo(HairProfile.HairThickness.THIN); + assertThat(saved.getValue().availableCareTimeMinutes()).isEqualTo(15); } @Test @@ -86,13 +115,15 @@ void rejectsMissingRequiredConsent() { @Test void rejectsPasswordWithoutNumber() { EmailSignupCommand command = new EmailSignupCommand( - "user@example.com", "onlyletters", "헤디", null, consents(true, true)); + "user@example.com", "onlyletters", "헤디", null, + consents(true, true), null); assertError(() -> service.signup(command), AccountError.WEAK_PASSWORD); } private EmailSignupCommand command(boolean terms, boolean privacy) { return new EmailSignupCommand( - "user@example.com", "Password123", "헤디", null, consents(terms, privacy)); + "user@example.com", "Password123", "헤디", null, + consents(terms, privacy), null); } private List consents(boolean terms, boolean privacy) { diff --git a/src/test/java/com/heddy/application/account/service/SocialSignupServiceTest.java b/src/test/java/com/heddy/application/account/service/SocialSignupServiceTest.java new file mode 100644 index 00000000..845236ba --- /dev/null +++ b/src/test/java/com/heddy/application/account/service/SocialSignupServiceTest.java @@ -0,0 +1,98 @@ +package com.heddy.application.account.service; + +import com.heddy.domain.account.model.Account; +import com.heddy.domain.account.model.AuthProvider; +import com.heddy.domain.account.model.ConsentDecision; +import com.heddy.domain.account.model.ConsentType; +import com.heddy.domain.account.model.HairProfile; +import com.heddy.domain.account.port.in.SignupHairProfileCommand; +import com.heddy.domain.account.port.in.SocialSignupCommand; +import com.heddy.domain.account.port.out.AccountRepositoryPort; +import com.heddy.domain.account.port.out.ConsentHistoryRepositoryPort; +import com.heddy.domain.account.port.out.HairProfileRepositoryPort; +import com.heddy.domain.account.port.out.SocialTokenVerifierPort; +import com.heddy.domain.account.port.out.UserProfileRepositoryPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class SocialSignupServiceTest { + + @Mock SocialTokenVerifierPort socialTokenVerifierPort; + @Mock AccountRepositoryPort accountRepositoryPort; + @Mock UserProfileRepositoryPort userProfileRepositoryPort; + @Mock HairProfileRepositoryPort hairProfileRepositoryPort; + @Mock ConsentHistoryRepositoryPort consentHistoryRepositoryPort; + @Mock SessionTokenService sessionTokenService; + @Mock SignupPhoneVerificationService signupPhoneVerificationService; + + private SocialSignupService service; + + @BeforeEach + void setUp() { + service = new SocialSignupService(socialTokenVerifierPort, accountRepositoryPort, + userProfileRepositoryPort, hairProfileRepositoryPort, + consentHistoryRepositoryPort, sessionTokenService, + signupPhoneVerificationService); + } + + @Test + void savesHairProfileWhenProvided() { + SignupHairProfileCommand hairProfile = new SignupHairProfileCommand( + HairProfile.HairType.WAVY, HairProfile.HairCondition.NORMAL, + HairProfile.HairLength.BELOW_SHOULDER, + HairProfile.HairThickness.NORMAL, 20); + SocialSignupCommand command = new SocialSignupCommand( + AuthProvider.GOOGLE, "provider-token", "헤디", null, + requiredConsents(), hairProfile); + given(socialTokenVerifierPort.verify(AuthProvider.GOOGLE, "provider-token")) + .willReturn(Optional.of(() -> "provider-subject")); + given(accountRepositoryPort.save(any())).willAnswer(invocation -> invocation.getArgument(0)); + given(userProfileRepositoryPort.save(any())).willAnswer(invocation -> invocation.getArgument(0)); + given(hairProfileRepositoryPort.save(any())).willAnswer(invocation -> invocation.getArgument(0)); + + service.signup(command); + + ArgumentCaptor account = ArgumentCaptor.forClass(Account.class); + verify(accountRepositoryPort).save(account.capture()); + ArgumentCaptor saved = ArgumentCaptor.forClass(HairProfile.class); + verify(hairProfileRepositoryPort).save(saved.capture()); + assertThat(saved.getValue().userId()).isEqualTo(account.getValue().userId()); + assertThat(saved.getValue().hairType()).isEqualTo(HairProfile.HairType.WAVY); + assertThat(saved.getValue().availableCareTimeMinutes()).isEqualTo(20); + } + + @Test + void skipsHairProfileWhenNotProvided() { + SocialSignupCommand command = new SocialSignupCommand( + AuthProvider.GOOGLE, "provider-token", "헤디", null, + requiredConsents(), null); + given(socialTokenVerifierPort.verify(AuthProvider.GOOGLE, "provider-token")) + .willReturn(Optional.of(() -> "provider-subject")); + given(accountRepositoryPort.save(any())).willAnswer(invocation -> invocation.getArgument(0)); + given(userProfileRepositoryPort.save(any())).willAnswer(invocation -> invocation.getArgument(0)); + + service.signup(command); + + verify(hairProfileRepositoryPort, never()).save(any()); + } + + private List requiredConsents() { + return List.of( + new ConsentDecision(ConsentType.TERMS_OF_SERVICE, true, "2026-08-01"), + new ConsentDecision(ConsentType.PRIVACY_POLICY, true, "2026-08-01")); + } +}