diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java index 12567a60..07ac19ea 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java @@ -3,10 +3,94 @@ import java.util.List; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; import site.icebang.domain.schedule.model.Schedule; +/** + * Schedule 데이터베이스 접근을 위한 MyBatis Mapper 인터페이스 + * + *

워크플로우의 스케줄 정보를 관리하며, 한 워크플로우에 여러 스케줄을 등록할 수 있지만 같은 크론식의 중복 등록은 방지합니다. + * + * @author bwnfo0702@gmail.com + * @since v0.1.0 + */ @Mapper public interface ScheduleMapper { + + /** + * 활성화된 모든 스케줄 조회 + * + * @return 활성 상태인 스케줄 목록 + */ List findAllActive(); + + /** + * 새로운 스케줄 등록 + * + * @param schedule 등록할 스케줄 정보 + * @return 영향받은 행 수 (1: 성공, 0: 실패) + */ + int insertSchedule(Schedule schedule); + + /** + * 특정 워크플로우의 모든 활성 스케줄 조회 + * + * @param workflowId 조회할 워크플로우 ID + * @return 해당 워크플로우의 활성 스케줄 목록 + */ + List findAllByWorkflowId(@Param("workflowId") Long workflowId); + + /** + * 특정 워크플로우에서 특정 크론식이 이미 존재하는지 확인 + * + * @param workflowId 워크플로우 ID + * @param cronExpression 확인할 크론 표현식 + * @return 중복 여부 (true: 이미 존재함, false: 존재하지 않음) + */ + boolean existsByWorkflowIdAndCronExpression( + @Param("workflowId") Long workflowId, @Param("cronExpression") String cronExpression); + + /** + * 특정 워크플로우의 특정 크론식을 가진 스케줄 조회 + * + * @param workflowId 워크플로우 ID + * @param cronExpression 크론 표현식 + * @return 해당하는 스케줄 (없으면 null) + */ + Schedule findByWorkflowIdAndCronExpression( + @Param("workflowId") Long workflowId, @Param("cronExpression") String cronExpression); + + /** + * 스케줄 활성화 상태 변경 + * + * @param id 스케줄 ID + * @param isActive 활성화 여부 + * @return 영향받은 행 수 + */ + int updateActiveStatus(@Param("id") Long id, @Param("isActive") boolean isActive); + + /** + * 스케줄 정보 수정 (크론식, 설명 등) + * + * @param schedule 수정할 스케줄 정보 + * @return 영향받은 행 수 + */ + int updateSchedule(Schedule schedule); + + /** + * 스케줄 삭제 (soft delete) + * + * @param id 삭제할 스케줄 ID + * @return 영향받은 행 수 + */ + int deleteSchedule(@Param("id") Long id); + + /** + * 워크플로우의 모든 스케줄 비활성화 + * + * @param workflowId 워크플로우 ID + * @return 영향받은 행 수 + */ + int deactivateAllByWorkflowId(@Param("workflowId") Long workflowId); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleCreateDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleCreateDto.java new file mode 100644 index 00000000..87fdcb5a --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleCreateDto.java @@ -0,0 +1,103 @@ +package site.icebang.domain.workflow.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import site.icebang.domain.schedule.model.Schedule; + +/** + * 스케줄 생성 요청 DTO + * + *

워크플로우 생성 시 함께 등록할 스케줄 정보를 담습니다. + * + *

역할: - API 요청 시 클라이언트로부터 스케줄 정보 수신 - 입력값 검증 (형식, 길이 등) - Schedule(Model)로 변환되어 DB 저장 + * + *

기존 ScheduleDto(응답용)와 통일성을 위해 camelCase 사용 + * + * @author bwnfo0702@gmail.com + * @since v0.1.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScheduleCreateDto { + /** + * 크론 표현식 (필수) + * + *

Quartz 크론 표현식 형식을 따릅니다. + * + *

+ * + *

정밀한 유효성 검증은 서비스 레이어에서 Quartz CronExpression으로 수행됩니다. + */ + @NotBlank(message = "크론 표현식은 필수입니다") + @Size(max = 50, message = "크론 표현식은 50자를 초과할 수 없습니다") + private String cronExpression; + + /** + * 사용자 친화적 스케줄 설명 + * + *

UI에 표시될 스케줄 설명입니다. 자동으로 생성됩니다. + * + *

+ */ + @Size(max = 20, message = "스케줄 설명은 자동으로 생성됩니다") + private String scheduleText; + + /** + * 스케줄 활성화 여부 (기본값: true) + * + *

false일 경우 DB에는 저장되지만 Quartz에 등록되지 않습니다. + */ + @Builder.Default private Boolean isActive = true; + + /** + * 스케줄 실행 시 추가 파라미터 (선택, JSON 형식) + * + *

워크플로우 실행 시 전달할 추가 파라미터를 JSON 문자열로 저장합니다. + * + *

{@code
+   * // 예시:
+   * {
+   *   "retryCount": 3,
+   *   "timeout": 300,
+   *   "notifyOnFailure": true
+   * }
+   * }
+ */ + private String parameters; + + /** + * Schedule(Model) 엔티티로 변환 + * + *

DTO의 정보를 DB 저장용 엔티티로 변환하며, 서비스 레이어에서 주입되는 workflowId와 userId를 함께 설정합니다. + * + * @param workflowId 연결할 워크플로우 ID + * @param userId 생성자 ID + * @return DB 저장 가능한 Schedule 엔티티 + */ + public Schedule toEntity(Long workflowId, Long userId) { + return Schedule.builder() + .workflowId(workflowId) + .cronExpression(this.cronExpression) + .scheduleText(this.scheduleText) + .isActive(this.isActive != null ? this.isActive : true) + .parameters(this.parameters) + .createdBy(userId) + .updatedBy(userId) + .build(); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java index bcd0cc56..f14b2aeb 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java @@ -1,9 +1,11 @@ package site.icebang.domain.workflow.dto; import java.math.BigInteger; +import java.util.List; import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.Valid; import jakarta.validation.constraints.*; import lombok.AllArgsConstructor; import lombok.Builder; @@ -14,7 +16,10 @@ * 워크플로우 생성 요청 DTO * *

프론트엔드에서 워크플로우 생성 시 필요한 모든 정보를 담는 DTO - 기본 정보: 이름, 설명 - 플랫폼 설정: 검색 플랫폼, 포스팅 플랫폼 - 계정 설정: 포스팅 계정 - * 정보 (JSON 형태로 저장) + * 정보 (JSON 형태로 저장) - 스케줄 설정: 선택적으로 여러 스케줄 등록 가능 + * + * @author bwnfo0702@gmail.com + * @since v0.1.0 */ @Data @Builder @@ -59,6 +64,18 @@ public class WorkflowCreateDto { @JsonProperty("is_enabled") private Boolean isEnabled = true; + /** + * 워크플로우에 등록할 스케줄 목록 (선택사항) + * + *

사용 시나리오: + * + *

+ */ + @Valid private List<@Valid ScheduleCreateDto> schedules; + // JSON 변환용 필드 (MyBatis에서 사용) private String defaultConfigJson; @@ -109,4 +126,13 @@ public boolean hasPostingConfig() { && postingAccountPassword != null && !postingAccountPassword.isBlank(); } + + /** + * 스케줄 설정이 있는지 확인 + * + * @return 스케줄이 1개 이상 있으면 true + */ + public boolean hasSchedules() { + return schedules != null && !schedules.isEmpty(); + } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java index 06a9ee5c..4ed2c4a0 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java @@ -1,10 +1,9 @@ package site.icebang.domain.workflow.service; import java.math.BigInteger; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; +import org.quartz.CronExpression; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,11 +12,12 @@ import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; +import site.icebang.common.exception.DuplicateDataException; import site.icebang.common.service.PageableService; -import site.icebang.domain.workflow.dto.ScheduleDto; -import site.icebang.domain.workflow.dto.WorkflowCardDto; -import site.icebang.domain.workflow.dto.WorkflowCreateDto; -import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; +import site.icebang.domain.schedule.mapper.ScheduleMapper; +import site.icebang.domain.schedule.model.Schedule; +import site.icebang.domain.schedule.service.QuartzScheduleService; +import site.icebang.domain.workflow.dto.*; import site.icebang.domain.workflow.mapper.WorkflowMapper; /** @@ -41,6 +41,8 @@ public class WorkflowService implements PageableService { private final WorkflowMapper workflowMapper; + private final ScheduleMapper scheduleMapper; + private final QuartzScheduleService quartzScheduleService; /** * 워크플로우 목록을 페이징 처리하여 조회합니다. @@ -91,7 +93,16 @@ public WorkflowDetailCardDto getWorkflowDetail(BigInteger workflowId) { return workflow; } - /** 워크플로우 생성 */ + /** + * 워크플로우 생성 (스케줄 포함 가능) + * + *

워크플로우와 스케줄을 하나의 트랜잭션으로 처리하여 원자성을 보장합니다. 스케줄이 포함된 경우 DB 저장 후 즉시 Quartz 스케줄러에 등록합니다. + * + * @param dto 워크플로우 생성 정보 (스케줄 선택사항) + * @param createdBy 생성자 ID + * @throws IllegalArgumentException 검증 실패 시 + * @throws RuntimeException 생성 중 오류 발생 시 + */ @Transactional public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { // 1. 기본 검증 @@ -100,12 +111,18 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { // 2. 비즈니스 검증 validateBusinessRules(dto); - // 3. 중복체크 + // 3. 스케줄 검증 (있는 경우만) + if (dto.hasSchedules()) { + validateSchedules(dto.getSchedules()); + } + + // 4. 워크플로우 이름 중복 체크 if (workflowMapper.existsByName(dto.getName())) { - throw new IllegalArgumentException("이미 존재하는 워크플로우 이름입니다 : " + dto.getName()); + throw new IllegalArgumentException("이미 존재하는 워크플로우 이름입니다: " + dto.getName()); } - // 4. 워크플로우 생성 + // 5. 워크플로우 생성 + Long workflowId = null; try { // JSON 설정 생성 String defaultConfigJson = dto.genertateDefaultConfigJson(); @@ -121,12 +138,24 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { throw new RuntimeException("워크플로우 생성에 실패했습니다"); } - log.info("워크플로우 생성 완료: {} (생성자: {})", dto.getName(), createdBy); + // 생성된 workflow ID 추출 + Object generatedId = params.get("id"); + workflowId = + (generatedId instanceof BigInteger) + ? ((BigInteger) generatedId).longValue() + : ((Number) generatedId).longValue(); + + log.info("워크플로우 생성 완료: {} (ID: {}, 생성자: {})", dto.getName(), workflowId, createdBy); } catch (Exception e) { log.error("워크플로우 생성 실패: {}", dto.getName(), e); throw new RuntimeException("워크플로우 생성 중 오류가 발생했습니다", e); } + + // 6. 스케줄 등록 (있는 경우만) + if (dto.hasSchedules() && workflowId != null) { + registerSchedules(workflowId, dto.getSchedules(), createdBy.longValue()); + } } /** 기본 입력값 검증 */ @@ -158,4 +187,117 @@ private void validateBusinessRules(WorkflowCreateDto dto) { } } } + + /** + * 스케줄 목록 검증 + * + *

크론 표현식 유효성 및 중복 검사를 수행합니다. + * + * @param schedules 검증할 스케줄 목록 + * @throws IllegalArgumentException 유효하지 않은 크론식 + * @throws DuplicateDataException 중복 크론식 발견 + */ + private void validateSchedules(List schedules) { + if (schedules == null || schedules.isEmpty()) { + return; + } + + // 중복 크론식 검사 (같은 요청 내에서) + Set cronExpressions = new HashSet<>(); + + for (ScheduleCreateDto schedule : schedules) { + String cron = schedule.getCronExpression(); + + // 1. 크론 표현식 유효성 검증 (Quartz 기준) + if (!isValidCronExpression(cron)) { + throw new IllegalArgumentException("유효하지 않은 크론 표현식입니다: " + cron); + } + + // 2. 중복 크론식 검사 + if (cronExpressions.contains(cron)) { + throw new DuplicateDataException("중복된 크론 표현식이 있습니다: " + cron); + } + cronExpressions.add(cron); + } + } + + /** + * Quartz 크론 표현식 유효성 검증 + * + * @param cronExpression 검증할 크론 표현식 + * @return 유효하면 true + */ + private boolean isValidCronExpression(String cronExpression) { + try { + new CronExpression(cronExpression); + return true; + } catch (Exception e) { + log.warn("유효하지 않은 크론 표현식: {}", cronExpression, e); + return false; + } + } + + /** + * 스케줄 목록 등록 (DB 저장 + Quartz 등록) + * + *

트랜잭션 내에서 DB 저장을 수행하고, Quartz 등록은 실패해도 워크플로우는 유지되도록 예외를 로그로만 처리합니다. + * + * @param workflowId 워크플로우 ID + * @param scheduleCreateDtos 등록할 스케줄 목록 + * @param userId 생성자 ID + */ + private void registerSchedules( + Long workflowId, List scheduleCreateDtos, Long userId) { + if (scheduleCreateDtos == null || scheduleCreateDtos.isEmpty()) { + return; + } + + log.info("스케줄 등록 시작: Workflow ID {} - {}개", workflowId, scheduleCreateDtos.size()); + + int successCount = 0; + int failCount = 0; + + for (ScheduleCreateDto dto : scheduleCreateDtos) { + try { + // 1. DTO → Model 변환 + Schedule schedule = dto.toEntity(workflowId, userId); + + // 2. DB 중복 체크 (같은 워크플로우 + 같은 크론식) + if (scheduleMapper.existsByWorkflowIdAndCronExpression( + workflowId, schedule.getCronExpression())) { + throw new DuplicateDataException( + "이미 동일한 크론식의 스케줄이 존재합니다: " + schedule.getCronExpression()); + } + + // 3. DB 저장 + int insertResult = scheduleMapper.insertSchedule(schedule); + if (insertResult != 1) { + log.error("스케줄 DB 저장 실패: Workflow ID {} - {}", workflowId, schedule.getCronExpression()); + failCount++; + continue; + } + + // 4. Quartz 등록 (실시간 반영) + quartzScheduleService.addOrUpdateSchedule(schedule); + + log.info( + "스케줄 등록 완료: Workflow ID {} - {} ({})", + workflowId, + schedule.getCronExpression(), + schedule.getScheduleText()); + successCount++; + + } catch (DuplicateDataException e) { + log.warn("스케줄 중복으로 등록 건너뜀: Workflow ID {} - {}", workflowId, dto.getCronExpression()); + failCount++; + // 중복은 경고만 하고 계속 진행 + } catch (Exception e) { + log.error("스케줄 등록 실패: Workflow ID {} - {}", workflowId, dto.getCronExpression(), e); + failCount++; + // 스케줄 등록 실패해도 워크플로우는 유지 + } + } + + log.info("스케줄 등록 완료: Workflow ID {} - 성공 {}개, 실패 {}개", workflowId, successCount, failCount); + } } diff --git a/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml index 80d6ffae..e89c06c9 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml @@ -2,6 +2,7 @@ + @@ -17,7 +18,98 @@ + + + + + INSERT INTO schedule ( + workflow_id, + cron_expression, + parameters, + is_active, + schedule_text, + created_at, + created_by, + updated_at, + updated_by + ) VALUES ( + #{workflowId}, + #{cronExpression}, + #{parameters}, + #{isActive}, + #{scheduleText}, + UTC_TIMESTAMP(), + #{createdBy}, + UTC_TIMESTAMP(), + #{updatedBy} + ) + + + + + + + + + + + + + + UPDATE schedule + SET is_active = #{isActive}, + updated_at = UTC_TIMESTAMP() + WHERE id = #{id} + + + + + UPDATE schedule + SET cron_expression = #{cronExpression}, + schedule_text = #{scheduleText}, + parameters = #{parameters}, + is_active = #{isActive}, + updated_at = UTC_TIMESTAMP(), + updated_by = #{updatedBy} + WHERE id = #{id} + + + + + UPDATE schedule + SET is_active = false, + updated_at = UTC_TIMESTAMP() + WHERE id = #{id} + + + + + UPDATE schedule + SET is_active = false, + updated_at = UTC_TIMESTAMP() + WHERE workflow_id = #{workflowId} + \ No newline at end of file diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java index 3d5ca4b8..08088a8c 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java @@ -3,7 +3,9 @@ import static org.assertj.core.api.Assertions.assertThat; import java.time.Instant; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.DisplayName; @@ -296,4 +298,283 @@ void createWorkflow_utc_time_validation() throws Exception { logCompletion("UTC 시간 기반 워크플로우 생성 검증 완료"); } + + @Test + @DisplayName("워크플로우 생성 시 단일 스케줄 등록 성공") + void createWorkflow_withSingleSchedule_success() { + performUserLogin(); + + logStep(1, "스케줄이 포함된 워크플로우 생성"); + + // 워크플로우 + 스케줄 요청 데이터 구성 + Map workflowRequest = new HashMap<>(); + workflowRequest.put("name", "매일 오전 9시 자동 실행 워크플로우"); + workflowRequest.put("description", "매일 오전 9시에 자동으로 실행되는 워크플로우"); + workflowRequest.put("search_platform", "naver"); + workflowRequest.put("posting_platform", "naver_blog"); + workflowRequest.put("posting_account_id", "test_account"); + workflowRequest.put("posting_account_password", "test_password"); + workflowRequest.put("is_enabled", true); + + // 스케줄 정보 추가 + List> schedules = new ArrayList<>(); + Map schedule = new HashMap<>(); + schedule.put("cronExpression", "0 0 9 * * ?"); // 매일 오전 9시 + schedule.put("scheduleText", "매일 오전 9시"); + schedule.put("isActive", true); + schedules.add(schedule); + + workflowRequest.put("schedules", schedules); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); + + logStep(2, "워크플로우 생성 요청 전송"); + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + + logStep(3, "응답 검증"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat((Boolean) response.getBody().get("success")).isTrue(); + + logSuccess("스케줄이 포함된 워크플로우 생성 성공"); + logDebug("응답: " + response.getBody()); + + logCompletion("단일 스케줄 등록 테스트 완료"); + } + + @Test + @DisplayName("워크플로우 생성 시 다중 스케줄 등록 성공") + void createWorkflow_withMultipleSchedules_success() { + performUserLogin(); + + logStep(1, "다중 스케줄이 포함된 워크플로우 생성"); + + // 워크플로우 기본 정보 + Map workflowRequest = new HashMap<>(); + workflowRequest.put("name", "다중 스케줄 워크플로우"); + workflowRequest.put("description", "여러 시간대에 실행되는 워크플로우"); + workflowRequest.put("search_platform", "naver"); + workflowRequest.put("posting_platform", "naver_blog"); + workflowRequest.put("posting_account_id", "test_multi"); + workflowRequest.put("posting_account_password", "test_pass123"); + workflowRequest.put("is_enabled", true); + + // 다중 스케줄 정보 추가 + List> schedules = new ArrayList<>(); + + // 스케줄 1: 매일 오전 9시 + Map schedule1 = new HashMap<>(); + schedule1.put("cronExpression", "0 0 9 * * ?"); + schedule1.put("scheduleText", "매일 오전 9시"); + schedule1.put("isActive", true); + schedules.add(schedule1); + + // 스케줄 2: 매일 오후 6시 + Map schedule2 = new HashMap<>(); + schedule2.put("cronExpression", "0 0 18 * * ?"); + schedule2.put("scheduleText", "매일 오후 6시"); + schedule2.put("isActive", true); + schedules.add(schedule2); + + // 스케줄 3: 평일 오후 2시 + Map schedule3 = new HashMap<>(); + schedule3.put("cronExpression", "0 0 14 ? * MON-FRI"); + schedule3.put("scheduleText", "평일 오후 2시"); + schedule3.put("isActive", true); + schedules.add(schedule3); + + workflowRequest.put("schedules", schedules); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); + + logStep(2, "워크플로우 생성 요청 전송 (3개 스케줄 포함)"); + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + + logStep(3, "응답 검증"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat((Boolean) response.getBody().get("success")).isTrue(); + + logSuccess("다중 스케줄이 포함된 워크플로우 생성 성공"); + logDebug("응답: " + response.getBody()); + + logCompletion("다중 스케줄 등록 테스트 완료"); + } + + @Test + @DisplayName("유효하지 않은 크론 표현식으로 스케줄 등록 시 실패") + void createWorkflow_withInvalidCronExpression_shouldFail() { + performUserLogin(); + + logStep(1, "잘못된 크론 표현식으로 워크플로우 생성 시도"); + + Map workflowRequest = new HashMap<>(); + workflowRequest.put("name", "잘못된 크론식 테스트"); + workflowRequest.put("search_platform", "naver"); + workflowRequest.put("is_enabled", true); + + // 잘못된 크론 표현식 + List> schedules = new ArrayList<>(); + Map schedule = new HashMap<>(); + schedule.put("cronExpression", "INVALID CRON"); // 잘못된 형식 + schedule.put("scheduleText", "잘못된 스케줄"); + schedule.put("isActive", true); + schedules.add(schedule); + + workflowRequest.put("schedules", schedules); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); + + logStep(2, "워크플로우 생성 요청 전송"); + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + + logStep(3, "에러 응답 검증"); + assertThat(response.getStatusCode()) + .isIn( + HttpStatus.BAD_REQUEST, + HttpStatus.UNPROCESSABLE_ENTITY, + HttpStatus.INTERNAL_SERVER_ERROR); + + logSuccess("유효하지 않은 크론 표현식 검증 확인"); + logDebug("에러 응답: " + response.getBody()); + + logCompletion("크론 표현식 검증 테스트 완료"); + } + + @Test + @DisplayName("중복된 크론 표현식으로 스케줄 등록 시 실패") + void createWorkflow_withDuplicateCronExpression_shouldFail() { + performUserLogin(); + + logStep(1, "중복된 크론식을 가진 워크플로우 생성 시도"); + + Map workflowRequest = new HashMap<>(); + workflowRequest.put("name", "중복 크론식 테스트"); + workflowRequest.put("search_platform", "naver"); + workflowRequest.put("is_enabled", true); + + // 동일한 크론 표현식을 가진 스케줄 2개 + List> schedules = new ArrayList<>(); + + Map schedule1 = new HashMap<>(); + schedule1.put("cronExpression", "0 0 9 * * ?"); // 매일 오전 9시 + schedule1.put("scheduleText", "매일 오전 9시 - 첫번째"); + schedule1.put("isActive", true); + schedules.add(schedule1); + + Map schedule2 = new HashMap<>(); + schedule2.put("cronExpression", "0 0 9 * * ?"); // 동일한 크론식 + schedule2.put("scheduleText", "매일 오전 9시 - 두번째"); + schedule2.put("isActive", true); + schedules.add(schedule2); + + workflowRequest.put("schedules", schedules); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); + + logStep(2, "워크플로우 생성 요청 전송"); + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + + logStep(3, "중복 크론식 에러 검증"); + assertThat(response.getStatusCode()) + .isIn(HttpStatus.BAD_REQUEST, HttpStatus.CONFLICT, HttpStatus.INTERNAL_SERVER_ERROR); + + logSuccess("중복 크론 표현식 검증 확인"); + logDebug("에러 응답: " + response.getBody()); + + logCompletion("중복 크론식 검증 테스트 완료"); + } + + @Test + @DisplayName("스케줄 없이 워크플로우 생성 후 정상 작동 확인") + void createWorkflow_withoutSchedule_success() { + performUserLogin(); + + logStep(1, "스케줄 없이 워크플로우 생성"); + + Map workflowRequest = new HashMap<>(); + workflowRequest.put("name", "스케줄 없는 워크플로우"); + workflowRequest.put("description", "수동 실행 전용 워크플로우"); + workflowRequest.put("search_platform", "naver"); + workflowRequest.put("posting_platform", "naver_blog"); + workflowRequest.put("posting_account_id", "manual_test"); + workflowRequest.put("posting_account_password", "manual_pass"); + workflowRequest.put("is_enabled", true); + // schedules 필드 없음 + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); + + logStep(2, "워크플로우 생성 요청 전송"); + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + + logStep(3, "응답 검증"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat((Boolean) response.getBody().get("success")).isTrue(); + + logSuccess("스케줄 없는 워크플로우 생성 성공"); + logDebug("응답: " + response.getBody()); + + logCompletion("스케줄 선택사항 테스트 완료"); + } + + @Test + @DisplayName("비활성화 스케줄로 워크플로우 생성 시 Quartz 미등록 확인") + void createWorkflow_withInactiveSchedule_shouldNotRegisterToQuartz() { + performUserLogin(); + + logStep(1, "비활성화 스케줄로 워크플로우 생성"); + + Map workflowRequest = new HashMap<>(); + workflowRequest.put("name", "비활성화 스케줄 테스트"); + workflowRequest.put("description", "DB에는 저장되지만 Quartz에는 등록되지 않음"); + workflowRequest.put("search_platform", "naver"); + workflowRequest.put("is_enabled", true); + + // 비활성화 스케줄 + List> schedules = new ArrayList<>(); + Map schedule = new HashMap<>(); + schedule.put("cronExpression", "0 0 10 * * ?"); + schedule.put("scheduleText", "매일 오전 10시 (비활성)"); + schedule.put("isActive", false); // 비활성화 + schedules.add(schedule); + + workflowRequest.put("schedules", schedules); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); + + logStep(2, "워크플로우 생성 요청 전송"); + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + + logStep(3, "응답 검증 - DB 저장은 성공하지만 Quartz 미등록"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat((Boolean) response.getBody().get("success")).isTrue(); + + logSuccess("비활성화 스케줄로 워크플로우 생성 성공"); + logDebug("응답: " + response.getBody()); + logDebug("비활성화 스케줄은 DB에 저장되지만 Quartz에는 등록되지 않음"); + + logCompletion("비활성화 스케줄 테스트 완료"); + } }