diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 7606011a..0c44771a 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -67,12 +67,14 @@ jobs: V_DISCORD_WEBHOOK_5XX_URL: ${{ secrets.DISCORD_WEBHOOK_5XX_URL }} V_DISCORD_WEBHOOK_4XX_URL: ${{ secrets.DISCORD_WEBHOOK_4XX_URL }} V_DISCORD_WEBHOOK_INQUIRY_URL: ${{ secrets.DISCORD_WEBHOOK_INQUIRY_URL }} + V_MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }} + V_MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }} with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} port: 2222 - envs: V_GHCR_TOKEN,V_GHCR_USER,V_DB_URL,V_DB_USERNAME,V_DB_PASSWORD,V_JWT_SECRET,V_GOOGLE_CLIENT_ID,V_GOOGLE_CLIENT_SECRET,V_KAKAO_CLIENT_ID,V_KAKAO_CLIENT_SECRET,V_CORS_ALLOWED_ORIGINS,V_OAUTH2_REDIRECT_BASE_URI,V_GOOGLE_REDIRECT_URI,V_KAKAO_REDIRECT_URI,V_DATA_PIPELINE_SECRET,V_DATA_PIPELINE_BASE_URL,V_REDIS_PASSWORD,V_DISCORD_WEBHOOK_5XX_URL,V_DISCORD_WEBHOOK_4XX_URL,V_DISCORD_WEBHOOK_INQUIRY_URL + envs: V_GHCR_TOKEN,V_GHCR_USER,V_DB_URL,V_DB_USERNAME,V_DB_PASSWORD,V_JWT_SECRET,V_GOOGLE_CLIENT_ID,V_GOOGLE_CLIENT_SECRET,V_KAKAO_CLIENT_ID,V_KAKAO_CLIENT_SECRET,V_CORS_ALLOWED_ORIGINS,V_OAUTH2_REDIRECT_BASE_URI,V_GOOGLE_REDIRECT_URI,V_KAKAO_REDIRECT_URI,V_DATA_PIPELINE_SECRET,V_DATA_PIPELINE_BASE_URL,V_REDIS_PASSWORD,V_DISCORD_WEBHOOK_5XX_URL,V_DISCORD_WEBHOOK_4XX_URL,V_DISCORD_WEBHOOK_INQUIRY_URL,V_MAIL_USERNAME,V_MAIL_PASSWORD script: | echo "$V_GHCR_TOKEN" | docker login ghcr.io -u "$V_GHCR_USER" --password-stdin @@ -98,6 +100,8 @@ jobs: printf 'DISCORD_WEBHOOK_5XX_URL=%s\n' "$V_DISCORD_WEBHOOK_5XX_URL" >> ~/compose/be.env printf 'DISCORD_WEBHOOK_4XX_URL=%s\n' "$V_DISCORD_WEBHOOK_4XX_URL" >> ~/compose/be.env printf 'DISCORD_WEBHOOK_INQUIRY_URL=%s\n' "$V_DISCORD_WEBHOOK_INQUIRY_URL" >> ~/compose/be.env + printf 'MAIL_USERNAME=%s\n' "$V_MAIL_USERNAME" >> ~/compose/be.env + printf 'MAIL_PASSWORD=%s\n' "$V_MAIL_PASSWORD" >> ~/compose/be.env chmod +x ~/scripts/deploy.sh bash ~/scripts/deploy.sh diff --git a/build.gradle b/build.gradle index e629fbd9..94fccae4 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-batch' implementation 'org.flywaydb:flyway-database-postgresql' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' diff --git a/src/main/java/com/Coming/Backend/BackendApplication.java b/src/main/java/com/Coming/Backend/BackendApplication.java index 43fa2ad5..11530061 100644 --- a/src/main/java/com/Coming/Backend/BackendApplication.java +++ b/src/main/java/com/Coming/Backend/BackendApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing +@EnableScheduling public class BackendApplication { public static void main(String[] args) { diff --git a/src/main/java/com/Coming/Backend/auth/entity/User.java b/src/main/java/com/Coming/Backend/auth/entity/User.java index acb8070c..7d9421e5 100644 --- a/src/main/java/com/Coming/Backend/auth/entity/User.java +++ b/src/main/java/com/Coming/Backend/auth/entity/User.java @@ -9,7 +9,6 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; -import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -51,26 +50,13 @@ public class User extends BaseTimeEntity { @Column(name = "birth_year") private Integer birthYear; - @Column(name = "agreed_terms") - private Boolean agreedTerms; - - @Column(name = "agreed_privacy") - private Boolean agreedPrivacy; - @Column(name = "agreed_marketing") private Boolean agreedMarketing; - @Column(name = "agreed_at") - private LocalDateTime agreedAt; - - public void completeRegistration(String nickname, int birthYear, - boolean agreedTerms, boolean agreedPrivacy, boolean agreedMarketing) { + public void completeRegistration(String nickname, int birthYear, boolean agreedMarketing) { this.nickname = nickname; this.birthYear = birthYear; - this.agreedTerms = agreedTerms; - this.agreedPrivacy = agreedPrivacy; this.agreedMarketing = agreedMarketing; - this.agreedAt = LocalDateTime.now(); this.role = UserRole.USER; } @@ -91,14 +77,15 @@ public void updateEmail(String email) { this.email = email; } + public boolean hasNoEmail() { + return email == null || email.isBlank(); + } + public void reactivate() { this.status = UserStatus.ACTIVE; this.role = UserRole.PENDING; this.nickname = null; this.birthYear = null; - this.agreedTerms = null; - this.agreedPrivacy = null; this.agreedMarketing = null; - this.agreedAt = null; } } diff --git a/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java b/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java index cf1542f0..d4626609 100644 --- a/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java +++ b/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java @@ -1,6 +1,7 @@ package com.Coming.Backend.auth.repository; import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserStatus; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Collection; @@ -14,4 +15,6 @@ public interface UserRepository extends JpaRepository { List findAllByIdIn(Collection ids); boolean existsByNickname(String nickname); + + List findByStatus(UserStatus status); } diff --git a/src/main/java/com/Coming/Backend/auth/service/AuthService.java b/src/main/java/com/Coming/Backend/auth/service/AuthService.java index 9439be4a..8d5a6b38 100644 --- a/src/main/java/com/Coming/Backend/auth/service/AuthService.java +++ b/src/main/java/com/Coming/Backend/auth/service/AuthService.java @@ -25,6 +25,14 @@ import com.Coming.Backend.auth.repository.UserRepository; import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.inquiry.repository.InquiryRepository; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.entity.UserPolicyAgreement; +import com.Coming.Backend.policy.exception.PolicyNotFoundException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.UserPolicyAgreementRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; @@ -43,6 +51,8 @@ public class AuthService { private final UserFollowArtistRepository userFollowArtistRepository; private final UserConcertCalendarRepository userConcertCalendarRepository; private final InquiryRepository inquiryRepository; + private final PolicyDocumentRepository policyDocumentRepository; + private final UserPolicyAgreementRepository userPolicyAgreementRepository; /** * Refresh Token을 검증하고 새 Access Token과 새 Refresh Token을 발급한다. @@ -128,11 +138,11 @@ public TokenResponse register(Long userId, RegisterRequest request) { user.completeRegistration( request.nickname(), request.birthYear(), - request.agreedTerms(), - request.agreedPrivacy(), Boolean.TRUE.equals(request.agreedMarketing()) ); try { + recordPolicyAgreement(userId, PolicyType.TERMS); + recordPolicyAgreement(userId, PolicyType.PRIVACY); userRepository.flush(); } catch (DataIntegrityViolationException e) { throw new NicknameDuplicateException(); @@ -142,6 +152,25 @@ public TokenResponse register(Long userId, RegisterRequest request) { return new TokenResponse(accessToken); } + /** + * 현재 시행 중인 정책 버전에 대한 사용자 동의 이력을 기록한다. 이미 동의 이력이 있으면 건너뛴다(탈퇴 후 재가입 대비). + */ + private void recordPolicyAgreement(Long userId, PolicyType type) { + PolicyDocument currentPolicy = policyDocumentRepository + .findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc(type, LocalDate.now()) + .orElseThrow(PolicyNotFoundException::new); + if (userPolicyAgreementRepository.existsByUserIdAndPolicyId(userId, currentPolicy.getId())) { + return; + } + userPolicyAgreementRepository.save( + UserPolicyAgreement.builder() + .userId(userId) + .policyId(currentPolicy.getId()) + .agreedAt(LocalDateTime.now()) + .build() + ); + } + private void validateRegisterRequest(RegisterRequest request) { if (!Boolean.TRUE.equals(request.agreedTerms()) || !Boolean.TRUE.equals(request.agreedPrivacy())) { throw new TermsNotAgreedException(); diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java index ae70ceb2..29abd30a 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -67,6 +67,10 @@ public enum ErrorCode { PIPELINE_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Data 파이프라인 서버 오류가 발생했습니다."), PIPELINE_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "Data 파이프라인 응답이 지연되고 있습니다. 잠시 후 다시 확인해주세요."), + // Policy + POLICY_VERSION_DUPLICATE(HttpStatus.CONFLICT, "이미 등록된 정책 버전입니다."), + POLICY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 정책입니다."), + // Common RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), INVALID_INPUT(HttpStatus.BAD_REQUEST, "잘못된 입력값입니다."), diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java new file mode 100644 index 00000000..7077595b --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java @@ -0,0 +1,54 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.job.parameters.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.Step; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class PolicyNotificationBatchConfig { + + private static final int CHUNK_SIZE = 20; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final PolicyNotificationTargetCreationTasklet policyNotificationTargetCreationTasklet; + private final PolicyNotificationPendingTargetReader policyNotificationPendingTargetReader; + private final PolicyNotificationMailProcessor policyNotificationMailProcessor; + private final PolicyNotificationTargetWriter policyNotificationTargetWriter; + + @Bean + public Job policyNotificationJob() { + return new JobBuilder("policyNotificationJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(targetCreationStep()) + .next(mailSendStep()) + .build(); + } + + @Bean + public Step targetCreationStep() { + return new StepBuilder("targetCreationStep", jobRepository) + .tasklet(policyNotificationTargetCreationTasklet, transactionManager) + .build(); + } + + @Bean + public Step mailSendStep() { + return new StepBuilder("mailSendStep", jobRepository) + .chunk(CHUNK_SIZE) + .reader(policyNotificationPendingTargetReader) + .processor(policyNotificationMailProcessor) + .writer(policyNotificationTargetWriter) + .transactionManager(transactionManager) + .build(); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java new file mode 100644 index 00000000..784d555f --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java @@ -0,0 +1,51 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.policy.event.PolicyRegisteredEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 정책 등록 트랜잭션이 커밋된 후, 해당 정책에 대한 알림 대상 생성·메일 발송 배치를 실행한다. + * AFTER_COMMIT 콜백과 같은 스레드에서 배치(자체 트랜잭션 포함)를 직접 실행하면 커밋 중이던 + * 트랜잭션 동기화 상태와 충돌해 JobInterruptedException이 발생하므로, 별도 스레드에서 실행하고 + * join으로 대기해 호출자 입장에서는 동기로 완료를 기다리게 한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyNotificationJobTrigger { + + private final JobOperator jobOperator; + private final Job policyNotificationJob; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onPolicyRegistered(PolicyRegisteredEvent event) { + Thread jobThread = Thread.ofVirtual().start(() -> runJob(event.policyId())); + try { + jobThread.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("정책 알림 배치 대기 중 인터럽트 발생 — policyId: {}", event.policyId(), e); + } + } + + private void runJob(Long policyId) { + try { + JobParameters jobParameters = policyNotificationJob.getJobParametersIncrementer().getNext( + new JobParametersBuilder() + .addLong("policyId", policyId) + .toJobParameters() + ); + jobOperator.run(policyNotificationJob, jobParameters); + } catch (Exception e) { + log.error("정책 알림 배치 실행 실패 — policyId: {}", policyId, e); + } + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java new file mode 100644 index 00000000..00c0ef1c --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java @@ -0,0 +1,45 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.exception.PolicyNotFoundException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.infrastructure.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * PENDING 상태의 PolicyNotificationTarget에 대해 정책 변경 고지 메일을 발송하고 상태(SENT/FAILED)를 갱신한다. + */ +@Component +@StepScope +@RequiredArgsConstructor +public class PolicyNotificationMailProcessor implements ItemProcessor { + + private final UserRepository userRepository; + private final PolicyDocumentRepository policyDocumentRepository; + private final PolicyNotificationSender policyNotificationSender; + + @Value("#{jobParameters['policyId']}") + private Long policyId; + + private PolicyDocument policyDocument; + + @Override + public PolicyNotificationTarget process(PolicyNotificationTarget target) { + User user = userRepository.findById(target.getUserId()).orElse(null); + policyNotificationSender.sendAndMark(target, user, resolvePolicyDocument()); + return target; + } + + private PolicyDocument resolvePolicyDocument() { + if (policyDocument == null) { + policyDocument = policyDocumentRepository.findById(policyId).orElseThrow(PolicyNotFoundException::new); + } + return policyDocument; + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReader.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReader.java new file mode 100644 index 00000000..f98831bc --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReader.java @@ -0,0 +1,49 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.Iterator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.infrastructure.item.ItemReader; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +/** + * PENDING 상태의 정책 알림 대상을 id 오름차순 커서 기반으로 읽는다. + * offset 페이징을 사용하면 처리된 항목이 PENDING에서 벗어나며 결과 집합이 줄어들어 + * 다음 페이지 조회 시 대상을 건너뛰는 문제가 있어, 마지막으로 읽은 id를 커서로 다음 배치를 조회한다. + */ +@Component +@StepScope +@RequiredArgsConstructor +public class PolicyNotificationPendingTargetReader implements ItemReader { + + private static final int PAGE_SIZE = 20; + + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + + @Value("#{jobParameters['policyId']}") + private Long policyId; + + private Long lastId = 0L; + private Iterator currentBatch = List.of().iterator(); + + @Override + public PolicyNotificationTarget read() { + if (!currentBatch.hasNext()) { + List batch = policyNotificationTargetRepository + .findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + policyId, NotificationStatus.PENDING, lastId, PageRequest.of(0, PAGE_SIZE)); + if (batch.isEmpty()) { + return null; + } + lastId = batch.get(batch.size() - 1).getId(); + currentBatch = batch.iterator(); + } + return currentBatch.next(); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java new file mode 100644 index 00000000..0aa0d58d --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java @@ -0,0 +1,55 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 발송 실패(FAILED)한 정책 알림 대상을 최대 3회까지 1시간 간격으로 재시도한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyNotificationRetryScheduler { + + private static final int MAX_RETRY_COUNT = 3; + + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + private final UserRepository userRepository; + private final PolicyDocumentRepository policyDocumentRepository; + private final PolicyNotificationSender policyNotificationSender; + + @Scheduled(fixedRate = 3_600_000) + @Transactional + public void retryFailedNotifications() { + List retryTargets = policyNotificationTargetRepository + .findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, MAX_RETRY_COUNT); + if (retryTargets.isEmpty()) { + return; + } + log.info("정책 알림 재시도 시작 — 대상 건수: {}", retryTargets.size()); + for (PolicyNotificationTarget target : retryTargets) { + retryOne(target); + } + } + + private void retryOne(PolicyNotificationTarget target) { + User user = userRepository.findById(target.getUserId()).orElse(null); + PolicyDocument policyDocument = policyDocumentRepository.findById(target.getPolicyId()).orElse(null); + if (policyDocument == null) { + target.markFailed(); + return; + } + policyNotificationSender.sendAndMark(target, user, policyDocument); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationSender.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationSender.java new file mode 100644 index 00000000..de60499f --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationSender.java @@ -0,0 +1,36 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 정책 알림 대상에게 메일 발송을 시도하고 결과에 따라 상태(SENT/FAILED)를 마킹한다. + * 배치 Step2 Processor와 재시도 스케줄러가 공통으로 사용한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyNotificationSender { + + private final PolicyNoticeMailSender policyNoticeMailSender; + + public void sendAndMark(PolicyNotificationTarget target, User user, PolicyDocument policyDocument) { + if (user == null || user.hasNoEmail()) { + log.warn("수신 이메일이 없어 발송 실패 처리 — targetId: {}", target.getId()); + target.markFailed(); + return; + } + try { + policyNoticeMailSender.send(user.getEmail(), policyDocument); + target.markSent(); + } catch (Exception e) { + log.warn("정책 변경 고지 메일 발송 실패 — targetId: {}", target.getId(), e); + target.markFailed(); + } + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java new file mode 100644 index 00000000..7a204763 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java @@ -0,0 +1,62 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.StepContribution; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.infrastructure.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +/** + * 정책 발행 시 ACTIVE 유저 중 아직 발송 대상이 아닌 유저를 PENDING 상태의 PolicyNotificationTarget으로 등록한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyNotificationTargetCreationTasklet implements Tasklet { + + private final UserRepository userRepository; + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + Long policyId = chunkContext.getStepContext().getStepExecution().getJobParameters().getLong("policyId"); + + Set existingTargetUserIds = policyNotificationTargetRepository.findByPolicyId(policyId).stream() + .map(PolicyNotificationTarget::getUserId) + .collect(Collectors.toSet()); + + List activeUsers = userRepository.findByStatus(UserStatus.ACTIVE); + int created = 0; + for (User user : activeUsers) { + if (existingTargetUserIds.contains(user.getId())) { + continue; + } + if (user.hasNoEmail()) { + log.warn("이메일이 없어 정책 알림 대상에서 제외 — userId: {}", user.getId()); + continue; + } + policyNotificationTargetRepository.save( + PolicyNotificationTarget.builder() + .policyId(policyId) + .userId(user.getId()) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build() + ); + created++; + } + log.info("정책 알림 대상 생성 완료 — policyId: {}, 생성 건수: {}", policyId, created); + return RepeatStatus.FINISHED; + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.java new file mode 100644 index 00000000..b9f946e0 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.java @@ -0,0 +1,20 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.infrastructure.item.Chunk; +import org.springframework.batch.infrastructure.item.ItemWriter; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PolicyNotificationTargetWriter implements ItemWriter { + + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + + @Override + public void write(Chunk chunk) { + policyNotificationTargetRepository.saveAll(chunk.getItems()); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/controller/PolicyController.java b/src/main/java/com/Coming/Backend/policy/controller/PolicyController.java new file mode 100644 index 00000000..ad7d817a --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/controller/PolicyController.java @@ -0,0 +1,32 @@ +package com.Coming.Backend.policy.controller; + +import com.Coming.Backend.policy.dto.PolicyRegisterRequest; +import com.Coming.Backend.policy.dto.PolicyResponse; +import com.Coming.Backend.policy.service.PolicyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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; + +@Tag(name = "Policy") +@RestController +@RequestMapping("/api/admin/policies") +@RequiredArgsConstructor +public class PolicyController { + + private final PolicyService policyService; + + @Operation(summary = "정책 버전 등록") + @ApiResponse(responseCode = "409", description = "POLICY_VERSION_DUPLICATE") + @PostMapping + public ResponseEntity registerPolicy(@Valid @RequestBody PolicyRegisterRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(policyService.registerPolicy(request)); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java b/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java new file mode 100644 index 00000000..2ab920d4 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java @@ -0,0 +1,27 @@ +package com.Coming.Backend.policy.dto; + +import com.Coming.Backend.policy.entity.PolicyType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +public record PolicyRegisterRequest( + @NotNull + PolicyType type, + + @NotBlank + @Size(max = 50) + String version, + + @NotNull + LocalDate effectiveDate, + + @NotBlank + String changeSummary, + + @NotBlank + @Size(max = 500) + String detailUrl +) { +} diff --git a/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java b/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java new file mode 100644 index 00000000..722f989c --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java @@ -0,0 +1,25 @@ +package com.Coming.Backend.policy.dto; + +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import java.time.LocalDate; + +public record PolicyResponse( + Long id, + PolicyType type, + String version, + LocalDate effectiveDate, + String changeSummary, + String detailUrl +) { + public static PolicyResponse from(PolicyDocument policyDocument) { + return new PolicyResponse( + policyDocument.getId(), + policyDocument.getType(), + policyDocument.getVersion(), + policyDocument.getEffectiveDate(), + policyDocument.getChangeSummary(), + policyDocument.getDetailUrl() + ); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/NotificationStatus.java b/src/main/java/com/Coming/Backend/policy/entity/NotificationStatus.java new file mode 100644 index 00000000..774b45cc --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/NotificationStatus.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.policy.entity; + +public enum NotificationStatus { + PENDING, SENT, FAILED +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java new file mode 100644 index 00000000..2058304b --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java @@ -0,0 +1,46 @@ +package com.Coming.Backend.policy.entity; + +import com.Coming.Backend.common.entity.BaseCreatedEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "policy_document") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class PolicyDocument extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 20) + private PolicyType type; + + @Column(name = "version", nullable = false, length = 50) + private String version; + + @Column(name = "effective_date", nullable = false) + private LocalDate effectiveDate; + + @Column(name = "change_summary", nullable = false, columnDefinition = "text") + private String changeSummary; + + @Column(name = "detail_url", nullable = false, length = 500) + private String detailUrl; +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java new file mode 100644 index 00000000..7a6ba985 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java @@ -0,0 +1,58 @@ +package com.Coming.Backend.policy.entity; + +import com.Coming.Backend.common.entity.BaseCreatedEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "policy_notification_target") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class PolicyNotificationTarget extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "policy_id", nullable = false) + private Long policyId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private NotificationStatus status; + + @Column(name = "sent_at") + private LocalDateTime sentAt; + + @Column(name = "retry_count", nullable = false) + private int retryCount; + + public void markSent() { + this.status = NotificationStatus.SENT; + this.sentAt = LocalDateTime.now(); + } + + public void markFailed() { + if (this.status == NotificationStatus.FAILED) { + this.retryCount += 1; + } + this.status = NotificationStatus.FAILED; + } +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyType.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyType.java new file mode 100644 index 00000000..d6eaf4a6 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyType.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.policy.entity; + +public enum PolicyType { + TERMS, PRIVACY +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/UserPolicyAgreement.java b/src/main/java/com/Coming/Backend/policy/entity/UserPolicyAgreement.java new file mode 100644 index 00000000..2c9158ca --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/UserPolicyAgreement.java @@ -0,0 +1,36 @@ +package com.Coming.Backend.policy.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "user_policy_agreement") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class UserPolicyAgreement { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "policy_id", nullable = false) + private Long policyId; + + @Column(name = "agreed_at", nullable = false) + private LocalDateTime agreedAt; +} diff --git a/src/main/java/com/Coming/Backend/policy/event/PolicyRegisteredEvent.java b/src/main/java/com/Coming/Backend/policy/event/PolicyRegisteredEvent.java new file mode 100644 index 00000000..60e08867 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/event/PolicyRegisteredEvent.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.policy.event; + +public record PolicyRegisteredEvent(Long policyId) { +} diff --git a/src/main/java/com/Coming/Backend/policy/exception/PolicyNotFoundException.java b/src/main/java/com/Coming/Backend/policy/exception/PolicyNotFoundException.java new file mode 100644 index 00000000..4318931c --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/exception/PolicyNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.policy.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class PolicyNotFoundException extends BusinessException { + + public PolicyNotFoundException() { + super(ErrorCode.POLICY_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/exception/PolicyVersionDuplicateException.java b/src/main/java/com/Coming/Backend/policy/exception/PolicyVersionDuplicateException.java new file mode 100644 index 00000000..ab706a51 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/exception/PolicyVersionDuplicateException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.policy.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class PolicyVersionDuplicateException extends BusinessException { + + public PolicyVersionDuplicateException() { + super(ErrorCode.POLICY_VERSION_DUPLICATE); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java new file mode 100644 index 00000000..cd4ae23f --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java @@ -0,0 +1,71 @@ +package com.Coming.Backend.policy.mail; + +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import java.io.UnsupportedEncodingException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +@Component +@RequiredArgsConstructor +public class PolicyNoticeMailSender { + + private static final String TEMPLATE_NAME = "mail/policy-change-notice"; + private static final String FROM_NAME = "커밍"; + private static final String LOGO_CONTENT_ID = "coming-logo"; + private static final String LOGO_PATH = "mail-assets/logo.png"; + + private final JavaMailSender javaMailSender; + private final TemplateEngine templateEngine; + + @Value("${spring.mail.username}") + private String fromAddress; + + /** + * 정책 변경 고지 메일을 발송한다. + * + * @throws IllegalStateException 메일 메시지 생성에 실패한 경우 + */ + public void send(String toEmail, PolicyDocument policyDocument) { + try { + MimeMessage message = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + helper.setTo(toEmail); + helper.setFrom(fromAddress, FROM_NAME); + helper.setSubject(buildSubject(policyDocument)); + helper.setText(renderHtml(policyDocument), true); + helper.addInline(LOGO_CONTENT_ID, new ClassPathResource(LOGO_PATH)); + javaMailSender.send(message); + } catch (MessagingException | UnsupportedEncodingException e) { + throw new IllegalStateException("정책 변경 고지 메일 메시지 생성에 실패했습니다.", e); + } + } + + private String buildSubject(PolicyDocument policyDocument) { + return "[커밍] " + labelOf(policyDocument.getType()) + " 변경 안내"; + } + + private String renderHtml(PolicyDocument policyDocument) { + Context context = new Context(); + context.setVariable("policyTypeLabel", labelOf(policyDocument.getType())); + context.setVariable("version", policyDocument.getVersion()); + context.setVariable("effectiveDate", policyDocument.getEffectiveDate()); + context.setVariable("changeSummary", policyDocument.getChangeSummary()); + context.setVariable("detailUrl", policyDocument.getDetailUrl()); + context.setVariable("fromAddress", fromAddress); + context.setVariable("logoContentId", LOGO_CONTENT_ID); + return templateEngine.process(TEMPLATE_NAME, context); + } + + private String labelOf(PolicyType type) { + return type == PolicyType.TERMS ? "이용약관" : "개인정보처리방침"; + } +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java new file mode 100644 index 00000000..9b76acea --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java @@ -0,0 +1,15 @@ +package com.Coming.Backend.policy.repository; + +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import java.time.LocalDate; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PolicyDocumentRepository extends JpaRepository { + + boolean existsByTypeAndVersion(PolicyType type, String version); + + Optional findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + PolicyType type, LocalDate today); +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java new file mode 100644 index 00000000..ae0fdb10 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java @@ -0,0 +1,17 @@ +package com.Coming.Backend.policy.repository; + +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PolicyNotificationTargetRepository extends JpaRepository { + + List findByPolicyId(Long policyId); + + List findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + Long policyId, NotificationStatus status, Long id, Pageable pageable); + + List findByStatusAndRetryCountLessThan(NotificationStatus status, int retryCount); +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/UserPolicyAgreementRepository.java b/src/main/java/com/Coming/Backend/policy/repository/UserPolicyAgreementRepository.java new file mode 100644 index 00000000..32f5ebb0 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/repository/UserPolicyAgreementRepository.java @@ -0,0 +1,9 @@ +package com.Coming.Backend.policy.repository; + +import com.Coming.Backend.policy.entity.UserPolicyAgreement; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserPolicyAgreementRepository extends JpaRepository { + + boolean existsByUserIdAndPolicyId(Long userId, Long policyId); +} diff --git a/src/main/java/com/Coming/Backend/policy/service/PolicyService.java b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java new file mode 100644 index 00000000..12ce0f01 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java @@ -0,0 +1,49 @@ +package com.Coming.Backend.policy.service; + +import com.Coming.Backend.policy.dto.PolicyRegisterRequest; +import com.Coming.Backend.policy.dto.PolicyResponse; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.event.PolicyRegisteredEvent; +import com.Coming.Backend.policy.exception.PolicyVersionDuplicateException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PolicyService { + + private final PolicyDocumentRepository policyDocumentRepository; + private final ApplicationEventPublisher eventPublisher; + + /** + * 새 정책 버전을 등록한다. 등록 완료(커밋) 후 정책 알림 배치가 트리거된다. + * + * @throws PolicyVersionDuplicateException 동일 type·version이 이미 등록된 경우 + */ + @Transactional + public PolicyResponse registerPolicy(PolicyRegisterRequest request) { + if (policyDocumentRepository.existsByTypeAndVersion(request.type(), request.version())) { + throw new PolicyVersionDuplicateException(); + } + PolicyDocument policyDocument = PolicyDocument.builder() + .type(request.type()) + .version(request.version()) + .effectiveDate(request.effectiveDate()) + .changeSummary(request.changeSummary()) + .detailUrl(request.detailUrl()) + .build(); + PolicyDocument saved; + try { + saved = policyDocumentRepository.saveAndFlush(policyDocument); + } catch (DataIntegrityViolationException e) { + throw new PolicyVersionDuplicateException(); + } + eventPublisher.publishEvent(new PolicyRegisteredEvent(saved.getId())); + return PolicyResponse.from(saved); + } +} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 9640fc22..7b55b7b3 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -2,4 +2,9 @@ discord: webhook: 5xx-url: ${DISCORD_WEBHOOK_5XX_URL} 4xx-url: ${DISCORD_WEBHOOK_4XX_URL} - inquiry-url: ${DISCORD_WEBHOOK_INQUIRY_URL} \ No newline at end of file + inquiry-url: ${DISCORD_WEBHOOK_INQUIRY_URL} + +spring: + mail: + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 66b29c4a..b4078e34 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -61,6 +61,27 @@ spring: pageable: max-page-size: 50 + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME:} + password: ${MAIL_PASSWORD:} + properties: + mail: + smtp: + auth: true + connectiontimeout: 5000 + timeout: 10000 + starttls: + enable: true + required: true + + batch: + job: + enabled: false + jdbc: + initialize-schema: never + jwt: secret: ${JWT_SECRET} access-token-expiry: 1800000 diff --git a/src/main/resources/db/migration/V32__create_policy_document.sql b/src/main/resources/db/migration/V32__create_policy_document.sql new file mode 100644 index 00000000..6e1be507 --- /dev/null +++ b/src/main/resources/db/migration/V32__create_policy_document.sql @@ -0,0 +1,11 @@ +CREATE TABLE policy_document ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + type varchar(20) NOT NULL, + version varchar(50) NOT NULL, + effective_date date NOT NULL, + change_summary text NOT NULL, + detail_url varchar(500) NOT NULL, + requires_reconsent boolean NOT NULL DEFAULT false, + created_at timestamp, + UNIQUE (type, version) +); diff --git a/src/main/resources/db/migration/V33__create_user_policy_agreement.sql b/src/main/resources/db/migration/V33__create_user_policy_agreement.sql new file mode 100644 index 00000000..402e0462 --- /dev/null +++ b/src/main/resources/db/migration/V33__create_user_policy_agreement.sql @@ -0,0 +1,22 @@ +CREATE TABLE user_policy_agreement ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + policy_id bigint NOT NULL, + agreed_at timestamp NOT NULL, + UNIQUE (user_id, policy_id) +); + +INSERT INTO policy_document (type, version, effective_date, change_summary, detail_url, requires_reconsent, created_at) +VALUES + ('TERMS', 'legacy', '2020-01-01', '시스템 마이그레이션 이전 동의 이력 보존용 레코드입니다.', '', false, now()), + ('PRIVACY', 'legacy', '2020-01-01', '시스템 마이그레이션 이전 동의 이력 보존용 레코드입니다.', '', false, now()); + +INSERT INTO user_policy_agreement (user_id, policy_id, agreed_at) +SELECT id, (SELECT id FROM policy_document WHERE type = 'TERMS' AND version = 'legacy'), agreed_at +FROM "user" +WHERE agreed_terms = true AND agreed_at IS NOT NULL; + +INSERT INTO user_policy_agreement (user_id, policy_id, agreed_at) +SELECT id, (SELECT id FROM policy_document WHERE type = 'PRIVACY' AND version = 'legacy'), agreed_at +FROM "user" +WHERE agreed_privacy = true AND agreed_at IS NOT NULL; diff --git a/src/main/resources/db/migration/V34__drop_legacy_agreement_columns.sql b/src/main/resources/db/migration/V34__drop_legacy_agreement_columns.sql new file mode 100644 index 00000000..2b2b9b13 --- /dev/null +++ b/src/main/resources/db/migration/V34__drop_legacy_agreement_columns.sql @@ -0,0 +1,3 @@ +ALTER TABLE "user" DROP COLUMN agreed_terms; +ALTER TABLE "user" DROP COLUMN agreed_privacy; +ALTER TABLE "user" DROP COLUMN agreed_at; diff --git a/src/main/resources/db/migration/V35__create_policy_notification_target.sql b/src/main/resources/db/migration/V35__create_policy_notification_target.sql new file mode 100644 index 00000000..4e01116d --- /dev/null +++ b/src/main/resources/db/migration/V35__create_policy_notification_target.sql @@ -0,0 +1,10 @@ +CREATE TABLE policy_notification_target ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + policy_id bigint NOT NULL, + user_id bigint NOT NULL, + status varchar(20) NOT NULL DEFAULT 'PENDING', + sent_at timestamp, + retry_count integer NOT NULL DEFAULT 0, + created_at timestamp, + UNIQUE (policy_id, user_id) +); diff --git a/src/main/resources/db/migration/V36__create_spring_batch_schema.sql b/src/main/resources/db/migration/V36__create_spring_batch_schema.sql new file mode 100644 index 00000000..b52b1b23 --- /dev/null +++ b/src/main/resources/db/migration/V36__create_spring_batch_schema.sql @@ -0,0 +1,79 @@ +-- Spring Batch 6.0.3 공식 PostgreSQL 스키마(schema-postgresql.sql)를 그대로 사용한다. +-- spring.batch.jdbc.initialize-schema=never로 설정해 부트 자동 초기화 대신 Flyway로 관리한다. + +CREATE TABLE BATCH_JOB_INSTANCE ( + JOB_INSTANCE_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT, + JOB_NAME VARCHAR(100) NOT NULL, + JOB_KEY VARCHAR(32) NOT NULL, + constraint JOB_INST_UN unique (JOB_NAME, JOB_KEY) +) ; + +CREATE TABLE BATCH_JOB_EXECUTION ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT, + JOB_INSTANCE_ID BIGINT NOT NULL, + CREATE_TIME TIMESTAMP NOT NULL, + START_TIME TIMESTAMP DEFAULT NULL, + END_TIME TIMESTAMP DEFAULT NULL, + STATUS VARCHAR(10), + EXIT_CODE VARCHAR(2500), + EXIT_MESSAGE VARCHAR(2500), + LAST_UPDATED TIMESTAMP, + constraint JOB_INST_EXEC_FK foreign key (JOB_INSTANCE_ID) + references BATCH_JOB_INSTANCE(JOB_INSTANCE_ID) +) ; + +CREATE TABLE BATCH_JOB_EXECUTION_PARAMS ( + JOB_EXECUTION_ID BIGINT NOT NULL, + PARAMETER_NAME VARCHAR(100) NOT NULL, + PARAMETER_TYPE VARCHAR(100) NOT NULL, + PARAMETER_VALUE VARCHAR(2500), + IDENTIFYING CHAR(1) NOT NULL, + constraint JOB_EXEC_PARAMS_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ; + +CREATE TABLE BATCH_STEP_EXECUTION ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT NOT NULL, + STEP_NAME VARCHAR(100) NOT NULL, + JOB_EXECUTION_ID BIGINT NOT NULL, + CREATE_TIME TIMESTAMP NOT NULL, + START_TIME TIMESTAMP DEFAULT NULL, + END_TIME TIMESTAMP DEFAULT NULL, + STATUS VARCHAR(10), + COMMIT_COUNT BIGINT, + READ_COUNT BIGINT, + FILTER_COUNT BIGINT, + WRITE_COUNT BIGINT, + READ_SKIP_COUNT BIGINT, + WRITE_SKIP_COUNT BIGINT, + PROCESS_SKIP_COUNT BIGINT, + ROLLBACK_COUNT BIGINT, + EXIT_CODE VARCHAR(2500), + EXIT_MESSAGE VARCHAR(2500), + LAST_UPDATED TIMESTAMP, + constraint JOB_EXEC_STEP_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ; + +CREATE TABLE BATCH_STEP_EXECUTION_CONTEXT ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT, + constraint STEP_EXEC_CTX_FK foreign key (STEP_EXECUTION_ID) + references BATCH_STEP_EXECUTION(STEP_EXECUTION_ID) +) ; + +CREATE TABLE BATCH_JOB_EXECUTION_CONTEXT ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT, + constraint JOB_EXEC_CTX_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ; + +CREATE SEQUENCE BATCH_STEP_EXECUTION_SEQ MAXVALUE 9223372036854775807 NO CYCLE; +CREATE SEQUENCE BATCH_JOB_EXECUTION_SEQ MAXVALUE 9223372036854775807 NO CYCLE; +CREATE SEQUENCE BATCH_JOB_INSTANCE_SEQ MAXVALUE 9223372036854775807 NO CYCLE; diff --git a/src/main/resources/db/migration/V37__drop_policy_document_requires_reconsent.sql b/src/main/resources/db/migration/V37__drop_policy_document_requires_reconsent.sql new file mode 100644 index 00000000..06d64aff --- /dev/null +++ b/src/main/resources/db/migration/V37__drop_policy_document_requires_reconsent.sql @@ -0,0 +1 @@ +ALTER TABLE policy_document DROP COLUMN requires_reconsent; diff --git a/src/main/resources/mail-assets/logo.png b/src/main/resources/mail-assets/logo.png new file mode 100644 index 00000000..94ec3917 Binary files /dev/null and b/src/main/resources/mail-assets/logo.png differ diff --git a/src/main/resources/templates/mail/policy-change-notice.html b/src/main/resources/templates/mail/policy-change-notice.html new file mode 100644 index 00000000..4625db73 --- /dev/null +++ b/src/main/resources/templates/mail/policy-change-notice.html @@ -0,0 +1,46 @@ + + + + + 정책 변경 안내 + + +
+ 커밍 + +

+ 이용약관 변경 안내 +

+ +

+ 안녕하세요, 커밍입니다.
+ 이용약관이 아래와 같이 변경되어 안내드립니다. +

+ + + + + + + + + + +
시행일자2026-01-01
변경 내용변경 내용 요약
+ +

+ 전체 원문은 아래 링크에서 확인하실 수 있습니다.

+ + https://comingg.com/policy + +

+ +
+ +

+ 본 메일은 관련 법령에 따른 정책 변경 사전 고지 목적으로 발송되는 필수 안내이며, 수신거부가 적용되지 않습니다.
+ 발신: 커밍 (coming@example.com) +

+
+ + diff --git a/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java b/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java index b3dd54f7..64430287 100644 --- a/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java +++ b/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java @@ -24,9 +24,15 @@ import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.inquiry.repository.InquiryRepository; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.UserPolicyAgreementRepository; +import java.time.LocalDate; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -58,6 +64,12 @@ class AuthServiceRegisterTest { @Mock private InquiryRepository inquiryRepository; + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private UserPolicyAgreementRepository userPolicyAgreementRepository; + private static final Long USER_ID = 1L; private static final String NEW_ACCESS_TOKEN = "new-access-token"; @@ -72,6 +84,17 @@ private User buildPendingUser() { .build(); } + private PolicyDocument buildPolicyDocument(PolicyType type) { + return PolicyDocument.builder() + .id(type == PolicyType.TERMS ? 1L : 2L) + .type(type) + .version("1.0.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("변경 요약") + .detailUrl("https://coming.example.com/policy") + .build(); + } + // ------------------------------------------------------------------------- // register // ------------------------------------------------------------------------- @@ -83,6 +106,12 @@ void should_return_token_and_change_role_to_user_when_registration_is_valid() { User user = buildPendingUser(); given(userRepository.existsByNickname("IU")).willReturn(false); given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + ArgumentMatchers.eq(PolicyType.TERMS), ArgumentMatchers.any(LocalDate.class))) + .willReturn(Optional.of(buildPolicyDocument(PolicyType.TERMS))); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + ArgumentMatchers.eq(PolicyType.PRIVACY), ArgumentMatchers.any(LocalDate.class))) + .willReturn(Optional.of(buildPolicyDocument(PolicyType.PRIVACY))); given(jwtProvider.generateAccessToken(USER_ID, UserRole.USER.name())).willReturn(NEW_ACCESS_TOKEN); // when diff --git a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java index 65fd9b21..97056881 100644 --- a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java +++ b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java @@ -2,9 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willDoNothing; import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import com.Coming.Backend.auth.dto.MarketingUpdateRequest; @@ -29,9 +34,17 @@ import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.inquiry.repository.InquiryRepository; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.entity.UserPolicyAgreement; +import com.Coming.Backend.policy.exception.PolicyNotFoundException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.UserPolicyAgreementRepository; +import java.time.LocalDate; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -63,6 +76,12 @@ class AuthServiceTest { @Mock private InquiryRepository inquiryRepository; + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private UserPolicyAgreementRepository userPolicyAgreementRepository; + private static final Long USER_ID = 1L; private static final String REFRESH_TOKEN = "valid-refresh-token"; private static final String NEW_REFRESH_TOKEN = "new-refresh-token"; @@ -80,6 +99,27 @@ private User buildUser() { .build(); } + private User buildPendingUser() { + return User.builder() + .id(USER_ID) + .role(UserRole.PENDING) + .status(UserStatus.ACTIVE) + .provider("google") + .providerId("google-123") + .build(); + } + + private PolicyDocument buildPolicyDocument(Long policyId, PolicyType type) { + return PolicyDocument.builder() + .id(policyId) + .type(type) + .version("1.0") + .effectiveDate(LocalDate.now()) + .changeSummary("최초 시행") + .detailUrl("https://coming.example.com/policy") + .build(); + } + // ------------------------------------------------------------------------- // refreshToken // ------------------------------------------------------------------------- @@ -329,16 +369,13 @@ void should_return_me_response_with_updated_nickname_when_nickname_is_given() { @Test void should_throw_NicknameDuplicateException_when_nickname_conflict_occurs_on_register() { // given - User user = User.builder() - .id(USER_ID) - .role(UserRole.PENDING) - .status(UserStatus.ACTIVE) - .provider("google") - .providerId("google-123") - .build(); + User user = buildPendingUser(); RegisterRequest request = new RegisterRequest("IU", 1993, true, true, false); given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); given(userRepository.existsByNickname("IU")).willReturn(false); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + any(PolicyType.class), any(LocalDate.class))) + .willReturn(Optional.of(buildPolicyDocument(10L, PolicyType.TERMS))); willThrow(DataIntegrityViolationException.class).given(userRepository).flush(); // when & then @@ -346,4 +383,73 @@ void should_throw_NicknameDuplicateException_when_nickname_conflict_occurs_on_re .isInstanceOf(NicknameDuplicateException.class) .hasMessage(ErrorCode.NICKNAME_DUPLICATE.getMessage()); } + + @Test + void should_save_user_policy_agreement_for_terms_and_privacy_when_register_succeeds() { + // given + User user = buildPendingUser(); + RegisterRequest request = new RegisterRequest("IU", 1993, true, true, false); + PolicyDocument termsPolicy = buildPolicyDocument(10L, PolicyType.TERMS); + PolicyDocument privacyPolicy = buildPolicyDocument(20L, PolicyType.PRIVACY); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNickname("IU")).willReturn(false); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.TERMS), any(LocalDate.class))) + .willReturn(Optional.of(termsPolicy)); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.PRIVACY), any(LocalDate.class))) + .willReturn(Optional.of(privacyPolicy)); + + // when + authService.register(USER_ID, request); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(UserPolicyAgreement.class); + verify(userPolicyAgreementRepository, times(2)).save(captor.capture()); + assertThat(captor.getAllValues()) + .extracting(UserPolicyAgreement::getUserId, UserPolicyAgreement::getPolicyId) + .containsExactlyInAnyOrder(tuple(USER_ID, 10L), tuple(USER_ID, 20L)); + } + + @Test + void should_not_save_user_policy_agreement_when_agreement_already_exists() { + // given + User user = buildPendingUser(); + RegisterRequest request = new RegisterRequest("IU", 1993, true, true, false); + PolicyDocument termsPolicy = buildPolicyDocument(10L, PolicyType.TERMS); + PolicyDocument privacyPolicy = buildPolicyDocument(20L, PolicyType.PRIVACY); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNickname("IU")).willReturn(false); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.TERMS), any(LocalDate.class))) + .willReturn(Optional.of(termsPolicy)); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.PRIVACY), any(LocalDate.class))) + .willReturn(Optional.of(privacyPolicy)); + given(userPolicyAgreementRepository.existsByUserIdAndPolicyId(USER_ID, 10L)).willReturn(true); + given(userPolicyAgreementRepository.existsByUserIdAndPolicyId(USER_ID, 20L)).willReturn(true); + + // when + authService.register(USER_ID, request); + + // then + verify(userPolicyAgreementRepository, never()).save(any()); + } + + @Test + void should_throw_PolicyNotFoundException_when_no_current_policy_exists() { + // given + User user = buildPendingUser(); + RegisterRequest request = new RegisterRequest("IU", 1993, true, true, false); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNickname("IU")).willReturn(false); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.TERMS), any(LocalDate.class))) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> authService.register(USER_ID, request)) + .isInstanceOf(PolicyNotFoundException.class) + .hasMessage(ErrorCode.POLICY_NOT_FOUND.getMessage()); + } } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java new file mode 100644 index 00000000..87daa753 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java @@ -0,0 +1,61 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.policy.event.PolicyRegisteredEvent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.RunIdIncrementer; +import org.springframework.batch.core.launch.JobOperator; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationJobTriggerTest { + + @InjectMocks + private PolicyNotificationJobTrigger policyNotificationJobTrigger; + + @Mock + private JobOperator jobOperator; + + @Mock + private Job policyNotificationJob; + + @Test + void should_run_job_with_policy_id_when_policy_registered_event_given() throws Exception { + // given + given(policyNotificationJob.getJobParametersIncrementer()).willReturn(new RunIdIncrementer()); + PolicyRegisteredEvent event = new PolicyRegisteredEvent(1L); + + // when + policyNotificationJobTrigger.onPolicyRegistered(event); + + // then + ArgumentCaptor jobParametersCaptor = ArgumentCaptor.forClass(JobParameters.class); + verify(jobOperator).run(eq(policyNotificationJob), jobParametersCaptor.capture()); + assertThat(jobParametersCaptor.getValue().getLong("policyId")).isEqualTo(1L); + } + + @Test + void should_not_throw_exception_when_job_operator_run_throws_exception() throws Exception { + // given + given(policyNotificationJob.getJobParametersIncrementer()).willReturn(new RunIdIncrementer()); + willThrow(new RuntimeException("job launch failed")).given(jobOperator).run(any(Job.class), any(JobParameters.class)); + PolicyRegisteredEvent event = new PolicyRegisteredEvent(1L); + + // when & then + assertThatCode(() -> policyNotificationJobTrigger.onPolicyRegistered(event)) + .doesNotThrowAnyException(); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java new file mode 100644 index 00000000..b1658203 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java @@ -0,0 +1,155 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.exception.PolicyNotFoundException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import java.time.LocalDate; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationMailProcessorTest { + + @InjectMocks + private PolicyNotificationMailProcessor policyNotificationMailProcessor; + + @Mock + private UserRepository userRepository; + + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private PolicyNotificationSender policyNotificationSender; + + private static final Long POLICY_ID = 1L; + + private User buildUser(Long userId, String email) { + return User.builder() + .id(userId) + .email(email) + .provider("google") + .providerId("provider-" + userId) + .nickname("nickname" + userId) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .birthYear(1995) + .agreedMarketing(true) + .build(); + } + + private PolicyDocument buildPolicyDocument() { + return PolicyDocument.builder() + .id(POLICY_ID) + .type(PolicyType.TERMS) + .version("1.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("이용약관 개정") + .detailUrl("https://coming.com/policy/terms/1.0") + .build(); + } + + private PolicyNotificationTarget buildTarget(Long userId) { + return PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(userId) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build(); + } + + @Test + void should_call_sender_with_found_user_when_user_exists_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + + ArgumentCaptor targetCaptor = ArgumentCaptor.forClass(PolicyNotificationTarget.class); + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + ArgumentCaptor policyDocumentCaptor = ArgumentCaptor.forClass(PolicyDocument.class); + + // when + PolicyNotificationTarget result = policyNotificationMailProcessor.process(target); + + // then + assertThat(result).isSameAs(target); + verify(policyNotificationSender).sendAndMark(targetCaptor.capture(), userCaptor.capture(), policyDocumentCaptor.capture()); + assertThat(targetCaptor.getValue()).isSameAs(target); + assertThat(userCaptor.getValue()).isSameAs(user); + assertThat(policyDocumentCaptor.getValue()).isSameAs(policyDocument); + } + + @Test + void should_call_sender_with_null_user_when_user_not_found_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + PolicyNotificationTarget target = buildTarget(1L); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(userRepository.findById(1L)).willReturn(Optional.empty()); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + + // when + policyNotificationMailProcessor.process(target); + + // then + verify(policyNotificationSender).sendAndMark(target, null, policyDocument); + } + + @Test + void should_call_policy_document_repository_only_once_when_process_called_twice_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + + // when + policyNotificationMailProcessor.process(buildTarget(1L)); + policyNotificationMailProcessor.process(buildTarget(1L)); + + // then + verify(policyDocumentRepository, times(1)).findById(POLICY_ID); + } + + @Test + void should_throw_policy_not_found_exception_when_policy_document_not_found_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + PolicyNotificationTarget target = buildTarget(1L); + + given(userRepository.findById(1L)).willReturn(Optional.of(buildUser(1L, "iu@coming.com"))); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> policyNotificationMailProcessor.process(target)) + .isInstanceOf(PolicyNotFoundException.class); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReaderTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReaderTest.java new file mode 100644 index 00000000..dd15ff97 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReaderTest.java @@ -0,0 +1,101 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationPendingTargetReaderTest { + + @InjectMocks + private PolicyNotificationPendingTargetReader policyNotificationPendingTargetReader; + + @Mock + private PolicyNotificationTargetRepository policyNotificationTargetRepository; + + private static final Long POLICY_ID = 1L; + + private PolicyNotificationTarget buildTarget(Long id) { + return PolicyNotificationTarget.builder() + .id(id) + .policyId(POLICY_ID) + .userId(id) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build(); + } + + @Test + void should_return_items_in_order_when_first_batch_fetched_given() { + // given + ReflectionTestUtils.setField(policyNotificationPendingTargetReader, "policyId", POLICY_ID); + PolicyNotificationTarget target1 = buildTarget(1L); + PolicyNotificationTarget target2 = buildTarget(2L); + given(policyNotificationTargetRepository.findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 0L, PageRequest.of(0, 20))) + .willReturn(List.of(target1, target2)); + + // when + PolicyNotificationTarget firstResult = policyNotificationPendingTargetReader.read(); + PolicyNotificationTarget secondResult = policyNotificationPendingTargetReader.read(); + + // then + assertThat(firstResult).isSameAs(target1); + assertThat(secondResult).isSameAs(target2); + verify(policyNotificationTargetRepository) + .findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 0L, PageRequest.of(0, 20)); + } + + @Test + void should_fetch_next_batch_using_last_read_id_as_cursor_when_current_batch_exhausted_given() { + // given + ReflectionTestUtils.setField(policyNotificationPendingTargetReader, "policyId", POLICY_ID); + List firstBatch = List.of(buildTarget(19L), buildTarget(20L)); + PolicyNotificationTarget target21 = buildTarget(21L); + given(policyNotificationTargetRepository.findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 0L, PageRequest.of(0, 20))) + .willReturn(firstBatch); + given(policyNotificationTargetRepository.findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 20L, PageRequest.of(0, 20))) + .willReturn(List.of(target21)); + + // when + policyNotificationPendingTargetReader.read(); + policyNotificationPendingTargetReader.read(); + PolicyNotificationTarget resultFromNextBatch = policyNotificationPendingTargetReader.read(); + + // then + assertThat(resultFromNextBatch).isSameAs(target21); + verify(policyNotificationTargetRepository) + .findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 20L, PageRequest.of(0, 20)); + } + + @Test + void should_return_null_when_repository_returns_empty_batch_given() { + // given + ReflectionTestUtils.setField(policyNotificationPendingTargetReader, "policyId", POLICY_ID); + given(policyNotificationTargetRepository.findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 0L, PageRequest.of(0, 20))) + .willReturn(List.of()); + + // when + PolicyNotificationTarget result = policyNotificationPendingTargetReader.read(); + + // then + assertThat(result).isNull(); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java new file mode 100644 index 00000000..ecb88792 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java @@ -0,0 +1,154 @@ +package com.Coming.Backend.policy.batch; + +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; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationRetrySchedulerTest { + + @InjectMocks + private PolicyNotificationRetryScheduler policyNotificationRetryScheduler; + + @Mock + private PolicyNotificationTargetRepository policyNotificationTargetRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private PolicyNotificationSender policyNotificationSender; + + private static final Long POLICY_ID = 1L; + + private User buildUser(Long userId, String email) { + return User.builder() + .id(userId) + .email(email) + .provider("google") + .providerId("provider-" + userId) + .nickname("nickname" + userId) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .birthYear(1995) + .agreedMarketing(true) + .build(); + } + + private PolicyDocument buildPolicyDocument() { + return PolicyDocument.builder() + .id(POLICY_ID) + .type(PolicyType.TERMS) + .version("1.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("이용약관 개정") + .detailUrl("https://coming.com/policy/terms/1.0") + .build(); + } + + private PolicyNotificationTarget buildTarget(Long userId) { + return PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(userId) + .status(NotificationStatus.FAILED) + .retryCount(1) + .build(); + } + + @Test + void should_do_nothing_when_no_retry_targets_given() { + // given + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(Collections.emptyList()); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + verifyNoInteractions(userRepository, policyDocumentRepository, policyNotificationSender); + } + + @Test + void should_mark_failed_without_calling_sender_when_policy_document_not_found_given() { + // given + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, "iu@coming.com"); + + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(List.of(target)); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.empty()); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(target.getRetryCount()).isEqualTo(2); + verify(policyNotificationSender, never()).sendAndMark(any(), any(), any()); + } + + @Test + void should_call_sender_with_found_user_and_policy_document_when_both_exist_given() { + // given + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(List.of(target)); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + verify(policyNotificationSender).sendAndMark(target, user, policyDocument); + } + + @Test + void should_call_sender_with_null_user_when_user_not_found_but_policy_document_found_given() { + // given + PolicyNotificationTarget target = buildTarget(1L); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(List.of(target)); + given(userRepository.findById(1L)).willReturn(Optional.empty()); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + verify(policyNotificationSender).sendAndMark(target, null, policyDocument); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java new file mode 100644 index 00000000..42b5d00b --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java @@ -0,0 +1,155 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationSenderTest { + + @InjectMocks + private PolicyNotificationSender policyNotificationSender; + + @Mock + private PolicyNoticeMailSender policyNoticeMailSender; + + private static final Long POLICY_ID = 1L; + + private User buildUser(Long userId, String email) { + return User.builder() + .id(userId) + .email(email) + .provider("google") + .providerId("provider-" + userId) + .nickname("nickname" + userId) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .birthYear(1995) + .agreedMarketing(true) + .build(); + } + + private PolicyDocument buildPolicyDocument() { + return PolicyDocument.builder() + .id(POLICY_ID) + .type(PolicyType.TERMS) + .version("1.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("이용약관 개정") + .detailUrl("https://coming.com/policy/terms/1.0") + .build(); + } + + private PolicyNotificationTarget buildTarget() { + return PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(1L) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build(); + } + + @Test + void should_mark_failed_when_user_is_null_given() { + // given + PolicyNotificationTarget target = buildTarget(); + PolicyDocument policyDocument = buildPolicyDocument(); + + // when + policyNotificationSender.sendAndMark(target, null, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + verify(policyNoticeMailSender, never()).send(any(), any()); + } + + @Test + void should_mark_failed_when_user_has_no_email_given() { + // given + PolicyNotificationTarget target = buildTarget(); + User user = buildUser(1L, null); + PolicyDocument policyDocument = buildPolicyDocument(); + + // when + policyNotificationSender.sendAndMark(target, user, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + verify(policyNoticeMailSender, never()).send(any(), any()); + } + + @Test + void should_mark_sent_when_send_succeeds_given() { + // given + PolicyNotificationTarget target = buildTarget(); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + // when + policyNotificationSender.sendAndMark(target, user, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.SENT); + assertThat(target.getSentAt()).isNotNull(); + verify(policyNoticeMailSender).send("iu@coming.com", policyDocument); + } + + @Test + void should_mark_failed_without_incrementing_retry_count_when_initial_send_throws_exception_given() { + // given + PolicyNotificationTarget target = buildTarget(); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + willThrow(new RuntimeException("smtp down")).given(policyNoticeMailSender).send("iu@coming.com", policyDocument); + + // when + policyNotificationSender.sendAndMark(target, user, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(target.getRetryCount()).isEqualTo(0); + } + + @Test + void should_mark_failed_and_increment_retry_count_when_already_failed_send_throws_exception_again_given() { + // given + PolicyNotificationTarget target = PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(1L) + .status(NotificationStatus.FAILED) + .retryCount(0) + .build(); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + willThrow(new RuntimeException("smtp down")).given(policyNoticeMailSender).send("iu@coming.com", policyDocument); + + // when + policyNotificationSender.sendAndMark(target, user, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(target.getRetryCount()).isEqualTo(1); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTaskletTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTaskletTest.java new file mode 100644 index 00000000..18a3702e --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTaskletTest.java @@ -0,0 +1,151 @@ +package com.Coming.Backend.policy.batch; + +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; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.job.JobExecution; +import org.springframework.batch.core.job.JobInstance; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.scope.context.StepContext; +import org.springframework.batch.core.step.StepContribution; +import org.springframework.batch.core.step.StepExecution; +import org.springframework.batch.infrastructure.repeat.RepeatStatus; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationTargetCreationTaskletTest { + + @InjectMocks + private PolicyNotificationTargetCreationTasklet policyNotificationTargetCreationTasklet; + + @Mock + private UserRepository userRepository; + + @Mock + private PolicyNotificationTargetRepository policyNotificationTargetRepository; + + private static final Long POLICY_ID = 1L; + + private User buildActiveUser(Long userId, String email) { + return User.builder() + .id(userId) + .email(email) + .provider("google") + .providerId("provider-" + userId) + .nickname("nickname" + userId) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .birthYear(1995) + .agreedMarketing(true) + .build(); + } + + private ChunkContext buildChunkContext(Long policyId) { + JobParameters jobParameters = new JobParametersBuilder() + .addLong("policyId", policyId) + .toJobParameters(); + JobInstance jobInstance = new JobInstance(1L, "policyNotificationJob"); + JobExecution jobExecution = new JobExecution(1L, jobInstance, jobParameters); + StepExecution stepExecution = new StepExecution(1L, "targetCreationStep", jobExecution); + return new ChunkContext(new StepContext(stepExecution)); + } + + @Test + void should_save_pending_target_when_active_user_with_email_and_no_existing_target_given() { + // given + ChunkContext chunkContext = buildChunkContext(POLICY_ID); + StepContribution stepContribution = new StepContribution(chunkContext.getStepContext().getStepExecution()); + User user = buildActiveUser(1L, "iu@coming.com"); + + given(policyNotificationTargetRepository.findByPolicyId(POLICY_ID)).willReturn(List.of()); + given(userRepository.findByStatus(UserStatus.ACTIVE)).willReturn(List.of(user)); + + ArgumentCaptor targetCaptor = ArgumentCaptor.forClass(PolicyNotificationTarget.class); + + // when + policyNotificationTargetCreationTasklet.execute(stepContribution, chunkContext); + + // then + verify(policyNotificationTargetRepository).save(targetCaptor.capture()); + PolicyNotificationTarget savedTarget = targetCaptor.getValue(); + assertThat(savedTarget.getPolicyId()).isEqualTo(POLICY_ID); + assertThat(savedTarget.getUserId()).isEqualTo(1L); + assertThat(savedTarget.getStatus()).isEqualTo(NotificationStatus.PENDING); + assertThat(savedTarget.getRetryCount()).isZero(); + } + + @Test + void should_skip_user_when_target_already_exists_for_policy_given() { + // given + ChunkContext chunkContext = buildChunkContext(POLICY_ID); + StepContribution stepContribution = new StepContribution(chunkContext.getStepContext().getStepExecution()); + User user = buildActiveUser(1L, "iu@coming.com"); + PolicyNotificationTarget existingTarget = PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(1L) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build(); + + given(policyNotificationTargetRepository.findByPolicyId(POLICY_ID)).willReturn(List.of(existingTarget)); + given(userRepository.findByStatus(UserStatus.ACTIVE)).willReturn(List.of(user)); + + // when + policyNotificationTargetCreationTasklet.execute(stepContribution, chunkContext); + + // then + verify(policyNotificationTargetRepository, never()).save(any(PolicyNotificationTarget.class)); + } + + @Test + void should_skip_user_when_email_is_null_given() { + // given + ChunkContext chunkContext = buildChunkContext(POLICY_ID); + StepContribution stepContribution = new StepContribution(chunkContext.getStepContext().getStepExecution()); + User user = buildActiveUser(1L, null); + + given(policyNotificationTargetRepository.findByPolicyId(POLICY_ID)).willReturn(List.of()); + given(userRepository.findByStatus(UserStatus.ACTIVE)).willReturn(List.of(user)); + + // when + policyNotificationTargetCreationTasklet.execute(stepContribution, chunkContext); + + // then + verify(policyNotificationTargetRepository, never()).save(any(PolicyNotificationTarget.class)); + } + + @Test + void should_return_finished_when_execute_completes_given() { + // given + ChunkContext chunkContext = buildChunkContext(POLICY_ID); + StepContribution stepContribution = new StepContribution(chunkContext.getStepContext().getStepExecution()); + + given(policyNotificationTargetRepository.findByPolicyId(POLICY_ID)).willReturn(List.of()); + given(userRepository.findByStatus(UserStatus.ACTIVE)).willReturn(List.of()); + + // when + RepeatStatus result = policyNotificationTargetCreationTasklet.execute(stepContribution, chunkContext); + + // then + assertThat(result).isEqualTo(RepeatStatus.FINISHED); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriterTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriterTest.java new file mode 100644 index 00000000..1d3f3eb1 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriterTest.java @@ -0,0 +1,56 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.infrastructure.item.Chunk; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationTargetWriterTest { + + @InjectMocks + private PolicyNotificationTargetWriter policyNotificationTargetWriter; + + @Mock + private PolicyNotificationTargetRepository policyNotificationTargetRepository; + + private static final Long POLICY_ID = 1L; + + private PolicyNotificationTarget buildTarget(Long userId, NotificationStatus status) { + return PolicyNotificationTarget.builder() + .id(userId) + .policyId(POLICY_ID) + .userId(userId) + .status(status) + .retryCount(0) + .build(); + } + + @Test + void should_save_all_chunk_items_when_write_called_given() { + // given + PolicyNotificationTarget sentTarget = buildTarget(1L, NotificationStatus.SENT); + PolicyNotificationTarget failedTarget = buildTarget(2L, NotificationStatus.FAILED); + Chunk chunk = new Chunk<>(List.of(sentTarget, failedTarget)); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + + // when + policyNotificationTargetWriter.write(chunk); + + // then + verify(policyNotificationTargetRepository).saveAll(captor.capture()); + assertThat(captor.getValue()).containsExactly(sentTarget, failedTarget); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/controller/PolicyControllerTest.java b/src/test/java/com/Coming/Backend/policy/controller/PolicyControllerTest.java new file mode 100644 index 00000000..4fc48b84 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/controller/PolicyControllerTest.java @@ -0,0 +1,132 @@ +package com.Coming.Backend.policy.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.discord.NoOpDiscordNotifier; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.policy.dto.PolicyRegisterRequest; +import com.Coming.Backend.policy.dto.PolicyResponse; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.exception.PolicyVersionDuplicateException; +import com.Coming.Backend.policy.service.PolicyService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class PolicyControllerTest { + + private MockMvc mockMvc; + + @Mock + private PolicyService policyService; + + @InjectMocks + private PolicyController policyController; + + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(policyController) + .setControllerAdvice(new GlobalExceptionHandler(new NoOpDiscordNotifier())) + .setValidator(validator) + .build(); + } + + private PolicyRegisterRequest validRequest() { + return new PolicyRegisterRequest( + PolicyType.TERMS, + "1.0.0", + LocalDate.of(2026, 1, 1), + "이용약관 개정", + "https://coming.example.com/policies/terms/1.0.0" + ); + } + + @Test + void should_return_201_when_register_request_is_valid() throws Exception { + // given + PolicyRegisterRequest request = validRequest(); + PolicyResponse response = new PolicyResponse( + 1L, PolicyType.TERMS, "1.0.0", LocalDate.of(2026, 1, 1), + "이용약관 개정", "https://coming.example.com/policies/terms/1.0.0"); + given(policyService.registerPolicy(any(PolicyRegisterRequest.class))).willReturn(response); + + // when & then + mockMvc.perform(post("/api/admin/policies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.type").value("TERMS")) + .andExpect(jsonPath("$.version").value("1.0.0")) + .andExpect(jsonPath("$.effectiveDate").value("2026-01-01")) + .andExpect(jsonPath("$.changeSummary").value("이용약관 개정")) + .andExpect(jsonPath("$.detailUrl").value("https://coming.example.com/policies/terms/1.0.0")); + } + + @Test + void should_return_400_when_type_is_null_on_register() throws Exception { + // given + PolicyRegisterRequest request = new PolicyRegisterRequest( + null, "1.0.0", LocalDate.of(2026, 1, 1), + "이용약관 개정", "https://coming.example.com/policies/terms/1.0.0"); + + // when & then + mockMvc.perform(post("/api/admin/policies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_400_when_version_exceeds_max_length_on_register() throws Exception { + // given + PolicyRegisterRequest request = new PolicyRegisterRequest( + PolicyType.TERMS, "1".repeat(51), LocalDate.of(2026, 1, 1), + "이용약관 개정", "https://coming.example.com/policies/terms/1.0.0"); + + // when & then + mockMvc.perform(post("/api/admin/policies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_409_when_policy_version_already_registered() throws Exception { + // given + PolicyRegisterRequest request = validRequest(); + given(policyService.registerPolicy(any(PolicyRegisterRequest.class))) + .willThrow(new PolicyVersionDuplicateException()); + + // when & then + mockMvc.perform(post("/api/admin/policies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.POLICY_VERSION_DUPLICATE.name())) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java b/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java new file mode 100644 index 00000000..7c9218af --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java @@ -0,0 +1,131 @@ +package com.Coming.Backend.policy.mail; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import jakarta.mail.Session; +import jakarta.mail.internet.MimeMessage; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.util.ReflectionTestUtils; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +@ExtendWith(MockitoExtension.class) +class PolicyNoticeMailSenderTest { + + @InjectMocks + private PolicyNoticeMailSender policyNoticeMailSender; + + @Mock + private JavaMailSender javaMailSender; + + @Mock + private TemplateEngine templateEngine; + + private static final String FROM_ADDRESS = "coming@example.com"; + private static final String TO_EMAIL = "user@example.com"; + + private PolicyDocument buildPolicyDocument(PolicyType type) { + return PolicyDocument.builder() + .id(1L) + .type(type) + .version("1.0.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("개인정보 수집 항목 변경") + .detailUrl("https://coming.example.com/policy/1.0.0") + .build(); + } + + @Test + void should_send_mail_when_valid_policy_document_given() { + // given + ReflectionTestUtils.setField(policyNoticeMailSender, "fromAddress", FROM_ADDRESS); + PolicyDocument policyDocument = buildPolicyDocument(PolicyType.TERMS); + + given(javaMailSender.createMimeMessage()).willReturn(new MimeMessage((Session) null)); + given(templateEngine.process(eq("mail/policy-change-notice"), any(Context.class))) + .willReturn("notice"); + + // when + policyNoticeMailSender.send(TO_EMAIL, policyDocument); + + // then + verify(templateEngine).process(eq("mail/policy-change-notice"), any(Context.class)); + verify(javaMailSender).send(any(MimeMessage.class)); + } + + @Test + void should_pass_policy_document_fields_to_template_context_when_send_called() { + // given + ReflectionTestUtils.setField(policyNoticeMailSender, "fromAddress", FROM_ADDRESS); + PolicyDocument policyDocument = buildPolicyDocument(PolicyType.PRIVACY); + + given(javaMailSender.createMimeMessage()).willReturn(new MimeMessage((Session) null)); + given(templateEngine.process(eq("mail/policy-change-notice"), any(Context.class))) + .willReturn("notice"); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + + // when + policyNoticeMailSender.send(TO_EMAIL, policyDocument); + + // then + verify(templateEngine).process(eq("mail/policy-change-notice"), contextCaptor.capture()); + Context capturedContext = contextCaptor.getValue(); + assertThat(capturedContext.getVariable("changeSummary")).isEqualTo(policyDocument.getChangeSummary()); + assertThat(capturedContext.getVariable("effectiveDate")).isEqualTo(policyDocument.getEffectiveDate()); + assertThat(capturedContext.getVariable("detailUrl")).isEqualTo(policyDocument.getDetailUrl()); + } + + @Test + void should_set_terms_label_when_policy_type_is_terms() { + // given + ReflectionTestUtils.setField(policyNoticeMailSender, "fromAddress", FROM_ADDRESS); + PolicyDocument policyDocument = buildPolicyDocument(PolicyType.TERMS); + + given(javaMailSender.createMimeMessage()).willReturn(new MimeMessage((Session) null)); + given(templateEngine.process(eq("mail/policy-change-notice"), any(Context.class))) + .willReturn("notice"); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + + // when + policyNoticeMailSender.send(TO_EMAIL, policyDocument); + + // then + verify(templateEngine).process(eq("mail/policy-change-notice"), contextCaptor.capture()); + assertThat(contextCaptor.getValue().getVariable("policyTypeLabel")).isEqualTo("이용약관"); + } + + @Test + void should_set_privacy_label_when_policy_type_is_privacy() { + // given + ReflectionTestUtils.setField(policyNoticeMailSender, "fromAddress", FROM_ADDRESS); + PolicyDocument policyDocument = buildPolicyDocument(PolicyType.PRIVACY); + + given(javaMailSender.createMimeMessage()).willReturn(new MimeMessage((Session) null)); + given(templateEngine.process(eq("mail/policy-change-notice"), any(Context.class))) + .willReturn("notice"); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + + // when + policyNoticeMailSender.send(TO_EMAIL, policyDocument); + + // then + verify(templateEngine).process(eq("mail/policy-change-notice"), contextCaptor.capture()); + assertThat(contextCaptor.getValue().getVariable("policyTypeLabel")).isEqualTo("개인정보처리방침"); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java new file mode 100644 index 00000000..654c346f --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java @@ -0,0 +1,116 @@ +package com.Coming.Backend.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.policy.dto.PolicyRegisterRequest; +import com.Coming.Backend.policy.dto.PolicyResponse; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.event.PolicyRegisteredEvent; +import com.Coming.Backend.policy.exception.PolicyVersionDuplicateException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; + +@ExtendWith(MockitoExtension.class) +class PolicyServiceTest { + + @InjectMocks + private PolicyService policyService; + + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private static final PolicyType POLICY_TYPE = PolicyType.TERMS; + private static final String VERSION = "1.0.0"; + + private PolicyRegisterRequest buildRequest() { + return new PolicyRegisterRequest( + POLICY_TYPE, + VERSION, + LocalDate.of(2026, 1, 1), + "이용약관 최초 등록", + "https://coming.example.com/policy/terms/1.0.0" + ); + } + + private PolicyDocument buildPolicyDocument(Long id, PolicyRegisterRequest request) { + return PolicyDocument.builder() + .id(id) + .type(request.type()) + .version(request.version()) + .effectiveDate(request.effectiveDate()) + .changeSummary(request.changeSummary()) + .detailUrl(request.detailUrl()) + .build(); + } + + @Test + void should_return_policy_response_when_valid_request_given() { + // given + PolicyRegisterRequest request = buildRequest(); + PolicyDocument savedPolicyDocument = buildPolicyDocument(1L, request); + + given(policyDocumentRepository.existsByTypeAndVersion(POLICY_TYPE, VERSION)).willReturn(false); + given(policyDocumentRepository.saveAndFlush(any(PolicyDocument.class))).willReturn(savedPolicyDocument); + + // when + PolicyResponse result = policyService.registerPolicy(request); + + // then + assertThat(result.id()).isEqualTo(1L); + assertThat(result.type()).isEqualTo(POLICY_TYPE); + assertThat(result.version()).isEqualTo(VERSION); + assertThat(result.effectiveDate()).isEqualTo(request.effectiveDate()); + assertThat(result.changeSummary()).isEqualTo(request.changeSummary()); + assertThat(result.detailUrl()).isEqualTo(request.detailUrl()); + verify(eventPublisher).publishEvent(new PolicyRegisteredEvent(1L)); + } + + @Test + void should_throw_policy_version_duplicate_exception_when_same_type_and_version_already_exists() { + // given + PolicyRegisterRequest request = buildRequest(); + + given(policyDocumentRepository.existsByTypeAndVersion(POLICY_TYPE, VERSION)).willReturn(true); + + // when & then + assertThatThrownBy(() -> policyService.registerPolicy(request)) + .isInstanceOf(PolicyVersionDuplicateException.class) + .hasMessage(ErrorCode.POLICY_VERSION_DUPLICATE.getMessage()); + verify(policyDocumentRepository, never()).saveAndFlush(any(PolicyDocument.class)); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void should_throw_policy_version_duplicate_exception_when_concurrent_insert_violates_unique_constraint() { + // given + PolicyRegisterRequest request = buildRequest(); + + given(policyDocumentRepository.existsByTypeAndVersion(POLICY_TYPE, VERSION)).willReturn(false); + willThrow(new DataIntegrityViolationException("duplicate key")) + .given(policyDocumentRepository).saveAndFlush(any(PolicyDocument.class)); + + // when & then + assertThatThrownBy(() -> policyService.registerPolicy(request)) + .isInstanceOf(PolicyVersionDuplicateException.class) + .hasMessage(ErrorCode.POLICY_VERSION_DUPLICATE.getMessage()); + verify(eventPublisher, never()).publishEvent(any()); + } +}