diff --git a/src/main/java/com/dnd/moddo/ModdoApplication.java b/src/main/java/com/dnd/moddo/ModdoApplication.java index fdb8f8ac..5faa7475 100644 --- a/src/main/java/com/dnd/moddo/ModdoApplication.java +++ b/src/main/java/com/dnd/moddo/ModdoApplication.java @@ -5,10 +5,12 @@ import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication(exclude = SecurityAutoConfiguration.class) @ConfigurationPropertiesScan @EnableFeignClients +@EnableScheduling public class ModdoApplication { public static void main(String[] args) { diff --git a/src/main/java/com/dnd/moddo/auth/infrastructure/security/JwtAuth.java b/src/main/java/com/dnd/moddo/auth/infrastructure/security/JwtAuth.java index f6c817ac..01b1a5f4 100644 --- a/src/main/java/com/dnd/moddo/auth/infrastructure/security/JwtAuth.java +++ b/src/main/java/com/dnd/moddo/auth/infrastructure/security/JwtAuth.java @@ -8,6 +8,8 @@ import com.dnd.moddo.auth.application.AuthDetailsService; import com.dnd.moddo.auth.infrastructure.security.exception.MissingTokenException; import com.dnd.moddo.auth.infrastructure.security.exception.TokenInvalidException; +import com.dnd.moddo.auth.model.AuthDetails; +import com.dnd.moddo.user.domain.Authority; import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; @@ -25,8 +27,19 @@ public Authentication getAuthentication(String token, String expectedTokenType) throw new MissingTokenException(); } + Long userId = claims.get(JwtConstants.AUTH_ID.message, Long.class); + String role = claims.get(JwtConstants.ROLE.message, String.class); + if (userId == null || role == null) { + throw new TokenInvalidException(); + } + + if (Authority.ADMIN.name().equals(role)) { + UserDetails adminDetails = new AuthDetails(userId, "admin", role); + return new UsernamePasswordAuthenticationToken(adminDetails, "", adminDetails.getAuthorities()); + } + UserDetails userDetails = authDetailsService.loadUserByUsername( - claims.get(JwtConstants.AUTH_ID.message).toString()); + userId.toString()); return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); } diff --git a/src/main/java/com/dnd/moddo/common/config/SecurityConfig.java b/src/main/java/com/dnd/moddo/common/config/SecurityConfig.java index 8dad4dd3..bcf98703 100644 --- a/src/main/java/com/dnd/moddo/common/config/SecurityConfig.java +++ b/src/main/java/com/dnd/moddo/common/config/SecurityConfig.java @@ -47,4 +47,4 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.build(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/dnd/moddo/common/logging/EventTaskFailureNotifier.java b/src/main/java/com/dnd/moddo/common/logging/EventTaskFailureNotifier.java new file mode 100644 index 00000000..40683174 --- /dev/null +++ b/src/main/java/com/dnd/moddo/common/logging/EventTaskFailureNotifier.java @@ -0,0 +1,42 @@ +package com.dnd.moddo.common.logging; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.dnd.moddo.outbox.domain.task.EventTask; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class EventTaskFailureNotifier { + private final ErrorNotifier errorNotifier; + + public void notifyRetryExhausted(EventTask eventTask) { + try { + errorNotifier.notifyError( + DiscordMessage.createDiscordMessage( + "# EventTask 재시도 초과", + List.of( + DiscordMessage.Embed.builder() + .title("EventTask 최종 실패") + .description( + "taskId: " + eventTask.getId() + "\n" + + "taskType: " + eventTask.getTaskType() + "\n" + + "targetUserId: " + eventTask.getTargetUserId() + "\n" + + "aggregateId: " + eventTask.getOutboxEvent().getAggregateId() + "\n" + + "attemptCount: " + eventTask.getAttemptCount() + "\n" + + "lastErrorMessage: " + eventTask.getLastErrorMessage() + ) + .build() + ) + ) + ); + } catch (Exception e) { + log.warn("Failed to notify exhausted EventTask. taskId={}", eventTask.getId(), e); + } + } +} diff --git a/src/main/java/com/dnd/moddo/event/application/command/CommandMemberService.java b/src/main/java/com/dnd/moddo/event/application/command/CommandMemberService.java index 2a4f715d..821bd67d 100644 --- a/src/main/java/com/dnd/moddo/event/application/command/CommandMemberService.java +++ b/src/main/java/com/dnd/moddo/event/application/command/CommandMemberService.java @@ -1,13 +1,11 @@ package com.dnd.moddo.event.application.command; -import java.util.List; - import org.springframework.stereotype.Service; +import com.dnd.moddo.event.application.impl.SettlementCompletionProcessor; import com.dnd.moddo.event.application.impl.MemberCreator; import com.dnd.moddo.event.application.impl.MemberDeleter; import com.dnd.moddo.event.application.impl.MemberUpdater; -import com.dnd.moddo.event.application.query.QueryMemberService; import com.dnd.moddo.event.domain.member.Member; import com.dnd.moddo.event.domain.settlement.Settlement; import com.dnd.moddo.event.presentation.request.MemberSaveRequest; @@ -22,8 +20,7 @@ public class CommandMemberService { private final MemberCreator memberCreator; private final MemberUpdater memberUpdater; private final MemberDeleter memberDeleter; - - private final QueryMemberService queryMemberService; + private final SettlementCompletionProcessor settlementCompletionProcessor; public MemberResponse createManager(Settlement settlement, Long userId) { Member member = memberCreator.createManagerForSettlement(settlement, userId); @@ -38,16 +35,7 @@ public MemberResponse addMember(Long settlementId, MemberSaveRequest request) { public MemberResponse updatePaymentStatus(Long appointmentMemberId, PaymentStatusUpdateRequest request) { Member member = memberUpdater.updatePaymentStatus(appointmentMemberId, request); - List members = queryMemberService.findAllBySettlementId( - member.getSettlementId()); - - boolean allPaid = members.stream() - .allMatch(Member::isPaid); - - if (allPaid) { - member.getSettlement().complete(); - } - + settlementCompletionProcessor.completeIfAllPaid(member.getSettlementId()); return MemberResponse.of(member); } diff --git a/src/main/java/com/dnd/moddo/event/application/command/CommandPaymentRequest.java b/src/main/java/com/dnd/moddo/event/application/command/CommandPaymentRequest.java index cf16d810..edf9ef78 100644 --- a/src/main/java/com/dnd/moddo/event/application/command/CommandPaymentRequest.java +++ b/src/main/java/com/dnd/moddo/event/application/command/CommandPaymentRequest.java @@ -2,6 +2,7 @@ import org.springframework.stereotype.Service; +import com.dnd.moddo.event.application.impl.SettlementCompletionProcessor; import com.dnd.moddo.event.application.impl.PaymentRequestCreator; import com.dnd.moddo.event.application.impl.PaymentRequestUpdater; import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; @@ -14,6 +15,7 @@ public class CommandPaymentRequest { private final PaymentRequestCreator paymentRequestCreator; private final PaymentRequestUpdater paymentRequestUpdater; + private final SettlementCompletionProcessor settlementCompletionProcessor; public PaymentRequestResponse createPaymentRequest(Long settlementId, Long userId) { PaymentRequest paymentRequest = paymentRequestCreator.createPaymentRequest(settlementId, userId); @@ -22,6 +24,7 @@ public PaymentRequestResponse createPaymentRequest(Long settlementId, Long userI public PaymentRequestResponse approvePaymentRequest(Long paymentRequestId, Long userId) { PaymentRequest paymentRequest = paymentRequestUpdater.approvePaymentRequest(paymentRequestId, userId); + settlementCompletionProcessor.completeIfAllPaid(paymentRequest.getSettlementId()); return PaymentRequestResponse.of(paymentRequest); } diff --git a/src/main/java/com/dnd/moddo/event/application/command/CommandSettlementService.java b/src/main/java/com/dnd/moddo/event/application/command/CommandSettlementService.java index fbbbb7ea..6a317b59 100644 --- a/src/main/java/com/dnd/moddo/event/application/command/CommandSettlementService.java +++ b/src/main/java/com/dnd/moddo/event/application/command/CommandSettlementService.java @@ -3,6 +3,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.dnd.moddo.event.application.impl.MemberCreator; import com.dnd.moddo.event.application.impl.SettlementCreator; import com.dnd.moddo.event.application.impl.SettlementReader; import com.dnd.moddo.event.application.impl.SettlementUpdater; @@ -24,11 +25,11 @@ public class CommandSettlementService { private final SettlementUpdater settlementUpdater; private final SettlementValidator settlementValidator; private final SettlementReader settlementReader; - private final CommandMemberService commandMemberService; + private final MemberCreator memberCreator; public SettlementSaveResponse createSettlement(SettlementRequest request, Long userId) { Settlement settlement = settlementCreator.createSettlement(request, userId); - MemberResponse manager = commandMemberService.createManager(settlement, userId); + MemberResponse manager = MemberResponse.of(memberCreator.createManagerForSettlement(settlement, userId)); return new SettlementSaveResponse(settlement.getCode(), manager); } diff --git a/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java b/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java index 5e3a7d34..eef13ff7 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java @@ -41,4 +41,14 @@ public Member findBySettlementIdAndUserId(Long settlementId, Long userId) { .orElseThrow(() -> new MemberNotFoundException(userId)); } + public boolean existsUnpaidMember(Long settlementId) { + return memberRepository.existsBySettlementIdAndIsPaidFalse(settlementId); + } + + public List findAssignedMembersBySettlementId(Long settlementId) { + return findAllBySettlementId(settlementId).stream() + .filter(Member::isAssigned) + .toList(); + } + } diff --git a/src/main/java/com/dnd/moddo/event/application/impl/SettlementCompletionProcessor.java b/src/main/java/com/dnd/moddo/event/application/impl/SettlementCompletionProcessor.java new file mode 100644 index 00000000..ccf59d7c --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/application/impl/SettlementCompletionProcessor.java @@ -0,0 +1,32 @@ +package com.dnd.moddo.event.application.impl; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.outbox.application.command.CommandOutboxEventService; +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SettlementCompletionProcessor { + private final MemberReader memberReader; + private final SettlementUpdater settlementUpdater; + private final CommandOutboxEventService commandOutboxEventService; + + @Transactional + public boolean completeIfAllPaid(Long settlementId) { + if (memberReader.existsUnpaidMember(settlementId)) { + return false; + } + + boolean completed = settlementUpdater.complete(settlementId); + if (completed) { + commandOutboxEventService.create(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, + settlementId); + } + return completed; + } +} diff --git a/src/main/java/com/dnd/moddo/event/application/impl/SettlementUpdater.java b/src/main/java/com/dnd/moddo/event/application/impl/SettlementUpdater.java index f001e8f8..f3e061de 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/SettlementUpdater.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/SettlementUpdater.java @@ -20,4 +20,8 @@ public Settlement updateAccount(SettlementAccountRequest request, Long settlemen settlement.updateAccount(request); return settlement; } + + public boolean complete(Long settlementId) { + return settlementRepository.markCompletedIfNotCompleted(settlementId) == 1; + } } diff --git a/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java b/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java index 7424671f..658b7862 100644 --- a/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java +++ b/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java @@ -31,6 +31,14 @@ select count(gm) > 0 """) Optional findBySettlementIdAndUserId(@Param("settlementId") Long settlementId, @Param("userId") Long userId); + @Query(""" + select count(gm) > 0 + from Member gm + where gm.settlement.id = :settlementId + and gm.isPaid = false + """) + boolean existsBySettlementIdAndIsPaidFalse(@Param("settlementId") Long settlementId); + default Member getById(Long id) { return findById(id) .orElseThrow(() -> new MemberNotFoundException(id)); diff --git a/src/main/java/com/dnd/moddo/event/infrastructure/SettlementRepository.java b/src/main/java/com/dnd/moddo/event/infrastructure/SettlementRepository.java index e5a220f4..7172a2ad 100644 --- a/src/main/java/com/dnd/moddo/event/infrastructure/SettlementRepository.java +++ b/src/main/java/com/dnd/moddo/event/infrastructure/SettlementRepository.java @@ -1,9 +1,11 @@ package com.dnd.moddo.event.infrastructure; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -23,6 +25,33 @@ default Settlement getById(Long groupId) { @Query("SELECT COUNT(s) FROM Settlement s WHERE s.createdAt BETWEEN :start AND :end") long countCreatedSettlement(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end); + @Query(""" + SELECT YEAR(s.createdAt), MONTH(s.createdAt), COUNT(s) + FROM Settlement s + WHERE s.createdAt >= :start + GROUP BY YEAR(s.createdAt), MONTH(s.createdAt) + ORDER BY YEAR(s.createdAt), MONTH(s.createdAt) + """) + List countMonthlySettlements(@Param("start") LocalDateTime start); + + @Query(""" + SELECT HOUR(s.createdAt), COUNT(s) + FROM Settlement s + GROUP BY HOUR(s.createdAt) + ORDER BY HOUR(s.createdAt) + """) + List countHourlySettlements(); + + List findByCompletedAtIsNotNull(); + + @Query(""" + SELECT s.writer + FROM Settlement s + GROUP BY s.writer + HAVING COUNT(s) > 1 + """) + List findRepeatWriters(); + @Query("SELECT COUNT(s) FROM Settlement s WHERE s.completedAt BETWEEN :start AND :end") long countCompletedSettlement(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end); @@ -34,6 +63,15 @@ SELECT COUNT(s) """) long countOverdue(@Param("limit") LocalDateTime limit); + @Modifying + @Query(""" + update Settlement s + set s.completedAt = CURRENT_TIMESTAMP + where s.id = :settlementId + and s.completedAt is null + """) + int markCompletedIfNotCompleted(@Param("settlementId") Long settlementId); + default Long getIdByCode(String code) { return findIdByCode(code).orElseThrow(() -> new GroupNotFoundException(code)); } diff --git a/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java b/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java new file mode 100644 index 00000000..5ab64912 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java @@ -0,0 +1,60 @@ +package com.dnd.moddo.outbox.application.command; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.common.logging.EventTaskFailureNotifier; +import com.dnd.moddo.outbox.application.impl.EventTaskRetryPolicy; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; +import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; +import com.dnd.moddo.reward.application.RewardService; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CommandEventTaskService { + private final EventTaskRepository eventTaskRepository; + private final RewardService rewardService; + private final EventTaskFailureNotifier eventTaskFailureNotifier; + + @Transactional + public void process(Long eventTaskId) { + int updatedCount = eventTaskRepository.claimProcessing( + eventTaskId, + EventTaskStatus.PROCESSING, + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + EventTaskRetryPolicy.MAX_RETRY_COUNT + ); + if (updatedCount == 0) { + return; + } + + EventTask eventTask = eventTaskRepository.getById(eventTaskId); + + try { + if (eventTask.getTaskType() == EventTaskType.REWARD_GRANT) { + rewardService.grant(eventTask.getOutboxEvent().getAggregateId(), eventTask.getTargetUserId()); + } else { + throw new IllegalStateException("Unsupported task type: " + eventTask.getTaskType()); + } + + eventTask.markCompleted(); + } catch (Exception exception) { + eventTask.markFailed(exception.getMessage()); + if (eventTask.getAttemptCount() >= EventTaskRetryPolicy.MAX_RETRY_COUNT) { + eventTask.markDead(exception.getMessage()); + eventTaskFailureNotifier.notifyRetryExhausted(eventTask); + } + } + } + + @Transactional + public void retry(Long eventTaskId) { + process(eventTaskId); + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/command/CommandOutboxEventService.java b/src/main/java/com/dnd/moddo/outbox/application/command/CommandOutboxEventService.java new file mode 100644 index 00000000..bf645b03 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/command/CommandOutboxEventService.java @@ -0,0 +1,28 @@ +package com.dnd.moddo.outbox.application.command; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.outbox.application.event.OutboxEventCreatedEvent; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class CommandOutboxEventService { + private final OutboxEventRepository outboxEventRepository; + private final ApplicationEventPublisher eventPublisher; + + public OutboxEvent create(OutboxEventType type, AggregateType aggregateType, Long aggregateId) { + OutboxEvent outboxEvent = OutboxEvent.pending(type, aggregateType, aggregateId); + OutboxEvent savedOutboxEvent = outboxEventRepository.save(outboxEvent); + eventPublisher.publishEvent(new OutboxEventCreatedEvent(savedOutboxEvent.getId())); + return savedOutboxEvent; + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEvent.java b/src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEvent.java new file mode 100644 index 00000000..8d1e8ca8 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEvent.java @@ -0,0 +1,9 @@ +package com.dnd.moddo.outbox.application.event; + +public record OutboxEventCreatedEvent(Long outboxEventId) { + public OutboxEventCreatedEvent { + if (outboxEventId <= 0) { + throw new IllegalArgumentException("outboxEventId가 0이상이어야 합니다."); + } + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEventListener.java b/src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEventListener.java new file mode 100644 index 00000000..33ce4c97 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEventListener.java @@ -0,0 +1,20 @@ +package com.dnd.moddo.outbox.application.event; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.dnd.moddo.outbox.application.impl.OutboxEventPublisher; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class OutboxEventCreatedEventListener { + private final OutboxEventPublisher outboxEventPublisher; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(OutboxEventCreatedEvent event) { + outboxEventPublisher.publish(event.outboxEventId()); + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskCreator.java b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskCreator.java new file mode 100644 index 00000000..c2618bd3 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskCreator.java @@ -0,0 +1,28 @@ +package com.dnd.moddo.outbox.application.impl; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; +import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class EventTaskCreator { + private final EventTaskRepository eventTaskRepository; + + public EventTask create(OutboxEvent outboxEvent, EventTaskType taskType, Long targetUserId) { + return eventTaskRepository.save(EventTask.pending(outboxEvent, taskType, targetUserId)); + } + + public List createAll(List eventTasks) { + return eventTaskRepository.saveAll(eventTasks); + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskRetryPolicy.java b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskRetryPolicy.java new file mode 100644 index 00000000..0f63eeb0 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskRetryPolicy.java @@ -0,0 +1,8 @@ +package com.dnd.moddo.outbox.application.impl; + +public final class EventTaskRetryPolicy { + public static final int MAX_RETRY_COUNT = 5; + + private EventTaskRetryPolicy() { + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskScheduler.java b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskScheduler.java new file mode 100644 index 00000000..332e37b0 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskScheduler.java @@ -0,0 +1,30 @@ +package com.dnd.moddo.outbox.application.impl; + +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.dnd.moddo.outbox.application.command.CommandEventTaskService; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; +import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class EventTaskScheduler { + private final EventTaskRepository eventTaskRepository; + private final CommandEventTaskService commandEventTaskService; + + @Scheduled(fixedDelay = 5000) + public void processPendingTasks() { + for (EventTask eventTask : eventTaskRepository.findTop30ByStatusInAndAttemptCountLessThanOrderByCreatedAtAsc( + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + EventTaskRetryPolicy.MAX_RETRY_COUNT + )) { + commandEventTaskService.process(eventTask.getId()); + } + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublishExecutor.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublishExecutor.java new file mode 100644 index 00000000..2a0aebcf --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublishExecutor.java @@ -0,0 +1,72 @@ +package com.dnd.moddo.outbox.application.impl; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.event.application.impl.MemberReader; +import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; +import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class OutboxEventPublishExecutor { + private final OutboxReader outboxReader; + private final EventTaskCreator eventTaskCreator; + private final MemberReader memberReader; + private final OutboxEventRepository outboxEventRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean claimProcessing(Long outboxEventId) { + int updatedCount = outboxEventRepository.claimProcessing( + outboxEventId, + OutboxEventStatus.PROCESSING, + OutboxEventStatus.PENDING + ); + return updatedCount > 0; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void appendTasks(Long outboxEventId) { + OutboxEvent outboxEvent = outboxReader.findById(outboxEventId); + if (outboxEvent.getEventType() == OutboxEventType.SETTLEMENT_COMPLETED) { + appendSettlementCompletedTasks(outboxEvent); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markPublished(Long outboxEventId) { + OutboxEvent outboxEvent = outboxReader.findById(outboxEventId); + outboxEvent.markPublished(); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markFailed(Long outboxEventId) { + OutboxEvent outboxEvent = outboxReader.findById(outboxEventId); + outboxEvent.markFailed(); + } + + private void appendSettlementCompletedTasks(OutboxEvent outboxEvent) { + List eventTasks = new ArrayList<>(); + + for (Member member : memberReader.findAssignedMembersBySettlementId(outboxEvent.getAggregateId())) { + Long targetUserId = member.getUserId(); + eventTasks.add(EventTask.pending(outboxEvent, EventTaskType.REWARD_GRANT, targetUserId)); + eventTasks.add(EventTask.pending(outboxEvent, EventTaskType.NOTIFICATION_SEND, targetUserId)); + } + + if (!eventTasks.isEmpty()) { + eventTaskCreator.createAll(eventTasks); + } + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java new file mode 100644 index 00000000..e01e6204 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java @@ -0,0 +1,56 @@ +package com.dnd.moddo.outbox.application.impl; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; + +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class OutboxEventPublisher { + private static final int PENDING_OUTBOX_BATCH_SIZE = 100; + + private final OutboxReader outboxReader; + private final OutboxEventPublishExecutor outboxEventPublishExecutor; + + public void publishPendingEvents() { + Slice pendingEvents = outboxReader.findByStatus( + OutboxEventStatus.PENDING, + PageRequest.of(0, PENDING_OUTBOX_BATCH_SIZE) + ); + + for (OutboxEvent outboxEvent : pendingEvents.getContent()) { + try { + publish(outboxEvent.getId()); + } catch (Exception e) { + log.error("Error publishing event to outbox publisher. outboxId={}", outboxEvent.getId(), e); + } + } + } + + public void publish(Long outboxEventId) { + if (!outboxEventPublishExecutor.claimProcessing(outboxEventId)) { + return; + } + + try { + outboxEventPublishExecutor.appendTasks(outboxEventId); + outboxEventPublishExecutor.markPublished(outboxEventId); + } catch (Exception exception) { + log.error("Failed to publish outbox event. outboxEventId={}", outboxEventId, exception); + try { + outboxEventPublishExecutor.markFailed(outboxEventId); + } catch (Exception markFailedException) { + log.error("Failed to mark outbox event as FAILED. outboxEventId={}", outboxEventId, + markFailedException); + throw markFailedException; + } + } + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventScheduler.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventScheduler.java new file mode 100644 index 00000000..8eb6ebe9 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventScheduler.java @@ -0,0 +1,23 @@ +package com.dnd.moddo.outbox.application.impl; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class OutboxEventScheduler { + private final OutboxEventPublisher outboxEventPublisher; + + @Scheduled(fixedDelay = 5000) + public void publishPendingEvents() { + try { + outboxEventPublisher.publishPendingEvents(); + } catch (Exception e) { + log.error("Error publishing events to outbox events queue", e); + } + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxReader.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxReader.java new file mode 100644 index 00000000..a6404446 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxReader.java @@ -0,0 +1,27 @@ +package com.dnd.moddo.outbox.application.impl; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; +import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class OutboxReader { + private final OutboxEventRepository outboxEventRepository; + + public Slice findByStatus(OutboxEventStatus status, Pageable pageable) { + return outboxEventRepository.findByStatusOrderByIdAsc(status, pageable); + } + + public OutboxEvent findById(Long outboxEventId) { + return outboxEventRepository.getById(outboxEventId); + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/domain/event/OutboxEvent.java b/src/main/java/com/dnd/moddo/outbox/domain/event/OutboxEvent.java new file mode 100644 index 00000000..b6bde6c5 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/domain/event/OutboxEvent.java @@ -0,0 +1,76 @@ +package com.dnd.moddo.outbox.domain.event; + +import java.time.LocalDateTime; + +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; + +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 lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "outbox") +@Entity +public class OutboxEvent { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OutboxEventType eventType; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private AggregateType aggregateType; + + @Column(nullable = false) + private Long aggregateId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OutboxEventStatus status; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + private LocalDateTime publishedAt; + + @Builder + private OutboxEvent(OutboxEventType eventType, AggregateType aggregateType, Long aggregateId) { + this.eventType = eventType; + this.aggregateType = aggregateType; + this.aggregateId = aggregateId; + this.status = OutboxEventStatus.PENDING; + this.createdAt = LocalDateTime.now(); + } + + public static OutboxEvent pending(OutboxEventType eventType, AggregateType aggregateType, Long aggregateId) { + return OutboxEvent.builder() + .eventType(eventType) + .aggregateType(aggregateType) + .aggregateId(aggregateId) + .build(); + } + + public void markPublished() { + this.status = OutboxEventStatus.PUBLISHED; + this.publishedAt = LocalDateTime.now(); + } + + public void markFailed() { + this.status = OutboxEventStatus.FAILED; + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/domain/event/type/AggregateType.java b/src/main/java/com/dnd/moddo/outbox/domain/event/type/AggregateType.java new file mode 100644 index 00000000..5727a103 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/domain/event/type/AggregateType.java @@ -0,0 +1,5 @@ +package com.dnd.moddo.outbox.domain.event.type; + +public enum AggregateType { + SETTLEMENT, +} diff --git a/src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventStatus.java b/src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventStatus.java new file mode 100644 index 00000000..df043f6f --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventStatus.java @@ -0,0 +1,8 @@ +package com.dnd.moddo.outbox.domain.event.type; + +public enum OutboxEventStatus { + PENDING, + PROCESSING, + PUBLISHED, + FAILED, +} diff --git a/src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventType.java b/src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventType.java new file mode 100644 index 00000000..5658c827 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventType.java @@ -0,0 +1,5 @@ +package com.dnd.moddo.outbox.domain.event.type; + +public enum OutboxEventType { + SETTLEMENT_COMPLETED, +} diff --git a/src/main/java/com/dnd/moddo/outbox/domain/task/EventTask.java b/src/main/java/com/dnd/moddo/outbox/domain/task/EventTask.java new file mode 100644 index 00000000..7fb89de8 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/domain/task/EventTask.java @@ -0,0 +1,98 @@ +package com.dnd.moddo.outbox.domain.task; + +import java.time.LocalDateTime; + +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "event_task") +@Entity +public class EventTask { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "outbox_id", nullable = false) + private OutboxEvent outboxEvent; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private EventTaskType taskType; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private EventTaskStatus status; + + @Column(name = "target_user_id") + private Long targetUserId; + + @Column(nullable = false) + private int attemptCount; + + private String lastErrorMessage; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + private LocalDateTime processedAt; + + @Builder + private EventTask(OutboxEvent outboxEvent, EventTaskType taskType, Long targetUserId) { + this.outboxEvent = outboxEvent; + this.taskType = taskType; + this.targetUserId = targetUserId; + this.status = EventTaskStatus.PENDING; + this.attemptCount = 0; + this.createdAt = LocalDateTime.now(); + } + + public static EventTask pending(OutboxEvent outboxEvent, EventTaskType taskType, Long targetUserId) { + return EventTask.builder() + .outboxEvent(outboxEvent) + .taskType(taskType) + .targetUserId(targetUserId) + .build(); + } + + public void markProcessing() { + this.status = EventTaskStatus.PROCESSING; + } + + public void markCompleted() { + this.status = EventTaskStatus.COMPLETED; + this.processedAt = LocalDateTime.now(); + this.lastErrorMessage = null; + } + + public void markFailed(String errorMessage) { + this.status = EventTaskStatus.FAILED; + this.attemptCount++; + this.lastErrorMessage = errorMessage; + } + + public void markDead(String errorMessage) { + this.status = EventTaskStatus.DEAD; + this.processedAt = LocalDateTime.now(); + this.lastErrorMessage = errorMessage; + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskStatus.java b/src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskStatus.java new file mode 100644 index 00000000..899077bd --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskStatus.java @@ -0,0 +1,9 @@ +package com.dnd.moddo.outbox.domain.task.type; + +public enum EventTaskStatus { + PENDING, + PROCESSING, + COMPLETED, + FAILED, + DEAD +} diff --git a/src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskType.java b/src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskType.java new file mode 100644 index 00000000..3d250517 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskType.java @@ -0,0 +1,6 @@ +package com.dnd.moddo.outbox.domain.task.type; + +public enum EventTaskType { + REWARD_GRANT, + NOTIFICATION_SEND, +} diff --git a/src/main/java/com/dnd/moddo/outbox/infrastructure/EventTaskRepository.java b/src/main/java/com/dnd/moddo/outbox/infrastructure/EventTaskRepository.java new file mode 100644 index 00000000..fcb6988d --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/infrastructure/EventTaskRepository.java @@ -0,0 +1,42 @@ +package com.dnd.moddo.outbox.infrastructure; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; + +import jakarta.persistence.EntityNotFoundException; + +public interface EventTaskRepository extends JpaRepository { + List findTop30ByStatusInAndAttemptCountLessThanOrderByCreatedAtAsc( + List statuses, + int attemptCount + ); + + List findTop10ByStatusOrderByCreatedAtDesc(EventTaskStatus status); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update EventTask eventTask + set eventTask.status = :processingStatus + where eventTask.id = :eventTaskId + and eventTask.status in :claimableStatuses + and eventTask.attemptCount < :maxRetryCount + """) + int claimProcessing( + @Param("eventTaskId") Long eventTaskId, + @Param("processingStatus") EventTaskStatus processingStatus, + @Param("claimableStatuses") List claimableStatuses, + @Param("maxRetryCount") int maxRetryCount + ); + + default EventTask getById(Long id) { + return findById(id) + .orElseThrow(() -> new EntityNotFoundException("Event task not found: " + id)); + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/infrastructure/OutboxEventRepository.java b/src/main/java/com/dnd/moddo/outbox/infrastructure/OutboxEventRepository.java new file mode 100644 index 00000000..bf8f6607 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/infrastructure/OutboxEventRepository.java @@ -0,0 +1,35 @@ +package com.dnd.moddo.outbox.infrastructure; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; + +import jakarta.persistence.EntityNotFoundException; + +public interface OutboxEventRepository extends JpaRepository { + Slice findByStatusOrderByIdAsc(OutboxEventStatus status, Pageable pageable); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update OutboxEvent outboxEvent + set outboxEvent.status = :processingStatus + where outboxEvent.id = :outboxEventId + and outboxEvent.status = :pendingStatus + """) + int claimProcessing( + @Param("outboxEventId") Long outboxEventId, + @Param("processingStatus") OutboxEventStatus processingStatus, + @Param("pendingStatus") OutboxEventStatus pendingStatus + ); + + default OutboxEvent getById(Long id) { + return findById(id) + .orElseThrow(() -> new EntityNotFoundException("Outbox event not found: " + id)); + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/presentation/EventTaskAdminController.java b/src/main/java/com/dnd/moddo/outbox/presentation/EventTaskAdminController.java new file mode 100644 index 00000000..adaa8e89 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/presentation/EventTaskAdminController.java @@ -0,0 +1,38 @@ +package com.dnd.moddo.outbox.presentation; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.dnd.moddo.auth.infrastructure.security.LoginUser; +import com.dnd.moddo.auth.model.exception.UserPermissionException; +import com.dnd.moddo.auth.presentation.response.LoginUserInfo; +import com.dnd.moddo.outbox.application.command.CommandEventTaskService; +import com.dnd.moddo.user.domain.Authority; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/event-tasks") +public class EventTaskAdminController { + private final CommandEventTaskService commandEventTaskService; + + @PostMapping("/{eventTaskId}/retry") + public ResponseEntity retry( + @PathVariable Long eventTaskId, + @LoginUser LoginUserInfo loginUser + ) { + validateAdmin(loginUser); + commandEventTaskService.retry(eventTaskId); + return ResponseEntity.ok().build(); + } + + private void validateAdmin(LoginUserInfo loginUser) { + if (!Authority.ADMIN.name().equals(loginUser.role())) { + throw new UserPermissionException(); + } + } +} diff --git a/src/main/java/com/dnd/moddo/reward/application/RewardService.java b/src/main/java/com/dnd/moddo/reward/application/RewardService.java new file mode 100644 index 00000000..d04afbfe --- /dev/null +++ b/src/main/java/com/dnd/moddo/reward/application/RewardService.java @@ -0,0 +1,40 @@ +package com.dnd.moddo.reward.application; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.reward.domain.character.Character; +import com.dnd.moddo.reward.domain.character.Collection; +import com.dnd.moddo.reward.domain.character.exception.SettlementCharacterNotFoundException; +import com.dnd.moddo.reward.infrastructure.CollectionRepository; +import com.dnd.moddo.reward.infrastructure.RewardQueryRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class RewardService { + private final RewardQueryRepository rewardQueryRepository; + private final CollectionRepository collectionRepository; + + public void manualGrant(Long settlementId, Long targetUserId) { + grant(settlementId, targetUserId); + } + + public void grant(Long settlementId, Long targetUserId) { + Character character = rewardQueryRepository.findBySettlementId(settlementId) + .orElseThrow(() -> new SettlementCharacterNotFoundException(settlementId)); + + if (collectionRepository.existsByUserIdAndCharacterId(targetUserId, character.getId())) { + return; + } + + try { + collectionRepository.save(Collection.acquire(targetUserId, character.getId())); + } catch (DataIntegrityViolationException exception) { + // Concurrent/manual duplicate grants are treated as idempotent success. + } + } +} diff --git a/src/main/java/com/dnd/moddo/reward/domain/character/Collection.java b/src/main/java/com/dnd/moddo/reward/domain/character/Collection.java index 3f04a059..92ac5335 100644 --- a/src/main/java/com/dnd/moddo/reward/domain/character/Collection.java +++ b/src/main/java/com/dnd/moddo/reward/domain/character/Collection.java @@ -8,14 +8,21 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "collections") +@Table( + name = "collections", + uniqueConstraints = { + @UniqueConstraint(name = "uk_collections_user_character", columnNames = {"user_id", "character_id"}) + } +) public class Collection { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -24,8 +31,25 @@ public class Collection { @Column(nullable = false) private LocalDateTime acquiredAt; + @Column(name = "character_id", nullable = false) private Long characterId; + @Column(name = "user_id", nullable = false) private Long userId; + @Builder + private Collection(LocalDateTime acquiredAt, Long characterId, Long userId) { + this.acquiredAt = acquiredAt; + this.characterId = characterId; + this.userId = userId; + } + + public static Collection acquire(Long userId, Long characterId) { + return Collection.builder() + .userId(userId) + .characterId(characterId) + .acquiredAt(LocalDateTime.now()) + .build(); + } + } diff --git a/src/main/java/com/dnd/moddo/reward/domain/character/exception/SettlementCharacterNotFoundException.java b/src/main/java/com/dnd/moddo/reward/domain/character/exception/SettlementCharacterNotFoundException.java new file mode 100644 index 00000000..08297f40 --- /dev/null +++ b/src/main/java/com/dnd/moddo/reward/domain/character/exception/SettlementCharacterNotFoundException.java @@ -0,0 +1,11 @@ +package com.dnd.moddo.reward.domain.character.exception; + +import org.springframework.http.HttpStatus; + +import com.dnd.moddo.common.exception.ModdoException; + +public class SettlementCharacterNotFoundException extends ModdoException { + public SettlementCharacterNotFoundException(Long settlementId) { + super(HttpStatus.NOT_FOUND, "정산에 연결된 캐릭터를 찾을 수 없습니다. settlementId=" + settlementId); + } +} diff --git a/src/main/java/com/dnd/moddo/reward/infrastructure/CharacterRepository.java b/src/main/java/com/dnd/moddo/reward/infrastructure/CharacterRepository.java index 5f398f43..7a9ac06b 100644 --- a/src/main/java/com/dnd/moddo/reward/infrastructure/CharacterRepository.java +++ b/src/main/java/com/dnd/moddo/reward/infrastructure/CharacterRepository.java @@ -10,7 +10,9 @@ public interface CharacterRepository extends JpaRepository { List findByRarity(int rarity); + List findAllByOrderByRarityDescIdDesc(); + default Character getById(Long characterId) { return findById(characterId).orElseThrow(() -> new CharacterNotFoundException()); } -} \ No newline at end of file +} diff --git a/src/main/java/com/dnd/moddo/reward/infrastructure/CollectionRepository.java b/src/main/java/com/dnd/moddo/reward/infrastructure/CollectionRepository.java index 98bea464..43f52b38 100644 --- a/src/main/java/com/dnd/moddo/reward/infrastructure/CollectionRepository.java +++ b/src/main/java/com/dnd/moddo/reward/infrastructure/CollectionRepository.java @@ -5,4 +5,5 @@ import com.dnd.moddo.reward.domain.character.Collection; public interface CollectionRepository extends JpaRepository { + boolean existsByUserIdAndCharacterId(Long userId, Long characterId); } diff --git a/src/main/java/com/dnd/moddo/reward/infrastructure/RewardQueryRepositoryImpl.java b/src/main/java/com/dnd/moddo/reward/infrastructure/RewardQueryRepositoryImpl.java index 23e83162..692d1960 100644 --- a/src/main/java/com/dnd/moddo/reward/infrastructure/RewardQueryRepositoryImpl.java +++ b/src/main/java/com/dnd/moddo/reward/infrastructure/RewardQueryRepositoryImpl.java @@ -65,10 +65,14 @@ public Optional findBySettlementId(Long settlementId) { QCharacter character = QCharacter.character; QSettlement settlement = QSettlement.settlement; - Character response = queryFactory.selectFrom(character) - .leftJoin(settlement).on(settlement.characterId.eq(character.id)) + Character response = queryFactory + .select(character) + .from(settlement) + .join(character).on(settlement.characterId.eq(character.id)) + .where(settlement.id.eq(settlementId)) .fetchOne(); return Optional.ofNullable(response); } + } diff --git a/src/main/java/com/dnd/moddo/reward/presentation/RewardAdminController.java b/src/main/java/com/dnd/moddo/reward/presentation/RewardAdminController.java new file mode 100644 index 00000000..bf033ff0 --- /dev/null +++ b/src/main/java/com/dnd/moddo/reward/presentation/RewardAdminController.java @@ -0,0 +1,42 @@ +package com.dnd.moddo.reward.presentation; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.dnd.moddo.auth.infrastructure.security.LoginUser; +import com.dnd.moddo.auth.model.exception.UserPermissionException; +import com.dnd.moddo.auth.presentation.response.LoginUserInfo; +import com.dnd.moddo.event.application.query.QuerySettlementService; +import com.dnd.moddo.reward.application.RewardService; +import com.dnd.moddo.user.domain.Authority; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/rewards") +public class RewardAdminController { + private final RewardService rewardService; + private final QuerySettlementService querySettlementService; + + @PostMapping("/groups/{code}/users/{userId}") + public ResponseEntity manualGrant( + @PathVariable String code, + @PathVariable Long userId, + @LoginUser LoginUserInfo loginUser + ) { + validateAdmin(loginUser); + Long settlementId = querySettlementService.findIdByCode(code); + rewardService.manualGrant(settlementId, userId); + return ResponseEntity.ok().build(); + } + + private void validateAdmin(LoginUserInfo loginUser) { + if (!Authority.ADMIN.name().equals(loginUser.role())) { + throw new UserPermissionException(); + } + } +} diff --git a/src/main/resources/config b/src/main/resources/config index 76a409ca..b94d1fea 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 76a409ca54e5acf352f138204a240a106da3681f +Subproject commit b94d1feafc0446a8c480c8a3c1f672254a6ad7fa diff --git a/src/test/java/com/dnd/moddo/domain/Member/service/CommandMemberServiceTest.java b/src/test/java/com/dnd/moddo/domain/Member/service/CommandMemberServiceTest.java index 98ef257e..e26d0d3f 100644 --- a/src/test/java/com/dnd/moddo/domain/Member/service/CommandMemberServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/Member/service/CommandMemberServiceTest.java @@ -18,7 +18,7 @@ import com.dnd.moddo.event.application.impl.MemberCreator; import com.dnd.moddo.event.application.impl.MemberDeleter; import com.dnd.moddo.event.application.impl.MemberUpdater; -import com.dnd.moddo.event.application.query.QueryMemberService; +import com.dnd.moddo.event.application.impl.SettlementCompletionProcessor; import com.dnd.moddo.event.domain.member.ExpenseRole; import com.dnd.moddo.event.domain.member.Member; import com.dnd.moddo.event.domain.settlement.Settlement; @@ -38,7 +38,7 @@ public class CommandMemberServiceTest { @Mock private MemberDeleter memberDeleter; @Mock - private QueryMemberService queryMemberService; + private SettlementCompletionProcessor settlementCompletionProcessor; @InjectMocks private CommandMemberService commandMemberService; @@ -104,8 +104,6 @@ void whenValidInfo_thenAddAppointmentMemberSuccess() { @Test void whenAllMembersPaid_thenSettlementCompleted() { // given - Settlement mockSettlement = mock(Settlement.class); - Member paidMember = Member.builder() .name("김반숙") .settlement(mockSettlement) @@ -121,9 +119,6 @@ void whenAllMembersPaid_thenSettlementCompleted() { when(memberUpdater.updatePaymentStatus(any(), eq(request))) .thenReturn(paidMember); - when(queryMemberService.findAllBySettlementId(any())) - .thenReturn(List.of(paidMember)); - // when MemberResponse response = commandMemberService.updatePaymentStatus(1L, request); @@ -134,9 +129,7 @@ void whenAllMembersPaid_thenSettlementCompleted() { assertThat(response.isPaid()).isTrue(); verify(memberUpdater).updatePaymentStatus(any(), eq(request)); - verify(queryMemberService).findAllBySettlementId(any()); - - verify(mockSettlement).complete(); + verify(settlementCompletionProcessor).completeIfAllPaid(mockSettlement.getId()); } @DisplayName("로그인 사용자가 참여자를 선택할 수 있다.") diff --git a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java index d500eba2..273c8e27 100644 --- a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java +++ b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java @@ -23,6 +23,8 @@ import com.dnd.moddo.event.infrastructure.MemberQueryRepository; import com.dnd.moddo.event.infrastructure.MemberRepository; import com.dnd.moddo.global.support.GroupTestFactory; +import com.dnd.moddo.global.support.UserTestFactory; +import com.dnd.moddo.user.domain.User; @ExtendWith(MockitoExtension.class) public class MemberReaderTest { @@ -182,4 +184,43 @@ void findBySettlementIdAndUserIdFail() { .isInstanceOf(MemberNotFoundException.class); } + @DisplayName("정산에 미납 멤버가 있는지 확인할 수 있다.") + @Test + void existsUnpaidMemberSuccess() { + Long settlementId = 1L; + when(memberRepository.existsBySettlementIdAndIsPaidFalse(settlementId)).thenReturn(true); + + boolean result = memberReader.existsUnpaidMember(settlementId); + + assertThat(result).isTrue(); + verify(memberRepository).existsBySettlementIdAndIsPaidFalse(settlementId); + } + + @DisplayName("연결된 멤버만 조회할 수 있다.") + @Test + void findAssignedMembersBySettlementIdSuccess() { + Long settlementId = mockSettlement.getId(); + User assignedUser = UserTestFactory.createWithEmail("assigned@test.com"); + List members = List.of( + Member.builder() + .name("연결된 참여자") + .settlement(mockSettlement) + .role(ExpenseRole.PARTICIPANT) + .user(assignedUser) + .build(), + Member.builder() + .name("미연결 참여자") + .settlement(mockSettlement) + .role(ExpenseRole.PARTICIPANT) + .build() + ); + + when(memberQueryRepository.findAllBySettlementId(eq(settlementId), eq(MemberSortType.CREATED))).thenReturn(members); + + List result = memberReader.findAssignedMembersBySettlementId(settlementId); + + assertThat(result.size()).isEqualTo(1); + assertThat(result.get(0).getName()).isEqualTo("연결된 참여자"); + } + } diff --git a/src/test/java/com/dnd/moddo/domain/common/logging/EventTaskFailureNotifierTest.java b/src/test/java/com/dnd/moddo/domain/common/logging/EventTaskFailureNotifierTest.java new file mode 100644 index 00000000..6aa1d3e3 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/common/logging/EventTaskFailureNotifierTest.java @@ -0,0 +1,86 @@ +package com.dnd.moddo.domain.common.logging; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.common.logging.DiscordMessage; +import com.dnd.moddo.common.logging.ErrorNotifier; +import com.dnd.moddo.common.logging.EventTaskFailureNotifier; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; + +@ExtendWith(MockitoExtension.class) +class EventTaskFailureNotifierTest { + + @Mock + private ErrorNotifier errorNotifier; + + @InjectMocks + private EventTaskFailureNotifier eventTaskFailureNotifier; + + @Test + @DisplayName("재시도 초과한 이벤트 태스크 정보를 Discord 알림으로 전송한다.") + void notifyRetryExhausted() { + OutboxEvent outboxEvent = OutboxEvent.pending( + OutboxEventType.SETTLEMENT_COMPLETED, + AggregateType.SETTLEMENT, + 1L + ); + setOutboxEventId(outboxEvent, 10L); + + EventTask eventTask = EventTask.pending(outboxEvent, EventTaskType.REWARD_GRANT, 20L); + setEventTaskId(eventTask, 30L); + eventTask.markFailed("failed-1"); + eventTask.markFailed("failed-2"); + + eventTaskFailureNotifier.notifyRetryExhausted(eventTask); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DiscordMessage.class); + verify(errorNotifier).notifyError(captor.capture()); + + DiscordMessage message = captor.getValue(); + assertThat(message.content()).isEqualTo("# EventTask 재시도 초과"); + assertThat(message.embeds()).hasSize(1); + assertThat(message.embeds().get(0).getDescription()) + .contains("taskId: 30") + .contains("taskType: REWARD_GRANT") + .contains("targetUserId: 20") + .contains("aggregateId: 1") + .contains("attemptCount: 2") + .contains("lastErrorMessage: failed-2"); + } + + private void setOutboxEventId(OutboxEvent outboxEvent, Long id) { + try { + java.lang.reflect.Field idField = OutboxEvent.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(outboxEvent, id); + } catch (ReflectiveOperationException exception) { + throw new RuntimeException(exception); + } + } + + private void setEventTaskId(EventTask eventTask, Long id) { + try { + java.lang.reflect.Field idField = EventTask.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(eventTask, id); + } catch (ReflectiveOperationException exception) { + throw new RuntimeException(exception); + } + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/controller/EventTaskAdminControllerTest.java b/src/test/java/com/dnd/moddo/domain/outbox/controller/EventTaskAdminControllerTest.java new file mode 100644 index 00000000..640349a6 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/controller/EventTaskAdminControllerTest.java @@ -0,0 +1,45 @@ +package com.dnd.moddo.domain.outbox.controller; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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.ResponseEntity; + +import com.dnd.moddo.auth.model.exception.UserPermissionException; +import com.dnd.moddo.auth.presentation.response.LoginUserInfo; +import com.dnd.moddo.outbox.application.command.CommandEventTaskService; +import com.dnd.moddo.outbox.presentation.EventTaskAdminController; + +@ExtendWith(MockitoExtension.class) +class EventTaskAdminControllerTest { + + @Mock + private CommandEventTaskService commandEventTaskService; + + @InjectMocks + private EventTaskAdminController eventTaskAdminController; + + @Test + @DisplayName("관리자는 이벤트 태스크 재시도를 요청할 수 있다.") + void retryByAdmin() { + ResponseEntity response = eventTaskAdminController.retry(1L, new LoginUserInfo(1L, "ADMIN")); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + verify(commandEventTaskService).retry(1L); + } + + @Test + @DisplayName("관리자가 아니면 이벤트 태스크 재시도를 요청할 수 없다.") + void retryForbiddenWhenNotAdmin() { + assertThatThrownBy(() -> eventTaskAdminController.retry(1L, new LoginUserInfo(1L, "USER"))) + .isInstanceOf(UserPermissionException.class); + + verify(commandEventTaskService, never()).retry(anyLong()); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/entity/EventTaskTest.java b/src/test/java/com/dnd/moddo/domain/outbox/entity/EventTaskTest.java new file mode 100644 index 00000000..87e205db --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/entity/EventTaskTest.java @@ -0,0 +1,97 @@ +package com.dnd.moddo.domain.outbox.entity; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; + +class EventTaskTest { + + @Test + @DisplayName("pending event task를 생성하면 기본 상태가 설정된다.") + void createPendingEventTask() { + OutboxEvent outboxEvent = OutboxEvent.pending( + OutboxEventType.SETTLEMENT_COMPLETED, + AggregateType.SETTLEMENT, + 1L + ); + + EventTask eventTask = EventTask.pending(outboxEvent, EventTaskType.REWARD_GRANT, 10L); + + assertThat(eventTask.getOutboxEvent()).isEqualTo(outboxEvent); + assertThat(eventTask.getTaskType()).isEqualTo(EventTaskType.REWARD_GRANT); + assertThat(eventTask.getTargetUserId()).isEqualTo(10L); + assertThat(eventTask.getStatus()).isEqualTo(EventTaskStatus.PENDING); + assertThat(eventTask.getAttemptCount()).isZero(); + assertThat(eventTask.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("처리 중 상태로 변경할 수 있다.") + void markProcessing() { + EventTask eventTask = EventTask.pending( + OutboxEvent.pending(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L), + EventTaskType.REWARD_GRANT, + 10L + ); + + eventTask.markProcessing(); + + assertThat(eventTask.getStatus()).isEqualTo(EventTaskStatus.PROCESSING); + } + + @Test + @DisplayName("완료 처리되면 completed 상태와 처리 시각이 설정된다.") + void markCompleted() { + EventTask eventTask = EventTask.pending( + OutboxEvent.pending(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L), + EventTaskType.REWARD_GRANT, + 10L + ); + + eventTask.markCompleted(); + + assertThat(eventTask.getStatus()).isEqualTo(EventTaskStatus.COMPLETED); + assertThat(eventTask.getProcessedAt()).isNotNull(); + assertThat(eventTask.getLastErrorMessage()).isNull(); + } + + @Test + @DisplayName("실패 처리되면 failed 상태와 시도 횟수, 오류 메시지가 기록된다.") + void markFailed() { + EventTask eventTask = EventTask.pending( + OutboxEvent.pending(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L), + EventTaskType.REWARD_GRANT, + 10L + ); + + eventTask.markFailed("failed"); + + assertThat(eventTask.getStatus()).isEqualTo(EventTaskStatus.FAILED); + assertThat(eventTask.getAttemptCount()).isEqualTo(1); + assertThat(eventTask.getLastErrorMessage()).isEqualTo("failed"); + } + + @Test + @DisplayName("dead 처리되면 dead 상태와 처리 시각, 오류 메시지가 기록된다.") + void markDead() { + EventTask eventTask = EventTask.pending( + OutboxEvent.pending(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L), + EventTaskType.REWARD_GRANT, + 10L + ); + + eventTask.markDead("dead"); + + assertThat(eventTask.getStatus()).isEqualTo(EventTaskStatus.DEAD); + assertThat(eventTask.getProcessedAt()).isNotNull(); + assertThat(eventTask.getLastErrorMessage()).isEqualTo("dead"); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/entity/OutboxEventTest.java b/src/test/java/com/dnd/moddo/domain/outbox/entity/OutboxEventTest.java new file mode 100644 index 00000000..a8ad3bb6 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/entity/OutboxEventTest.java @@ -0,0 +1,60 @@ +package com.dnd.moddo.domain.outbox.entity; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; + +class OutboxEventTest { + + @Test + @DisplayName("pending outbox event를 생성하면 기본 상태가 설정된다.") + void createPendingOutboxEvent() { + OutboxEvent outboxEvent = OutboxEvent.pending( + OutboxEventType.SETTLEMENT_COMPLETED, + AggregateType.SETTLEMENT, + 1L + ); + + assertThat(outboxEvent.getEventType()).isEqualTo(OutboxEventType.SETTLEMENT_COMPLETED); + assertThat(outboxEvent.getAggregateType()).isEqualTo(AggregateType.SETTLEMENT); + assertThat(outboxEvent.getAggregateId()).isEqualTo(1L); + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEventStatus.PENDING); + assertThat(outboxEvent.getCreatedAt()).isNotNull(); + assertThat(outboxEvent.getPublishedAt()).isNull(); + } + + @Test + @DisplayName("publish 처리되면 published 상태와 시각이 설정된다.") + void markPublished() { + OutboxEvent outboxEvent = OutboxEvent.pending( + OutboxEventType.SETTLEMENT_COMPLETED, + AggregateType.SETTLEMENT, + 1L + ); + + outboxEvent.markPublished(); + + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEventStatus.PUBLISHED); + assertThat(outboxEvent.getPublishedAt()).isNotNull(); + } + + @Test + @DisplayName("실패 처리되면 failed 상태가 된다.") + void markFailed() { + OutboxEvent outboxEvent = OutboxEvent.pending( + OutboxEventType.SETTLEMENT_COMPLETED, + AggregateType.SETTLEMENT, + 1L + ); + + outboxEvent.markFailed(); + + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEventStatus.FAILED); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceProcessTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceProcessTest.java new file mode 100644 index 00000000..10bf1893 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceProcessTest.java @@ -0,0 +1,157 @@ +package com.dnd.moddo.domain.outbox.service; + +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.common.logging.EventTaskFailureNotifier; +import com.dnd.moddo.outbox.application.command.CommandEventTaskService; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; +import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; +import com.dnd.moddo.reward.application.RewardService; + +@ExtendWith(MockitoExtension.class) +class CommandEventTaskServiceProcessTest { + + @Mock + private EventTaskRepository eventTaskRepository; + + @Mock + private RewardService rewardService; + + @Mock + private EventTaskFailureNotifier eventTaskFailureNotifier; + + @InjectMocks + private CommandEventTaskService commandEventTaskService; + + @Test + @DisplayName("선점에 실패한 태스크는 처리하지 않는다.") + void skipWhenClaimProcessingFails() { + when(eventTaskRepository.claimProcessing( + 1L, + EventTaskStatus.PROCESSING, + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + 5 + )).thenReturn(0); + + commandEventTaskService.process(1L); + + verify(eventTaskRepository, never()).getById(anyLong()); + verifyNoInteractions(rewardService); + } + + @Test + @DisplayName("REWARD_GRANT 태스크를 성공적으로 처리하면 완료 처리한다.") + void processRewardGrantTask() { + EventTask eventTask = mock(EventTask.class); + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(eventTaskRepository.claimProcessing( + 1L, + EventTaskStatus.PROCESSING, + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + 5 + )).thenReturn(1); + when(eventTaskRepository.getById(1L)).thenReturn(eventTask); + when(eventTask.getTaskType()).thenReturn(EventTaskType.REWARD_GRANT); + when(eventTask.getOutboxEvent()).thenReturn(outboxEvent); + when(outboxEvent.getAggregateId()).thenReturn(10L); + when(eventTask.getTargetUserId()).thenReturn(20L); + + commandEventTaskService.process(1L); + + verify(eventTaskRepository).claimProcessing( + 1L, + EventTaskStatus.PROCESSING, + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + 5 + ); + verify(rewardService).grant(10L, 20L); + verify(eventTask).markCompleted(); + verify(eventTask, never()).markFailed(anyString()); + } + + @Test + @DisplayName("실패했지만 최대 재시도 전이면 운영 알림은 보내지 않는다.") + void doesNotNotifyWhenRetryNotExhausted() { + EventTask eventTask = mock(EventTask.class); + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(eventTaskRepository.claimProcessing( + 1L, + EventTaskStatus.PROCESSING, + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + 5 + )).thenReturn(1); + when(eventTaskRepository.getById(1L)).thenReturn(eventTask); + when(eventTask.getTaskType()).thenReturn(EventTaskType.REWARD_GRANT); + when(eventTask.getOutboxEvent()).thenReturn(outboxEvent); + when(outboxEvent.getAggregateId()).thenReturn(10L); + when(eventTask.getTargetUserId()).thenReturn(20L); + doThrow(new RuntimeException("grant failed")).when(rewardService).grant(10L, 20L); + when(eventTask.getAttemptCount()).thenReturn(3); + + commandEventTaskService.process(1L); + + verify(eventTask).markFailed("grant failed"); + verify(eventTaskFailureNotifier, never()).notifyRetryExhausted(any()); + } + + @Test + @DisplayName("최대 재시도에 도달한 실패 태스크는 운영 알림을 보낸다.") + void notifyWhenRetryExhausted() { + EventTask eventTask = mock(EventTask.class); + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(eventTaskRepository.claimProcessing( + 1L, + EventTaskStatus.PROCESSING, + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + 5 + )).thenReturn(1); + when(eventTaskRepository.getById(1L)).thenReturn(eventTask); + when(eventTask.getTaskType()).thenReturn(EventTaskType.REWARD_GRANT); + when(eventTask.getOutboxEvent()).thenReturn(outboxEvent); + when(outboxEvent.getAggregateId()).thenReturn(10L); + when(eventTask.getTargetUserId()).thenReturn(20L); + doThrow(new RuntimeException("grant failed")).when(rewardService).grant(10L, 20L); + when(eventTask.getAttemptCount()).thenReturn(5); + + commandEventTaskService.process(1L); + + verify(eventTask).markFailed("grant failed"); + verify(eventTask).markDead("grant failed"); + verify(eventTaskFailureNotifier).notifyRetryExhausted(eventTask); + } + + @Test + @DisplayName("지원하지 않는 태스크 타입은 실패 처리한다.") + void failWhenTaskTypeUnsupported() { + EventTask eventTask = mock(EventTask.class); + when(eventTaskRepository.claimProcessing( + 1L, + EventTaskStatus.PROCESSING, + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + 5 + )).thenReturn(1); + when(eventTaskRepository.getById(1L)).thenReturn(eventTask); + when(eventTask.getTaskType()).thenReturn(EventTaskType.NOTIFICATION_SEND); + when(eventTask.getAttemptCount()).thenReturn(1); + + commandEventTaskService.process(1L); + + verifyNoInteractions(rewardService); + verify(eventTask).markFailed("Unsupported task type: NOTIFICATION_SEND"); + verify(eventTask, never()).markCompleted(); + verify(eventTask, never()).markDead(anyString()); + verify(eventTaskFailureNotifier, never()).notifyRetryExhausted(any()); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceTest.java new file mode 100644 index 00000000..4152b62d --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceTest.java @@ -0,0 +1,59 @@ +package com.dnd.moddo.domain.outbox.service; + +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.common.logging.EventTaskFailureNotifier; +import com.dnd.moddo.outbox.application.command.CommandEventTaskService; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; +import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; +import com.dnd.moddo.reward.application.RewardService; + +@ExtendWith(MockitoExtension.class) +class CommandEventTaskServiceTest { + + @Mock + private EventTaskRepository eventTaskRepository; + + @Mock + private RewardService rewardService; + + @Mock + private EventTaskFailureNotifier eventTaskFailureNotifier; + + @InjectMocks + private CommandEventTaskService commandEventTaskService; + + @Test + @DisplayName("이벤트 태스크 재처리를 요청할 수 있다.") + void retry() { + EventTask eventTask = mock(EventTask.class); + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(eventTaskRepository.claimProcessing( + 1L, + EventTaskStatus.PROCESSING, + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + 5 + )).thenReturn(1); + when(eventTaskRepository.getById(1L)).thenReturn(eventTask); + when(eventTask.getTaskType()).thenReturn(EventTaskType.REWARD_GRANT); + when(eventTask.getOutboxEvent()).thenReturn(outboxEvent); + when(outboxEvent.getAggregateId()).thenReturn(10L); + when(eventTask.getTargetUserId()).thenReturn(20L); + + commandEventTaskService.retry(1L); + + verify(rewardService).grant(10L, 20L); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/CommandOutboxEventServiceTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandOutboxEventServiceTest.java new file mode 100644 index 00000000..5eef5fb2 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandOutboxEventServiceTest.java @@ -0,0 +1,61 @@ +package com.dnd.moddo.domain.outbox.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.outbox.application.command.CommandOutboxEventService; +import com.dnd.moddo.outbox.application.event.OutboxEventCreatedEvent; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; + +@ExtendWith(MockitoExtension.class) +class CommandOutboxEventServiceTest { + + @Mock + private OutboxEventRepository outboxEventRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private CommandOutboxEventService commandOutboxEventService; + + @Test + @DisplayName("아웃박스 이벤트를 저장하고 생성 이벤트를 발행한다.") + void create() { + OutboxEvent outboxEvent = OutboxEvent.pending(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, + 1L); + setOutboxEventId(outboxEvent, 10L); + when(outboxEventRepository.save(any(OutboxEvent.class))).thenReturn(outboxEvent); + + OutboxEvent result = commandOutboxEventService.create( + OutboxEventType.SETTLEMENT_COMPLETED, + AggregateType.SETTLEMENT, + 1L + ); + + assertThat(result).isEqualTo(outboxEvent); + verify(outboxEventRepository).save(any(OutboxEvent.class)); + verify(eventPublisher).publishEvent(new OutboxEventCreatedEvent(10L)); + } + + private void setOutboxEventId(OutboxEvent outboxEvent, Long id) { + try { + java.lang.reflect.Field idField = OutboxEvent.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(outboxEvent, id); + } catch (ReflectiveOperationException exception) { + throw new RuntimeException(exception); + } + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/OutboxEventCreatedEventTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/OutboxEventCreatedEventTest.java new file mode 100644 index 00000000..f134b2be --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/OutboxEventCreatedEventTest.java @@ -0,0 +1,31 @@ +package com.dnd.moddo.domain.outbox.service; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.dnd.moddo.outbox.application.event.OutboxEventCreatedEvent; + +class OutboxEventCreatedEventTest { + + @Test + @DisplayName("양수 outboxEventId로 이벤트를 생성할 수 있다.") + void createEvent() { + OutboxEventCreatedEvent event = new OutboxEventCreatedEvent(1L); + + assertThat(event.outboxEventId()).isEqualTo(1L); + } + + @Test + @DisplayName("0 이하 outboxEventId로 이벤트를 생성하면 예외가 발생한다.") + void throwExceptionWhenOutboxEventIdIsNotPositive() { + assertThatThrownBy(() -> new OutboxEventCreatedEvent(0L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("outboxEventId가 0이상이어야 합니다."); + + assertThatThrownBy(() -> new OutboxEventCreatedEvent(-1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("outboxEventId가 0이상이어야 합니다."); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskCreatorTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskCreatorTest.java new file mode 100644 index 00000000..93c20d6d --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskCreatorTest.java @@ -0,0 +1,60 @@ +package com.dnd.moddo.domain.outbox.service.implementation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.outbox.application.impl.EventTaskCreator; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; +import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; + +@ExtendWith(MockitoExtension.class) +class EventTaskCreatorTest { + + @Mock + private EventTaskRepository eventTaskRepository; + + @InjectMocks + private EventTaskCreator eventTaskCreator; + + @Test + @DisplayName("이벤트 태스크를 생성해서 저장한다.") + void create() { + OutboxEvent outboxEvent = mock(OutboxEvent.class); + EventTask savedTask = mock(EventTask.class); + when(eventTaskRepository.save(any(EventTask.class))).thenReturn(savedTask); + + EventTask result = eventTaskCreator.create(outboxEvent, EventTaskType.REWARD_GRANT, 20L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EventTask.class); + verify(eventTaskRepository).save(captor.capture()); + assertThat(captor.getValue().getOutboxEvent()).isEqualTo(outboxEvent); + assertThat(captor.getValue().getTaskType()).isEqualTo(EventTaskType.REWARD_GRANT); + assertThat(captor.getValue().getTargetUserId()).isEqualTo(20L); + assertThat(result).isEqualTo(savedTask); + } + + @Test + @DisplayName("이벤트 태스크 목록을 배치 저장한다.") + void createAll() { + EventTask first = mock(EventTask.class); + EventTask second = mock(EventTask.class); + when(eventTaskRepository.saveAll(List.of(first, second))).thenReturn(List.of(first, second)); + + List result = eventTaskCreator.createAll(List.of(first, second)); + + verify(eventTaskRepository).saveAll(List.of(first, second)); + assertThat(result).containsExactly(first, second); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskSchedulerTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskSchedulerTest.java new file mode 100644 index 00000000..79e5febd --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskSchedulerTest.java @@ -0,0 +1,50 @@ +package com.dnd.moddo.domain.outbox.service.implementation; + +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.outbox.application.command.CommandEventTaskService; +import com.dnd.moddo.outbox.application.impl.EventTaskRetryPolicy; +import com.dnd.moddo.outbox.application.impl.EventTaskScheduler; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; +import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; + +@ExtendWith(MockitoExtension.class) +class EventTaskSchedulerTest { + + @Mock + private EventTaskRepository eventTaskRepository; + + @Mock + private CommandEventTaskService commandEventTaskService; + + @InjectMocks + private EventTaskScheduler eventTaskScheduler; + + @Test + @DisplayName("스케줄러는 처리 대상 태스크를 최대 30개 조회해 순서대로 처리한다.") + void processPendingTasks() { + EventTask first = mock(EventTask.class); + EventTask second = mock(EventTask.class); + when(first.getId()).thenReturn(1L); + when(second.getId()).thenReturn(2L); + when(eventTaskRepository.findTop30ByStatusInAndAttemptCountLessThanOrderByCreatedAtAsc( + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + EventTaskRetryPolicy.MAX_RETRY_COUNT + )).thenReturn(List.of(first, second)); + + eventTaskScheduler.processPendingTasks(); + + verify(commandEventTaskService).process(1L); + verify(commandEventTaskService).process(2L); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventCreatedEventListenerTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventCreatedEventListenerTest.java new file mode 100644 index 00000000..2efbeee9 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventCreatedEventListenerTest.java @@ -0,0 +1,32 @@ +package com.dnd.moddo.domain.outbox.service.implementation; + +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.outbox.application.event.OutboxEventCreatedEvent; +import com.dnd.moddo.outbox.application.event.OutboxEventCreatedEventListener; +import com.dnd.moddo.outbox.application.impl.OutboxEventPublisher; + +@ExtendWith(MockitoExtension.class) +class OutboxEventCreatedEventListenerTest { + + @Mock + private OutboxEventPublisher outboxEventPublisher; + + @InjectMocks + private OutboxEventCreatedEventListener listener; + + @Test + @DisplayName("아웃박스 생성 이벤트를 받으면 publish를 호출한다.") + void handle() { + listener.handle(new OutboxEventCreatedEvent(1L)); + + verify(outboxEventPublisher).publish(1L); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublishExecutorTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublishExecutorTest.java new file mode 100644 index 00000000..ad44e6f0 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublishExecutorTest.java @@ -0,0 +1,123 @@ +package com.dnd.moddo.domain.outbox.service.implementation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.event.application.impl.MemberReader; +import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.outbox.application.impl.EventTaskCreator; +import com.dnd.moddo.outbox.application.impl.OutboxEventPublishExecutor; +import com.dnd.moddo.outbox.application.impl.OutboxReader; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; +import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; + +@ExtendWith(MockitoExtension.class) +class OutboxEventPublishExecutorTest { + + @Mock + private OutboxReader outboxReader; + + @Mock + private EventTaskCreator eventTaskCreator; + + @Mock + private MemberReader memberReader; + + @Mock + private OutboxEventRepository outboxEventRepository; + + @InjectMocks + private OutboxEventPublishExecutor outboxEventPublishExecutor; + + @Test + @DisplayName("claimProcessing은 선점 update 결과를 boolean으로 반환한다.") + void claimProcessing() { + when(outboxEventRepository.claimProcessing(1L, OutboxEventStatus.PROCESSING, OutboxEventStatus.PENDING)) + .thenReturn(1) + .thenReturn(0); + + boolean claimed = outboxEventPublishExecutor.claimProcessing(1L); + boolean notClaimed = outboxEventPublishExecutor.claimProcessing(1L); + + org.assertj.core.api.Assertions.assertThat(claimed).isTrue(); + org.assertj.core.api.Assertions.assertThat(notClaimed).isFalse(); + } + + @Test + @DisplayName("SETTLEMENT_COMPLETED 이벤트는 구성원별 태스크를 생성한다.") + void appendTasksForSettlementCompleted() { + OutboxEvent outboxEvent = mock(OutboxEvent.class); + Member first = mock(Member.class); + Member second = mock(Member.class); + when(outboxReader.findById(1L)).thenReturn(outboxEvent); + when(outboxEvent.getEventType()).thenReturn(OutboxEventType.SETTLEMENT_COMPLETED); + when(outboxEvent.getAggregateId()).thenReturn(10L); + when(memberReader.findAssignedMembersBySettlementId(10L)).thenReturn(List.of(first, second)); + when(first.getUserId()).thenReturn(20L); + when(second.getUserId()).thenReturn(30L); + + outboxEventPublishExecutor.appendTasks(1L); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(eventTaskCreator).createAll(captor.capture()); + assertThat(captor.getValue()).hasSize(4); + assertThat(captor.getValue()) + .extracting(EventTask::getTaskType, EventTask::getTargetUserId) + .containsExactly( + tuple(EventTaskType.REWARD_GRANT, 20L), + tuple(EventTaskType.NOTIFICATION_SEND, 20L), + tuple(EventTaskType.REWARD_GRANT, 30L), + tuple(EventTaskType.NOTIFICATION_SEND, 30L) + ); + } + + @Test + @DisplayName("할당된 구성원이 없으면 태스크를 추가하지 않는다.") + void skipAppendTasksWhenNoAssignedMembers() { + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(outboxReader.findById(1L)).thenReturn(outboxEvent); + when(outboxEvent.getEventType()).thenReturn(OutboxEventType.SETTLEMENT_COMPLETED); + when(outboxEvent.getAggregateId()).thenReturn(10L); + when(memberReader.findAssignedMembersBySettlementId(10L)).thenReturn(List.of()); + + outboxEventPublishExecutor.appendTasks(1L); + + verify(eventTaskCreator, never()).createAll(anyList()); + } + + @Test + @DisplayName("markPublished는 아웃박스 이벤트를 published 상태로 변경한다.") + void markPublished() { + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(outboxReader.findById(1L)).thenReturn(outboxEvent); + + outboxEventPublishExecutor.markPublished(1L); + + verify(outboxEvent).markPublished(); + } + + @Test + @DisplayName("markFailed는 아웃박스 이벤트를 failed 상태로 변경한다.") + void markFailed() { + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(outboxReader.findById(1L)).thenReturn(outboxEvent); + + outboxEventPublishExecutor.markFailed(1L); + + verify(outboxEvent).markFailed(); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java new file mode 100644 index 00000000..4afba791 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java @@ -0,0 +1,128 @@ +package com.dnd.moddo.domain.outbox.service.implementation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; + +import com.dnd.moddo.outbox.application.impl.OutboxReader; +import com.dnd.moddo.outbox.application.impl.OutboxEventPublishExecutor; +import com.dnd.moddo.outbox.application.impl.OutboxEventPublisher; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; + +@ExtendWith(MockitoExtension.class) +class OutboxEventPublisherTest { + + @Mock + private OutboxReader outboxReader; + + @Mock + private OutboxEventPublishExecutor outboxEventPublishExecutor; + + @InjectMocks + private OutboxEventPublisher outboxEventPublisher; + + @Test + @DisplayName("PENDING 아웃박스 이벤트를 publish하면 태스크를 추가하고 published 상태로 변경한다.") + void publishPendingOutboxEvent() { + when(outboxEventPublishExecutor.claimProcessing(1L)).thenReturn(true); + + outboxEventPublisher.publish(1L); + + verify(outboxEventPublishExecutor).appendTasks(1L); + verify(outboxEventPublishExecutor).markPublished(1L); + verify(outboxEventPublishExecutor, never()).markFailed(1L); + } + + @Test + @DisplayName("태스크 추가 중 예외가 발생하면 failed 상태로 변경한다.") + void markFailedWhenAppendTaskThrowsException() { + when(outboxEventPublishExecutor.claimProcessing(1L)).thenReturn(true); + doThrow(new RuntimeException("append failed")).when(outboxEventPublishExecutor).appendTasks(1L); + + outboxEventPublisher.publish(1L); + + verify(outboxEventPublishExecutor).markFailed(1L); + verify(outboxEventPublishExecutor, never()).markPublished(1L); + } + + @Test + @DisplayName("pending 아웃박스 이벤트 목록을 순서대로 publish한다.") + void publishPendingEvents() { + OutboxEvent first = mock(OutboxEvent.class); + OutboxEvent second = mock(OutboxEvent.class); + Slice pendingEvents = new PageImpl<>(List.of(first, second), PageRequest.of(0, 100), 2); + when(first.getId()).thenReturn(1L); + when(second.getId()).thenReturn(2L); + when(outboxReader.findByStatus(OutboxEventStatus.PENDING, PageRequest.of(0, 100))).thenReturn(pendingEvents); + when(outboxEventPublishExecutor.claimProcessing(1L)).thenReturn(true); + when(outboxEventPublishExecutor.claimProcessing(2L)).thenReturn(true); + + outboxEventPublisher.publishPendingEvents(); + + verify(outboxEventPublishExecutor).appendTasks(1L); + verify(outboxEventPublishExecutor).markPublished(1L); + verify(outboxEventPublishExecutor).appendTasks(2L); + verify(outboxEventPublishExecutor).markPublished(2L); + } + + @Test + @DisplayName("배치 발행 중 한 이벤트가 실패해도 다음 pending 이벤트 처리를 계속한다.") + void continuePublishingPendingEventsWhenOneEventFails() { + OutboxEvent first = mock(OutboxEvent.class); + OutboxEvent second = mock(OutboxEvent.class); + Slice pendingEvents = new PageImpl<>(List.of(first, second), PageRequest.of(0, 100), 2); + when(first.getId()).thenReturn(1L); + when(second.getId()).thenReturn(2L); + when(outboxReader.findByStatus(OutboxEventStatus.PENDING, PageRequest.of(0, 100))).thenReturn(pendingEvents); + when(outboxEventPublishExecutor.claimProcessing(1L)).thenReturn(true); + when(outboxEventPublishExecutor.claimProcessing(2L)).thenReturn(true); + doThrow(new RuntimeException("append failed")).when(outboxEventPublishExecutor).appendTasks(1L); + doThrow(new RuntimeException("mark failed")).when(outboxEventPublishExecutor).markFailed(1L); + + outboxEventPublisher.publishPendingEvents(); + + verify(outboxEventPublishExecutor).appendTasks(1L); + verify(outboxEventPublishExecutor).markFailed(1L); + verify(outboxEventPublishExecutor).appendTasks(2L); + verify(outboxEventPublishExecutor).markPublished(2L); + } + + @Test + @DisplayName("이미 다른 트랜잭션이 선점했으면 publish를 건너뛴다.") + void skipWhenOutboxEventAlreadyProcessed() { + when(outboxEventPublishExecutor.claimProcessing(1L)).thenReturn(false); + + outboxEventPublisher.publish(1L); + + verify(outboxEventPublishExecutor, never()).appendTasks(1L); + verify(outboxEventPublishExecutor, never()).markPublished(1L); + verify(outboxEventPublishExecutor, never()).markFailed(1L); + } + + @Test + @DisplayName("failed 상태 변경에도 실패하면 예외를 다시 던진다.") + void throwWhenMarkFailedThrowsException() { + when(outboxEventPublishExecutor.claimProcessing(1L)).thenReturn(true); + doThrow(new RuntimeException("append failed")).when(outboxEventPublishExecutor).appendTasks(1L); + doThrow(new RuntimeException("mark failed")).when(outboxEventPublishExecutor).markFailed(1L); + + assertThatThrownBy(() -> outboxEventPublisher.publish(1L)) + .isInstanceOf(RuntimeException.class) + .hasMessage("mark failed"); + + verify(outboxEventPublishExecutor).markFailed(1L); + verify(outboxEventPublishExecutor, never()).markPublished(1L); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventSchedulerTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventSchedulerTest.java new file mode 100644 index 00000000..df81df54 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventSchedulerTest.java @@ -0,0 +1,31 @@ +package com.dnd.moddo.domain.outbox.service.implementation; + +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.outbox.application.impl.OutboxEventPublisher; +import com.dnd.moddo.outbox.application.impl.OutboxEventScheduler; + +@ExtendWith(MockitoExtension.class) +class OutboxEventSchedulerTest { + + @Mock + private OutboxEventPublisher outboxEventPublisher; + + @InjectMocks + private OutboxEventScheduler outboxEventScheduler; + + @Test + @DisplayName("스케줄러는 pending 아웃박스 이벤트 발행을 주기적으로 위임한다.") + void publishPendingEvents() { + outboxEventScheduler.publishPendingEvents(); + + verify(outboxEventPublisher).publishPendingEvents(); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxReaderTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxReaderTest.java new file mode 100644 index 00000000..29aeca5b --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxReaderTest.java @@ -0,0 +1,54 @@ +package com.dnd.moddo.domain.outbox.service.implementation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; + +import com.dnd.moddo.outbox.application.impl.OutboxReader; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; +import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; + +@ExtendWith(MockitoExtension.class) +class OutboxReaderTest { + + @Mock + private OutboxEventRepository outboxEventRepository; + + @InjectMocks + private OutboxReader outboxReader; + + @Test + @DisplayName("상태로 아웃박스 이벤트 목록을 배치 조회한다.") + void findByStatus() { + OutboxEvent first = mock(OutboxEvent.class); + OutboxEvent second = mock(OutboxEvent.class); + PageRequest pageRequest = PageRequest.of(0, 100); + Slice events = new PageImpl<>(java.util.List.of(first, second), pageRequest, 2); + when(outboxEventRepository.findByStatusOrderByIdAsc(OutboxEventStatus.PENDING, pageRequest)).thenReturn(events); + + Slice result = outboxReader.findByStatus(OutboxEventStatus.PENDING, pageRequest); + + assertThat(result.getContent()).containsExactly(first, second); + } + + @Test + @DisplayName("아이디로 아웃박스 이벤트를 조회한다.") + void findById() { + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(outboxEventRepository.getById(1L)).thenReturn(outboxEvent); + + OutboxEvent result = outboxReader.findById(1L); + + assertThat(result).isEqualTo(outboxEvent); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/CommandPaymentRequestTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/CommandPaymentRequestTest.java index 15745d65..c9212ff2 100644 --- a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/CommandPaymentRequestTest.java +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/CommandPaymentRequestTest.java @@ -13,6 +13,7 @@ import com.dnd.moddo.event.application.command.CommandPaymentRequest; import com.dnd.moddo.event.application.impl.PaymentRequestCreator; import com.dnd.moddo.event.application.impl.PaymentRequestUpdater; +import com.dnd.moddo.event.application.impl.SettlementCompletionProcessor; import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; import com.dnd.moddo.event.domain.paymentRequest.PaymentRequestStatus; import com.dnd.moddo.event.presentation.response.PaymentRequestResponse; @@ -26,6 +27,9 @@ class CommandPaymentRequestTest { @Mock private PaymentRequestUpdater paymentRequestUpdater; + @Mock + private SettlementCompletionProcessor settlementCompletionProcessor; + @InjectMocks private CommandPaymentRequest commandPaymentRequest; @@ -47,13 +51,14 @@ void createPaymentRequest() { void approvePaymentRequest() { PaymentRequest paymentRequest = mock(PaymentRequest.class); stubPaymentRequest(paymentRequest, PaymentRequestStatus.APPROVED); - when(paymentRequestUpdater.approvePaymentRequest(1L, 2L)).thenReturn(paymentRequest); + when(paymentRequestUpdater.approvePaymentRequest(1L, 2L)).thenReturn(paymentRequest); - PaymentRequestResponse response = commandPaymentRequest.approvePaymentRequest(1L, 2L); + PaymentRequestResponse response = commandPaymentRequest.approvePaymentRequest(1L, 2L); - assertThat(response.id()).isEqualTo(1L); - assertThat(response.status()).isEqualTo(PaymentRequestStatus.APPROVED); - } + assertThat(response.id()).isEqualTo(1L); + assertThat(response.status()).isEqualTo(PaymentRequestStatus.APPROVED); + verify(settlementCompletionProcessor).completeIfAllPaid(2L); + } @Test @DisplayName("입금 확인 요청을 거절할 수 있다.") diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestUpdaterTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestUpdaterTest.java index 6c0e6054..da5b00f4 100644 --- a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestUpdaterTest.java +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestUpdaterTest.java @@ -36,18 +36,18 @@ class PaymentRequestUpdaterTest { void approvePaymentRequestSuccess() { Long paymentRequestId = 1L; Long userId = 100L; - PaymentRequest paymentRequest = mock(PaymentRequest.class); + PaymentRequest paymentRequest = mock(PaymentRequest.class); - when(paymentRequestRepository.getById(paymentRequestId)).thenReturn(paymentRequest); - when(paymentRequest.getRequestMemberId()).thenReturn(10L); + when(paymentRequestRepository.getById(paymentRequestId)).thenReturn(paymentRequest); + when(paymentRequest.getRequestMemberId()).thenReturn(10L); - PaymentRequest result = paymentRequestUpdater.approvePaymentRequest(paymentRequestId, userId); + PaymentRequest result = paymentRequestUpdater.approvePaymentRequest(paymentRequestId, userId); - assertThat(result).isEqualTo(paymentRequest); - verify(paymentRequestValidator).validateProcessRequest(paymentRequest, userId); - verify(memberUpdater).updatePaymentStatus(10L, true); - verify(paymentRequest).approve(); - } + assertThat(result).isEqualTo(paymentRequest); + verify(paymentRequestValidator).validateProcessRequest(paymentRequest, userId); + verify(memberUpdater).updatePaymentStatus(10L, true); + verify(paymentRequest).approve(); + } @Test @DisplayName("입금 확인 요청을 거절할 수 있다.") diff --git a/src/test/java/com/dnd/moddo/domain/reward/controller/RewardAdminControllerTest.java b/src/test/java/com/dnd/moddo/domain/reward/controller/RewardAdminControllerTest.java new file mode 100644 index 00000000..fc1c2439 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/reward/controller/RewardAdminControllerTest.java @@ -0,0 +1,58 @@ +package com.dnd.moddo.domain.reward.controller; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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.ResponseEntity; + +import com.dnd.moddo.auth.model.exception.UserPermissionException; +import com.dnd.moddo.auth.presentation.response.LoginUserInfo; +import com.dnd.moddo.event.application.query.QuerySettlementService; +import com.dnd.moddo.reward.application.RewardService; +import com.dnd.moddo.reward.presentation.RewardAdminController; + +@ExtendWith(MockitoExtension.class) +class RewardAdminControllerTest { + + @Mock + private RewardService rewardService; + + @Mock + private QuerySettlementService querySettlementService; + + @InjectMocks + private RewardAdminController rewardAdminController; + + @Test + @DisplayName("관리자는 수동 보상 지급을 요청할 수 있다.") + void manualGrantByAdmin() { + when(querySettlementService.findIdByCode("group-code")).thenReturn(10L); + + ResponseEntity response = rewardAdminController.manualGrant( + "group-code", + 2L, + new LoginUserInfo(1L, "ADMIN") + ); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + verify(rewardService).manualGrant(10L, 2L); + } + + @Test + @DisplayName("관리자가 아니면 수동 보상 지급을 요청할 수 없다.") + void manualGrantForbiddenWhenNotAdmin() { + assertThatThrownBy(() -> rewardAdminController.manualGrant( + "group-code", + 2L, + new LoginUserInfo(1L, "USER") + )).isInstanceOf(UserPermissionException.class); + + verify(rewardService, never()).manualGrant(anyLong(), anyLong()); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/reward/service/RewardServiceGrantTest.java b/src/test/java/com/dnd/moddo/domain/reward/service/RewardServiceGrantTest.java new file mode 100644 index 00000000..869d6490 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/reward/service/RewardServiceGrantTest.java @@ -0,0 +1,109 @@ +package com.dnd.moddo.domain.reward.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +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.dao.DataIntegrityViolationException; + +import com.dnd.moddo.reward.application.RewardService; +import com.dnd.moddo.reward.domain.character.Character; +import com.dnd.moddo.reward.domain.character.exception.SettlementCharacterNotFoundException; +import com.dnd.moddo.reward.infrastructure.CollectionRepository; +import com.dnd.moddo.reward.infrastructure.RewardQueryRepository; + +@ExtendWith(MockitoExtension.class) +class RewardServiceGrantTest { + + @Mock + private RewardQueryRepository rewardQueryRepository; + + @Mock + private CollectionRepository collectionRepository; + + @InjectMocks + private RewardService rewardService; + + @Test + @DisplayName("정산에 연결된 캐릭터가 없으면 예외가 발생한다.") + void throwExceptionWhenSettlementCharacterNotFound() { + when(rewardQueryRepository.findBySettlementId(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> rewardService.grant(1L, 2L)) + .isInstanceOf(SettlementCharacterNotFoundException.class); + } + + @Test + @DisplayName("이미 같은 캐릭터를 보유 중이면 저장하지 않는다.") + void doNotSaveWhenCharacterAlreadyGranted() { + Character character = Character.builder() + .name("모또") + .rarity(1) + .imageUrl("image") + .imageBigUrl("image-big") + .build(); + setCharacterId(character, 3L); + + when(rewardQueryRepository.findBySettlementId(1L)).thenReturn(Optional.of(character)); + when(collectionRepository.existsByUserIdAndCharacterId(2L, 3L)).thenReturn(true); + + rewardService.grant(1L, 2L); + + verify(collectionRepository, never()).save(any()); + } + + @Test + @DisplayName("중복 지급으로 인한 제약조건 예외는 멱등 성공으로 처리한다.") + void swallowDuplicateGrantException() { + Character character = Character.builder() + .name("모또") + .rarity(1) + .imageUrl("image") + .imageBigUrl("image-big") + .build(); + setCharacterId(character, 3L); + + when(rewardQueryRepository.findBySettlementId(1L)).thenReturn(Optional.of(character)); + when(collectionRepository.existsByUserIdAndCharacterId(2L, 3L)).thenReturn(false); + doThrow(new DataIntegrityViolationException("duplicate")).when(collectionRepository).save(any()); + + assertThatCode(() -> rewardService.grant(1L, 2L)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("보상을 지급할 수 있으면 컬렉션을 저장한다.") + void saveCollectionWhenGrantAvailable() { + Character character = Character.builder() + .name("모또") + .rarity(1) + .imageUrl("image") + .imageBigUrl("image-big") + .build(); + setCharacterId(character, 3L); + + when(rewardQueryRepository.findBySettlementId(1L)).thenReturn(Optional.of(character)); + when(collectionRepository.existsByUserIdAndCharacterId(2L, 3L)).thenReturn(false); + + rewardService.grant(1L, 2L); + + verify(collectionRepository).save(any()); + } + + private void setCharacterId(Character character, Long id) { + try { + java.lang.reflect.Field idField = Character.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(character, id); + } catch (ReflectiveOperationException exception) { + throw new RuntimeException(exception); + } + } +} diff --git a/src/test/java/com/dnd/moddo/domain/reward/service/RewardServiceTest.java b/src/test/java/com/dnd/moddo/domain/reward/service/RewardServiceTest.java new file mode 100644 index 00000000..c90305b1 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/reward/service/RewardServiceTest.java @@ -0,0 +1,38 @@ +package com.dnd.moddo.domain.reward.service; + +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.reward.application.RewardService; +import com.dnd.moddo.reward.infrastructure.CollectionRepository; +import com.dnd.moddo.reward.infrastructure.RewardQueryRepository; + +@ExtendWith(MockitoExtension.class) +class RewardServiceTest { + + @Mock + private RewardQueryRepository rewardQueryRepository; + + @Mock + private CollectionRepository collectionRepository; + + @InjectMocks + private RewardService rewardService; + + @Test + @DisplayName("수동 보상 지급을 위임한다.") + void manualGrant() { + RewardService spyService = spy(rewardService); + doNothing().when(spyService).grant(1L, 2L); + + spyService.manualGrant(1L, 2L); + + verify(spyService).grant(1L, 2L); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/settlement/service/CommandSettlementServiceTest.java b/src/test/java/com/dnd/moddo/domain/settlement/service/CommandSettlementServiceTest.java index 00809e58..dc3eca48 100644 --- a/src/test/java/com/dnd/moddo/domain/settlement/service/CommandSettlementServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/settlement/service/CommandSettlementServiceTest.java @@ -15,12 +15,14 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.dnd.moddo.auth.infrastructure.security.JwtProvider; -import com.dnd.moddo.event.application.command.CommandMemberService; import com.dnd.moddo.event.application.command.CommandSettlementService; +import com.dnd.moddo.event.application.impl.MemberCreator; +import com.dnd.moddo.event.application.impl.MemberReader; import com.dnd.moddo.event.application.impl.SettlementCreator; import com.dnd.moddo.event.application.impl.SettlementReader; import com.dnd.moddo.event.application.impl.SettlementUpdater; import com.dnd.moddo.event.application.impl.SettlementValidator; +import com.dnd.moddo.event.domain.member.Member; import com.dnd.moddo.event.domain.member.ExpenseRole; import com.dnd.moddo.event.domain.settlement.Settlement; import com.dnd.moddo.event.presentation.request.SettlementAccountRequest; @@ -46,7 +48,9 @@ class CommandSettlementServiceTest { @Mock private JwtProvider jwtProvider; @Mock - private CommandMemberService commandMemberService; + private MemberCreator memberCreator; + @Mock + private MemberReader memberReader; @InjectMocks private CommandSettlementService commandSettlementService; @@ -75,9 +79,15 @@ void createSettlement() { "김모또", null, 1L, true, LocalDateTime.now()); - when(settlementCreator.createSettlement(any(SettlementRequest.class), anyLong())).thenReturn(settlement); - when(settlement.getCode()).thenReturn("code"); - when(commandMemberService.createManager(any(), any())).thenReturn(memberResponse); + when(settlementCreator.createSettlement(any(SettlementRequest.class), anyLong())).thenReturn(settlement); + when(settlement.getCode()).thenReturn("code"); + Member manager = Member.builder() + .name("김모또") + .settlement(settlement) + .profileId(0) + .role(ExpenseRole.MANAGER) + .build(); + when(memberCreator.createManagerForSettlement(any(), any())).thenReturn(manager); // When SettlementSaveResponse response = commandSettlementService.createSettlement(settlementRequest, 1L); @@ -85,11 +95,11 @@ void createSettlement() { // Then assertThat(response).isNotNull(); assertThat(response.groupToken()).isEqualTo("code"); - assertThat(response.manager().role()).isEqualTo(ExpenseRole.MANAGER); + assertThat(response.manager().role()).isEqualTo(ExpenseRole.MANAGER); - verify(settlementCreator, times(1)).createSettlement(any(SettlementRequest.class), anyLong()); - verify(commandMemberService, times(1)).createManager(any(), any()); - } + verify(settlementCreator, times(1)).createSettlement(any(SettlementRequest.class), anyLong()); + verify(memberCreator, times(1)).createManagerForSettlement(any(), any()); + } @Test @DisplayName("그룹의 계좌 정보를 업데이트할 수 있다.") diff --git a/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementCompletionProcessorTest.java b/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementCompletionProcessorTest.java new file mode 100644 index 00000000..2000a4a0 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementCompletionProcessorTest.java @@ -0,0 +1,72 @@ +package com.dnd.moddo.domain.settlement.service.implementation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.event.application.impl.MemberReader; +import com.dnd.moddo.event.application.impl.SettlementCompletionProcessor; +import com.dnd.moddo.event.application.impl.SettlementUpdater; +import com.dnd.moddo.outbox.application.command.CommandOutboxEventService; +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; + +@ExtendWith(MockitoExtension.class) +class SettlementCompletionProcessorTest { + + @Mock + private MemberReader memberReader; + + @Mock + private SettlementUpdater settlementUpdater; + + @Mock + private CommandOutboxEventService commandOutboxEventService; + + @InjectMocks + private SettlementCompletionProcessor settlementCompletionProcessor; + + @Test + @DisplayName("미납 멤버가 있으면 정산을 완료하지 않는다.") + void doesNotCompleteWhenUnpaidMemberExists() { + when(memberReader.existsUnpaidMember(1L)).thenReturn(true); + + boolean result = settlementCompletionProcessor.completeIfAllPaid(1L); + + assertThat(result).isFalse(); + verify(settlementUpdater, never()).complete(anyLong()); + verify(commandOutboxEventService, never()).create(any(), any(), anyLong()); + } + + @Test + @DisplayName("정산이 방금 완료되면 아웃박스 이벤트를 생성한다.") + void createsOutboxEventWhenSettlementCompleted() { + when(memberReader.existsUnpaidMember(1L)).thenReturn(false); + when(settlementUpdater.complete(1L)).thenReturn(true); + + boolean result = settlementCompletionProcessor.completeIfAllPaid(1L); + + assertThat(result).isTrue(); + verify(settlementUpdater).complete(1L); + verify(commandOutboxEventService).create(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L); + } + + @Test + @DisplayName("이미 완료된 정산이면 아웃박스 이벤트를 생성하지 않는다.") + void doesNotCreateOutboxEventWhenSettlementAlreadyCompleted() { + when(memberReader.existsUnpaidMember(1L)).thenReturn(false); + when(settlementUpdater.complete(1L)).thenReturn(false); + + boolean result = settlementCompletionProcessor.completeIfAllPaid(1L); + + assertThat(result).isFalse(); + verify(settlementUpdater).complete(1L); + verify(commandOutboxEventService, never()).create(any(), any(), anyLong()); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementUpdaterTest.java b/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementUpdaterTest.java index 3a85f0f4..8f097439 100644 --- a/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementUpdaterTest.java +++ b/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementUpdaterTest.java @@ -55,4 +55,28 @@ void updateAccountNotFoundGroup() { .isInstanceOf(GroupNotFoundException.class); } + @DisplayName("정산이 아직 완료되지 않았다면 완료 처리할 수 있다.") + @Test + void completeSuccess() { + Long settlementId = 1L; + when(settlementRepository.markCompletedIfNotCompleted(settlementId)).thenReturn(1); + + boolean result = settlementUpdater.complete(settlementId); + + assertThat(result).isTrue(); + verify(settlementRepository).markCompletedIfNotCompleted(settlementId); + } + + @DisplayName("이미 완료된 정산이면 다시 완료 처리하지 않는다.") + @Test + void completeAlreadyCompletedSettlement() { + Long settlementId = 1L; + when(settlementRepository.markCompletedIfNotCompleted(settlementId)).thenReturn(0); + + boolean result = settlementUpdater.complete(settlementId); + + assertThat(result).isFalse(); + verify(settlementRepository).markCompletedIfNotCompleted(settlementId); + } + }