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 크론 표현식 형식을 따릅니다.
+ *
+ *
+ * 초 분 시 일 월 요일 (년도)
+ * 예시: "0 9 * * *" (매일 9시 0분 0초)
+ * 예시: "0 0 14 * * MON-FRI" (평일 오후 2시)
+ *
+ *
+ * 정밀한 유효성 검증은 서비스 레이어에서 Quartz CronExpression으로 수행됩니다.
+ */
+ @NotBlank(message = "크론 표현식은 필수입니다")
+ @Size(max = 50, message = "크론 표현식은 50자를 초과할 수 없습니다")
+ private String cronExpression;
+
+ /**
+ * 사용자 친화적 스케줄 설명
+ *
+ *
UI에 표시될 스케줄 설명입니다. 자동으로 생성됩니다.
+ *
+ *
+ * 예시: "매일 오전 9시"
+ * 예시: "평일 오후 2시"
+ * 예시: "매주 금요일 6시"
+ *
+ */
+ @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;
+ /**
+ * 워크플로우에 등록할 스케줄 목록 (선택사항)
+ *
+ *
사용 시나리오:
+ *
+ *
+ * null 또는 빈 리스트: 스케줄 없이 워크플로우만 생성
+ * 1개 이상: 해당 스케줄들을 함께 등록 (트랜잭션 보장)
+ *
+ */
+ @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 @@
+
SELECT * FROM schedule WHERE is_active = true
+
+
+
+ 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}
+ )
+
+
+
+
+ SELECT *
+ FROM schedule
+ WHERE workflow_id = #{workflowId}
+ AND is_active = true
+ ORDER BY id
+
+
+
+
+ SELECT COUNT(*) > 0
+ FROM schedule
+ WHERE workflow_id = #{workflowId}
+ AND cron_expression = #{cronExpression}
+ AND is_active = true
+
+
+
+
+ SELECT *
+ FROM schedule
+ WHERE workflow_id = #{workflowId}
+ AND cron_expression = #{cronExpression}
+ AND is_active = true
+ LIMIT 1
+
+
+
+
+ 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("비활성화 스케줄 테스트 완료");
+ }
}