diff --git a/src/main/java/kr/swyp/backend/common/config/SecurityConfig.java b/src/main/java/kr/swyp/backend/common/config/SecurityConfig.java index 103d6ce..5bf82ca 100644 --- a/src/main/java/kr/swyp/backend/common/config/SecurityConfig.java +++ b/src/main/java/kr/swyp/backend/common/config/SecurityConfig.java @@ -79,6 +79,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/error").permitAll() .requestMatchers("/error/**").permitAll() .requestMatchers("/actuator/**").permitAll() + .requestMatchers("/terms").permitAll() .anyRequest().authenticated()) .addFilterBefore(usernamePasswordAuthenticationFilter(), diff --git a/src/main/java/kr/swyp/backend/member/repository/MemberTermsAgreementRepository.java b/src/main/java/kr/swyp/backend/member/repository/MemberTermsAgreementRepository.java new file mode 100644 index 0000000..d17b369 --- /dev/null +++ b/src/main/java/kr/swyp/backend/member/repository/MemberTermsAgreementRepository.java @@ -0,0 +1,16 @@ +package kr.swyp.backend.member.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import kr.swyp.backend.member.domain.MemberTermsAgreement; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberTermsAgreementRepository extends JpaRepository { + + List findAllByMemberId(UUID memberId); + + Optional findByMemberIdAndTermsId(UUID memberId, Long termsId); + + void deleteAllByMemberId(UUID memberId); +} diff --git a/src/main/java/kr/swyp/backend/member/service/MemberServiceImpl.java b/src/main/java/kr/swyp/backend/member/service/MemberServiceImpl.java index 961a9c3..115880b 100644 --- a/src/main/java/kr/swyp/backend/member/service/MemberServiceImpl.java +++ b/src/main/java/kr/swyp/backend/member/service/MemberServiceImpl.java @@ -15,6 +15,7 @@ import kr.swyp.backend.member.repository.MemberCheckRateRepository; import kr.swyp.backend.member.repository.MemberRepository; import kr.swyp.backend.member.repository.MemberSocialLoginInfoRepository; +import kr.swyp.backend.member.repository.MemberTermsAgreementRepository; import kr.swyp.backend.member.repository.MemberWithdrawalLogRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -30,6 +31,7 @@ public class MemberServiceImpl implements MemberService { private final MemberWithdrawalLogRepository memberWithdrawalLogRepository; private final MemberCheckRateRepository memberCheckRateRepository; private final FriendRepository friendRepository; + private final MemberTermsAgreementRepository memberTermsAgreementRepository; @Transactional(readOnly = true) public MemberInfoResponse getMemberInfo(UUID memberId) { @@ -57,6 +59,9 @@ public void withdrawMember(UUID memberId, MemberWithdrawRequest request) { memberCheckRateRepository.findByMember(member) .ifPresent(memberCheckRateRepository::delete); + // 약관 동의 정보 삭제 + memberTermsAgreementRepository.deleteAllByMemberId(memberId); + // 탈퇴 처리 member.updateWithdrawnAt(); diff --git a/src/main/java/kr/swyp/backend/term/controller/TermController.java b/src/main/java/kr/swyp/backend/term/controller/TermController.java new file mode 100644 index 0000000..e6eaa20 --- /dev/null +++ b/src/main/java/kr/swyp/backend/term/controller/TermController.java @@ -0,0 +1,52 @@ +package kr.swyp.backend.term.controller; + +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; +import kr.swyp.backend.member.dto.MemberDetails; +import kr.swyp.backend.term.dto.TermDto.TermResponse; +import kr.swyp.backend.term.dto.TermDto.TermsAgreementRequest; +import kr.swyp.backend.term.dto.TermDto.TermsAgreementResponse; +import kr.swyp.backend.term.service.TermService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/terms") +@RequiredArgsConstructor +public class TermController { + + private final TermService termService; + + @GetMapping + public ResponseEntity> getAllTerms() { + List terms = termService.getAllTerms(); + return ResponseEntity.ok(terms); + } + + @GetMapping("/me") + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN', 'SUPER_ADMIN')") + public ResponseEntity getMyTermsAgreements( + @AuthenticationPrincipal MemberDetails memberDetails) { + UUID memberId = memberDetails.getMemberId(); + TermsAgreementResponse response = termService.getMemberTermsAgreements(memberId); + return ResponseEntity.ok(response); + } + + @PostMapping("/me") + @PreAuthorize("hasAnyAuthority('USER', 'ADMIN', 'SUPER_ADMIN')") + public ResponseEntity saveMyTermsAgreements( + @AuthenticationPrincipal MemberDetails memberDetails, + @Valid @RequestBody TermsAgreementRequest request) { + UUID memberId = memberDetails.getMemberId(); + termService.saveMemberTermsAgreements(memberId, request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/kr/swyp/backend/term/dto/TermDto.java b/src/main/java/kr/swyp/backend/term/dto/TermDto.java new file mode 100644 index 0000000..62d248b --- /dev/null +++ b/src/main/java/kr/swyp/backend/term/dto/TermDto.java @@ -0,0 +1,100 @@ +package kr.swyp.backend.term.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; +import java.util.List; +import kr.swyp.backend.member.domain.MemberTermsAgreement; +import kr.swyp.backend.term.domain.Term; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class TermDto { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TermResponse { + + private Long termId; + private String title; + private String version; + private Boolean isRequired; + + public static TermResponse fromEntity(Term term) { + return TermResponse.builder() + .termId(term.getId()) + .title(term.getTitle()) + .version(term.getVersion()) + .isRequired(term.getIsRequired()) + .build(); + } + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TermAgreementRequest { + + @NotNull(message = "약관 ID는 필수입니다.") + private Long termId; + + @NotNull(message = "동의 여부는 필수입니다.") + private Boolean isAgreed; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TermsAgreementRequest { + + @NotEmpty(message = "약관 동의 목록은 비어있을 수 없습니다.") + private List agreements; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TermAgreementResponse { + + private Long termId; + private String title; + private String version; + private Boolean isRequired; + private Boolean isAgreed; + private LocalDateTime agreedAt; + + public static TermAgreementResponse fromEntity(Term term, + MemberTermsAgreement agreement) { + return TermAgreementResponse.builder() + .termId(term.getId()) + .title(term.getTitle()) + .version(term.getVersion()) + .isRequired(term.getIsRequired()) + .isAgreed(agreement != null && agreement.getIsAgreed()) + .agreedAt(agreement != null ? agreement.getCreatedAt() : null) + .build(); + } + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TermsAgreementResponse { + + private List agreements; + + public static TermsAgreementResponse of(List agreements) { + return TermsAgreementResponse.builder() + .agreements(agreements) + .build(); + } + } +} diff --git a/src/main/java/kr/swyp/backend/term/repository/TermRepository.java b/src/main/java/kr/swyp/backend/term/repository/TermRepository.java new file mode 100644 index 0000000..f833f6f --- /dev/null +++ b/src/main/java/kr/swyp/backend/term/repository/TermRepository.java @@ -0,0 +1,10 @@ +package kr.swyp.backend.term.repository; + +import java.util.Optional; +import kr.swyp.backend.term.domain.Term; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TermRepository extends JpaRepository { + + Optional findByTitle(String title); +} diff --git a/src/main/java/kr/swyp/backend/term/service/TermService.java b/src/main/java/kr/swyp/backend/term/service/TermService.java new file mode 100644 index 0000000..78d9411 --- /dev/null +++ b/src/main/java/kr/swyp/backend/term/service/TermService.java @@ -0,0 +1,26 @@ +package kr.swyp.backend.term.service; + +import java.util.List; +import java.util.UUID; +import kr.swyp.backend.term.dto.TermDto.TermAgreementResponse; +import kr.swyp.backend.term.dto.TermDto.TermResponse; +import kr.swyp.backend.term.dto.TermDto.TermsAgreementRequest; +import kr.swyp.backend.term.dto.TermDto.TermsAgreementResponse; + +public interface TermService { + + /** + * 모든 약관 목록 조회. + */ + List getAllTerms(); + + /** + * 특정 회원의 약관 동의 상태 조회. + */ + TermsAgreementResponse getMemberTermsAgreements(UUID memberId); + + /** + * 회원의 약관 동의 저장/업데이트. + */ + void saveMemberTermsAgreements(UUID memberId, TermsAgreementRequest request); +} diff --git a/src/main/java/kr/swyp/backend/term/service/TermServiceImpl.java b/src/main/java/kr/swyp/backend/term/service/TermServiceImpl.java new file mode 100644 index 0000000..6d83713 --- /dev/null +++ b/src/main/java/kr/swyp/backend/term/service/TermServiceImpl.java @@ -0,0 +1,100 @@ +package kr.swyp.backend.term.service; + +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.UUID; +import java.util.stream.Collectors; +import kr.swyp.backend.member.domain.MemberTermsAgreement; +import kr.swyp.backend.member.repository.MemberTermsAgreementRepository; +import kr.swyp.backend.term.domain.Term; +import kr.swyp.backend.term.dto.TermDto.TermAgreementRequest; +import kr.swyp.backend.term.dto.TermDto.TermAgreementResponse; +import kr.swyp.backend.term.dto.TermDto.TermResponse; +import kr.swyp.backend.term.dto.TermDto.TermsAgreementRequest; +import kr.swyp.backend.term.dto.TermDto.TermsAgreementResponse; +import kr.swyp.backend.term.repository.TermRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TermServiceImpl implements TermService { + + private final TermRepository termRepository; + private final MemberTermsAgreementRepository memberTermsAgreementRepository; + + @Override + @Transactional(readOnly = true) + public List getAllTerms() { + return termRepository.findAll().stream() + .map(TermResponse::fromEntity) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public TermsAgreementResponse getMemberTermsAgreements(UUID memberId) { + // 모든 약관 조회 + List allTerms = termRepository.findAll(); + + // 회원의 약관 동의 정보 조회 + List memberAgreements = + memberTermsAgreementRepository.findAllByMemberId(memberId); + + // termId를 키로 하는 Map 생성 + Map agreementMap = memberAgreements.stream() + .collect(Collectors.toMap( + MemberTermsAgreement::getTermsId, + agreement -> agreement + )); + + // 모든 약관에 대해 동의 상태를 포함한 응답 생성 + List responses = allTerms.stream() + .map(term -> TermAgreementResponse.fromEntity( + term, + agreementMap.get(term.getId()) + )) + .collect(Collectors.toList()); + + return TermsAgreementResponse.of(responses); + } + + @Override + @Transactional + public void saveMemberTermsAgreements(UUID memberId, TermsAgreementRequest request) { + for (TermAgreementRequest agreementRequest : request.getAgreements()) { + // 약관 존재 여부 확인 + Term term = termRepository.findById(agreementRequest.getTermId()) + .orElseThrow(() -> new NoSuchElementException( + "약관을 찾을 수 없습니다. ID: " + agreementRequest.getTermId())); + + // 필수 약관인데 동의하지 않은 경우 예외 발생 + if (term.getIsRequired() && !agreementRequest.getIsAgreed()) { + throw new IllegalArgumentException( + "필수 약관에 동의해야 합니다: " + term.getTitle()); + } + + // 기존 동의 정보 조회 + MemberTermsAgreement existingAgreement = + memberTermsAgreementRepository.findByMemberIdAndTermsId( + memberId, agreementRequest.getTermId() + ).orElse(null); + + if (existingAgreement != null) { + // 기존 레코드가 있으면 삭제 후 새로 생성 (업데이트) + memberTermsAgreementRepository.delete(existingAgreement); + } + + // 새로운 동의 정보 저장 + MemberTermsAgreement newAgreement = MemberTermsAgreement.builder() + .memberId(memberId) + .termsId(agreementRequest.getTermId()) + .isAgreed(agreementRequest.getIsAgreed()) + .build(); + + memberTermsAgreementRepository.save(newAgreement); + } + } +} diff --git a/src/test/java/kr/swyp/backend/member/service/MemberServiceImplTest.java b/src/test/java/kr/swyp/backend/member/service/MemberServiceImplTest.java index a4d919d..c449987 100644 --- a/src/test/java/kr/swyp/backend/member/service/MemberServiceImplTest.java +++ b/src/test/java/kr/swyp/backend/member/service/MemberServiceImplTest.java @@ -14,10 +14,14 @@ import kr.swyp.backend.friend.repository.FriendRepository; import kr.swyp.backend.member.domain.Member; import kr.swyp.backend.member.domain.MemberCheckRate; +import kr.swyp.backend.member.domain.MemberTermsAgreement; import kr.swyp.backend.member.dto.MemberDto.MemberWithdrawRequest; import kr.swyp.backend.member.enums.RoleType; import kr.swyp.backend.member.repository.MemberCheckRateRepository; import kr.swyp.backend.member.repository.MemberRepository; +import kr.swyp.backend.member.repository.MemberTermsAgreementRepository; +import kr.swyp.backend.term.domain.Term; +import kr.swyp.backend.term.repository.TermRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -42,6 +46,12 @@ class MemberServiceImplTest { @Autowired private MemberCheckRateRepository memberCheckRateRepository; + @Autowired + private MemberTermsAgreementRepository memberTermsAgreementRepository; + + @Autowired + private TermRepository termRepository; + @Test @DisplayName("회원탈퇴를 할 수 있어야 한다.") void 회원탈퇴를_할_수_있어야_한다() { @@ -146,6 +156,53 @@ class MemberServiceImplTest { assertThat(friends).isEmpty(); } + @Test + @DisplayName("회원 탈퇴 시 약관 동의 정보가 삭제되어야 한다.") + void 회원_탈퇴_시_약관_동의_정보가_삭제되어야_한다() { + // given + String username = "test@test.com"; + String nickname = "test"; + Member member = createMember(username, nickname); + + // 약관 생성 + Term term1 = Term.builder() + .title("서비스 이용 약관") + .version("1.0") + .isRequired(true) + .build(); + Term term2 = Term.builder() + .title("개인정보 수집 및 이용 동의서") + .version("1.0") + .isRequired(true) + .build(); + termRepository.saveAll(List.of(term1, term2)); + + // 약관 동의 생성 + MemberTermsAgreement agreement1 = MemberTermsAgreement.builder() + .memberId(member.getMemberId()) + .termsId(term1.getId()) + .isAgreed(true) + .build(); + MemberTermsAgreement agreement2 = MemberTermsAgreement.builder() + .memberId(member.getMemberId()) + .termsId(term2.getId()) + .isAgreed(true) + .build(); + memberTermsAgreementRepository.saveAll(List.of(agreement1, agreement2)); + + // when + memberService.withdrawMember(member.getMemberId(), + MemberWithdrawRequest.builder() + .reasonType("test") + .customReason("test") + .build()); + + // then + List agreements = + memberTermsAgreementRepository.findAllByMemberId(member.getMemberId()); + assertThat(agreements).isEmpty(); + } + private Member createMember(String username, String nickname) { Member member = Member.builder() diff --git a/src/test/java/kr/swyp/backend/term/controller/TermControllerTest.java b/src/test/java/kr/swyp/backend/term/controller/TermControllerTest.java new file mode 100644 index 0000000..9eba6d1 --- /dev/null +++ b/src/test/java/kr/swyp/backend/term/controller/TermControllerTest.java @@ -0,0 +1,432 @@ +package kr.swyp.backend.term.controller; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.transaction.Transactional; +import java.time.LocalDateTime; +import java.util.List; +import kr.swyp.backend.authentication.provider.TokenProvider; +import kr.swyp.backend.common.desciptor.ErrorDescriptor; +import kr.swyp.backend.member.domain.Member; +import kr.swyp.backend.member.domain.MemberTermsAgreement; +import kr.swyp.backend.member.dto.MemberDetails; +import kr.swyp.backend.member.enums.RoleType; +import kr.swyp.backend.member.repository.MemberRepository; +import kr.swyp.backend.member.repository.MemberTermsAgreementRepository; +import kr.swyp.backend.term.domain.Term; +import kr.swyp.backend.term.dto.TermDto.TermAgreementRequest; +import kr.swyp.backend.term.dto.TermDto.TermsAgreementRequest; +import kr.swyp.backend.term.repository.TermRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@Transactional +class TermControllerTest { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String TOKEN_PREFIX = "Bearer "; + + private final FieldDescriptor[] termResponseDescriptor = { + fieldWithPath("[].termId").description("약관 ID"), + fieldWithPath("[].title").description("약관 제목"), + fieldWithPath("[].version").description("약관 버전"), + fieldWithPath("[].isRequired").description("필수 여부") + }; + + private final FieldDescriptor[] termsAgreementResponseDescriptor = { + fieldWithPath("agreements[].termId").description("약관 ID"), + fieldWithPath("agreements[].title").description("약관 제목"), + fieldWithPath("agreements[].version").description("약관 버전"), + fieldWithPath("agreements[].isRequired").description("필수 여부"), + fieldWithPath("agreements[].isAgreed").description("동의 여부"), + fieldWithPath("agreements[].agreedAt").description("동의 시각").optional() + }; + + private final FieldDescriptor[] termsAgreementRequestDescriptor = { + fieldWithPath("agreements[].termId").description("약관 ID"), + fieldWithPath("agreements[].isAgreed").description("동의 여부") + }; + + private final String url = "/terms"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TokenProvider tokenProvider; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private TermRepository termRepository; + + @Autowired + private MemberTermsAgreementRepository memberTermsAgreementRepository; + + private Member testMember; + private String accessToken; + private Term serviceTerms; + private Term privacyTerms; + private Term privacyPolicyTerms; + + @BeforeEach + void setUp() { + // 테스트용 회원 생성 + testMember = memberRepository.save( + Member.builder() + .username("testuser@example.com") + .password("encoded_password") + .nickname("테스트유저") + .isActive(true) + .notificationAgreedAt(LocalDateTime.now()) + .build() + ); + + // 역할 추가 + testMember.addRole(RoleType.USER); + testMember = memberRepository.save(testMember); + + // JWT 생성 + accessToken = tokenProvider.generateAccessToken( + new UsernamePasswordAuthenticationToken( + new MemberDetails( + testMember.getMemberId(), + testMember.getUsername(), + testMember.getPassword(), + List.of(new SimpleGrantedAuthority(RoleType.USER.name())) + ), + null, + List.of(new SimpleGrantedAuthority(RoleType.USER.name())) + ) + ); + + // 테스트용 약관 생성 + serviceTerms = termRepository.save(Term.builder() + .title("서비스 이용 약관") + .version("1.0") + .isRequired(true) + .build()); + + privacyTerms = termRepository.save(Term.builder() + .title("개인정보 수집 및 이용 동의서") + .version("1.0") + .isRequired(true) + .build()); + + privacyPolicyTerms = termRepository.save(Term.builder() + .title("개인정보 처리방침") + .version("1.0") + .isRequired(false) + .build()); + } + + @Test + @DisplayName("모든 약관 목록을 조회할 수 있어야 한다") + void 모든_약관_목록을_조회할_수_있어야_한다() throws Exception { + // when + ResultActions result = mockMvc.perform(get(url)) + .andExpect(status().isOk()); + + // then + result.andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value( + org.hamcrest.Matchers.greaterThanOrEqualTo(3))) + .andExpect(jsonPath("$[0].title").exists()) + .andExpect(jsonPath("$[0].version").exists()) + .andExpect(jsonPath("$[0].isRequired").exists()); + + // docs + result.andDo(document("약관 목록 조회", + "모든 약관의 목록을 조회한다.", + "약관 목록 조회", + false, + false, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + responseFields(termResponseDescriptor) + )); + } + + @Test + @DisplayName("내 약관 동의 상태를 조회할 수 있어야 한다") + void 내_약관_동의_상태를_조회할_수_있어야_한다() throws Exception { + // given - 일부 약관에만 동의한 상태 + memberTermsAgreementRepository.save(MemberTermsAgreement.builder() + .memberId(testMember.getMemberId()) + .termsId(serviceTerms.getId()) + .isAgreed(true) + .build()); + + // when + ResultActions result = mockMvc.perform(get(url + "/me") + .header(AUTHORIZATION_HEADER, TOKEN_PREFIX + accessToken)) + .andExpect(status().isOk()); + + // then + result.andExpect(jsonPath("$.agreements").isArray()) + .andExpect(jsonPath("$.agreements.length()").value(3)) + .andExpect(jsonPath("$.agreements[?(@.title == '서비스 이용 약관')].isAgreed").value(true)) + .andExpect(jsonPath("$.agreements[?(@.title == '개인정보 수집 및 이용 동의서')].isAgreed") + .value(false)); + + // docs + result.andDo(document("내 약관 동의 상태 조회", + "현재 로그인한 회원의 약관 동의 상태를 조회한다. 모든 약관에 대해 동의 여부와 동의 시각을 포함한다.", + "내 약관 동의 상태 조회", + false, + false, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("발급받은 JWT")), + responseFields(termsAgreementResponseDescriptor) + )); + } + + @Test + @DisplayName("약관 동의를 저장할 수 있어야 한다") + void 약관_동의를_저장할_수_있어야_한다() throws Exception { + // given + TermsAgreementRequest request = TermsAgreementRequest.builder() + .agreements(List.of( + TermAgreementRequest.builder() + .termId(serviceTerms.getId()) + .isAgreed(true) + .build(), + TermAgreementRequest.builder() + .termId(privacyTerms.getId()) + .isAgreed(true) + .build(), + TermAgreementRequest.builder() + .termId(privacyPolicyTerms.getId()) + .isAgreed(true) + .build() + )) + .build(); + + // when + ResultActions result = mockMvc.perform(post(url + "/me") + .header(AUTHORIZATION_HEADER, TOKEN_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // then + List agreements = memberTermsAgreementRepository.findAllByMemberId( + testMember.getMemberId()); + org.assertj.core.api.Assertions.assertThat(agreements).hasSize(3); + org.assertj.core.api.Assertions.assertThat(agreements) + .allMatch(MemberTermsAgreement::getIsAgreed); + + // docs + result.andDo(document("약관 동의 저장", + "회원의 약관 동의를 저장하거나 업데이트한다. 필수 약관에 미동의 시 400 에러가 발생한다.", + "약관 동의 저장", + false, + false, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("발급받은 JWT")), + requestFields(termsAgreementRequestDescriptor) + )); + } + + @Test + @DisplayName("필수 약관에 미동의 시 400 에러가 발생해야 한다") + void 필수_약관에_미동의_시_400_에러가_발생해야_한다() throws Exception { + // given + TermsAgreementRequest request = TermsAgreementRequest.builder() + .agreements(List.of( + TermAgreementRequest.builder() + .termId(serviceTerms.getId()) + .isAgreed(false) // 필수 약관인데 미동의 + .build() + )) + .build(); + + // when + ResultActions result = mockMvc.perform(post(url + "/me") + .header(AUTHORIZATION_HEADER, TOKEN_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + + // then + result.andExpect(jsonPath("$.message").value("필수 약관에 동의해야 합니다: 서비스 이용 약관")); + + // docs + result.andDo(document("약관 동의 실패 - 필수 약관 미동의", + "필수 약관에 동의하지 않으면 400 에러가 발생한다.", + "필수 약관 미동의", + false, + false, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("발급받은 JWT")), + requestFields(termsAgreementRequestDescriptor), + responseFields(ErrorDescriptor.errorResponseFieldDescriptors) + )); + } + + @Test + @DisplayName("존재하지 않는 약관에 동의 시 404 에러가 발생해야 한다") + void 존재하지_않는_약관에_동의_시_404_에러가_발생해야_한다() throws Exception { + // given + TermsAgreementRequest request = TermsAgreementRequest.builder() + .agreements(List.of( + TermAgreementRequest.builder() + .termId(999L) // 존재하지 않는 약관 ID + .isAgreed(true) + .build() + )) + .build(); + + // when + ResultActions result = mockMvc.perform(post(url + "/me") + .header(AUTHORIZATION_HEADER, TOKEN_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()); + + // then + result.andExpect(jsonPath("$.message").value("약관을 찾을 수 없습니다. ID: 999")); + + // docs + result.andDo(document("약관 동의 실패 - 존재하지 않는 약관", + "존재하지 않는 약관 ID로 동의 시도 시 404 에러가 발생한다.", + "존재하지 않는 약관", + false, + false, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("발급받은 JWT")), + requestFields(termsAgreementRequestDescriptor), + responseFields(ErrorDescriptor.errorResponseFieldDescriptors) + )); + } + + @Test + @DisplayName("약관 동의를 업데이트할 수 있어야 한다") + void 약관_동의를_업데이트할_수_있어야_한다() throws Exception { + // given - 초기 동의 저장 + memberTermsAgreementRepository.save(MemberTermsAgreement.builder() + .memberId(testMember.getMemberId()) + .termsId(serviceTerms.getId()) + .isAgreed(true) + .build()); + + // 선택 약관에 새로 동의 + TermsAgreementRequest request = TermsAgreementRequest.builder() + .agreements(List.of( + TermAgreementRequest.builder() + .termId(serviceTerms.getId()) + .isAgreed(true) + .build(), + TermAgreementRequest.builder() + .termId(privacyTerms.getId()) + .isAgreed(true) + .build(), + TermAgreementRequest.builder() + .termId(privacyPolicyTerms.getId()) + .isAgreed(true) // 새로 동의 + .build() + )) + .build(); + + // when + ResultActions result = mockMvc.perform(post(url + "/me") + .header(AUTHORIZATION_HEADER, TOKEN_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // then + List agreements = memberTermsAgreementRepository.findAllByMemberId( + testMember.getMemberId()); + org.assertj.core.api.Assertions.assertThat(agreements).hasSize(3); + + // docs + result.andDo(document("약관 동의 업데이트", + "이미 동의한 약관을 포함하여 약관 동의를 업데이트한다.", + "약관 동의 업데이트", + false, + false, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("발급받은 JWT")), + requestFields(termsAgreementRequestDescriptor) + )); + } + + @Test + @DisplayName("빈 약관 동의 목록으로 요청 시 400 에러가 발생해야 한다") + void 빈_약관_동의_목록으로_요청_시_400_에러가_발생해야_한다() throws Exception { + // given + TermsAgreementRequest request = TermsAgreementRequest.builder() + .agreements(List.of()) // 빈 목록 + .build(); + + // when + ResultActions result = mockMvc.perform(post(url + "/me") + .header(AUTHORIZATION_HEADER, TOKEN_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + + // then + result.andExpect(jsonPath("$.message").exists()); + + // docs + result.andDo(document("약관 동의 실패 - 빈 목록", + "빈 약관 동의 목록으로 요청 시 400 에러가 발생한다.", + "빈 약관 동의 목록", + false, + false, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("발급받은 JWT")), + requestFields( + fieldWithPath("agreements").description("약관 동의 목록 (빈 배열)") + ), + responseFields(ErrorDescriptor.errorResponseFieldDescriptors) + )); + } +} diff --git a/src/test/java/kr/swyp/backend/term/service/TermServiceImplTest.java b/src/test/java/kr/swyp/backend/term/service/TermServiceImplTest.java new file mode 100644 index 0000000..5ac8ecd --- /dev/null +++ b/src/test/java/kr/swyp/backend/term/service/TermServiceImplTest.java @@ -0,0 +1,272 @@ +package kr.swyp.backend.term.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import kr.swyp.backend.member.domain.Member; +import kr.swyp.backend.member.domain.MemberTermsAgreement; +import kr.swyp.backend.member.enums.RoleType; +import kr.swyp.backend.member.repository.MemberRepository; +import kr.swyp.backend.member.repository.MemberTermsAgreementRepository; +import kr.swyp.backend.term.domain.Term; +import kr.swyp.backend.term.dto.TermDto.TermAgreementRequest; +import kr.swyp.backend.term.dto.TermDto.TermAgreementResponse; +import kr.swyp.backend.term.dto.TermDto.TermResponse; +import kr.swyp.backend.term.dto.TermDto.TermsAgreementRequest; +import kr.swyp.backend.term.dto.TermDto.TermsAgreementResponse; +import kr.swyp.backend.term.repository.TermRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class TermServiceImplTest { + + @Autowired + private TermService termService; + + @Autowired + private TermRepository termRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberTermsAgreementRepository memberTermsAgreementRepository; + + private Member testMember; + private Term serviceTerms; + private Term privacyTerms; + private Term privacyPolicyTerms; + + @BeforeEach + void setUp() { + // 테스트용 회원 생성 + testMember = Member.builder() + .username("test@test.com") + .nickname("테스트유저") + .password(" ") + .isActive(true) + .notificationAgreedAt(LocalDateTime.now()) + .build(); + testMember.addRole(RoleType.USER); + testMember = memberRepository.save(testMember); + + // 테스트용 약관 생성 + serviceTerms = Term.builder() + .title("서비스 이용 약관") + .version("1.0") + .isRequired(true) + .build(); + serviceTerms = termRepository.save(serviceTerms); + + privacyTerms = Term.builder() + .title("개인정보 수집 및 이용 동의서") + .version("1.0") + .isRequired(true) + .build(); + privacyTerms = termRepository.save(privacyTerms); + + privacyPolicyTerms = Term.builder() + .title("개인정보 처리방침") + .version("1.0") + .isRequired(false) + .build(); + privacyPolicyTerms = termRepository.save(privacyPolicyTerms); + } + + @Test + @DisplayName("모든 약관 목록을 조회할 수 있어야 한다") + void 모든_약관_목록을_조회할_수_있어야_한다() { + // when + List terms = termService.getAllTerms(); + + // then + assertThat(terms).hasSize(3); + assertThat(terms).extracting("title") + .contains("서비스 이용 약관", "개인정보 수집 및 이용 동의서", "개인정보 처리방침"); + } + + @Test + @DisplayName("회원의 약관 동의 상태를 조회할 수 있어야 한다") + void 회원의_약관_동의_상태를_조회할_수_있어야_한다() { + // given + MemberTermsAgreement agreement = MemberTermsAgreement.builder() + .memberId(testMember.getMemberId()) + .termsId(serviceTerms.getId()) + .isAgreed(true) + .build(); + memberTermsAgreementRepository.save(agreement); + + // when + TermsAgreementResponse response = termService.getMemberTermsAgreements( + testMember.getMemberId()); + + // then + assertThat(response.getAgreements()).hasSize(3); + + TermAgreementResponse serviceTermAgreement = response.getAgreements().stream() + .filter(a -> a.getTermId().equals(serviceTerms.getId())) + .findFirst() + .orElseThrow(); + + assertThat(serviceTermAgreement.getIsAgreed()).isTrue(); + assertThat(serviceTermAgreement.getAgreedAt()).isNotNull(); + + TermAgreementResponse privacyTermAgreement = response.getAgreements().stream() + .filter(a -> a.getTermId().equals(privacyTerms.getId())) + .findFirst() + .orElseThrow(); + + assertThat(privacyTermAgreement.getIsAgreed()).isFalse(); + assertThat(privacyTermAgreement.getAgreedAt()).isNull(); + } + + @Test + @DisplayName("회원의 약관 동의를 저장할 수 있어야 한다") + void 회원의_약관_동의를_저장할_수_있어야_한다() { + // given + TermsAgreementRequest request = TermsAgreementRequest.builder() + .agreements(List.of( + TermAgreementRequest.builder() + .termId(serviceTerms.getId()) + .isAgreed(true) + .build(), + TermAgreementRequest.builder() + .termId(privacyTerms.getId()) + .isAgreed(true) + .build(), + TermAgreementRequest.builder() + .termId(privacyPolicyTerms.getId()) + .isAgreed(true) + .build() + )) + .build(); + + // when + termService.saveMemberTermsAgreements(testMember.getMemberId(), request); + + // then + List agreements = memberTermsAgreementRepository.findAllByMemberId( + testMember.getMemberId()); + + assertThat(agreements).hasSize(3); + assertThat(agreements).allMatch(MemberTermsAgreement::getIsAgreed); + } + + @Test + @DisplayName("필수 약관에 동의하지 않으면 예외가 발생해야 한다") + void 필수_약관에_동의하지_않으면_예외가_발생해야_한다() { + // given + TermsAgreementRequest request = TermsAgreementRequest.builder() + .agreements(List.of( + TermAgreementRequest.builder() + .termId(serviceTerms.getId()) + .isAgreed(false) // 필수 약관인데 동의 안 함 + .build() + )) + .build(); + + // when & then + assertThatThrownBy(() -> + termService.saveMemberTermsAgreements(testMember.getMemberId(), request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("필수 약관에 동의해야 합니다"); + } + + @Test + @DisplayName("약관 동의를 업데이트할 수 있어야 한다") + void 약관_동의를_업데이트할_수_있어야_한다() { + // given - 초기 동의 + TermsAgreementRequest initialRequest = TermsAgreementRequest.builder() + .agreements(List.of( + TermAgreementRequest.builder() + .termId(serviceTerms.getId()) + .isAgreed(true) + .build() + )) + .build(); + termService.saveMemberTermsAgreements(testMember.getMemberId(), initialRequest); + + // when - 동의 상태 변경 (필수 약관이므로 실제로는 동의 철회 불가능하지만 테스트용) + TermsAgreementRequest updateRequest = TermsAgreementRequest.builder() + .agreements(List.of( + TermAgreementRequest.builder() + .termId(serviceTerms.getId()) + .isAgreed(true) // 여전히 true + .build(), + TermAgreementRequest.builder() + .termId(privacyPolicyTerms.getId()) + .isAgreed(true) // 새로 추가 + .build() + )) + .build(); + termService.saveMemberTermsAgreements(testMember.getMemberId(), updateRequest); + + // then + List agreements = memberTermsAgreementRepository.findAllByMemberId( + testMember.getMemberId()); + + assertThat(agreements).hasSize(2); + assertThat(agreements).extracting(MemberTermsAgreement::getTermsId) + .contains(serviceTerms.getId(), privacyPolicyTerms.getId()); + } + + @Test + @DisplayName("존재하지 않는 약관에 동의하려고 하면 예외가 발생해야 한다") + void 존재하지_않는_약관에_동의하려고_하면_예외가_발생해야_한다() { + // given + TermsAgreementRequest request = TermsAgreementRequest.builder() + .agreements(List.of( + TermAgreementRequest.builder() + .termId(999L) // 존재하지 않는 약관 ID + .isAgreed(true) + .build() + )) + .build(); + + // when & then + assertThatThrownBy(() -> + termService.saveMemberTermsAgreements(testMember.getMemberId(), request)) + .isInstanceOf(java.util.NoSuchElementException.class) + .hasMessageContaining("약관을 찾을 수 없습니다"); + } + + @Test + @DisplayName("새로운 약관이 추가되면 조회 시 포함되어야 한다") + void 새로운_약관이_추가되면_조회_시_포함되어야_한다() { + // given - 새로운 약관 추가 + Term newTerm = Term.builder() + .title("마케팅 정보 수신 동의") + .version("1.0") + .isRequired(false) + .build(); + termRepository.save(newTerm); + + // when + TermsAgreementResponse response = termService.getMemberTermsAgreements( + testMember.getMemberId()); + + // then + assertThat(response.getAgreements()).hasSize(4); + assertThat(response.getAgreements()).extracting("title") + .contains("마케팅 정보 수신 동의"); + + TermAgreementResponse marketingAgreement = response.getAgreements().stream() + .filter(a -> a.getTitle().equals("마케팅 정보 수신 동의")) + .findFirst() + .orElseThrow(); + + assertThat(marketingAgreement.getIsAgreed()).isFalse(); + assertThat(marketingAgreement.getIsRequired()).isFalse(); + } +}