diff --git a/src/main/java/com/fowoco/server/approval/application/ApprovalDomainEvents.java b/src/main/java/com/fowoco/server/approval/application/ApprovalDomainEvents.java new file mode 100644 index 00000000..9e70efbe --- /dev/null +++ b/src/main/java/com/fowoco/server/approval/application/ApprovalDomainEvents.java @@ -0,0 +1,54 @@ +package com.fowoco.server.approval.application; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.reliability.domain.DomainEventEnvelope; +import com.fowoco.server.reliability.domain.EventActorType; +import com.fowoco.server.reliability.domain.SafeEventPayload; +import com.fowoco.server.task.domain.Task; +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +final class ApprovalDomainEvents { + + private static final String PAYLOAD_VERSION = "1"; + private static final String AGGREGATE_TYPE = "Task"; + private static final Set APPROVAL_REQUESTED_FIELDS = Set.of( + "task_title", + "task_type" + ); + + private ApprovalDomainEvents() { + } + + static DomainEventEnvelope approvalRequested( + UUID eventId, + Task task, + ActorContext actor, + RequestMetadata metadata, + Instant occurredAt + ) { + return new DomainEventEnvelope( + eventId, + "ApprovalRequested", + PAYLOAD_VERSION, + AGGREGATE_TYPE, + task.taskId(), + task.companyId(), + EventActorType.HR_USER, + actor.actorId(), + metadata.requestId(), + metadata.traceId(), + occurredAt, + SafeEventPayload.of( + APPROVAL_REQUESTED_FIELDS, + Map.of( + "task_title", task.title(), + "task_type", task.taskType() + ) + ) + ); + } +} diff --git a/src/main/java/com/fowoco/server/approval/application/ApprovalService.java b/src/main/java/com/fowoco/server/approval/application/ApprovalService.java index c0ec5193..b65e48bf 100644 --- a/src/main/java/com/fowoco/server/approval/application/ApprovalService.java +++ b/src/main/java/com/fowoco/server/approval/application/ApprovalService.java @@ -19,6 +19,7 @@ import com.fowoco.server.common.error.ApiException; import com.fowoco.server.common.error.ErrorCode; import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.reliability.application.port.DomainEventPublisher; import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.common.web.RequestMetadata; import com.fowoco.server.settings.application.port.CompanySettingsRepository; @@ -58,6 +59,7 @@ public class ApprovalService implements ApprovalControlPort { private final SafeJsonService safeJsonService; private final UuidGenerator uuidGenerator; private final Clock clock; + private final DomainEventPublisher eventPublisher; public ApprovalService( ActorAuthorizer actorAuthorizer, @@ -72,7 +74,8 @@ public ApprovalService( AuditEventRepository auditRepository, SafeJsonService safeJsonService, UuidGenerator uuidGenerator, - Clock clock + Clock clock, + DomainEventPublisher eventPublisher ) { this.actorAuthorizer = actorAuthorizer; this.tenantDatabaseContext = tenantDatabaseContext; @@ -87,6 +90,7 @@ public ApprovalService( this.safeJsonService = safeJsonService; this.uuidGenerator = uuidGenerator; this.clock = clock; + this.eventPublisher = eventPublisher; } @Transactional @@ -144,6 +148,13 @@ public ApprovalResult requestApproval( metadata, now ); + eventPublisher.publish(ApprovalDomainEvents.approvalRequested( + uuidGenerator.generate(), + savedTask, + actor, + metadata, + now + )); return result(savedApproval, savedTask); } diff --git a/src/main/java/com/fowoco/server/company/application/port/CompanyRepository.java b/src/main/java/com/fowoco/server/company/application/port/CompanyRepository.java index 12331773..ad75780d 100644 --- a/src/main/java/com/fowoco/server/company/application/port/CompanyRepository.java +++ b/src/main/java/com/fowoco/server/company/application/port/CompanyRepository.java @@ -1,12 +1,12 @@ package com.fowoco.server.company.application.port; import com.fowoco.server.company.domain.Company; +import java.util.List; import java.util.Optional; import java.util.UUID; public interface CompanyRepository { - Optional findById(UUID companyId); - void insert(Company company); + List findAllIds(); } diff --git a/src/main/java/com/fowoco/server/company/infrastructure/persistence/JpaCompanyRepository.java b/src/main/java/com/fowoco/server/company/infrastructure/persistence/JpaCompanyRepository.java index 78038033..1d179e7b 100644 --- a/src/main/java/com/fowoco/server/company/infrastructure/persistence/JpaCompanyRepository.java +++ b/src/main/java/com/fowoco/server/company/infrastructure/persistence/JpaCompanyRepository.java @@ -3,6 +3,7 @@ import com.fowoco.server.company.application.port.CompanyRepository; import com.fowoco.server.company.domain.Company; import jakarta.persistence.EntityManager; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -30,4 +31,11 @@ public void insert(Company company) { entityManager.persist(CompanyJpaEntity.fromDomain(company)); entityManager.flush(); } + + @Override + public List findAllIds() { + return entityManager + .createQuery("SELECT company.companyId FROM CompanyJpaEntity company", UUID.class) + .getResultList(); + } } diff --git a/src/main/java/com/fowoco/server/notification/application/DueSoonCompanyNotifier.java b/src/main/java/com/fowoco/server/notification/application/DueSoonCompanyNotifier.java new file mode 100644 index 00000000..5417f0b9 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/DueSoonCompanyNotifier.java @@ -0,0 +1,60 @@ +package com.fowoco.server.notification.application; + +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.notification.application.port.NotificationRepository; +import com.fowoco.server.notification.domain.Notification; +import com.fowoco.server.notification.domain.NotificationTargetType; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import java.time.Clock; +import java.time.LocalDate; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class DueSoonCompanyNotifier { + + private final TaskRepository taskRepository; + private final NotificationRepository notificationRepository; + private final TenantDatabaseContext tenantDatabaseContext; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public DueSoonCompanyNotifier( + TaskRepository taskRepository, + NotificationRepository notificationRepository, + TenantDatabaseContext tenantDatabaseContext, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.taskRepository = taskRepository; + this.notificationRepository = notificationRepository; + this.tenantDatabaseContext = tenantDatabaseContext; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void processCompany(UUID companyId, LocalDate today, LocalDate windowEnd) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + taskRepository.findOpenTasksDueBetween(companyId, today, windowEnd) + .forEach(this::createDueSoonNotification); + } + + private void createDueSoonNotification(Task task) { + Notification notification = Notification.create( + uuidGenerator.generate(), + task.companyId(), + task.createdBy(), + NotificationTargetType.TASK, + task.taskId(), + "마감이 임박했습니다: " + task.title(), + clock.instant(), + clock.instant() + ); + notificationRepository.insert(notification); + } +} diff --git a/src/main/java/com/fowoco/server/notification/application/DueSoonNotificationScheduler.java b/src/main/java/com/fowoco/server/notification/application/DueSoonNotificationScheduler.java new file mode 100644 index 00000000..c637a368 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/DueSoonNotificationScheduler.java @@ -0,0 +1,38 @@ +package com.fowoco.server.notification.application; + +import com.fowoco.server.company.application.port.CompanyRepository; +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class DueSoonNotificationScheduler { + + private static final int UPCOMING_DAYS = 7; + + private final CompanyRepository companyRepository; + private final DueSoonCompanyNotifier companyNotifier; + private final Clock clock; + + public DueSoonNotificationScheduler( + CompanyRepository companyRepository, + DueSoonCompanyNotifier companyNotifier, + Clock clock + ) { + this.companyRepository = companyRepository; + this.companyNotifier = companyNotifier; + this.clock = clock; + } + + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") + public void notifyDueSoonTasks() { + LocalDate today = LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul"))); + LocalDate windowEnd = today.plusDays(UPCOMING_DAYS); + + companyRepository.findAllIds().forEach(companyId -> + companyNotifier.processCompany(companyId, today, windowEnd) + ); + } +} diff --git a/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java b/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java new file mode 100644 index 00000000..2f8ce539 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/NotificationEventHandler.java @@ -0,0 +1,143 @@ +package com.fowoco.server.notification.application; + +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.notification.application.port.NotificationRepository; +import com.fowoco.server.notification.domain.Notification; +import com.fowoco.server.notification.domain.NotificationTargetType; +import com.fowoco.server.reliability.application.port.DomainEventHandler; +import com.fowoco.server.reliability.domain.DomainEventEnvelope; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskSource; +import java.time.Clock; +import java.util.Set; +import org.springframework.stereotype.Component; + +@Component +public final class NotificationEventHandler implements DomainEventHandler { + + private static final String HANDLER_NAME = "notificationFromTaskEvents"; + private static final String TASK_CREATED = "TaskCreated"; + private static final String APPROVAL_REQUESTED = "ApprovalRequested"; + private static final String WORKER_RESPONSE_SUBMITTED = "WorkerResponseSubmitted"; + private static final String TASK_NEEDS_INFO = "TaskNeedsInfo"; + private static final Set SUPPORTED_EVENTS = + Set.of(TASK_CREATED, APPROVAL_REQUESTED, WORKER_RESPONSE_SUBMITTED, TASK_NEEDS_INFO); + + private final TaskRepository taskRepository; + private final NotificationRepository notificationRepository; + private final TenantDatabaseContext tenantDatabaseContext; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public NotificationEventHandler( + TaskRepository taskRepository, + NotificationRepository notificationRepository, + TenantDatabaseContext tenantDatabaseContext, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.taskRepository = taskRepository; + this.notificationRepository = notificationRepository; + this.tenantDatabaseContext = tenantDatabaseContext; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Override + public String handlerName() { + return HANDLER_NAME; + } + + @Override + public boolean supports(String eventType) { + return SUPPORTED_EVENTS.contains(eventType); + } + + @Override + public void handle(DomainEventEnvelope event) { + if (TASK_CREATED.equals(event.eventType())) { + handleTaskCreated(event); + } else if (APPROVAL_REQUESTED.equals(event.eventType())) { + handleApprovalRequested(event); + } else if (WORKER_RESPONSE_SUBMITTED.equals(event.eventType())) { + handleWorkerResponseSubmitted(event); + } else if (TASK_NEEDS_INFO.equals(event.eventType())) { + handleTaskNeedsInfo(event); + } + } + + private void handleTaskCreated(DomainEventEnvelope event) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(event.companyId()); + Task task = taskRepository.findByIdAndCompanyId(event.aggregateId(), event.companyId()) + .orElseThrow(() -> new IllegalStateException("task not found for TaskCreated event")); + + if (task.source() != TaskSource.AI_CANDIDATE) { + return; + } + + Notification notification = Notification.create( + uuidGenerator.generate(), + task.companyId(), + event.actorId(), + NotificationTargetType.TASK, + task.taskId(), + "Agent 분석이 완료됐습니다: " + task.title(), + event.occurredAt(), + clock.instant() + ); + notificationRepository.insert(notification); + } + + private void handleApprovalRequested(DomainEventEnvelope event) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(event.companyId()); + Object taskTitle = event.payload().values().get("task_title"); + + Notification notification = Notification.create( + uuidGenerator.generate(), + event.companyId(), + event.actorId(), + NotificationTargetType.TASK, + event.aggregateId(), + "승인 요청이 도착했습니다: " + taskTitle, + event.occurredAt(), + clock.instant() + ); + notificationRepository.insert(notification); + } + + private void handleWorkerResponseSubmitted(DomainEventEnvelope event) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(event.companyId()); + Object taskTitle = event.payload().values().get("task_title"); + + Notification notification = Notification.create( + uuidGenerator.generate(), + event.companyId(), + event.actorId(), + NotificationTargetType.TASK, + event.aggregateId(), + "문서 제출이 완료됐습니다: " + taskTitle, + event.occurredAt(), + clock.instant() + ); + notificationRepository.insert(notification); + } + + private void handleTaskNeedsInfo(DomainEventEnvelope event) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(event.companyId()); + Object taskTitle = event.payload().values().get("task_title"); + + Notification notification = Notification.create( + uuidGenerator.generate(), + event.companyId(), + event.actorId(), + NotificationTargetType.TASK, + event.aggregateId(), + "문서 보완이 필요합니다: " + taskTitle, + event.occurredAt(), + clock.instant() + ); + notificationRepository.insert(notification); + } +} diff --git a/src/main/java/com/fowoco/server/task/application/TaskDomainEvents.java b/src/main/java/com/fowoco/server/task/application/TaskDomainEvents.java index aa5bf74a..0b2b5f0d 100644 --- a/src/main/java/com/fowoco/server/task/application/TaskDomainEvents.java +++ b/src/main/java/com/fowoco/server/task/application/TaskDomainEvents.java @@ -28,6 +28,10 @@ final class TaskDomainEvents { "previous_status", "status" ); + private static final Set TASK_NEEDS_INFO_FIELDS = Set.of( + "task_title", + "task_type" + ); private TaskDomainEvents() { } @@ -85,6 +89,35 @@ static DomainEventEnvelope taskCancelled( ); } + /** + * 필수 체크리스트 항목이 미완료로 바뀌거나 필수 슬롯이 채워지지 않아 + * Task가 NEEDS_INFO로 전이될 때 발행한다. "문서 보완이 필요하다"는 + * 신호를, 이 재평가를 트리거한 HR 본인에게 알림으로 전달하기 위함이다. + */ + static DomainEventEnvelope taskNeedsInfo( + UUID eventId, + Task task, + ActorContext actor, + RequestMetadata metadata, + Instant occurredAt + ) { + return envelope( + eventId, + "TaskNeedsInfo", + task, + actor, + metadata, + occurredAt, + SafeEventPayload.of( + TASK_NEEDS_INFO_FIELDS, + Map.of( + "task_title", task.title(), + "task_type", task.taskType() + ) + ) + ); + } + private static DomainEventEnvelope envelope( UUID eventId, String eventType, diff --git a/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java index 30824324..96e7bd49 100644 --- a/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java +++ b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java @@ -386,6 +386,9 @@ public TaskResult updateChecklistItem( now, metadata ); + eventPublisher.publish(TaskDomainEvents.taskNeedsInfo( + uuidGenerator.generate(), savedTask, actor, metadata, now + )); } recordTransitionIfChanged( savedTask, diff --git a/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java b/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java index 18bb6033..34c6b937 100644 --- a/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java +++ b/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java @@ -17,6 +17,7 @@ public interface TaskRepository { long countByCompanyIdAndStatus(UUID companyId, TaskStatus status); long countOpenTasksDueOn(UUID companyId, LocalDate dueDate); long countOpenTasksByCompanyId(UUID companyId); + List findOpenTasksDueBetween(UUID companyId, LocalDate from, LocalDate to); Task save(Task task); record TaskSearchCriteria( diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java index 3c78bec1..7c906b04 100644 --- a/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java @@ -78,6 +78,13 @@ public long countOpenTasksByCompanyId(UUID companyId) { return repository.countOpenTasksByCompanyId(companyId); } + @Override + public List findOpenTasksDueBetween(UUID companyId, LocalDate from, LocalDate to) { + return repository.findOpenTasksDueBetween(companyId, from, to).stream() + .map(TaskJpaEntity::toDomain) + .toList(); + } + @Override public Task save(Task task) { TaskJpaEntity entity = repository.findByTaskIdAndCompanyId(task.taskId(), task.companyId()) diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java index e4851abd..fedb3df9 100644 --- a/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java @@ -82,4 +82,19 @@ SELECT COUNT(task) AND task.status <> com.fowoco.server.task.domain.TaskStatus.CANCELLED """) long countOpenTasksByCompanyId(@Param("companyId") UUID companyId); + + @Query(""" + SELECT task + FROM TaskJpaEntity task + WHERE task.companyId = :companyId + AND task.dueDate BETWEEN :fromDate AND :toDate + AND task.status <> com.fowoco.server.task.domain.TaskStatus.COMPLETED + AND task.status <> com.fowoco.server.task.domain.TaskStatus.CANCELLED + ORDER BY task.dueDate ASC + """) + java.util.List findOpenTasksDueBetween( + @Param("companyId") UUID companyId, + @Param("fromDate") LocalDate fromDate, + @Param("toDate") LocalDate toDate + ); } diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDomainEvents.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDomainEvents.java new file mode 100644 index 00000000..762d67c1 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDomainEvents.java @@ -0,0 +1,52 @@ +package com.fowoco.server.workerlink.application; + +import com.fowoco.server.reliability.domain.DomainEventEnvelope; +import com.fowoco.server.reliability.domain.EventActorType; +import com.fowoco.server.reliability.domain.SafeEventPayload; +import com.fowoco.server.task.domain.Task; +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +final class WorkerResponseDomainEvents { + + private static final String PAYLOAD_VERSION = "1"; + private static final String AGGREGATE_TYPE = "Task"; + private static final Set RESPONSE_SUBMITTED_FIELDS = Set.of( + "task_title", + "task_type" + ); + + private WorkerResponseDomainEvents() { + } + + static DomainEventEnvelope responseSubmitted( + UUID eventId, + UUID responseId, + Task task, + UUID companyId, + Instant occurredAt + ) { + return new DomainEventEnvelope( + eventId, + "WorkerResponseSubmitted", + PAYLOAD_VERSION, + AGGREGATE_TYPE, + task.taskId(), + companyId, + EventActorType.WORKER_LINK, + task.createdBy(), + responseId.toString(), + null, + occurredAt, + SafeEventPayload.of( + RESPONSE_SUBMITTED_FIELDS, + Map.of( + "task_title", task.title(), + "task_type", task.taskType() + ) + ) + ); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java index c26f251c..0ce84f7e 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java @@ -43,6 +43,8 @@ public class WorkerResponseService { private final AuditEventRepository auditRepository; private final UuidGenerator uuidGenerator; private final Clock clock; + private final com.fowoco.server.task.application.port.TaskRepository taskRepository; + private final com.fowoco.server.reliability.application.port.DomainEventPublisher eventPublisher; public WorkerResponseService( WorkerLinkTenantBootstrap workerLinkTenantBootstrap, @@ -53,7 +55,9 @@ public WorkerResponseService( StoredFileRepository storedFileRepository, AuditEventRepository auditRepository, UuidGenerator uuidGenerator, - Clock clock + Clock clock, + com.fowoco.server.task.application.port.TaskRepository taskRepository, + com.fowoco.server.reliability.application.port.DomainEventPublisher eventPublisher ) { this.workerLinkTenantBootstrap = workerLinkTenantBootstrap; this.tenantDatabaseContext = tenantDatabaseContext; @@ -64,6 +68,8 @@ public WorkerResponseService( this.auditRepository = auditRepository; this.uuidGenerator = uuidGenerator; this.clock = clock; + this.taskRepository = taskRepository; + this.eventPublisher = eventPublisher; } @Transactional @@ -142,10 +148,22 @@ public WorkerResponseSubmitResult submit(WorkerResponseSubmitCommand command, Re "근로자 응답 제출: " + command.responseType(), now )); - + if (command.responseType() == WorkerResponseType.DOCUMENT_SUBMITTED) { + publishResponseSubmittedEvent(link, responseId, companyId, now); + } return new WorkerResponseSubmitResult(responseId, now); } + private void publishResponseSubmittedEvent( + WorkerLink link, UUID responseId, UUID companyId, Instant now + ) { + taskRepository.findByIdAndCompanyId(link.taskId(), companyId).ifPresent(task -> + eventPublisher.publish(WorkerResponseDomainEvents.responseSubmitted( + uuidGenerator.generate(), responseId, task, companyId, now + )) + ); + } + private boolean requiresHrReview(WorkerResponseType responseType) { return responseType != WorkerResponseType.ACKNOWLEDGED; } diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java index 69d101a1..eaa904d5 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java @@ -17,6 +17,7 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.boot.DefaultApplicationArguments; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -254,6 +255,11 @@ public void insert(Company company) { throw new IllegalStateException("duplicate company"); } } + + @Override + public List findAllIds() { + return List.copyOf(companies.keySet()); + } } private static final class InMemoryUserAccountRepository implements UserAccountRepository { diff --git a/src/test/java/com/fowoco/server/dashboard/application/DashboardQueryServiceTest.java b/src/test/java/com/fowoco/server/dashboard/application/DashboardQueryServiceTest.java index c6fa5d39..ea1a46b2 100644 --- a/src/test/java/com/fowoco/server/dashboard/application/DashboardQueryServiceTest.java +++ b/src/test/java/com/fowoco/server/dashboard/application/DashboardQueryServiceTest.java @@ -88,6 +88,11 @@ public long countOpenTasksDueOn(UUID companyId, LocalDate dueDate) { public long countOpenTasksByCompanyId(UUID companyId) { return 0; } + + @Override + public List findOpenTasksDueBetween(UUID companyId, LocalDate from, LocalDate to) { + return List.of(); + } } private static final class NoopWorkerRepository implements WorkerRepository { diff --git a/src/test/java/com/fowoco/server/notification/DueSoonNotificationSchedulerIntegrationTest.java b/src/test/java/com/fowoco/server/notification/DueSoonNotificationSchedulerIntegrationTest.java new file mode 100644 index 00000000..aadd8859 --- /dev/null +++ b/src/test/java/com/fowoco/server/notification/DueSoonNotificationSchedulerIntegrationTest.java @@ -0,0 +1,106 @@ +package com.fowoco.server.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.notification.application.DueSoonNotificationScheduler; +import java.time.LocalDate; +import java.util.UUID; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DueSoonNotificationSchedulerIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("56000000-0000-0000-0000-000000000001"); + private static final UUID WORKER_A = UUID.fromString("57000000-0000-0000-0000-000000000001"); + private static final UUID CREATOR_ID = UUID.fromString("58000000-0000-0000-0000-000000000001"); + private static final UUID CASE_ID = UUID.fromString("59000000-0000-0000-0000-000000000001"); + @Autowired + private DueSoonNotificationScheduler scheduler; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeAll + void seedCompanyAndWorker() { + jdbcTemplate.update( + """ + INSERT INTO company (company_id, name, status, created_at, updated_at, version) + VALUES (?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + COMPANY_A, "마감임박스케줄러테스트사업장" + ); + jdbcTemplate.update( + """ + INSERT INTO worker (worker_id, company_id, display_name, work_status) + VALUES (?, ?, ?, 'ACTIVE') + """, + WORKER_A, COMPANY_A, "테스트근로자" + ); + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, + role, status, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, 'x', 'HR', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + CREATOR_ID, COMPANY_A, "duesoon.creator@example.com", "duesoon.creator@example.com" + ); + } + + @BeforeEach + void resetTaskAndNotification() { + jdbcTemplate.update("DELETE FROM notification"); + jdbcTemplate.update("DELETE FROM task_checklist_item"); + jdbcTemplate.update("DELETE FROM task"); + } + + @Test + void notifyDueSoonTasksRunsWithoutTransactionErrorAndCreatesNotification() { + UUID taskId = UUID.randomUUID(); + jdbcTemplate.update( + """ + INSERT INTO task ( + task_id, company_id, target_type, worker_id, case_id, task_type, + workflow_id, workflow_catalog_version, title, description, + business_data_json, critical_fingerprint, source, status, + due_date, content_revision, version, created_by, updated_by, + created_at, updated_at + ) VALUES (?, ?, 'WORKER', ?, ?, 'RECONTRACT', 'WF-CON-001', '0.2.0', + '마감임박테스트업무', '설명', '{}', ?, 'MANUAL', 'DRAFT', + ?, 0, 0, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + taskId, COMPANY_A, WORKER_A, CASE_ID, "a".repeat(64), + java.sql.Date.valueOf(LocalDate.now().plusDays(3)), + CREATOR_ID, CREATOR_ID + ); + + scheduler.notifyDueSoonTasks(); + + Long notificationCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM notification WHERE company_id = ?", Long.class, COMPANY_A + ); + assertThat(notificationCount).isEqualTo(1L); + + String title = jdbcTemplate.queryForObject( + "SELECT title FROM notification WHERE company_id = ?", String.class, COMPANY_A + ); + UUID notifiedUserId = jdbcTemplate.queryForObject( + "SELECT user_id FROM notification WHERE company_id = ?", UUID.class, COMPANY_A + ); + System.out.println("=== DueSoonNotificationScheduler 실제 생성 결과 ==="); + System.out.println("title: " + title); + System.out.println("user_id (Task 생성자): " + notifiedUserId); + System.out.println("expected creator_id : " + CREATOR_ID); + assertThat(title).contains("마감이 임박했습니다"); + assertThat(notifiedUserId).isEqualTo(CREATOR_ID); + } +} diff --git a/src/test/java/com/fowoco/server/notification/NotificationEventHandlerIntegrationTest.java b/src/test/java/com/fowoco/server/notification/NotificationEventHandlerIntegrationTest.java new file mode 100644 index 00000000..1ff57008 --- /dev/null +++ b/src/test/java/com/fowoco/server/notification/NotificationEventHandlerIntegrationTest.java @@ -0,0 +1,171 @@ +package com.fowoco.server.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import com.fowoco.server.reliability.application.OutboxProcessor; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.UUID; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class NotificationEventHandlerIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("54000000-0000-0000-0000-000000000001"); + private static final UUID HR_A = UUID.fromString("55000000-0000-0000-0000-000000000001"); + private static final String HR_A_EMAIL = "hr.notifgen.a@example.com"; + private static final String PASSWORD = "Test-password-1!"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private OutboxProcessor outboxProcessor; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeAll + void seedCompanyAndUser() { + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM notification"); + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + jdbcTemplate.update("DELETE FROM audit_event"); + jdbcTemplate.update("DELETE FROM approval_request"); + jdbcTemplate.update("DELETE FROM task_transition_history"); + jdbcTemplate.update("DELETE FROM task_checklist_item"); + jdbcTemplate.update("DELETE FROM task"); + jdbcTemplate.update("DELETE FROM worker_document"); + jdbcTemplate.update("DELETE FROM worker"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + insertCompany(COMPANY_A, "알림생성 사업장 A"); + String passwordHash = passwordEncoder.encode(PASSWORD); + insertUser(HR_A, COMPANY_A, HR_A_EMAIL, passwordHash); + } + + @BeforeEach + void resetState() { + jdbcTemplate.update("DELETE FROM notification"); + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + jdbcTemplate.update("DELETE FROM task_checklist_item"); + jdbcTemplate.update("DELETE FROM task"); + jdbcTemplate.update("DELETE FROM worker"); + } + + @Test + void manualTaskCreationDoesNotCreateNotification() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(accessToken, "일반업무근로자"); + createManualTask(accessToken, workerId); + + assertThat(outboxProcessor.processAvailable()).isEqualTo(1); + + HttpResponse response = authorizedGet("/api/v1/notifications", accessToken); + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items")).isEmpty(); + } + + private String registerWorker(String accessToken, String displayName) throws Exception { + String body = """ + {"display_name": "%s"} + """.formatted(displayName); + HttpResponse response = postJson("/api/v1/workers", body, accessToken); + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(201); + return JsonPath.read(response.body(), "$.worker_id"); + } + + private void createManualTask(String accessToken, String workerId) throws Exception { + String body = """ + { + "worker_id":"%s", + "task_type":"RECONTRACT", + "workflow_id":"WF-CON-001", + "title":"수동 재계약 업무", + "description":"테스트용", + "due_date":"2026-08-20", + "business_data":{"monthly_wage":2500000} + } + """.formatted(workerId); + HttpResponse response = postJson("/api/v1/tasks", body, accessToken); + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(201); + } + + private void insertCompany(UUID companyId, String name) { + jdbcTemplate.update( + """ + INSERT INTO company (company_id, name, status, created_at, updated_at, version) + VALUES (?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + companyId, name + ); + } + + private void insertUser(UUID userId, UUID companyId, String email, String passwordHash) { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, + role, status, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, 'HR', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + userId, companyId, email, email, passwordHash + ); + } + + private HttpResponse login(String email) throws Exception { + String body = """ + {"email":"%s","password":"%s"} + """.formatted(email, PASSWORD); + return postJson("/api/v1/auth/login", body, null); + } + + private String accessToken(HttpResponse loginResponse) { + assertThat(loginResponse.statusCode()).isEqualTo(200); + return JsonPath.read(loginResponse.body(), "$.access_token"); + } + + private HttpResponse authorizedGet(String path, String accessToken) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .GET() + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse postJson(String path, String body, String accessToken) throws Exception { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)); + if (accessToken != null) { + requestBuilder.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken); + } + return httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } +} diff --git a/src/test/java/com/fowoco/server/notification/application/DueSoonCompanyNotifierTest.java b/src/test/java/com/fowoco/server/notification/application/DueSoonCompanyNotifierTest.java new file mode 100644 index 00000000..083156f3 --- /dev/null +++ b/src/test/java/com/fowoco/server/notification/application/DueSoonCompanyNotifierTest.java @@ -0,0 +1,97 @@ +package com.fowoco.server.notification.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.notification.application.port.NotificationRepository; +import com.fowoco.server.notification.domain.Notification; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskSource; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class DueSoonCompanyNotifierTest { + + private static final UUID COMPANY_A = UUID.fromString("50000000-0000-0000-0000-000000000001"); + private static final UUID CREATOR_ID = UUID.fromString("51000000-0000-0000-0000-000000000001"); + private static final UUID NEW_ID = UUID.fromString("99000000-0000-0000-0000-000000000001"); + private static final Instant NOW = Instant.parse("2026-08-10T03:00:00Z"); + + private final TaskRepository taskRepository = mock(TaskRepository.class); + private final NotificationRepository notificationRepository = mock(NotificationRepository.class); + private final TenantDatabaseContext tenantDatabaseContext = mock(TenantDatabaseContext.class); + private final UuidGenerator uuidGenerator = mock(UuidGenerator.class); + private final Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + + private final DueSoonCompanyNotifier notifier = new DueSoonCompanyNotifier( + taskRepository, notificationRepository, tenantDatabaseContext, uuidGenerator, clock + ); + + @Test + void createsNotificationForEachDueSoonTask() { + Task taskA = dueSoonTask(); + Task taskB = dueSoonTask(); + when(taskRepository.findOpenTasksDueBetween( + org.mockito.ArgumentMatchers.eq(COMPANY_A), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any() + )).thenReturn(List.of(taskA, taskB)); + when(uuidGenerator.generate()).thenReturn(NEW_ID); + + notifier.processCompany(COMPANY_A, LocalDate.of(2026, 8, 10), LocalDate.of(2026, 8, 17)); + + verify(notificationRepository, times(2)).insert(org.mockito.ArgumentMatchers.any(Notification.class)); + } + + @Test + void notificationGoesToTaskCreator() { + Task task = dueSoonTask(); + when(taskRepository.findOpenTasksDueBetween( + org.mockito.ArgumentMatchers.eq(COMPANY_A), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any() + )).thenReturn(List.of(task)); + when(uuidGenerator.generate()).thenReturn(NEW_ID); + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + + notifier.processCompany(COMPANY_A, LocalDate.of(2026, 8, 10), LocalDate.of(2026, 8, 17)); + + verify(notificationRepository).insert(captor.capture()); + Notification notification = captor.getValue(); + assertThat(notification.userId()).isEqualTo(CREATOR_ID); + assertThat(notification.title()).contains("마감이 임박했습니다"); + } + + @Test + void setsTenantContextForTheGivenCompany() { + when(taskRepository.findOpenTasksDueBetween( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any() + )).thenReturn(List.of()); + + notifier.processCompany(COMPANY_A, LocalDate.of(2026, 8, 10), LocalDate.of(2026, 8, 17)); + + verify(tenantDatabaseContext).setCompanyIdForCurrentTransaction(COMPANY_A); + } + + private Task dueSoonTask() { + return Task.create( + UUID.randomUUID(), COMPANY_A, UUID.randomUUID(), UUID.randomUUID(), + TaskType.RECONTRACT, "WF-CON-001", "0.2.0", + "재계약 준비", "설명", "{}", "a".repeat(64), + TaskSource.MANUAL, TaskStatus.DRAFT, LocalDate.of(2026, 8, 14), + CREATOR_ID, NOW + ); + } +} diff --git a/src/test/java/com/fowoco/server/notification/application/DueSoonNotificationSchedulerTest.java b/src/test/java/com/fowoco/server/notification/application/DueSoonNotificationSchedulerTest.java new file mode 100644 index 00000000..0061a2d2 --- /dev/null +++ b/src/test/java/com/fowoco/server/notification/application/DueSoonNotificationSchedulerTest.java @@ -0,0 +1,65 @@ +package com.fowoco.server.notification.application; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fowoco.server.company.application.port.CompanyRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class DueSoonNotificationSchedulerTest { + + private static final UUID COMPANY_A = UUID.fromString("50000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("50000000-0000-0000-0000-000000000002"); + // KST 기준 2026-08-10 정오 (경계값 문제를 피하기 위해 자정 근처는 피함) + private static final Instant NOW = Instant.parse("2026-08-10T03:00:00Z"); + + private final CompanyRepository companyRepository = mock(CompanyRepository.class); + private final DueSoonCompanyNotifier companyNotifier = mock(DueSoonCompanyNotifier.class); + private final Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + + private final DueSoonNotificationScheduler scheduler = new DueSoonNotificationScheduler( + companyRepository, companyNotifier, clock + ); + + @Test + void processesEachCompanySeparately() { + when(companyRepository.findAllIds()).thenReturn(List.of(COMPANY_A, COMPANY_B)); + + scheduler.notifyDueSoonTasks(); + + verify(companyNotifier, times(1)).processCompany( + org.mockito.ArgumentMatchers.eq(COMPANY_A), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any() + ); + verify(companyNotifier, times(1)).processCompany( + org.mockito.ArgumentMatchers.eq(COMPANY_B), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any() + ); + } + + @Test + void usesKoreaDateNotUtcDateForWindow() { + when(companyRepository.findAllIds()).thenReturn(List.of(COMPANY_A)); + ArgumentCaptor fromCaptor = ArgumentCaptor.forClass(LocalDate.class); + ArgumentCaptor toCaptor = ArgumentCaptor.forClass(LocalDate.class); + + scheduler.notifyDueSoonTasks(); + + verify(companyNotifier).processCompany( + org.mockito.ArgumentMatchers.eq(COMPANY_A), fromCaptor.capture(), toCaptor.capture() + ); + // NOW = 2026-08-10T03:00:00Z = KST 2026-08-10 정오이므로, + // from은 오늘(8/10), to는 7일 후(8/17)여야 한다. + org.assertj.core.api.Assertions.assertThat(fromCaptor.getValue()).isEqualTo(LocalDate.of(2026, 8, 10)); + org.assertj.core.api.Assertions.assertThat(toCaptor.getValue()).isEqualTo(LocalDate.of(2026, 8, 17)); + } +} diff --git a/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java b/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java new file mode 100644 index 00000000..05e4e1d4 --- /dev/null +++ b/src/test/java/com/fowoco/server/notification/application/NotificationEventHandlerTest.java @@ -0,0 +1,216 @@ +package com.fowoco.server.notification.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.notification.application.port.NotificationRepository; +import com.fowoco.server.notification.domain.Notification; +import com.fowoco.server.reliability.domain.DomainEventEnvelope; +import com.fowoco.server.reliability.domain.EventActorType; +import com.fowoco.server.reliability.domain.SafeEventPayload; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskSource; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import java.time.Clock; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class NotificationEventHandlerTest { + + private static final UUID COMPANY_ID = UUID.fromString("50000000-0000-0000-0000-000000000001"); + private static final UUID TASK_ID = UUID.fromString("94000000-0000-0000-0000-000000000001"); + private static final UUID ACTOR_ID = UUID.fromString("51000000-0000-0000-0000-000000000001"); + private static final UUID NEW_ID = UUID.fromString("99000000-0000-0000-0000-000000000001"); + private static final Instant NOW = Instant.parse("2026-08-09T00:00:00Z"); + + private final TaskRepository taskRepository = mock(TaskRepository.class); + private final NotificationRepository notificationRepository = mock(NotificationRepository.class); + private final TenantDatabaseContext tenantDatabaseContext = mock(TenantDatabaseContext.class); + private final UuidGenerator uuidGenerator = mock(UuidGenerator.class); + private final Clock clock = Clock.fixed(NOW, java.time.ZoneOffset.UTC); + + private final NotificationEventHandler handler = new NotificationEventHandler( + taskRepository, notificationRepository, tenantDatabaseContext, uuidGenerator, clock + ); + + @Test + void supportsTaskCreatedAndApprovalRequested() { + assertThat(handler.supports("TaskCreated")).isTrue(); + assertThat(handler.supports("ApprovalRequested")).isTrue(); + assertThat(handler.supports("WorkerResponseSubmitted")).isTrue(); + assertThat(handler.supports("TaskNeedsInfo")).isTrue(); + assertThat(handler.supports("TaskCancelled")).isFalse(); + assertThat(handler.supports("SomethingElse")).isFalse(); + } + + @Test + void createsNotificationForAiCandidateTask() { + Task task = aiCandidateTask(); + when(taskRepository.findByIdAndCompanyId(TASK_ID, COMPANY_ID)).thenReturn(Optional.of(task)); + when(uuidGenerator.generate()).thenReturn(NEW_ID); + + handler.handle(taskCreatedEvent()); + + verify(notificationRepository).insert(org.mockito.ArgumentMatchers.any(Notification.class)); + } + + @Test + void doesNotCreateNotificationForManualTask() { + Task task = manualTask(); + when(taskRepository.findByIdAndCompanyId(TASK_ID, COMPANY_ID)).thenReturn(Optional.of(task)); + + handler.handle(taskCreatedEvent()); + + verify(notificationRepository, never()).insert(org.mockito.ArgumentMatchers.any(Notification.class)); + } + + @Test + void createsNotificationForApprovalRequestedAndSendsToRequester() { + when(uuidGenerator.generate()).thenReturn(NEW_ID); + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + + handler.handle(approvalRequestedEvent()); + + verify(notificationRepository).insert(captor.capture()); + Notification notification = captor.getValue(); + assertThat(notification.userId()).isEqualTo(ACTOR_ID); + assertThat(notification.title()).contains("재계약 준비"); + } + + @Test + void createsNotificationForWorkerResponseSubmitted() { + when(uuidGenerator.generate()).thenReturn(NEW_ID); + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + + handler.handle(workerResponseSubmittedEvent()); + + verify(notificationRepository).insert(captor.capture()); + Notification notification = captor.getValue(); + assertThat(notification.userId()).isEqualTo(ACTOR_ID); + assertThat(notification.title()).contains("문서 제출이 완료됐습니다"); + assertThat(notification.title()).contains("재계약 준비"); + } + + @Test + void createsNotificationForTaskNeedsInfo() { + when(uuidGenerator.generate()).thenReturn(NEW_ID); + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + + handler.handle(taskNeedsInfoEvent()); + + verify(notificationRepository).insert(captor.capture()); + Notification notification = captor.getValue(); + assertThat(notification.userId()).isEqualTo(ACTOR_ID); + assertThat(notification.title()).contains("문서 보완이 필요합니다"); + assertThat(notification.title()).contains("재계약 준비"); + } + + private DomainEventEnvelope taskCreatedEvent() { + return new DomainEventEnvelope( + UUID.randomUUID(), + "TaskCreated", + "1", + "Task", + TASK_ID, + COMPANY_ID, + EventActorType.HR_USER, + UUID.randomUUID(), + "req-1", + "12345678901234567890123456789012", + NOW, + SafeEventPayload.of(Set.of("source"), Map.of("source", TaskSource.AI_CANDIDATE)) + ); + } + + private DomainEventEnvelope approvalRequestedEvent() { + return new DomainEventEnvelope( + UUID.randomUUID(), + "ApprovalRequested", + "1", + "Task", + TASK_ID, + COMPANY_ID, + EventActorType.HR_USER, + ACTOR_ID, + "req-2", + "12345678901234567890123456789012", + NOW, + SafeEventPayload.of( + Set.of("task_title", "task_type"), + Map.of("task_title", "재계약 준비", "task_type", TaskType.RECONTRACT) + ) + ); + } + + private DomainEventEnvelope workerResponseSubmittedEvent() { + return new DomainEventEnvelope( + UUID.randomUUID(), + "WorkerResponseSubmitted", + "1", + "Task", + TASK_ID, + COMPANY_ID, + EventActorType.WORKER_LINK, + ACTOR_ID, + "worker-response-submit", + "12345678901234567890123456789012", + NOW, + SafeEventPayload.of( + Set.of("task_title", "task_type"), + Map.of("task_title", "재계약 준비", "task_type", TaskType.RECONTRACT) + ) + ); + } + + private DomainEventEnvelope taskNeedsInfoEvent() { + return new DomainEventEnvelope( + UUID.randomUUID(), + "TaskNeedsInfo", + "1", + "Task", + TASK_ID, + COMPANY_ID, + EventActorType.HR_USER, + ACTOR_ID, + "req-3", + "12345678901234567890123456789012", + NOW, + SafeEventPayload.of( + Set.of("task_title", "task_type"), + Map.of("task_title", "재계약 준비", "task_type", TaskType.RECONTRACT) + ) + ); + } + + private Task aiCandidateTask() { + return Task.create( + TASK_ID, COMPANY_ID, UUID.randomUUID(), UUID.randomUUID(), + TaskType.RECONTRACT, "WF-CON-001", "0.2.0", + "AI 추천: 재계약 준비", "설명", "{}", "a".repeat(64), + TaskSource.AI_CANDIDATE, TaskStatus.DRAFT, null, + UUID.randomUUID(), NOW + ); + } + + private Task manualTask() { + return Task.create( + TASK_ID, COMPANY_ID, UUID.randomUUID(), UUID.randomUUID(), + TaskType.RECONTRACT, "WF-CON-001", "0.2.0", + "수동 재계약", "설명", "{}", "a".repeat(64), + TaskSource.MANUAL, TaskStatus.DRAFT, null, + UUID.randomUUID(), NOW + ); + } +} diff --git a/src/test/java/com/fowoco/server/task/RenewalExecutionIntegrationTest.java b/src/test/java/com/fowoco/server/task/RenewalExecutionIntegrationTest.java index 13fc840a..7a83c72c 100644 --- a/src/test/java/com/fowoco/server/task/RenewalExecutionIntegrationTest.java +++ b/src/test/java/com/fowoco/server/task/RenewalExecutionIntegrationTest.java @@ -99,6 +99,8 @@ void resetAndSeed() { )); capturedRequest.set(null); jdbcTemplate.update("DELETE FROM document_request_draft"); + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); jdbcTemplate.update("DELETE FROM audit_event"); jdbcTemplate.update("DELETE FROM approval_request"); jdbcTemplate.update("DELETE FROM task_transition_history"); diff --git a/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java index 07758f64..ea92a720 100644 --- a/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java @@ -153,6 +153,11 @@ public void insert(Company company) { throw new IllegalStateException("duplicate company"); } } + + @Override + public List findAllIds() { + return List.copyOf(companies.keySet()); + } } private static final class InMemoryWorkerRepository implements WorkerRepository {