tags;
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNInvalidationRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNInvalidationRequest.java
new file mode 100644
index 00000000..0f3d7d59
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNInvalidationRequest.java
@@ -0,0 +1,45 @@
+package com.agenticcp.core.domain.cloud.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * CDN 캐시 무효화 요청 DTO
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor(access = AccessLevel.PROTECTED)
+public class CDNInvalidationRequest {
+
+ /**
+ * 무효화할 경로 목록 (필수)
+ * 예: ["/images/*", "/css/*", "/index.html"]
+ * 모든 경로를 무효화하려면 ["/*"] 사용
+ * 최대 3000개까지 가능
+ */
+ @NotEmpty(message = "무효화할 경로 목록은 필수입니다")
+ @jakarta.validation.constraints.Size(max = 3000, message = "무효화 경로는 최대 3000개까지 가능합니다")
+ private List<@NotBlank(message = "경로는 비어있을 수 없습니다")
+ @jakarta.validation.constraints.Pattern(
+ regexp = "^/.*|^\\*$",
+ message = "경로는 '/'로 시작하거나 '*'이어야 합니다"
+ ) String> paths;
+
+ /**
+ * Caller Reference (고유 식별자, 중복 방지용)
+ * 미제공 시 자동 생성됩니다.
+ */
+ private String callerReference;
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNInvalidationResponse.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNInvalidationResponse.java
new file mode 100644
index 00000000..55b54184
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNInvalidationResponse.java
@@ -0,0 +1,70 @@
+package com.agenticcp.core.domain.cloud.dto;
+
+import com.agenticcp.core.domain.cloud.port.model.cdn.InvalidationResult;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * CDN 캐시 무효화 응답 DTO
+ *
+ * 외부 API 클라이언트에게 반환되는 캐시 무효화 결과입니다.
+ * 포트 모델(InvalidationResult)을 DTO로 변환하여 인터페이스 계층과 도메인 계층을 분리합니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CDNInvalidationResponse {
+
+ /**
+ * 무효화 ID
+ */
+ private String invalidationId;
+
+ /**
+ * Distribution ID
+ */
+ private String distributionId;
+
+ /**
+ * 무효화 상태
+ * - InProgress: 진행 중
+ * - Completed: 완료
+ */
+ private String status;
+
+ /**
+ * 생성 시간
+ */
+ private LocalDateTime createTime;
+
+ /**
+ * 무효화된 경로 목록
+ */
+ private List paths;
+
+ /**
+ * InvalidationResult에서 CDNInvalidationResponse로 변환
+ *
+ * @param result 포트 모델 (도메인 계층)
+ * @return API 응답 DTO (인터페이스 계층)
+ */
+ public static CDNInvalidationResponse from(InvalidationResult result) {
+ return CDNInvalidationResponse.builder()
+ .invalidationId(result.invalidationId())
+ .distributionId(result.distributionId())
+ .status(result.status())
+ .createTime(result.createTime())
+ .paths(result.paths())
+ .build();
+ }
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java b/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java
index 2360bc37..f364a3e5 100644
--- a/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java
+++ b/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java
@@ -349,6 +349,7 @@ public enum ResourceType {
LOAD_BALANCER,
DATABASE,
BUCKET,
+ CDN_DISTRIBUTION,
FUNCTION,
CONTAINER,
CLUSTER,
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CacheBehaviorConfig.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CacheBehaviorConfig.java
new file mode 100644
index 00000000..ac3f2c5c
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CacheBehaviorConfig.java
@@ -0,0 +1,56 @@
+package com.agenticcp.core.domain.cloud.port.model.cdn;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Builder;
+
+import java.util.List;
+
+/**
+ * CDN 캐시 동작 설정 VO
+ *
+ * Distribution의 캐시 동작(Cache Behavior) 설정을 표현합니다.
+ * 경로 패턴별로 캐시 TTL, 허용 메서드, 압축 등을 설정할 수 있습니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@Builder
+public record CacheBehaviorConfig(
+ @JsonProperty("pathPattern")
+ @Pattern(
+ regexp = "^/.*|^\\*$",
+ message = "경로 패턴은 '/'로 시작하거나 '*'이어야 합니다"
+ )
+ String pathPattern,
+
+ @JsonProperty("ttl")
+ @Min(value = 0, message = "TTL은 0 이상이어야 합니다")
+ Long ttl, // Time to Live (초)
+
+ @JsonProperty("allowedMethods")
+ @Size(min = 1, message = "최소 하나 이상의 HTTP 메서드가 필요합니다")
+ List<@Pattern(
+ regexp = "^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH)$",
+ message = "허용된 HTTP 메서드는 GET, POST, PUT, DELETE, HEAD, OPTIONS, PATCH 중 하나여야 합니다"
+ ) String> allowedMethods, // GET, POST, PUT, DELETE 등
+
+ @JsonProperty("compress")
+ Boolean compress, // Gzip 압축 여부
+
+ @JsonProperty("viewerProtocolPolicy")
+ @jakarta.validation.constraints.Size(max = 50, message = "Viewer Protocol Policy는 최대 50자까지 가능합니다")
+ String viewerProtocolPolicy, // CSP별로 다른 값 사용 가능 (예: AWS: allow-all/https-only/redirect-to-https, GCP: ALLOW_ALL/HTTPS_ONLY 등)
+
+ @JsonProperty("cachePolicyId")
+ @jakarta.validation.constraints.Size(max = 255, message = "Cache Policy ID는 최대 255자까지 가능합니다")
+ String cachePolicyId, // CSP별 Managed Cache Policy ID (선택, AWS/GCP/Azure 각각 다른 형식)
+
+ @JsonProperty("originRequestPolicyId")
+ @jakarta.validation.constraints.Size(max = 255, message = "Origin Request Policy ID는 최대 255자까지 가능합니다")
+ String originRequestPolicyId // CSP별 Managed Origin Request Policy ID (선택, AWS/GCP/Azure 각각 다른 형식)
+) {
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CreateDistributionCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CreateDistributionCommand.java
new file mode 100644
index 00000000..6237a352
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CreateDistributionCommand.java
@@ -0,0 +1,34 @@
+package com.agenticcp.core.domain.cloud.port.model.cdn;
+
+import com.agenticcp.core.domain.cloud.entity.CloudProvider;
+import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential;
+import lombok.Builder;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * CDN Distribution 생성 명령
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@Builder
+public record CreateDistributionCommand(
+ CloudProvider.ProviderType providerType,
+ String accountScope,
+ String serviceKey,
+ String resourceType,
+ String distributionName,
+ String comment,
+ Boolean enabled,
+ OriginConfig origin,
+ List cacheBehaviors,
+ List aliases,
+ String sslCertificateId,
+ Map tags,
+ String tenantKey,
+ CloudSessionCredential session
+) {
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CreateInvalidationCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CreateInvalidationCommand.java
new file mode 100644
index 00000000..d3d8eab9
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CreateInvalidationCommand.java
@@ -0,0 +1,23 @@
+package com.agenticcp.core.domain.cloud.port.model.cdn;
+
+import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential;
+import lombok.Builder;
+
+import java.util.List;
+
+/**
+ * 캐시 무효화 생성 명령
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@Builder
+public record CreateInvalidationCommand(
+ String accountScope,
+ String distributionId,
+ List paths, // 무효화할 경로 목록 (예: /images/*, /css/*)
+ String callerReference, // 고유 식별자 (중복 방지용)
+ CloudSessionCredential session
+) {
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/DeleteDistributionCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/DeleteDistributionCommand.java
new file mode 100644
index 00000000..f9092810
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/DeleteDistributionCommand.java
@@ -0,0 +1,22 @@
+package com.agenticcp.core.domain.cloud.port.model.cdn;
+
+import com.agenticcp.core.domain.cloud.entity.CloudProvider;
+import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential;
+import lombok.Builder;
+
+/**
+ * CDN Distribution 삭제 명령
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@Builder
+public record DeleteDistributionCommand(
+ CloudProvider.ProviderType providerType,
+ String accountScope,
+ String distributionId,
+ String etag, // 동시성 제어용
+ CloudSessionCredential session
+) {
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/InvalidationResult.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/InvalidationResult.java
new file mode 100644
index 00000000..e48afeb1
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/InvalidationResult.java
@@ -0,0 +1,23 @@
+package com.agenticcp.core.domain.cloud.port.model.cdn;
+
+import lombok.Builder;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 캐시 무효화 결과
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@Builder
+public record InvalidationResult(
+ String invalidationId,
+ String distributionId,
+ String status, // InProgress, Completed
+ LocalDateTime createTime,
+ List paths
+) {
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/OriginConfig.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/OriginConfig.java
new file mode 100644
index 00000000..8490dab8
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/OriginConfig.java
@@ -0,0 +1,63 @@
+package com.agenticcp.core.domain.cloud.port.model.cdn;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Max;
+import lombok.Builder;
+
+/**
+ * CDN Origin 설정 VO
+ *
+ * Distribution의 Origin 설정을 표현합니다.
+ * PUBLIC_S3, CUSTOM(ELB, EC2 등) Origin 타입을 지원합니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@Builder
+public record OriginConfig(
+ @JsonProperty("id")
+ @NotBlank(message = "Origin ID는 필수입니다")
+ @Pattern(regexp = "^[a-zA-Z0-9_-]+$", message = "Origin ID는 영문자, 숫자, 하이픈(-), 언더스코어(_)만 사용할 수 있습니다")
+ String id,
+
+ @JsonProperty("domainName")
+ @NotBlank(message = "Origin 도메인 이름은 필수입니다")
+ @Pattern(
+ regexp = "^([a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}$|^[a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?$",
+ message = "유효한 도메인 형식이어야 합니다"
+ )
+ String domainName,
+
+ @JsonProperty("type")
+ @NotNull(message = "Origin 타입은 필수입니다")
+ OriginType type,
+
+ // Custom Origin 설정 (ELB, EC2 등)
+ @JsonProperty("httpPort")
+ @Min(value = 1, message = "HTTP 포트는 1 이상이어야 합니다")
+ @Max(value = 65535, message = "HTTP 포트는 65535 이하여야 합니다")
+ Integer httpPort, // 기본 80
+
+ @JsonProperty("httpsPort")
+ @Min(value = 1, message = "HTTPS 포트는 1 이상이어야 합니다")
+ @Max(value = 65535, message = "HTTPS 포트는 65535 이하여야 합니다")
+ Integer httpsPort, // 기본 443
+
+ @JsonProperty("originProtocolPolicy")
+ @jakarta.validation.constraints.Size(max = 50, message = "Origin Protocol Policy는 최대 50자까지 가능합니다")
+ String originProtocolPolicy // CSP별로 다른 값 사용 가능 (예: AWS: http-only/https-only/match-viewer, GCP: HTTP_ONLY/HTTPS_ONLY 등)
+) {
+
+ /**
+ * Origin 타입
+ */
+ public enum OriginType {
+ PUBLIC_S3, // 퍼블릭 S3 버킷
+ CUSTOM // 커스텀 Origin (ELB, EC2, 외부 웹서버 등)
+ }
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/UpdateDistributionCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/UpdateDistributionCommand.java
new file mode 100644
index 00000000..1cd894ff
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/UpdateDistributionCommand.java
@@ -0,0 +1,32 @@
+package com.agenticcp.core.domain.cloud.port.model.cdn;
+
+import com.agenticcp.core.domain.cloud.entity.CloudProvider;
+import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential;
+import lombok.Builder;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * CDN Distribution 수정 명령
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@Builder
+public record UpdateDistributionCommand(
+ CloudProvider.ProviderType providerType,
+ String accountScope,
+ String distributionId,
+ String etag, // 동시성 제어용
+ String comment,
+ Boolean enabled,
+ List cacheBehaviors,
+ List aliases,
+ String sslCertificateId,
+ Map tags,
+ String tenantKey,
+ CloudSessionCredential session
+) {
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNDiscoveryPort.java b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNDiscoveryPort.java
new file mode 100644
index 00000000..fd529a52
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNDiscoveryPort.java
@@ -0,0 +1,45 @@
+package com.agenticcp.core.domain.cloud.port.outbound.cdn;
+
+import com.agenticcp.core.domain.cloud.dto.CDNDistributionQueryRequest;
+import com.agenticcp.core.domain.cloud.entity.CloudResource;
+import org.springframework.data.domain.Page;
+
+import java.util.Optional;
+
+/**
+ * CDN Distribution 조회 포트
+ *
+ * CDN Distribution 조회 기능을 정의합니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+public interface CDNDiscoveryPort {
+
+ /**
+ * CDN Distribution 목록을 조회합니다.
+ *
+ * @param query 조회 조건 (세션, 페이징, 필터링, 태그 포함)
+ * @return CloudResource 페이지 (빈 페이지 가능, null 반환 금지)
+ */
+ Page listDistributions(CDNDistributionQueryRequest query);
+
+ /**
+ * 특정 CDN Distribution을 조회합니다.
+ *
+ * @param distributionId Distribution ID
+ * @param accountScope 조회 대상 Cloud 계정 범위
+ * @return CloudResource (존재하지 않으면 Optional.empty())
+ */
+ Optional getDistribution(String accountScope, String distributionId);
+
+ /**
+ * Distribution 존재 여부를 확인합니다.
+ *
+ * @param accountScope 확인 대상 Cloud 계정 범위
+ * @param distributionId Distribution ID
+ * @return 존재 여부
+ */
+ boolean distributionExists(String accountScope, String distributionId);
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNInvalidationPort.java b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNInvalidationPort.java
new file mode 100644
index 00000000..ec6e9bf9
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNInvalidationPort.java
@@ -0,0 +1,40 @@
+package com.agenticcp.core.domain.cloud.port.outbound.cdn;
+
+import com.agenticcp.core.domain.cloud.port.model.cdn.CreateInvalidationCommand;
+import com.agenticcp.core.domain.cloud.port.model.cdn.InvalidationResult;
+
+import java.util.Optional;
+
+/**
+ * CDN 캐시 무효화 포트
+ *
+ * CDN Distribution의 캐시 무효화 생성 및 조회 기능을 정의합니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+public interface CDNInvalidationPort {
+
+ /**
+ * 캐시 무효화를 생성합니다.
+ *
+ * @param command 무효화 생성 명령 (세션, Distribution ID, 경로 목록 포함)
+ * @return 생성된 무효화 결과 (무효화 ID, 상태 포함)
+ */
+ InvalidationResult createInvalidation(CreateInvalidationCommand command);
+
+ /**
+ * 캐시 무효화 상태를 조회합니다.
+ *
+ * @param accountScope 조회 대상 Cloud 계정 범위
+ * @param distributionId Distribution ID
+ * @param invalidationId 무효화 ID
+ * @return 무효화 결과 (존재하지 않으면 Optional.empty())
+ */
+ Optional getInvalidation(
+ String accountScope,
+ String distributionId,
+ String invalidationId
+ );
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNManagementPort.java b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNManagementPort.java
new file mode 100644
index 00000000..6e3519f5
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNManagementPort.java
@@ -0,0 +1,43 @@
+package com.agenticcp.core.domain.cloud.port.outbound.cdn;
+
+import com.agenticcp.core.domain.cloud.entity.CloudResource;
+import com.agenticcp.core.domain.cloud.port.model.cdn.CreateDistributionCommand;
+import com.agenticcp.core.domain.cloud.port.model.cdn.DeleteDistributionCommand;
+import com.agenticcp.core.domain.cloud.port.model.cdn.UpdateDistributionCommand;
+
+/**
+ * CDN Distribution 관리 포트
+ *
+ * CDN Distribution의 생성, 수정, 삭제 기능을 정의합니다.
+ * CloudFront Distribution은 start/stop 개념이 없으므로 별도의 관리 포트로 분리합니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+public interface CDNManagementPort {
+
+ /**
+ * CDN Distribution을 생성합니다.
+ *
+ * @param command 생성 명령 (세션, Origin, CacheBehavior, 태그 포함)
+ * @return 생성된 CloudResource
+ */
+ CloudResource createDistribution(CreateDistributionCommand command);
+
+ /**
+ * CDN Distribution 설정을 업데이트합니다.
+ *
+ * @param command 업데이트 명령 (세션, ETag, 설정 변경사항 포함)
+ * @return 업데이트된 CloudResource
+ */
+ CloudResource updateDistribution(UpdateDistributionCommand command);
+
+ /**
+ * CDN Distribution을 삭제합니다.
+ * Distribution은 삭제 전 반드시 비활성화(Disabled) 상태여야 합니다.
+ *
+ * @param command 삭제 명령 (세션, Distribution ID, ETag 포함)
+ */
+ void deleteDistribution(DeleteDistributionCommand command);
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/cdn/CDNPortRouter.java b/src/main/java/com/agenticcp/core/domain/cloud/service/cdn/CDNPortRouter.java
new file mode 100644
index 00000000..07fd64c2
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/service/cdn/CDNPortRouter.java
@@ -0,0 +1,153 @@
+package com.agenticcp.core.domain.cloud.service.cdn;
+
+import com.agenticcp.core.domain.cloud.adapter.outbound.common.ProviderScoped;
+import com.agenticcp.core.domain.cloud.entity.CloudProvider;
+import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNDiscoveryPort;
+import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNInvalidationPort;
+import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNManagementPort;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * CDN Distribution 포트 라우터
+ *
+ * 헥사고날 아키텍처의 애플리케이션 계층에서 클라우드 프로바이더 타입에 따라
+ * 적절한 CDN Distribution 포트 구현체를 선택하는 라우터입니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@Component
+@Slf4j
+public class CDNPortRouter {
+
+ private final Map managementPorts;
+ private final Map discoveryPorts;
+ private final Map invalidationPorts;
+
+ /**
+ * 생성자 - Spring이 자동으로 어댑터들을 주입합니다.
+ *
+ * @param managementPortList CDN Distribution 관리 포트 구현체 목록
+ * @param discoveryPortList CDN Distribution 발견 포트 구현체 목록
+ * @param invalidationPortList CDN 캐시 무효화 포트 구현체 목록
+ */
+ public CDNPortRouter(
+ List managementPortList,
+ List discoveryPortList,
+ List invalidationPortList) {
+
+ // Management 포트 맵 초기화
+ this.managementPorts = managementPortList.stream()
+ .filter(port -> port instanceof ProviderScoped)
+ .collect(Collectors.toMap(
+ port -> ((ProviderScoped) port).getProviderType(),
+ port -> port,
+ (existing, replacement) -> {
+ log.warn("중복된 CDN Distribution Management 포트 발견: provider={}, existing={}, replacement={}",
+ ((ProviderScoped) existing).getProviderType(),
+ existing.getClass().getSimpleName(),
+ replacement.getClass().getSimpleName());
+ return existing; // 기존 것을 유지
+ }
+ ));
+
+ // Discovery 포트 맵 초기화
+ this.discoveryPorts = discoveryPortList.stream()
+ .filter(port -> port instanceof ProviderScoped)
+ .collect(Collectors.toMap(
+ port -> ((ProviderScoped) port).getProviderType(),
+ port -> port,
+ (existing, replacement) -> {
+ log.warn("중복된 CDN Distribution Discovery 포트 발견: provider={}, existing={}, replacement={}",
+ ((ProviderScoped) existing).getProviderType(),
+ existing.getClass().getSimpleName(),
+ replacement.getClass().getSimpleName());
+ return existing; // 기존 것을 유지
+ }
+ ));
+
+ // Invalidation 포트 맵 초기화
+ this.invalidationPorts = invalidationPortList.stream()
+ .filter(port -> port instanceof ProviderScoped)
+ .collect(Collectors.toMap(
+ port -> ((ProviderScoped) port).getProviderType(),
+ port -> port,
+ (existing, replacement) -> {
+ log.warn("중복된 CDN Invalidation 포트 발견: provider={}, existing={}, replacement={}",
+ ((ProviderScoped) existing).getProviderType(),
+ existing.getClass().getSimpleName(),
+ replacement.getClass().getSimpleName());
+ return existing; // 기존 것을 유지
+ }
+ ));
+
+ log.info("[CDNPortRouter] CDN Distribution 포트 라우터 초기화 완료: managementPorts={}, discoveryPorts={}, invalidationPorts={}",
+ managementPorts.keySet(), discoveryPorts.keySet(), invalidationPorts.keySet());
+ }
+
+ /**
+ * 지정된 프로바이더 타입에 해당하는 CDN Distribution 관리 포트를 반환합니다.
+ *
+ * @param providerType 클라우드 프로바이더 타입
+ * @return CDN Distribution 관리 포트
+ * @throws IllegalArgumentException 지원하지 않는 프로바이더 타입인 경우
+ */
+ public CDNManagementPort management(CloudProvider.ProviderType providerType) {
+ CDNManagementPort port = managementPorts.get(providerType);
+ if (port == null) {
+ log.error("[CDNPortRouter] 지원하지 않는 프로바이더 타입: {}, 지원되는 타입: {}", providerType, managementPorts.keySet());
+ throw new IllegalArgumentException("지원하지 않는 프로바이더 타입입니다: " + providerType);
+ }
+
+ log.debug("[CDNPortRouter] CDN Distribution Management 포트 선택: provider={}, port={}",
+ providerType, port.getClass().getSimpleName());
+
+ return port;
+ }
+
+ /**
+ * 지정된 프로바이더 타입에 해당하는 CDN Distribution 발견 포트를 반환합니다.
+ *
+ * @param providerType 클라우드 프로바이더 타입
+ * @return CDN Distribution 발견 포트
+ * @throws IllegalArgumentException 지원하지 않는 프로바이더 타입인 경우
+ */
+ public CDNDiscoveryPort discovery(CloudProvider.ProviderType providerType) {
+ CDNDiscoveryPort port = discoveryPorts.get(providerType);
+ if (port == null) {
+ log.error("[CDNPortRouter] 지원하지 않는 프로바이더 타입: {}, 지원되는 타입: {}", providerType, discoveryPorts.keySet());
+ throw new IllegalArgumentException("지원하지 않는 프로바이더 타입입니다: " + providerType);
+ }
+
+ log.debug("[CDNPortRouter] CDN Distribution Discovery 포트 선택: provider={}, port={}",
+ providerType, port.getClass().getSimpleName());
+
+ return port;
+ }
+
+ /**
+ * 지정된 프로바이더 타입에 해당하는 CDN 캐시 무효화 포트를 반환합니다.
+ *
+ * @param providerType 클라우드 프로바이더 타입
+ * @return CDN 캐시 무효화 포트
+ * @throws IllegalArgumentException 지원하지 않는 프로바이더 타입인 경우
+ */
+ public CDNInvalidationPort invalidation(CloudProvider.ProviderType providerType) {
+ CDNInvalidationPort port = invalidationPorts.get(providerType);
+ if (port == null) {
+ log.error("[CDNPortRouter] 지원하지 않는 프로바이더 타입: {}, 지원되는 타입: {}", providerType, invalidationPorts.keySet());
+ throw new IllegalArgumentException("지원하지 않는 프로바이더 타입입니다: " + providerType);
+ }
+
+ log.debug("[CDNPortRouter] CDN Invalidation 포트 선택: provider={}, port={}",
+ providerType, port.getClass().getSimpleName());
+
+ return port;
+ }
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseService.java b/src/main/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseService.java
new file mode 100644
index 00000000..c227f7ed
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseService.java
@@ -0,0 +1,416 @@
+package com.agenticcp.core.domain.cloud.service.cdn;
+
+import com.agenticcp.core.common.context.TenantContextHolder;
+import com.agenticcp.core.common.exception.BusinessException;
+import com.agenticcp.core.domain.cloud.capability.CapabilityGuard;
+import com.agenticcp.core.domain.cloud.dto.CDNDistributionQueryRequest;
+import com.agenticcp.core.domain.cloud.dto.ResourceRegistrationRequest;
+import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType;
+import com.agenticcp.core.domain.cloud.entity.CloudResource;
+import com.agenticcp.core.domain.cloud.exception.CloudErrorCode;
+import com.agenticcp.core.domain.cloud.exception.CredentialErrorCode;
+import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential;
+import com.agenticcp.core.domain.cloud.port.model.cdn.*;
+import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort;
+import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNDiscoveryPort;
+import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNInvalidationPort;
+import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNManagementPort;
+import com.agenticcp.core.domain.cloud.repository.CloudResourceRepository;
+import com.agenticcp.core.domain.cloud.service.helper.CloudResourceManagementHelper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * CDN Distribution 유스케이스 서비스
+ *
+ * CDN Distribution의 생성, 조회, 수정, 삭제 및 캐시 무효화 기능을 제공합니다.
+ * CSP에서 리소스를 생성/수정/삭제한 후 CloudResource 엔티티를 DB에 저장/업데이트합니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CDNUseCaseService {
+
+ private static final String SERVICE_KEY = "CloudFront";
+ private static final String RESOURCE_TYPE = "CDN_DISTRIBUTION";
+
+ private final CDNPortRouter cdnPortRouter;
+ private final CapabilityGuard capabilityGuard;
+ private final AccountCredentialManagementPort accountCredentialManagementPort;
+ private final CloudResourceManagementHelper resourceHelper;
+ private final CloudResourceRepository cloudResourceRepository;
+
+ /**
+ * CDN Distribution을 생성합니다.
+ * CSP에서 Distribution 생성 후 CloudResource 엔티티를 DB에 저장합니다.
+ *
+ * 보상 트랜잭션: DB 저장 실패 시 CSP에 생성된 Distribution을 삭제하여
+ * 데이터 정합성(Ghost Resource 방지)을 보장합니다.
+ *
+ * @param command 생성 명령
+ * @return 생성된 Distribution CloudResource
+ * @throws BusinessException DB 저장 실패 및 보상 트랜잭션 실행 시
+ */
+ @Transactional
+ public CloudResource createDistribution(CreateDistributionCommand command) {
+ capabilityGuard.ensureSupported(
+ command.providerType(),
+ SERVICE_KEY,
+ RESOURCE_TYPE,
+ CapabilityGuard.Operation.TAGGING
+ );
+
+ // tenantKey 획득
+ String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow();
+
+ // accountScope 검증
+ String accountScope = command.accountScope();
+ validateAccountScope(accountScope);
+
+ // JIT 세션 획득
+ CloudSessionCredential session = acquireSession(accountScope, command.providerType());
+
+ // Command에 세션 설정
+ CreateDistributionCommand commandWithSession = CreateDistributionCommand.builder()
+ .providerType(command.providerType())
+ .accountScope(command.accountScope())
+ .serviceKey(command.serviceKey())
+ .resourceType(command.resourceType())
+ .distributionName(command.distributionName())
+ .comment(command.comment())
+ .enabled(command.enabled())
+ .origin(command.origin())
+ .cacheBehaviors(command.cacheBehaviors())
+ .aliases(command.aliases())
+ .sslCertificateId(command.sslCertificateId())
+ .tags(command.tags())
+ .tenantKey(tenantKey)
+ .session(session)
+ .build();
+
+ // CSP에서 Distribution 생성
+ CDNManagementPort managementPort = cdnPortRouter.management(command.providerType());
+ CloudResource distribution = managementPort.createDistribution(commandWithSession);
+
+ // DB에 CloudResource 저장 (실패 시 보상 트랜잭션 실행)
+ try {
+ String resourceName = command.distributionName() != null
+ ? command.distributionName()
+ : distribution.getResourceId();
+
+ ResourceRegistrationRequest registrationRequest = ResourceRegistrationRequest.builder()
+ .resourceId(distribution.getResourceId())
+ .resourceName(resourceName)
+ .resourceType(CloudResource.ResourceType.CDN_DISTRIBUTION)
+ .tags(command.tags())
+ .attributes(Map.of()) // Origin, CacheBehavior 등은 metadata에 저장됨
+ .build();
+
+ // CloudResource 등록
+ CloudResource savedResource = resourceHelper.registerResource(
+ command.providerType(),
+ SERVICE_KEY,
+ registrationRequest
+ );
+
+ // distribution 객체의 metadata를 DB에 저장된 리소스에 설정
+ if (distribution.getMetadata() != null && !distribution.getMetadata().isEmpty()) {
+ savedResource.setMetadata(distribution.getMetadata());
+ cloudResourceRepository.save(savedResource);
+ log.debug("[CDNUseCaseService] Distribution metadata 저장 완료: distributionId={}",
+ distribution.getResourceId());
+ }
+ } catch (Exception e) {
+ log.error("[CDNUseCaseService] DB 저장 실패, 보상 트랜잭션 실행: distributionId={}, error={}",
+ distribution.getResourceId(), e.getMessage());
+
+ // 보상 트랜잭션: CSP에 생성된 Distribution 삭제
+ executeCompensatingTransaction(
+ command.providerType(),
+ managementPort,
+ distribution.getResourceId(),
+ accountScope,
+ session
+ );
+
+ throw new BusinessException(
+ CloudErrorCode.RESOURCE_CREATION_FAILED,
+ "Distribution 생성 후 DB 저장 실패로 인해 롤백되었습니다: " + distribution.getResourceId()
+ );
+ }
+
+ return distribution;
+ }
+
+ /**
+ * CDN Distribution을 조회합니다.
+ *
+ * @param accountScope 계정 스코프
+ * @param distributionId Distribution ID
+ * @param providerType 프로바이더 타입
+ * @return Distribution CloudResource (존재하지 않으면 Optional.empty())
+ */
+ @Transactional(readOnly = true)
+ public Optional getDistribution(String accountScope, String distributionId, ProviderType providerType) {
+ validateAccountScope(accountScope);
+
+ CDNDiscoveryPort discoveryPort = cdnPortRouter.discovery(providerType);
+ return discoveryPort.getDistribution(accountScope, distributionId);
+ }
+
+ /**
+ * CDN Distribution 목록을 조회합니다.
+ *
+ * @param query 조회 쿼리
+ * @return Distribution 목록 (페이징 정보 포함)
+ */
+ @Transactional(readOnly = true)
+ public Page listDistributions(CDNDistributionQueryRequest query) {
+ validateAccountScope(query.accountScope());
+
+ // JIT 세션 획득
+ CloudSessionCredential session = acquireSession(query.accountScope(), query.providerType());
+
+ // Query에 세션 설정
+ CDNDistributionQueryRequest queryWithSession = CDNDistributionQueryRequest.builder()
+ .providerType(query.providerType())
+ .accountScope(query.accountScope())
+ .distributionName(query.distributionName())
+ .enabled(query.enabled())
+ .tags(query.tags())
+ .tenantKey(query.tenantKey())
+ .page(query.page())
+ .size(query.size())
+ .sortBy(query.sortBy())
+ .sortDirection(query.sortDirection())
+ .session(session)
+ .build();
+
+ CDNDiscoveryPort discoveryPort = cdnPortRouter.discovery(query.providerType());
+ return discoveryPort.listDistributions(queryWithSession);
+ }
+
+ /**
+ * CDN Distribution을 수정합니다.
+ *
+ * @param command 수정 명령
+ * @return 수정된 Distribution CloudResource
+ */
+ @Transactional
+ public CloudResource updateDistribution(UpdateDistributionCommand command) {
+ capabilityGuard.ensureSupported(
+ command.providerType(),
+ SERVICE_KEY,
+ RESOURCE_TYPE,
+ CapabilityGuard.Operation.TAGGING
+ );
+
+ // tenantKey 획득
+ String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow();
+
+ // accountScope 검증
+ String accountScope = command.accountScope();
+ validateAccountScope(accountScope);
+
+ // JIT 세션 획득
+ CloudSessionCredential session = acquireSession(accountScope, command.providerType());
+
+ // Command에 세션 설정
+ UpdateDistributionCommand commandWithSession = UpdateDistributionCommand.builder()
+ .providerType(command.providerType())
+ .accountScope(command.accountScope())
+ .distributionId(command.distributionId())
+ .etag(command.etag())
+ .comment(command.comment())
+ .enabled(command.enabled())
+ .cacheBehaviors(command.cacheBehaviors())
+ .aliases(command.aliases())
+ .sslCertificateId(command.sslCertificateId())
+ .tags(command.tags())
+ .tenantKey(tenantKey)
+ .session(session)
+ .build();
+
+ CDNManagementPort managementPort = cdnPortRouter.management(command.providerType());
+ CloudResource distribution = managementPort.updateDistribution(commandWithSession);
+
+ // DB 업데이트는 별도 동기화 작업으로 처리 (VPC 패턴과 동일)
+ // 여기서는 CSP 업데이트만 수행하고 CloudResource 반환
+
+ return distribution;
+ }
+
+ /**
+ * CDN Distribution을 삭제합니다.
+ *
+ * @param command 삭제 명령
+ */
+ @Transactional
+ public void deleteDistribution(DeleteDistributionCommand command) {
+ capabilityGuard.ensureSupported(
+ command.providerType(),
+ SERVICE_KEY,
+ RESOURCE_TYPE,
+ CapabilityGuard.Operation.TERMINATE
+ );
+
+ // accountScope 검증
+ String accountScope = command.accountScope();
+ validateAccountScope(accountScope);
+
+ // JIT 세션 획득
+ CloudSessionCredential session = acquireSession(accountScope, command.providerType());
+
+ // Command에 세션 설정
+ DeleteDistributionCommand commandWithSession = DeleteDistributionCommand.builder()
+ .providerType(command.providerType())
+ .accountScope(command.accountScope())
+ .distributionId(command.distributionId())
+ .etag(command.etag())
+ .session(session)
+ .build();
+
+ CDNManagementPort managementPort = cdnPortRouter.management(command.providerType());
+
+ // CSP에서 Distribution 삭제
+ managementPort.deleteDistribution(commandWithSession);
+
+ // DB 소프트 삭제
+ resourceHelper.softDeleteResource(command.distributionId());
+ }
+
+ /**
+ * 캐시 무효화를 생성합니다.
+ *
+ * @param command 무효화 생성 명령
+ * @return 생성된 무효화 결과
+ */
+ @Transactional
+ public InvalidationResult createInvalidation(CreateInvalidationCommand command) {
+ validateAccountScope(command.accountScope());
+
+ // JIT 세션 획득
+ CloudSessionCredential session = acquireSession(command.accountScope(), ProviderType.AWS);
+
+ // Command에 세션 설정
+ CreateInvalidationCommand commandWithSession = CreateInvalidationCommand.builder()
+ .accountScope(command.accountScope())
+ .distributionId(command.distributionId())
+ .paths(command.paths())
+ .callerReference(command.callerReference())
+ .session(session)
+ .build();
+
+ CDNInvalidationPort invalidationPort = cdnPortRouter.invalidation(ProviderType.AWS);
+ return invalidationPort.createInvalidation(commandWithSession);
+ }
+
+ /**
+ * 캐시 무효화 상태를 조회합니다.
+ *
+ * @param accountScope 계정 스코프
+ * @param distributionId Distribution ID
+ * @param invalidationId 무효화 ID
+ * @param providerType 프로바이더 타입
+ * @return 무효화 결과 (존재하지 않으면 Optional.empty())
+ */
+ @Transactional(readOnly = true)
+ public Optional getInvalidation(
+ String accountScope,
+ String distributionId,
+ String invalidationId,
+ ProviderType providerType) {
+ validateAccountScope(accountScope);
+
+ CDNInvalidationPort invalidationPort = cdnPortRouter.invalidation(providerType);
+ return invalidationPort.getInvalidation(accountScope, distributionId, invalidationId);
+ }
+
+ /**
+ * 보상 트랜잭션: CSP에 생성된 Distribution을 삭제합니다.
+ * Ghost Resource 방지를 위해 DB 저장 실패 시 호출됩니다.
+ */
+ private void executeCompensatingTransaction(
+ ProviderType providerType,
+ CDNManagementPort managementPort,
+ String distributionId,
+ String accountScope,
+ CloudSessionCredential session
+ ) {
+ try {
+ log.warn("[CDNUseCaseService] 보상 트랜잭션 실행: CSP Distribution 삭제 시도 - distributionId={}", distributionId);
+
+ // ETag 조회를 위해 먼저 Distribution 조회
+ CDNDiscoveryPort discoveryPort = cdnPortRouter.discovery(providerType);
+ Optional distributionOpt = discoveryPort.getDistribution(accountScope, distributionId);
+
+ if (distributionOpt.isPresent()) {
+ // metadata에서 ETag 추출 (간단히 처리, 실제로는 파싱 필요)
+ String etag = "dummy-etag"; // 실제로는 metadata에서 추출
+
+ DeleteDistributionCommand deleteCommand = DeleteDistributionCommand.builder()
+ .providerType(providerType)
+ .accountScope(accountScope)
+ .distributionId(distributionId)
+ .etag(etag)
+ .session(session)
+ .build();
+
+ managementPort.deleteDistribution(deleteCommand);
+ log.info("[CDNUseCaseService] 보상 트랜잭션 완료: CSP Distribution 삭제 성공 - distributionId={}", distributionId);
+ } else {
+ log.warn("[CDNUseCaseService] 보상 트랜잭션: Distribution을 찾을 수 없음 - distributionId={}", distributionId);
+ }
+ } catch (Exception compensationError) {
+ // 보상 트랜잭션도 실패한 경우 - Ghost Resource 발생
+ log.error("[CDNUseCaseService] 보상 트랜잭션 실패: Ghost Resource 발생 가능 - distributionId={}, error={}",
+ distributionId, compensationError.getMessage());
+ }
+ }
+
+ /**
+ * accountScope를 검증합니다.
+ */
+ private void validateAccountScope(String accountScope) {
+ if (accountScope == null || accountScope.trim().isEmpty()) {
+ throw new BusinessException(
+ CloudErrorCode.ACCOUNT_SCOPE_REQUIRED,
+ "AccountScope가 필요합니다"
+ );
+ }
+ }
+
+ /**
+ * 세션을 획득합니다.
+ */
+ private CloudSessionCredential acquireSession(String accountScope, ProviderType providerType) {
+ String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow();
+
+ try {
+ CloudSessionCredential session = accountCredentialManagementPort.getSession(
+ tenantKey, accountScope, providerType);
+ log.debug("[CDNUseCaseService] 세션 획득 완료: expiresAt={}", session.getExpiresAt());
+ return session;
+ } catch (BusinessException e) {
+ if (e.getErrorCode() == CredentialErrorCode.CREDENTIAL_NOT_FOUND) {
+ log.error("[CDNUseCaseService] 자격증명을 찾을 수 없습니다: tenantKey={}, accountScope={}",
+ tenantKey, accountScope);
+ throw new BusinessException(
+ CloudErrorCode.ACCOUNT_NOT_CONFIGURED,
+ "계정이 설정되지 않았습니다"
+ );
+ }
+ throw e;
+ }
+ }
+}
+
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontDiscoveryAdapterTest.java b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontDiscoveryAdapterTest.java
new file mode 100644
index 00000000..e915d5f2
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontDiscoveryAdapterTest.java
@@ -0,0 +1,546 @@
+package com.agenticcp.core.domain.cloud.adapter.outbound.aws.cloudfront;
+
+import com.agenticcp.core.common.context.TenantContextHolder;
+import com.agenticcp.core.common.exception.BusinessException;
+import com.agenticcp.core.domain.cloud.adapter.outbound.aws.account.AwsSessionCredential;
+import com.agenticcp.core.domain.cloud.adapter.outbound.aws.config.AwsCloudFrontConfig;
+import com.agenticcp.core.domain.cloud.dto.CDNDistributionQueryRequest;
+import com.agenticcp.core.domain.cloud.entity.CloudProvider;
+import com.agenticcp.core.domain.cloud.entity.CloudResource;
+import com.agenticcp.core.domain.cloud.exception.CloudErrorCode;
+import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort;
+import com.agenticcp.core.domain.cloud.repository.CloudProviderRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.data.domain.Page;
+import software.amazon.awssdk.services.cloudfront.CloudFrontClient;
+import software.amazon.awssdk.services.cloudfront.model.*;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.util.*;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * AwsCloudFrontDiscoveryAdapter 단위 테스트
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+@DisplayName("AwsCloudFrontDiscoveryAdapter 테스트")
+class AwsCloudFrontDiscoveryAdapterTest {
+
+ @Mock
+ private AwsCloudFrontMapper mapper;
+
+ @Mock
+ private CloudProviderRepository cloudProviderRepository;
+
+ @Mock
+ private AccountCredentialManagementPort accountCredentialManagementPort;
+
+ @Mock
+ private AwsCloudFrontConfig awsCloudFrontConfig;
+
+ @Mock
+ private AwsCloudFrontErrorTranslator errorTranslator;
+
+ @Mock
+ private CloudFrontClient cloudFrontClient;
+
+ @InjectMocks
+ private AwsCloudFrontDiscoveryAdapter adapter;
+
+ private static final String TENANT_KEY = "test-tenant";
+ private static final String ACCOUNT_SCOPE = "123456789012";
+ private static final String DISTRIBUTION_ID = "E2QWRUHAPOMQZL";
+ private static final String DISTRIBUTION_ARN = "arn:aws:cloudfront::123456789012:distribution/E2QWRUHAPOMQZL";
+ private static final String ETAG = "ETAG123456789";
+
+ private AwsSessionCredential session;
+
+ @BeforeEach
+ void setUp() {
+ session = AwsSessionCredential.builder()
+ .accessKeyId("AKIA_TEST")
+ .secretAccessKey("secret")
+ .sessionToken("token")
+ .region("us-east-1")
+ .expiresAt(LocalDateTime.now().plusHours(1))
+ .build();
+
+ // CloudFrontClient close()가 예외를 던지지 않도록 설정
+ doNothing().when(cloudFrontClient).close();
+
+ // errorTranslator 기본 모킹: 예외가 발생할 경우를 대비
+ lenient().when(errorTranslator.translate(any(Exception.class)))
+ .thenAnswer(invocation -> {
+ Exception e = invocation.getArgument(0);
+ return new BusinessException(CloudErrorCode.CLOUD_PROVIDER_UNAVAILABLE, e.getMessage());
+ });
+
+ // 공통 Mock 설정: 모든 테스트에서 사용되는 기본 설정
+ lenient().when(accountCredentialManagementPort.getSession(
+ eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(CloudProvider.ProviderType.AWS)))
+ .thenReturn(session);
+ lenient().when(awsCloudFrontConfig.createCloudFrontClient(session))
+ .thenReturn(cloudFrontClient);
+ }
+
+ @Nested
+ @DisplayName("Distribution 목록 조회 테스트")
+ class ListDistributionsTest {
+
+ @Test
+ @DisplayName("정상적인 Distribution 목록 조회")
+ void listDistributions_Success() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ // accountCredentialManagementPort와 awsCloudFrontConfig는 @BeforeEach에서 설정됨
+
+ DistributionSummary summary1 = createMockDistributionSummary("E2QWRUHAPOMQZL", true);
+ DistributionSummary summary2 = createMockDistributionSummary("E2QWRUHAPOMQZL2", false);
+
+ DistributionList distributionList = DistributionList.builder()
+ .items(summary1, summary2)
+ .isTruncated(false)
+ .nextMarker(null)
+ .build();
+
+ ListDistributionsResponse listResponse = ListDistributionsResponse.builder()
+ .distributionList(distributionList)
+ .build();
+
+ when(cloudFrontClient.listDistributions(any(ListDistributionsRequest.class)))
+ .thenReturn(listResponse);
+
+ CloudResource resource1 = createMockCloudResource("E2QWRUHAPOMQZL", "dist-1");
+ CloudResource resource2 = createMockCloudResource("E2QWRUHAPOMQZL2", "dist-2");
+
+ when(mapper.toCloudResource(eq(summary1), any(CDNDistributionQueryRequest.class)))
+ .thenReturn(resource1);
+ when(mapper.toCloudResource(eq(summary2), any(CDNDistributionQueryRequest.class)))
+ .thenReturn(resource2);
+
+ when(cloudFrontClient.listTagsForResource(any(ListTagsForResourceRequest.class)))
+ .thenReturn(ListTagsForResourceResponse.builder()
+ .tags(Tags.builder()
+ .items(Collections.emptyList())
+ .build())
+ .build());
+
+ CDNDistributionQueryRequest query = CDNDistributionQueryRequest.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .tenantKey(TENANT_KEY)
+ .page(0)
+ .size(20)
+ .sortBy("name")
+ .sortDirection("asc")
+ .build();
+
+ // When
+ Page result = adapter.listDistributions(query);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getContent()).hasSize(2);
+ assertThat(result.getTotalElements()).isEqualTo(2);
+
+ verify(accountCredentialManagementPort).getSession(
+ eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(CloudProvider.ProviderType.AWS));
+ verify(cloudFrontClient).listDistributions(any(ListDistributionsRequest.class));
+ }
+ }
+
+ @Test
+ @DisplayName("Distribution 이름 필터링")
+ void listDistributions_WithNameFilter_Success() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ // accountCredentialManagementPort와 awsCloudFrontConfig는 @BeforeEach에서 설정됨
+
+ DistributionSummary summary1 = createMockDistributionSummary("E2QWRUHAPOMQZL", true);
+ DistributionSummary summary2 = createMockDistributionSummary("E2QWRUHAPOMQZL2", false);
+
+ DistributionList distributionList = DistributionList.builder()
+ .items(summary1, summary2)
+ .isTruncated(false)
+ .build();
+
+ when(cloudFrontClient.listDistributions(any(ListDistributionsRequest.class)))
+ .thenReturn(ListDistributionsResponse.builder()
+ .distributionList(distributionList)
+ .build());
+
+ CloudResource resource1 = createMockCloudResource("E2QWRUHAPOMQZL", "test-distribution");
+ CloudResource resource2 = createMockCloudResource("E2QWRUHAPOMQZL2", "other-distribution");
+
+ when(mapper.toCloudResource(any(DistributionSummary.class), any(CDNDistributionQueryRequest.class)))
+ .thenReturn(resource1, resource2);
+
+ when(cloudFrontClient.listTagsForResource(any(ListTagsForResourceRequest.class)))
+ .thenReturn(ListTagsForResourceResponse.builder()
+ .tags(Tags.builder().items(Collections.emptyList()).build())
+ .build());
+
+ CDNDistributionQueryRequest query = CDNDistributionQueryRequest.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .tenantKey(TENANT_KEY)
+ .distributionName("test")
+ .page(0)
+ .size(20)
+ .build();
+
+ // When
+ Page result = adapter.listDistributions(query);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getContent()).hasSize(1);
+ assertThat(result.getContent().get(0).getResourceName()).contains("test");
+ }
+ }
+
+ @Test
+ @DisplayName("태그 필터링")
+ void listDistributions_WithTagFilter_Success() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ // accountCredentialManagementPort와 awsCloudFrontConfig는 @BeforeEach에서 설정됨
+
+ DistributionSummary summary = createMockDistributionSummary(DISTRIBUTION_ID, true);
+ DistributionList distributionList = DistributionList.builder()
+ .items(summary)
+ .isTruncated(false)
+ .build();
+
+ when(cloudFrontClient.listDistributions(any(ListDistributionsRequest.class)))
+ .thenReturn(ListDistributionsResponse.builder()
+ .distributionList(distributionList)
+ .build());
+
+ CloudResource resource = createMockCloudResource(DISTRIBUTION_ID, "test-distribution");
+ resource.setTags(Map.of("Environment", "Test", "Project", "CDN"));
+
+ when(mapper.toCloudResource(any(DistributionSummary.class), any(CDNDistributionQueryRequest.class)))
+ .thenReturn(resource);
+
+ when(cloudFrontClient.listTagsForResource(any(ListTagsForResourceRequest.class)))
+ .thenReturn(ListTagsForResourceResponse.builder()
+ .tags(Tags.builder()
+ .items(
+ Tag.builder().key("Environment").value("Test").build(),
+ Tag.builder().key("Project").value("CDN").build()
+ )
+ .build())
+ .build());
+
+ CDNDistributionQueryRequest query = CDNDistributionQueryRequest.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .tenantKey(TENANT_KEY)
+ .tags(Map.of("Environment", "Test"))
+ .page(0)
+ .size(20)
+ .build();
+
+ // When
+ Page result = adapter.listDistributions(query);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getContent()).hasSize(1);
+ }
+ }
+
+ @Test
+ @DisplayName("페이징 처리")
+ void listDistributions_WithPagination_Success() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ // accountCredentialManagementPort와 awsCloudFrontConfig는 @BeforeEach에서 설정됨
+
+ List summaries = new ArrayList<>();
+ for (int i = 0; i < 25; i++) {
+ summaries.add(createMockDistributionSummary("E" + i, true));
+ }
+
+ DistributionList distributionList = DistributionList.builder()
+ .items(summaries)
+ .isTruncated(false)
+ .build();
+
+ when(cloudFrontClient.listDistributions(any(ListDistributionsRequest.class)))
+ .thenReturn(ListDistributionsResponse.builder()
+ .distributionList(distributionList)
+ .build());
+
+ CloudResource mockResource = createMockCloudResource("E1", "dist-1");
+ when(mapper.toCloudResource(any(DistributionSummary.class), any(CDNDistributionQueryRequest.class)))
+ .thenReturn(mockResource);
+
+ when(cloudFrontClient.listTagsForResource(any(ListTagsForResourceRequest.class)))
+ .thenReturn(ListTagsForResourceResponse.builder()
+ .tags(Tags.builder().items(Collections.emptyList()).build())
+ .build());
+
+ CDNDistributionQueryRequest query = CDNDistributionQueryRequest.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .tenantKey(TENANT_KEY)
+ .page(0)
+ .size(10)
+ .build();
+
+ // When
+ Page result = adapter.listDistributions(query);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getSize()).isEqualTo(10);
+ assertThat(result.getTotalElements()).isEqualTo(25);
+ }
+ }
+
+ @Test
+ @DisplayName("AccountScope 누락 시 예외 발생")
+ void listDistributions_WithoutAccountScope_ThrowsException() {
+ // Given
+ CDNDistributionQueryRequest query = CDNDistributionQueryRequest.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope(null)
+ .tenantKey(TENANT_KEY)
+ .build();
+
+ // When & Then
+ assertThatThrownBy(() -> adapter.listDistributions(query))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("AccountScope");
+ }
+ }
+
+ @Nested
+ @DisplayName("Distribution 단건 조회 테스트")
+ class GetDistributionTest {
+
+ @Test
+ @DisplayName("정상적인 Distribution 조회")
+ void getDistribution_Success() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ // accountCredentialManagementPort와 awsCloudFrontConfig는 @BeforeEach에서 설정됨
+
+ Distribution distribution = createMockDistribution(DISTRIBUTION_ID, true);
+ GetDistributionResponse getResponse = GetDistributionResponse.builder()
+ .distribution(distribution)
+ .eTag(ETAG)
+ .build();
+
+ when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class)))
+ .thenReturn(getResponse);
+
+ CloudResource mockResource = createMockCloudResource(DISTRIBUTION_ID, "test-distribution");
+ when(mapper.toCloudResource(
+ eq(distribution), eq(ETAG), eq(CloudProvider.ProviderType.AWS), anyMap()))
+ .thenReturn(mockResource);
+
+ when(cloudFrontClient.listTagsForResource(any(ListTagsForResourceRequest.class)))
+ .thenReturn(ListTagsForResourceResponse.builder()
+ .tags(Tags.builder()
+ .items(
+ Tag.builder().key("Environment").value("Test").build()
+ )
+ .build())
+ .build());
+
+ // When
+ Optional result = adapter.getDistribution(ACCOUNT_SCOPE, DISTRIBUTION_ID);
+
+ // Then
+ assertThat(result).isPresent();
+ assertThat(result.get().getResourceId()).isEqualTo(DISTRIBUTION_ID);
+
+ verify(accountCredentialManagementPort).getSession(
+ eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(CloudProvider.ProviderType.AWS));
+ verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class));
+ verify(cloudFrontClient).listTagsForResource(any(ListTagsForResourceRequest.class));
+ }
+ }
+
+ @Test
+ @DisplayName("Distribution이 존재하지 않을 때 Optional.empty 반환")
+ void getDistribution_NotFound_ReturnsEmpty() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ // accountCredentialManagementPort와 awsCloudFrontConfig는 @BeforeEach에서 설정됨
+
+ when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class)))
+ .thenThrow(NoSuchDistributionException.builder()
+ .message("Distribution not found")
+ .build());
+
+ // When
+ Optional result = adapter.getDistribution(ACCOUNT_SCOPE, DISTRIBUTION_ID);
+
+ // Then
+ assertThat(result).isEmpty();
+
+ verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class));
+ verify(cloudFrontClient, never()).listTagsForResource(any(ListTagsForResourceRequest.class));
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("Distribution 존재 여부 확인 테스트")
+ class DistributionExistsTest {
+
+ @Test
+ @DisplayName("Distribution이 존재할 때 true 반환")
+ void distributionExists_Exists_ReturnsTrue() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ // accountCredentialManagementPort와 awsCloudFrontConfig는 @BeforeEach에서 설정됨
+
+ Distribution distribution = createMockDistribution(DISTRIBUTION_ID, true);
+ GetDistributionResponse getResponse = GetDistributionResponse.builder()
+ .distribution(distribution)
+ .eTag(ETAG)
+ .build();
+
+ when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class)))
+ .thenReturn(getResponse);
+
+ // When
+ boolean exists = adapter.distributionExists(ACCOUNT_SCOPE, DISTRIBUTION_ID);
+
+ // Then
+ assertThat(exists).isTrue();
+
+ verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class));
+ }
+ }
+
+ @Test
+ @DisplayName("Distribution이 존재하지 않을 때 false 반환")
+ void distributionExists_NotExists_ReturnsFalse() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ // accountCredentialManagementPort와 awsCloudFrontConfig는 @BeforeEach에서 설정됨
+
+ when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class)))
+ .thenThrow(NoSuchDistributionException.builder()
+ .message("Distribution not found")
+ .build());
+
+ // When
+ boolean exists = adapter.distributionExists(ACCOUNT_SCOPE, DISTRIBUTION_ID);
+
+ // Then
+ assertThat(exists).isFalse();
+
+ verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class));
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("ProviderScoped 테스트")
+ class ProviderScopedTest {
+
+ @Test
+ @DisplayName("getProviderType은 AWS를 반환")
+ void getProviderType_ReturnsAWS() {
+ // When
+ CloudProvider.ProviderType result = adapter.getProviderType();
+
+ // Then
+ assertThat(result).isEqualTo(CloudProvider.ProviderType.AWS);
+ }
+ }
+
+ /**
+ * Mock DistributionSummary 객체 생성 헬퍼 메서드
+ */
+ private DistributionSummary createMockDistributionSummary(String distributionId, boolean enabled) {
+ return DistributionSummary.builder()
+ .id(distributionId)
+ .arn("arn:aws:cloudfront::123456789012:distribution/" + distributionId)
+ .status(enabled ? "Deployed" : "InProgress")
+ .domainName("d1234567890.cloudfront.net")
+ .enabled(enabled)
+ .comment("Test distribution")
+ .lastModifiedTime(Instant.now())
+ .build();
+ }
+
+ /**
+ * Mock Distribution 객체 생성 헬퍼 메서드
+ */
+ private Distribution createMockDistribution(String distributionId, boolean enabled) {
+ DistributionConfig config = DistributionConfig.builder()
+ .enabled(enabled)
+ .comment("Test distribution")
+ .callerReference("test-caller-ref")
+ .origins(Origins.builder()
+ .quantity(1)
+ .items(Origin.builder()
+ .id("origin-1")
+ .domainName("example.com")
+ .build())
+ .build())
+ .defaultCacheBehavior(DefaultCacheBehavior.builder()
+ .targetOriginId("origin-1")
+ .viewerProtocolPolicy(ViewerProtocolPolicy.REDIRECT_TO_HTTPS)
+ .build())
+ .build();
+
+ return Distribution.builder()
+ .id(distributionId)
+ .arn(DISTRIBUTION_ARN)
+ .status(enabled ? "Deployed" : "InProgress")
+ .domainName("d1234567890.cloudfront.net")
+ .distributionConfig(config)
+ .lastModifiedTime(Instant.now())
+ .build();
+ }
+
+ /**
+ * Mock CloudResource 객체 생성 헬퍼 메서드
+ */
+ private CloudResource createMockCloudResource(String resourceId, String resourceName) {
+ return CloudResource.builder()
+ .resourceId(resourceId)
+ .resourceName(resourceName)
+ .displayName(resourceName)
+ .resourceType(CloudResource.ResourceType.CDN_DISTRIBUTION)
+ .lifecycleState(CloudResource.LifecycleState.RUNNING)
+ .createdInCloud(LocalDateTime.now())
+ .build();
+ }
+}
+
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontInvalidationAdapterTest.java b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontInvalidationAdapterTest.java
new file mode 100644
index 00000000..2040728c
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontInvalidationAdapterTest.java
@@ -0,0 +1,465 @@
+package com.agenticcp.core.domain.cloud.adapter.outbound.aws.cloudfront;
+
+import com.agenticcp.core.common.context.TenantContextHolder;
+import com.agenticcp.core.common.exception.BusinessException;
+import com.agenticcp.core.domain.cloud.adapter.outbound.aws.account.AwsSessionCredential;
+import com.agenticcp.core.domain.cloud.adapter.outbound.aws.config.AwsCloudFrontConfig;
+import com.agenticcp.core.domain.cloud.entity.CloudProvider;
+import com.agenticcp.core.domain.cloud.exception.CloudErrorCode;
+import com.agenticcp.core.domain.cloud.port.model.cdn.CreateInvalidationCommand;
+import com.agenticcp.core.domain.cloud.port.model.cdn.InvalidationResult;
+import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import software.amazon.awssdk.services.cloudfront.CloudFrontClient;
+import software.amazon.awssdk.services.cloudfront.model.*;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * AwsCloudFrontInvalidationAdapter 단위 테스트
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+@DisplayName("AwsCloudFrontInvalidationAdapter 테스트")
+class AwsCloudFrontInvalidationAdapterTest {
+
+ @Mock
+ private AwsCloudFrontConfig awsCloudFrontConfig;
+
+ @Mock
+ private AwsCloudFrontErrorTranslator errorTranslator;
+
+ @Mock
+ private AccountCredentialManagementPort accountCredentialManagementPort;
+
+ @Mock
+ private CloudFrontClient cloudFrontClient;
+
+ @InjectMocks
+ private AwsCloudFrontInvalidationAdapter adapter;
+
+ private static final String TENANT_KEY = "test-tenant";
+ private static final String ACCOUNT_SCOPE = "123456789012";
+ private static final String DISTRIBUTION_ID = "E2QWRUHAPOMQZL";
+ private static final String INVALIDATION_ID = "I2J3K4L5M6N7O";
+
+ private AwsSessionCredential session;
+
+ @BeforeEach
+ void setUp() {
+ session = AwsSessionCredential.builder()
+ .accessKeyId("AKIA_TEST")
+ .secretAccessKey("secret")
+ .sessionToken("token")
+ .region("us-east-1")
+ .expiresAt(LocalDateTime.now().plusHours(1))
+ .build();
+
+ // CloudFrontClient close()가 예외를 던지지 않도록 설정
+ doNothing().when(cloudFrontClient).close();
+
+ // errorTranslator 기본 모킹
+ lenient().when(errorTranslator.translate(any(Exception.class)))
+ .thenAnswer(invocation -> {
+ Exception e = invocation.getArgument(0);
+ return new BusinessException(CloudErrorCode.CLOUD_PROVIDER_UNAVAILABLE, e.getMessage());
+ });
+
+ // 공통 Mock 설정
+ lenient().when(accountCredentialManagementPort.getSession(
+ eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(CloudProvider.ProviderType.AWS)))
+ .thenReturn(session);
+ lenient().when(awsCloudFrontConfig.createCloudFrontClient(session))
+ .thenReturn(cloudFrontClient);
+ }
+
+ @Nested
+ @DisplayName("캐시 무효화 생성 테스트")
+ class CreateInvalidationTest {
+
+ @Test
+ @DisplayName("정상적인 무효화 생성 (CallerReference 제공)")
+ void createInvalidation_Success_WithCallerReference() {
+ // Given
+ CreateInvalidationCommand command = CreateInvalidationCommand.builder()
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionId(DISTRIBUTION_ID)
+ .paths(List.of("/path1", "/path2"))
+ .callerReference("custom-reference-123")
+ .build();
+
+ Instant createTime = Instant.now();
+ Invalidation invalidation = Invalidation.builder()
+ .id(INVALIDATION_ID)
+ .status("InProgress")
+ .createTime(createTime)
+ .invalidationBatch(InvalidationBatch.builder()
+ .paths(Paths.builder()
+ .quantity(2)
+ .items(List.of("/path1", "/path2"))
+ .build())
+ .callerReference("custom-reference-123")
+ .build())
+ .build();
+
+ CreateInvalidationResponse response = CreateInvalidationResponse.builder()
+ .invalidation(invalidation)
+ .location("https://cloudfront.amazonaws.com/invalidation/" + INVALIDATION_ID)
+ .build();
+
+ try (MockedStatic mockedTenant = mockStatic(TenantContextHolder.class)) {
+ mockedTenant.when(TenantContextHolder::getCurrentTenantKeyOrThrow)
+ .thenReturn(TENANT_KEY);
+
+ when(cloudFrontClient.createInvalidation(any(CreateInvalidationRequest.class)))
+ .thenReturn(response);
+
+ // When
+ InvalidationResult result = adapter.createInvalidation(command);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.invalidationId()).isEqualTo(INVALIDATION_ID);
+ assertThat(result.distributionId()).isEqualTo(DISTRIBUTION_ID);
+ assertThat(result.status()).isEqualTo("InProgress");
+ assertThat(result.paths()).containsExactly("/path1", "/path2");
+ assertThat(result.createTime()).isNotNull();
+
+ verify(cloudFrontClient).createInvalidation(any(CreateInvalidationRequest.class));
+ }
+ }
+
+ @Test
+ @DisplayName("정상적인 무효화 생성 (CallerReference 미제공, 자동 생성)")
+ void createInvalidation_Success_WithoutCallerReference() {
+ // Given
+ CreateInvalidationCommand command = CreateInvalidationCommand.builder()
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionId(DISTRIBUTION_ID)
+ .paths(List.of("/*"))
+ .callerReference(null)
+ .build();
+
+ Instant createTime = Instant.now();
+ Invalidation invalidation = Invalidation.builder()
+ .id(INVALIDATION_ID)
+ .status("InProgress")
+ .createTime(createTime)
+ .invalidationBatch(InvalidationBatch.builder()
+ .paths(Paths.builder()
+ .quantity(1)
+ .items(List.of("/*"))
+ .build())
+ .callerReference("auto-generated-reference")
+ .build())
+ .build();
+
+ CreateInvalidationResponse response = CreateInvalidationResponse.builder()
+ .invalidation(invalidation)
+ .build();
+
+ try (MockedStatic mockedTenant = mockStatic(TenantContextHolder.class)) {
+ mockedTenant.when(TenantContextHolder::getCurrentTenantKeyOrThrow)
+ .thenReturn(TENANT_KEY);
+
+ when(cloudFrontClient.createInvalidation(any(CreateInvalidationRequest.class)))
+ .thenReturn(response);
+
+ // When
+ InvalidationResult result = adapter.createInvalidation(command);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.invalidationId()).isEqualTo(INVALIDATION_ID);
+ assertThat(result.paths()).containsExactly("/*");
+
+ verify(cloudFrontClient).createInvalidation(any(CreateInvalidationRequest.class));
+ }
+ }
+
+ @Test
+ @DisplayName("정상적인 무효화 생성 (경로 목록 비어있음, 기본값 사용)")
+ void createInvalidation_Success_WithEmptyPaths() {
+ // Given
+ CreateInvalidationCommand command = CreateInvalidationCommand.builder()
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionId(DISTRIBUTION_ID)
+ .paths(List.of())
+ .callerReference("test-reference")
+ .build();
+
+ Instant createTime = Instant.now();
+ Invalidation invalidation = Invalidation.builder()
+ .id(INVALIDATION_ID)
+ .status("InProgress")
+ .createTime(createTime)
+ .invalidationBatch(InvalidationBatch.builder()
+ .paths(Paths.builder()
+ .quantity(1)
+ .items(List.of("/*"))
+ .build())
+ .callerReference("test-reference")
+ .build())
+ .build();
+
+ CreateInvalidationResponse response = CreateInvalidationResponse.builder()
+ .invalidation(invalidation)
+ .build();
+
+ try (MockedStatic mockedTenant = mockStatic(TenantContextHolder.class)) {
+ mockedTenant.when(TenantContextHolder::getCurrentTenantKeyOrThrow)
+ .thenReturn(TENANT_KEY);
+
+ when(cloudFrontClient.createInvalidation(any(CreateInvalidationRequest.class)))
+ .thenReturn(response);
+
+ // When
+ InvalidationResult result = adapter.createInvalidation(command);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.paths()).containsExactly("/*"); // 기본값 사용
+
+ verify(cloudFrontClient).createInvalidation(any(CreateInvalidationRequest.class));
+ }
+ }
+
+ @Test
+ @DisplayName("무효화 생성 실패 (AccountScope 없음)")
+ void createInvalidation_WithoutAccountScope_ThrowsException() {
+ // Given
+ CreateInvalidationCommand command = CreateInvalidationCommand.builder()
+ .accountScope(null)
+ .distributionId(DISTRIBUTION_ID)
+ .paths(List.of("/path1"))
+ .build();
+
+ // When & Then
+ assertThatThrownBy(() -> adapter.createInvalidation(command))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("AccountScope가 필요합니다");
+
+ verify(cloudFrontClient, never()).createInvalidation(any(CreateInvalidationRequest.class));
+ }
+
+ @Test
+ @DisplayName("무효화 생성 실패 (AWS SDK 예외)")
+ void createInvalidation_AwsSdkException_ThrowsBusinessException() {
+ // Given
+ CreateInvalidationCommand command = CreateInvalidationCommand.builder()
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionId(DISTRIBUTION_ID)
+ .paths(List.of("/path1"))
+ .build();
+
+ try (MockedStatic mockedTenant = mockStatic(TenantContextHolder.class)) {
+ mockedTenant.when(TenantContextHolder::getCurrentTenantKeyOrThrow)
+ .thenReturn(TENANT_KEY);
+
+ doThrow(CloudFrontException.builder()
+ .message("Distribution not found")
+ .statusCode(404)
+ .build())
+ .when(cloudFrontClient).createInvalidation(any(CreateInvalidationRequest.class));
+
+ BusinessException translatedException = new BusinessException(
+ CloudErrorCode.CLOUD_RESOURCE_NOT_FOUND, "Distribution not found");
+ when(errorTranslator.translate(any(CloudFrontException.class)))
+ .thenReturn(translatedException);
+
+ // When & Then
+ assertThatThrownBy(() -> adapter.createInvalidation(command))
+ .isInstanceOf(BusinessException.class)
+ .extracting("errorCode")
+ .isEqualTo(CloudErrorCode.CLOUD_RESOURCE_NOT_FOUND);
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("캐시 무효화 조회 테스트")
+ class GetInvalidationTest {
+
+ @Test
+ @DisplayName("정상적인 무효화 조회")
+ void getInvalidation_Success() {
+ // Given
+ Instant createTime = Instant.now();
+ Invalidation invalidation = Invalidation.builder()
+ .id(INVALIDATION_ID)
+ .status("Completed")
+ .createTime(createTime)
+ .invalidationBatch(InvalidationBatch.builder()
+ .paths(Paths.builder()
+ .quantity(2)
+ .items(List.of("/path1", "/path2"))
+ .build())
+ .callerReference("test-reference")
+ .build())
+ .build();
+
+ GetInvalidationResponse response = GetInvalidationResponse.builder()
+ .invalidation(invalidation)
+ .build();
+
+ try (MockedStatic mockedTenant = mockStatic(TenantContextHolder.class)) {
+ mockedTenant.when(TenantContextHolder::getCurrentTenantKeyOrThrow)
+ .thenReturn(TENANT_KEY);
+
+ when(cloudFrontClient.getInvalidation(any(GetInvalidationRequest.class)))
+ .thenReturn(response);
+
+ // When
+ Optional result = adapter.getInvalidation(
+ ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID);
+
+ // Then
+ assertThat(result).isPresent();
+ InvalidationResult invalidationResult = result.get();
+ assertThat(invalidationResult.invalidationId()).isEqualTo(INVALIDATION_ID);
+ assertThat(invalidationResult.distributionId()).isEqualTo(DISTRIBUTION_ID);
+ assertThat(invalidationResult.status()).isEqualTo("Completed");
+ assertThat(invalidationResult.paths()).containsExactly("/path1", "/path2");
+ assertThat(invalidationResult.createTime()).isNotNull();
+
+ verify(cloudFrontClient).getInvalidation(any(GetInvalidationRequest.class));
+ }
+ }
+
+ @Test
+ @DisplayName("무효화 조회 실패 (존재하지 않음)")
+ void getInvalidation_NotFound_ReturnsEmpty() {
+ // Given
+ try (MockedStatic mockedTenant = mockStatic(TenantContextHolder.class)) {
+ mockedTenant.when(TenantContextHolder::getCurrentTenantKeyOrThrow)
+ .thenReturn(TENANT_KEY);
+
+ when(cloudFrontClient.getInvalidation(any(GetInvalidationRequest.class)))
+ .thenThrow(NoSuchInvalidationException.builder()
+ .message("Invalidation not found")
+ .build());
+
+ // When
+ Optional result = adapter.getInvalidation(
+ ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID);
+
+ // Then
+ assertThat(result).isEmpty();
+
+ verify(cloudFrontClient).getInvalidation(any(GetInvalidationRequest.class));
+ }
+ }
+
+ @Test
+ @DisplayName("무효화 조회 실패 (경로 정보 없음)")
+ void getInvalidation_WithoutPaths_ReturnsEmptyPaths() {
+ // Given
+ Instant createTime = Instant.now();
+ Invalidation invalidation = Invalidation.builder()
+ .id(INVALIDATION_ID)
+ .status("InProgress")
+ .createTime(createTime)
+ .invalidationBatch(InvalidationBatch.builder()
+ .paths((Paths) null) // 경로 정보 없음
+ .build())
+ .build();
+
+ GetInvalidationResponse response = GetInvalidationResponse.builder()
+ .invalidation(invalidation)
+ .build();
+
+ try (MockedStatic mockedTenant = mockStatic(TenantContextHolder.class)) {
+ mockedTenant.when(TenantContextHolder::getCurrentTenantKeyOrThrow)
+ .thenReturn(TENANT_KEY);
+
+ when(cloudFrontClient.getInvalidation(any(GetInvalidationRequest.class)))
+ .thenReturn(response);
+
+ // When
+ Optional result = adapter.getInvalidation(
+ ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID);
+
+ // Then
+ assertThat(result).isPresent();
+ assertThat(result.get().paths()).isEmpty(); // 빈 리스트 반환
+
+ verify(cloudFrontClient).getInvalidation(any(GetInvalidationRequest.class));
+ }
+ }
+
+ @Test
+ @DisplayName("무효화 조회 실패 (AccountScope 없음)")
+ void getInvalidation_WithoutAccountScope_ThrowsException() {
+ // When & Then
+ assertThatThrownBy(() -> adapter.getInvalidation(null, DISTRIBUTION_ID, INVALIDATION_ID))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("AccountScope가 필요합니다");
+
+ verify(cloudFrontClient, never()).getInvalidation(any(GetInvalidationRequest.class));
+ }
+
+ @Test
+ @DisplayName("무효화 조회 실패 (AWS SDK 예외)")
+ void getInvalidation_AwsSdkException_ThrowsBusinessException() {
+ // Given
+ try (MockedStatic mockedTenant = mockStatic(TenantContextHolder.class)) {
+ mockedTenant.when(TenantContextHolder::getCurrentTenantKeyOrThrow)
+ .thenReturn(TENANT_KEY);
+
+ doThrow(CloudFrontException.builder()
+ .message("Access denied")
+ .statusCode(403)
+ .build())
+ .when(cloudFrontClient).getInvalidation(any(GetInvalidationRequest.class));
+
+ BusinessException translatedException = new BusinessException(
+ CloudErrorCode.PERMISSION_DENIED, "Access denied");
+ when(errorTranslator.translate(any(CloudFrontException.class)))
+ .thenReturn(translatedException);
+
+ // When & Then
+ assertThatThrownBy(() -> adapter.getInvalidation(
+ ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID))
+ .isInstanceOf(BusinessException.class)
+ .extracting("errorCode")
+ .isEqualTo(CloudErrorCode.PERMISSION_DENIED);
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("ProviderScoped 테스트")
+ class ProviderScopedTest {
+
+ @Test
+ @DisplayName("getProviderType은 AWS를 반환")
+ void getProviderType_ReturnsAWS() {
+ // When
+ CloudProvider.ProviderType result = adapter.getProviderType();
+
+ // Then
+ assertThat(result).isEqualTo(CloudProvider.ProviderType.AWS);
+ }
+ }
+}
+
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontManagementAdapterTest.java b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontManagementAdapterTest.java
new file mode 100644
index 00000000..2b7e4152
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontManagementAdapterTest.java
@@ -0,0 +1,634 @@
+package com.agenticcp.core.domain.cloud.adapter.outbound.aws.cloudfront;
+
+import com.agenticcp.core.common.exception.BusinessException;
+import com.agenticcp.core.domain.cloud.adapter.outbound.aws.account.AwsSessionCredential;
+import com.agenticcp.core.domain.cloud.adapter.outbound.aws.config.AwsCloudFrontConfig;
+import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType;
+import com.agenticcp.core.domain.cloud.entity.CloudResource;
+import com.agenticcp.core.domain.cloud.port.model.cdn.*;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import software.amazon.awssdk.services.cloudfront.CloudFrontClient;
+import software.amazon.awssdk.services.cloudfront.model.*;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.any;
+
+/**
+ * AwsCloudFrontManagementAdapter 단위 테스트
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("AwsCloudFrontManagementAdapter 테스트")
+class AwsCloudFrontManagementAdapterTest {
+
+ @Mock
+ private AwsCloudFrontConfig awsCloudFrontConfig;
+
+ @Mock
+ private AwsCloudFrontMapper awsCloudFrontMapper;
+
+ @Mock
+ private CloudFrontClient cloudFrontClient;
+
+ @InjectMocks
+ private AwsCloudFrontManagementAdapter adapter;
+
+ private static final String DISTRIBUTION_ID = "E2QWRUHAPOMQZL";
+ private static final String DISTRIBUTION_ARN = "arn:aws:cloudfront::123456789012:distribution/E2QWRUHAPOMQZL";
+ private static final String ETAG = "ETAG123456789";
+ private static final String ACCOUNT_SCOPE = "123456789012";
+ private static final String TENANT_KEY = "test-tenant";
+
+ private AwsSessionCredential mockSession;
+ private OriginConfig originConfig;
+ private CacheBehaviorConfig cacheBehaviorConfig;
+
+ @BeforeEach
+ void setUp() {
+ mockSession = AwsSessionCredential.builder()
+ .accessKeyId("AKIA_TEST")
+ .secretAccessKey("secret")
+ .sessionToken("token")
+ .region("us-east-1")
+ .expiresAt(LocalDateTime.now().plusHours(1))
+ .build();
+
+ originConfig = OriginConfig.builder()
+ .id("origin-1")
+ .domainName("example.com")
+ .type(OriginConfig.OriginType.CUSTOM)
+ .httpPort(80)
+ .httpsPort(443)
+ .originProtocolPolicy("https-only")
+ .build();
+
+ cacheBehaviorConfig = CacheBehaviorConfig.builder()
+ .pathPattern("/*")
+ .ttl(86400L)
+ .allowedMethods(List.of("GET", "HEAD", "OPTIONS"))
+ .compress(true)
+ .viewerProtocolPolicy("redirect-to-https")
+ .build();
+ }
+
+ @Nested
+ @DisplayName("Distribution 생성 테스트")
+ class CreateDistributionTest {
+
+ @Test
+ @DisplayName("정상적인 Distribution 생성 (태그 없음)")
+ void createDistribution_Success_WithoutTags() {
+ // Given
+ CreateDistributionCommand command = CreateDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionName("test-distribution")
+ .comment("Test distribution")
+ .enabled(true)
+ .origin(originConfig)
+ .cacheBehaviors(List.of(cacheBehaviorConfig))
+ .tenantKey(TENANT_KEY)
+ .session(mockSession)
+ .build();
+
+ when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient);
+
+ Distribution distribution = createMockDistribution(DISTRIBUTION_ID, true);
+ CreateDistributionResponse createResponse = CreateDistributionResponse.builder()
+ .distribution(distribution)
+ .eTag(ETAG)
+ .location("https://cloudfront.amazonaws.com/distribution/" + DISTRIBUTION_ID)
+ .build();
+
+ when(cloudFrontClient.createDistribution(any(CreateDistributionRequest.class)))
+ .thenReturn(createResponse);
+
+ CloudResource mockResource = CloudResource.builder()
+ .resourceId(DISTRIBUTION_ID)
+ .resourceName("test-distribution")
+ .build();
+
+ when(awsCloudFrontMapper.toCloudResource(eq(distribution), eq(ETAG), eq(command)))
+ .thenReturn(mockResource);
+
+ // When
+ CloudResource result = adapter.createDistribution(command);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getResourceId()).isEqualTo(DISTRIBUTION_ID);
+ assertThat(result.getResourceName()).isEqualTo("test-distribution");
+
+ verify(awsCloudFrontConfig).createCloudFrontClient(mockSession);
+ verify(cloudFrontClient).createDistribution(any(CreateDistributionRequest.class));
+ verify(cloudFrontClient, never()).tagResource(any(TagResourceRequest.class));
+ verify(awsCloudFrontMapper).toCloudResource(eq(distribution), eq(ETAG), eq(command));
+ }
+
+ @Test
+ @DisplayName("정상적인 Distribution 생성 (태그 포함)")
+ void createDistribution_Success_WithTags() {
+ // Given
+ Map tags = Map.of(
+ "Environment", "Test",
+ "Project", "CDN"
+ );
+
+ CreateDistributionCommand command = CreateDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionName("test-distribution")
+ .comment("Test distribution")
+ .enabled(true)
+ .origin(originConfig)
+ .cacheBehaviors(List.of(cacheBehaviorConfig))
+ .tags(tags)
+ .tenantKey(TENANT_KEY)
+ .session(mockSession)
+ .build();
+
+ when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient);
+
+ Distribution distribution = createMockDistribution(DISTRIBUTION_ID, true);
+ CreateDistributionResponse createResponse = CreateDistributionResponse.builder()
+ .distribution(distribution)
+ .eTag(ETAG)
+ .location("https://cloudfront.amazonaws.com/distribution/" + DISTRIBUTION_ID)
+ .build();
+
+ when(cloudFrontClient.createDistribution(any(CreateDistributionRequest.class)))
+ .thenReturn(createResponse);
+ when(cloudFrontClient.tagResource(any(TagResourceRequest.class)))
+ .thenReturn(TagResourceResponse.builder().build());
+
+ CloudResource mockResource = CloudResource.builder()
+ .resourceId(DISTRIBUTION_ID)
+ .resourceName("test-distribution")
+ .build();
+
+ when(awsCloudFrontMapper.toCloudResource(eq(distribution), eq(ETAG), eq(command)))
+ .thenReturn(mockResource);
+
+ // When
+ CloudResource result = adapter.createDistribution(command);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getResourceId()).isEqualTo(DISTRIBUTION_ID);
+
+ verify(awsCloudFrontConfig).createCloudFrontClient(mockSession);
+ verify(cloudFrontClient).createDistribution(any(CreateDistributionRequest.class));
+ verify(cloudFrontClient).tagResource(any(TagResourceRequest.class));
+ verify(awsCloudFrontMapper).toCloudResource(eq(distribution), eq(ETAG), eq(command));
+ }
+
+ @Test
+ @DisplayName("Distribution 생성 실패 - CloudFrontException 발생")
+ void createDistribution_Failure_CloudFrontException() {
+ // Given
+ CreateDistributionCommand command = CreateDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionName("test-distribution")
+ .origin(originConfig)
+ .tenantKey(TENANT_KEY)
+ .session(mockSession)
+ .build();
+
+ when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient);
+ doThrow(CloudFrontException.builder()
+ .message("Too many distributions")
+ .statusCode(400)
+ .build())
+ .when(cloudFrontClient).createDistribution(any(CreateDistributionRequest.class));
+
+ // When & Then
+ assertThatThrownBy(() -> adapter.createDistribution(command))
+ .isInstanceOf(BusinessException.class);
+
+ verify(awsCloudFrontConfig).createCloudFrontClient(mockSession);
+ verify(cloudFrontClient).createDistribution(any(CreateDistributionRequest.class));
+ verify(awsCloudFrontMapper, never()).toCloudResource(
+ any(Distribution.class), anyString(), any(CreateDistributionCommand.class));
+ }
+
+ @Test
+ @DisplayName("Distribution 생성 후 태그 추가 실패 - 경고만 발생하고 계속 진행")
+ void createDistribution_TagFailure_Continues() {
+ // Given
+ Map tags = Map.of("Environment", "Test");
+
+ CreateDistributionCommand command = CreateDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionName("test-distribution")
+ .origin(originConfig)
+ .cacheBehaviors(List.of(cacheBehaviorConfig))
+ .tags(tags)
+ .tenantKey(TENANT_KEY)
+ .session(mockSession)
+ .build();
+
+ when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient);
+
+ Distribution distribution = createMockDistribution(DISTRIBUTION_ID, true);
+ CreateDistributionResponse createResponse = CreateDistributionResponse.builder()
+ .distribution(distribution)
+ .eTag(ETAG)
+ .build();
+
+ when(cloudFrontClient.createDistribution(any(CreateDistributionRequest.class)))
+ .thenReturn(createResponse);
+ when(cloudFrontClient.tagResource(any(TagResourceRequest.class)))
+ .thenThrow(CloudFrontException.builder()
+ .message("Access denied")
+ .statusCode(403)
+ .build());
+
+ CloudResource mockResource = CloudResource.builder()
+ .resourceId(DISTRIBUTION_ID)
+ .build();
+
+ when(awsCloudFrontMapper.toCloudResource(eq(distribution), eq(ETAG), eq(command)))
+ .thenReturn(mockResource);
+
+ // When
+ CloudResource result = adapter.createDistribution(command);
+
+ // Then
+ assertThat(result).isNotNull();
+ verify(cloudFrontClient).tagResource(any(TagResourceRequest.class));
+ verify(awsCloudFrontMapper).toCloudResource(eq(distribution), eq(ETAG), eq(command));
+ }
+ }
+
+ @Nested
+ @DisplayName("Distribution 수정 테스트")
+ class UpdateDistributionTest {
+
+ @Test
+ @DisplayName("정상적인 Distribution 수정")
+ void updateDistribution_Success() {
+ // Given
+ UpdateDistributionCommand command = UpdateDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionId(DISTRIBUTION_ID)
+ .etag(ETAG)
+ .comment("Updated comment")
+ .enabled(false)
+ .cacheBehaviors(List.of(cacheBehaviorConfig))
+ .tenantKey(TENANT_KEY)
+ .session(mockSession)
+ .build();
+
+ when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient);
+
+ Distribution currentDistribution = createMockDistribution(DISTRIBUTION_ID, true);
+ GetDistributionResponse getResponse = GetDistributionResponse.builder()
+ .distribution(currentDistribution)
+ .eTag(ETAG)
+ .build();
+
+ when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class)))
+ .thenReturn(getResponse);
+
+ Distribution updatedDistribution = createMockDistribution(DISTRIBUTION_ID, false);
+ UpdateDistributionResponse updateResponse = UpdateDistributionResponse.builder()
+ .distribution(updatedDistribution)
+ .eTag("ETAG_UPDATED")
+ .build();
+
+ when(cloudFrontClient.updateDistribution(any(UpdateDistributionRequest.class)))
+ .thenReturn(updateResponse);
+
+ CloudResource mockResource = CloudResource.builder()
+ .resourceId(DISTRIBUTION_ID)
+ .build();
+
+ when(awsCloudFrontMapper.toCloudResource(eq(updatedDistribution), eq("ETAG_UPDATED"), eq(command)))
+ .thenReturn(mockResource);
+
+ // When
+ CloudResource result = adapter.updateDistribution(command);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getResourceId()).isEqualTo(DISTRIBUTION_ID);
+
+ verify(awsCloudFrontConfig).createCloudFrontClient(mockSession);
+ verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class));
+ verify(cloudFrontClient).updateDistribution(any(UpdateDistributionRequest.class));
+ verify(awsCloudFrontMapper).toCloudResource(eq(updatedDistribution), eq("ETAG_UPDATED"), eq(command));
+ }
+
+ @Test
+ @DisplayName("Distribution 수정 실패 - ETag 불일치 (PreconditionFailed)")
+ void updateDistribution_Failure_ETagMismatch() {
+ // Given
+ UpdateDistributionCommand command = UpdateDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionId(DISTRIBUTION_ID)
+ .etag("OLD_ETAG")
+ .comment("Updated comment")
+ .tenantKey(TENANT_KEY)
+ .session(mockSession)
+ .build();
+
+ when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient);
+
+ Distribution currentDistribution = createMockDistribution(DISTRIBUTION_ID, true);
+ GetDistributionResponse getResponse = GetDistributionResponse.builder()
+ .distribution(currentDistribution)
+ .eTag("NEW_ETAG")
+ .build();
+
+ when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class)))
+ .thenReturn(getResponse);
+
+ doThrow(CloudFrontException.builder()
+ .message("Precondition failed")
+ .statusCode(412)
+ .build())
+ .when(cloudFrontClient).updateDistribution(any(UpdateDistributionRequest.class));
+
+ // When & Then
+ assertThatThrownBy(() -> adapter.updateDistribution(command))
+ .isInstanceOf(BusinessException.class);
+
+ verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class));
+ verify(cloudFrontClient).updateDistribution(any(UpdateDistributionRequest.class));
+ }
+
+ @Test
+ @DisplayName("Distribution 수정 실패 - Distribution 없음")
+ void updateDistribution_Failure_DistributionNotFound() {
+ // Given
+ UpdateDistributionCommand command = UpdateDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionId(DISTRIBUTION_ID)
+ .etag(ETAG)
+ .comment("Updated comment")
+ .tenantKey(TENANT_KEY)
+ .session(mockSession)
+ .build();
+
+ when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient);
+ doThrow(NoSuchDistributionException.builder()
+ .message("Distribution not found")
+ .build())
+ .when(cloudFrontClient).getDistribution(any(GetDistributionRequest.class));
+
+ // When & Then
+ assertThatThrownBy(() -> adapter.updateDistribution(command))
+ .isInstanceOf(BusinessException.class);
+
+ verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class));
+ verify(cloudFrontClient, never()).updateDistribution(any(UpdateDistributionRequest.class));
+ }
+ }
+
+ @Nested
+ @DisplayName("Distribution 삭제 테스트")
+ class DeleteDistributionTest {
+
+ @Test
+ @DisplayName("정상적인 Distribution 삭제 (활성화된 경우 - 비활성화 후 삭제)")
+ void deleteDistribution_Success_Enabled() {
+ // Given
+ DeleteDistributionCommand command = DeleteDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionId(DISTRIBUTION_ID)
+ .etag(ETAG)
+ .session(mockSession)
+ .build();
+
+ when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient);
+
+ Distribution enabledDistribution = createMockDistribution(DISTRIBUTION_ID, true);
+ GetDistributionResponse getResponse = GetDistributionResponse.builder()
+ .distribution(enabledDistribution)
+ .eTag(ETAG)
+ .build();
+
+ // waitForDeployment에서도 getDistribution을 호출하므로 Deployed 상태 반환
+ DistributionConfig deployedConfig = DistributionConfig.builder()
+ .enabled(false)
+ .comment("Test distribution")
+ .callerReference("test-caller-ref")
+ .origins(Origins.builder()
+ .quantity(1)
+ .items(Origin.builder()
+ .id("origin-1")
+ .domainName("example.com")
+ .build())
+ .build())
+ .defaultCacheBehavior(DefaultCacheBehavior.builder()
+ .targetOriginId("origin-1")
+ .viewerProtocolPolicy(ViewerProtocolPolicy.REDIRECT_TO_HTTPS)
+ .build())
+ .build();
+
+ Distribution deployedDistribution = Distribution.builder()
+ .id(DISTRIBUTION_ID)
+ .arn(DISTRIBUTION_ARN)
+ .status("Deployed") // 배포 완료 상태
+ .domainName("d1234567890.cloudfront.net")
+ .distributionConfig(deployedConfig)
+ .build();
+
+ GetDistributionResponse deployedResponse = GetDistributionResponse.builder()
+ .distribution(deployedDistribution)
+ .eTag("ETAG_DEPLOYED")
+ .build();
+
+ // 첫 번째 호출: deleteDistribution에서 상태 확인 (enabled=true, status=Deployed)
+ // 두 번째 호출: waitForDeployment에서 배포 상태 확인 (status=Deployed)
+ when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class)))
+ .thenReturn(getResponse) // 첫 번째: 활성화된 상태
+ .thenReturn(deployedResponse); // 두 번째: 배포 완료 상태
+
+ Distribution disabledDistribution = createMockDistribution(DISTRIBUTION_ID, false);
+ UpdateDistributionResponse updateResponse = UpdateDistributionResponse.builder()
+ .distribution(disabledDistribution)
+ .eTag("ETAG_DISABLED")
+ .build();
+
+ when(cloudFrontClient.updateDistribution(any(UpdateDistributionRequest.class)))
+ .thenReturn(updateResponse);
+ when(cloudFrontClient.deleteDistribution(any(DeleteDistributionRequest.class)))
+ .thenReturn(DeleteDistributionResponse.builder().build());
+
+ // When
+ adapter.deleteDistribution(command);
+
+ // Then
+ verify(awsCloudFrontConfig).createCloudFrontClient(mockSession);
+ // getDistribution은 최소 2번 호출됨 (상태 확인 + 배포 완료 확인)
+ verify(cloudFrontClient, atLeast(2)).getDistribution(any(GetDistributionRequest.class));
+ verify(cloudFrontClient).updateDistribution(any(UpdateDistributionRequest.class));
+ verify(cloudFrontClient).deleteDistribution(any(DeleteDistributionRequest.class));
+ }
+
+ @Test
+ @DisplayName("정상적인 Distribution 삭제 (비활성화된 경우 - 바로 삭제)")
+ void deleteDistribution_Success_Disabled() {
+ // Given
+ DeleteDistributionCommand command = DeleteDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionId(DISTRIBUTION_ID)
+ .etag(ETAG)
+ .session(mockSession)
+ .build();
+
+ when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient);
+
+ Distribution disabledDistribution = createMockDistribution(DISTRIBUTION_ID, false);
+ GetDistributionResponse getResponse = GetDistributionResponse.builder()
+ .distribution(disabledDistribution)
+ .eTag(ETAG)
+ .build();
+
+ when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class)))
+ .thenReturn(getResponse);
+ when(cloudFrontClient.deleteDistribution(any(DeleteDistributionRequest.class)))
+ .thenReturn(DeleteDistributionResponse.builder().build());
+
+ // When
+ adapter.deleteDistribution(command);
+
+ // Then
+ verify(awsCloudFrontConfig).createCloudFrontClient(mockSession);
+ verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class));
+ verify(cloudFrontClient, never()).updateDistribution(any(UpdateDistributionRequest.class));
+ verify(cloudFrontClient).deleteDistribution(any(DeleteDistributionRequest.class));
+ }
+
+ @Test
+ @DisplayName("Distribution 삭제 실패 - Distribution 없음")
+ void deleteDistribution_Failure_DistributionNotFound() {
+ // Given
+ DeleteDistributionCommand command = DeleteDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionId(DISTRIBUTION_ID)
+ .etag(ETAG)
+ .session(mockSession)
+ .build();
+
+ when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient);
+ doThrow(NoSuchDistributionException.builder()
+ .message("Distribution not found")
+ .build())
+ .when(cloudFrontClient).getDistribution(any(GetDistributionRequest.class));
+
+ // When & Then
+ assertThatThrownBy(() -> adapter.deleteDistribution(command))
+ .isInstanceOf(BusinessException.class);
+
+ verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class));
+ verify(cloudFrontClient, never()).deleteDistribution(any(DeleteDistributionRequest.class));
+ }
+
+ @Test
+ @DisplayName("Distribution 삭제 실패 - 삭제 중 예외 발생")
+ void deleteDistribution_Failure_DeleteException() {
+ // Given
+ DeleteDistributionCommand command = DeleteDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionId(DISTRIBUTION_ID)
+ .etag(ETAG)
+ .session(mockSession)
+ .build();
+
+ when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient);
+
+ Distribution disabledDistribution = createMockDistribution(DISTRIBUTION_ID, false);
+ GetDistributionResponse getResponse = GetDistributionResponse.builder()
+ .distribution(disabledDistribution)
+ .eTag(ETAG)
+ .build();
+
+ when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class)))
+ .thenReturn(getResponse);
+ doThrow(CloudFrontException.builder()
+ .message("Distribution is still deployed")
+ .statusCode(409)
+ .build())
+ .when(cloudFrontClient).deleteDistribution(any(DeleteDistributionRequest.class));
+
+ // When & Then
+ assertThatThrownBy(() -> adapter.deleteDistribution(command))
+ .isInstanceOf(BusinessException.class);
+
+ verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class));
+ verify(cloudFrontClient).deleteDistribution(any(DeleteDistributionRequest.class));
+ }
+ }
+
+ @Nested
+ @DisplayName("ProviderScoped 테스트")
+ class ProviderScopedTest {
+
+ @Test
+ @DisplayName("getProviderType은 AWS를 반환")
+ void getProviderType_ReturnsAWS() {
+ // When
+ ProviderType result = adapter.getProviderType();
+
+ // Then
+ assertThat(result).isEqualTo(ProviderType.AWS);
+ }
+ }
+
+ /**
+ * Mock Distribution 객체 생성 헬퍼 메서드
+ */
+ private Distribution createMockDistribution(String distributionId, boolean enabled) {
+ DistributionConfig config = DistributionConfig.builder()
+ .enabled(enabled)
+ .comment("Test distribution")
+ .callerReference("test-caller-ref")
+ .origins(Origins.builder()
+ .quantity(1)
+ .items(Origin.builder()
+ .id("origin-1")
+ .domainName("example.com")
+ .build())
+ .build())
+ .defaultCacheBehavior(DefaultCacheBehavior.builder()
+ .targetOriginId("origin-1")
+ .viewerProtocolPolicy(ViewerProtocolPolicy.REDIRECT_TO_HTTPS)
+ .build())
+ .build();
+
+ return Distribution.builder()
+ .id(distributionId)
+ .arn(DISTRIBUTION_ARN)
+ .status(enabled ? "Deployed" : "InProgress")
+ .domainName("d1234567890.cloudfront.net")
+ .distributionConfig(config)
+ .build();
+ }
+}
+
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseServiceDbSyncTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseServiceDbSyncTest.java
new file mode 100644
index 00000000..88cc1ed3
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseServiceDbSyncTest.java
@@ -0,0 +1,394 @@
+package com.agenticcp.core.domain.cloud.service.cdn;
+
+import com.agenticcp.core.common.context.TenantContextHolder;
+import com.agenticcp.core.common.exception.BusinessException;
+import com.agenticcp.core.domain.cloud.capability.CapabilityGuard;
+import com.agenticcp.core.domain.cloud.dto.ResourceRegistrationRequest;
+import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType;
+import com.agenticcp.core.domain.cloud.entity.CloudResource;
+import com.agenticcp.core.domain.cloud.exception.CloudErrorCode;
+import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential;
+import com.agenticcp.core.domain.cloud.port.model.cdn.CreateDistributionCommand;
+import com.agenticcp.core.domain.cloud.port.model.cdn.DeleteDistributionCommand;
+import com.agenticcp.core.domain.cloud.port.model.cdn.OriginConfig;
+import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort;
+import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNDiscoveryPort;
+import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNManagementPort;
+import com.agenticcp.core.domain.cloud.repository.CloudResourceRepository;
+import com.agenticcp.core.domain.cloud.service.helper.CloudResourceManagementHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * CDNUseCaseService DB 동기화 로직 단위 테스트
+ * CSP 작업 후 CloudResource 엔티티가 올바르게 DB에 저장/삭제되는지 검증합니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("CDNUseCaseService DB 동기화 테스트")
+class CDNUseCaseServiceDbSyncTest {
+
+ @Mock
+ private CDNPortRouter cdnPortRouter;
+
+ @Mock
+ private CDNManagementPort managementPort;
+
+ @Mock
+ private CDNDiscoveryPort discoveryPort;
+
+ @Mock
+ private CapabilityGuard capabilityGuard;
+
+ @Mock
+ private AccountCredentialManagementPort accountCredentialManagementPort;
+
+ @Mock
+ private CloudResourceManagementHelper resourceHelper;
+
+ @Mock
+ private CloudResourceRepository cloudResourceRepository;
+
+ private CDNUseCaseService cdnUseCaseService;
+ private CloudSessionCredential mockSession;
+
+ private static final ProviderType PROVIDER_TYPE = ProviderType.AWS;
+ private static final String ACCOUNT_SCOPE = "123456789012";
+ private static final String TENANT_KEY = "tenant-test";
+ private static final String DISTRIBUTION_ID = "E1234567890ABC";
+ private static final String DISTRIBUTION_NAME = "my-test-distribution";
+ private static final String METADATA_JSON = "{\"origin\":{\"id\":\"origin-1\",\"domainName\":\"example.com\"}}";
+
+ @BeforeEach
+ void setUp() {
+ TenantContextHolder.setTenantKey(TENANT_KEY);
+
+ cdnUseCaseService = new CDNUseCaseService(
+ cdnPortRouter,
+ capabilityGuard,
+ accountCredentialManagementPort,
+ resourceHelper,
+ cloudResourceRepository
+ );
+
+ mockSession = mock(CloudSessionCredential.class);
+ when(mockSession.getExpiresAt()).thenReturn(LocalDateTime.now().plusHours(1));
+
+ // 공통 Mock 설정
+ lenient().when(cdnPortRouter.management(PROVIDER_TYPE)).thenReturn(managementPort);
+ lenient().when(cdnPortRouter.discovery(PROVIDER_TYPE)).thenReturn(discoveryPort);
+ lenient().when(accountCredentialManagementPort.getSession(eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(PROVIDER_TYPE)))
+ .thenReturn(mockSession);
+ lenient().doNothing().when(capabilityGuard).ensureSupported(any(), anyString(), anyString(), any());
+ }
+
+ @AfterEach
+ void tearDown() {
+ TenantContextHolder.clear();
+ }
+
+ @Nested
+ @DisplayName("Distribution 생성 테스트")
+ class CreateDistributionTest {
+
+ @Test
+ @DisplayName("Distribution 생성 성공 시 CloudResource가 DB에 저장되고 metadata도 저장된다")
+ void createDistribution_Success_SavesCloudResourceWithMetadata() {
+ // Given
+ Map tags = Map.of("Environment", "test");
+ OriginConfig origin = OriginConfig.builder()
+ .id("origin-1")
+ .domainName("example.com")
+ .type(OriginConfig.OriginType.CUSTOM)
+ .build();
+
+ CreateDistributionCommand command = CreateDistributionCommand.builder()
+ .providerType(PROVIDER_TYPE)
+ .accountScope(ACCOUNT_SCOPE)
+ .serviceKey("CloudFront")
+ .distributionName(DISTRIBUTION_NAME)
+ .origin(origin)
+ .tags(tags)
+ .build();
+
+ CloudResource mockCreatedDistribution = CloudResource.builder()
+ .resourceId(DISTRIBUTION_ID)
+ .resourceName(DISTRIBUTION_NAME)
+ .metadata(METADATA_JSON)
+ .build();
+
+ CloudResource mockSavedResource = CloudResource.builder()
+ .resourceId(DISTRIBUTION_ID)
+ .resourceName(DISTRIBUTION_NAME)
+ .build();
+
+ when(managementPort.createDistribution(any())).thenReturn(mockCreatedDistribution);
+ when(resourceHelper.registerResource(
+ eq(PROVIDER_TYPE),
+ eq("CloudFront"),
+ any(ResourceRegistrationRequest.class)
+ )).thenReturn(mockSavedResource);
+ when(cloudResourceRepository.save(any(CloudResource.class))).thenReturn(mockSavedResource);
+
+ // When
+ CloudResource result = cdnUseCaseService.createDistribution(command);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getResourceId()).isEqualTo(DISTRIBUTION_ID);
+
+ verify(resourceHelper).registerResource(
+ eq(PROVIDER_TYPE),
+ eq("CloudFront"),
+ any(ResourceRegistrationRequest.class)
+ );
+ // metadata 저장 검증
+ verify(cloudResourceRepository).save(argThat(resource ->
+ resource.getMetadata() != null && resource.getMetadata().equals(METADATA_JSON)
+ ));
+ }
+
+ @Test
+ @DisplayName("Distribution 이름이 없으면 Distribution ID가 리소스 이름으로 사용된다")
+ void createDistribution_NoDistributionName_UsesDistributionIdAsResourceName() {
+ // Given
+ OriginConfig origin = OriginConfig.builder()
+ .id("origin-1")
+ .domainName("example.com")
+ .type(OriginConfig.OriginType.CUSTOM)
+ .build();
+
+ CreateDistributionCommand command = CreateDistributionCommand.builder()
+ .providerType(PROVIDER_TYPE)
+ .accountScope(ACCOUNT_SCOPE)
+ .serviceKey("CloudFront")
+ .distributionName(null) // Distribution 이름 없음
+ .origin(origin)
+ .build();
+
+ CloudResource mockCreatedDistribution = CloudResource.builder()
+ .resourceId(DISTRIBUTION_ID)
+ .resourceName(DISTRIBUTION_ID)
+ .metadata(METADATA_JSON)
+ .build();
+
+ CloudResource mockSavedResource = CloudResource.builder()
+ .resourceId(DISTRIBUTION_ID)
+ .resourceName(DISTRIBUTION_ID)
+ .build();
+
+ when(managementPort.createDistribution(any())).thenReturn(mockCreatedDistribution);
+ when(resourceHelper.registerResource(any(), any(), any())).thenReturn(mockSavedResource);
+ when(cloudResourceRepository.save(any(CloudResource.class))).thenReturn(mockSavedResource);
+
+ // When
+ cdnUseCaseService.createDistribution(command);
+
+ // Then
+ verify(resourceHelper).registerResource(
+ eq(PROVIDER_TYPE),
+ eq("CloudFront"),
+ any(ResourceRegistrationRequest.class)
+ );
+ }
+
+ @Test
+ @DisplayName("metadata가 없으면 metadata 저장은 스킵된다")
+ void createDistribution_NoMetadata_SkipsMetadataSave() {
+ // Given
+ OriginConfig origin = OriginConfig.builder()
+ .id("origin-1")
+ .domainName("example.com")
+ .type(OriginConfig.OriginType.CUSTOM)
+ .build();
+
+ CreateDistributionCommand command = CreateDistributionCommand.builder()
+ .providerType(PROVIDER_TYPE)
+ .accountScope(ACCOUNT_SCOPE)
+ .serviceKey("CloudFront")
+ .distributionName(DISTRIBUTION_NAME)
+ .origin(origin)
+ .build();
+
+ CloudResource mockCreatedDistribution = CloudResource.builder()
+ .resourceId(DISTRIBUTION_ID)
+ .resourceName(DISTRIBUTION_NAME)
+ .metadata(null) // metadata 없음
+ .build();
+
+ CloudResource mockSavedResource = CloudResource.builder()
+ .resourceId(DISTRIBUTION_ID)
+ .resourceName(DISTRIBUTION_NAME)
+ .build();
+
+ when(managementPort.createDistribution(any())).thenReturn(mockCreatedDistribution);
+ when(resourceHelper.registerResource(any(), any(), any())).thenReturn(mockSavedResource);
+
+ // When
+ cdnUseCaseService.createDistribution(command);
+
+ // Then
+ verify(resourceHelper).registerResource(any(), any(), any());
+ // metadata가 null이면 저장하지 않음
+ verify(cloudResourceRepository, never()).save(any(CloudResource.class));
+ }
+
+ @Test
+ @DisplayName("DB 저장 실패 시 보상 트랜잭션이 실행되고 예외가 발생한다")
+ void createDistribution_DbSaveFails_CompensatingTransactionExecuted() {
+ // Given
+ OriginConfig origin = OriginConfig.builder()
+ .id("origin-1")
+ .domainName("example.com")
+ .type(OriginConfig.OriginType.CUSTOM)
+ .build();
+
+ CreateDistributionCommand command = CreateDistributionCommand.builder()
+ .providerType(PROVIDER_TYPE)
+ .accountScope(ACCOUNT_SCOPE)
+ .serviceKey("CloudFront")
+ .distributionName(DISTRIBUTION_NAME)
+ .origin(origin)
+ .build();
+
+ CloudResource mockCreatedDistribution = CloudResource.builder()
+ .resourceId(DISTRIBUTION_ID)
+ .resourceName(DISTRIBUTION_NAME)
+ .metadata(METADATA_JSON)
+ .build();
+
+ when(managementPort.createDistribution(any())).thenReturn(mockCreatedDistribution);
+ // DB 저장 실패
+ doThrow(new RuntimeException("DB 저장 실패")).when(resourceHelper)
+ .registerResource(any(), any(), any());
+ // 보상 트랜잭션을 위한 Distribution 조회
+ when(discoveryPort.getDistribution(eq(ACCOUNT_SCOPE), eq(DISTRIBUTION_ID)))
+ .thenReturn(Optional.of(mockCreatedDistribution));
+
+ // When & Then
+ assertThatThrownBy(() -> cdnUseCaseService.createDistribution(command))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(exception -> {
+ BusinessException be = (BusinessException) exception;
+ assertThat(be.getErrorCode()).isEqualTo(CloudErrorCode.RESOURCE_CREATION_FAILED);
+ });
+
+ // 보상 트랜잭션 실행 검증: CSP Distribution 삭제 호출됨
+ verify(discoveryPort).getDistribution(eq(ACCOUNT_SCOPE), eq(DISTRIBUTION_ID));
+ verify(managementPort).deleteDistribution(any(DeleteDistributionCommand.class));
+ }
+
+ @Test
+ @DisplayName("보상 트랜잭션도 실패하면 Ghost Resource 경고 로그가 출력된다")
+ void createDistribution_CompensationFails_GhostResourceWarningLogged() {
+ // Given
+ OriginConfig origin = OriginConfig.builder()
+ .id("origin-1")
+ .domainName("example.com")
+ .type(OriginConfig.OriginType.CUSTOM)
+ .build();
+
+ CreateDistributionCommand command = CreateDistributionCommand.builder()
+ .providerType(PROVIDER_TYPE)
+ .accountScope(ACCOUNT_SCOPE)
+ .serviceKey("CloudFront")
+ .distributionName(DISTRIBUTION_NAME)
+ .origin(origin)
+ .build();
+
+ CloudResource mockCreatedDistribution = CloudResource.builder()
+ .resourceId(DISTRIBUTION_ID)
+ .resourceName(DISTRIBUTION_NAME)
+ .metadata(METADATA_JSON)
+ .build();
+
+ when(managementPort.createDistribution(any())).thenReturn(mockCreatedDistribution);
+ // DB 저장 실패
+ doThrow(new RuntimeException("DB 저장 실패")).when(resourceHelper)
+ .registerResource(any(), any(), any());
+ // 보상 트랜잭션을 위한 Distribution 조회
+ when(discoveryPort.getDistribution(eq(ACCOUNT_SCOPE), eq(DISTRIBUTION_ID)))
+ .thenReturn(Optional.of(mockCreatedDistribution));
+ // 보상 트랜잭션(CSP 삭제)도 실패
+ doThrow(new RuntimeException("CSP 삭제 실패")).when(managementPort)
+ .deleteDistribution(any(DeleteDistributionCommand.class));
+
+ // When & Then
+ assertThatThrownBy(() -> cdnUseCaseService.createDistribution(command))
+ .isInstanceOf(BusinessException.class);
+
+ // 보상 트랜잭션 시도 검증
+ verify(discoveryPort).getDistribution(eq(ACCOUNT_SCOPE), eq(DISTRIBUTION_ID));
+ verify(managementPort).deleteDistribution(any(DeleteDistributionCommand.class));
+ // Ghost Resource 발생 - 실제로는 모니터링/배치로 처리 필요
+ }
+ }
+
+ @Nested
+ @DisplayName("Distribution 삭제 테스트")
+ class DeleteDistributionTest {
+
+ @Test
+ @DisplayName("Distribution 삭제 시 소프트 삭제가 수행된다")
+ void deleteDistribution_Success_SoftDeletesResource() {
+ // Given
+ DeleteDistributionCommand command = DeleteDistributionCommand.builder()
+ .providerType(PROVIDER_TYPE)
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionId(DISTRIBUTION_ID)
+ .etag("dummy-etag")
+ .build();
+
+ doNothing().when(managementPort).deleteDistribution(any());
+
+ // When
+ cdnUseCaseService.deleteDistribution(command);
+
+ // Then
+ verify(managementPort).deleteDistribution(any(DeleteDistributionCommand.class));
+ verify(resourceHelper).softDeleteResource(DISTRIBUTION_ID);
+ }
+
+ @Test
+ @DisplayName("DB에 리소스가 없어도 CSP 삭제는 성공한다")
+ void deleteDistribution_ResourceNotInDb_CspDeletionSucceeds() {
+ // Given
+ DeleteDistributionCommand command = DeleteDistributionCommand.builder()
+ .providerType(PROVIDER_TYPE)
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionId(DISTRIBUTION_ID)
+ .etag("dummy-etag")
+ .build();
+
+ doNothing().when(managementPort).deleteDistribution(any());
+ // Helper 내부에서 리소스가 없으면 로그만 출력하고 예외 발생 안함
+
+ // When
+ cdnUseCaseService.deleteDistribution(command);
+
+ // Then
+ verify(managementPort).deleteDistribution(any(DeleteDistributionCommand.class)); // CSP 작업 성공
+ verify(resourceHelper).softDeleteResource(DISTRIBUTION_ID);
+ }
+ }
+}
+
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseServiceTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseServiceTest.java
new file mode 100644
index 00000000..f17cbb40
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseServiceTest.java
@@ -0,0 +1,619 @@
+package com.agenticcp.core.domain.cloud.service.cdn;
+
+import com.agenticcp.core.common.context.TenantContextHolder;
+import com.agenticcp.core.common.exception.BusinessException;
+import com.agenticcp.core.domain.cloud.adapter.outbound.aws.account.AwsSessionCredential;
+import com.agenticcp.core.domain.cloud.capability.CapabilityGuard;
+import com.agenticcp.core.domain.cloud.dto.CDNDistributionQueryRequest;
+import com.agenticcp.core.domain.cloud.dto.ResourceRegistrationRequest;
+import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType;
+import com.agenticcp.core.domain.cloud.entity.CloudResource;
+import com.agenticcp.core.domain.cloud.exception.CloudErrorCode;
+import com.agenticcp.core.domain.cloud.exception.CredentialErrorCode;
+import com.agenticcp.core.domain.cloud.port.model.cdn.*;
+import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort;
+import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNDiscoveryPort;
+import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNInvalidationPort;
+import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNManagementPort;
+import com.agenticcp.core.domain.cloud.repository.CloudResourceRepository;
+import com.agenticcp.core.domain.cloud.service.helper.CloudResourceManagementHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * CDNUseCaseService 단위 테스트
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("CDNUseCaseService 단위 테스트")
+class CDNUseCaseServiceTest {
+
+ @Mock
+ private CDNPortRouter cdnPortRouter;
+
+ @Mock
+ private CapabilityGuard capabilityGuard;
+
+ @Mock
+ private AccountCredentialManagementPort accountCredentialManagementPort;
+
+ @Mock
+ private CloudResourceManagementHelper resourceHelper;
+
+ @Mock
+ private CDNManagementPort managementPort;
+
+ @Mock
+ private CDNDiscoveryPort discoveryPort;
+
+ @Mock
+ private CDNInvalidationPort invalidationPort;
+
+ @Mock
+ private CloudResourceRepository cloudResourceRepository;
+
+ @InjectMocks
+ private CDNUseCaseService cdnUseCaseService;
+
+ private static final String TENANT_KEY = "test-tenant";
+ private static final String ACCOUNT_SCOPE = "123456789012";
+ private static final String DISTRIBUTION_ID = "E2QWRUHAPOMQZL";
+ private static final String DISTRIBUTION_NAME = "test-distribution";
+ private static final String ETAG = "ETAG123456789";
+ private static final String INVALIDATION_ID = "I2J3K4L5M6N7O";
+
+ private AwsSessionCredential mockSession;
+ private CloudResource mockDistribution;
+ private OriginConfig originConfig;
+ private CacheBehaviorConfig cacheBehaviorConfig;
+
+ @BeforeEach
+ void setUp() {
+ TenantContextHolder.setTenantKey(TENANT_KEY);
+
+ mockSession = AwsSessionCredential.builder()
+ .accessKeyId("AKIA_TEST")
+ .secretAccessKey("secret")
+ .sessionToken("token")
+ .region("us-east-1")
+ .expiresAt(LocalDateTime.now().plusHours(1))
+ .build();
+
+ mockDistribution = CloudResource.builder()
+ .resourceId(DISTRIBUTION_ID)
+ .resourceName(DISTRIBUTION_NAME)
+ .displayName(DISTRIBUTION_NAME)
+ .resourceType(CloudResource.ResourceType.CDN_DISTRIBUTION)
+ .lifecycleState(CloudResource.LifecycleState.RUNNING)
+ .build();
+
+ originConfig = OriginConfig.builder()
+ .id("origin-1")
+ .domainName("example.com")
+ .type(OriginConfig.OriginType.CUSTOM)
+ .httpPort(80)
+ .httpsPort(443)
+ .originProtocolPolicy("https-only")
+ .build();
+
+ cacheBehaviorConfig = CacheBehaviorConfig.builder()
+ .pathPattern("/*")
+ .ttl(86400L)
+ .allowedMethods(List.of("GET", "HEAD", "OPTIONS"))
+ .compress(true)
+ .viewerProtocolPolicy("redirect-to-https")
+ .build();
+
+ // 기본 Mock 설정
+ lenient().doNothing().when(capabilityGuard).ensureSupported(
+ any(ProviderType.class),
+ anyString(),
+ anyString(),
+ any(CapabilityGuard.Operation.class)
+ );
+
+ lenient().when(cdnPortRouter.management(ProviderType.AWS)).thenReturn(managementPort);
+ lenient().when(cdnPortRouter.discovery(ProviderType.AWS)).thenReturn(discoveryPort);
+ lenient().when(cdnPortRouter.invalidation(ProviderType.AWS)).thenReturn(invalidationPort);
+
+ // 공통 Mock 설정: 모든 테스트에서 사용되는 기본 설정
+ lenient().when(accountCredentialManagementPort.getSession(
+ eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(ProviderType.AWS)))
+ .thenReturn(mockSession);
+ lenient().when(cloudResourceRepository.save(any(CloudResource.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+ }
+
+ @AfterEach
+ void tearDown() {
+ TenantContextHolder.clear();
+ }
+
+ @Nested
+ @DisplayName("Distribution 생성 테스트")
+ class CreateDistributionTest {
+
+ @Test
+ @DisplayName("정상적인 Distribution 생성")
+ void createDistribution_Success() {
+ // Given
+ CreateDistributionCommand command = CreateDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .serviceKey("CloudFront")
+ .resourceType("CDN_DISTRIBUTION")
+ .distributionName(DISTRIBUTION_NAME)
+ .comment("Test distribution")
+ .enabled(true)
+ .origin(originConfig)
+ .cacheBehaviors(List.of(cacheBehaviorConfig))
+ .tags(Map.of("Environment", "Test"))
+ .build();
+
+ // accountCredentialManagementPort는 @BeforeEach에서 설정됨
+ CloudResource distributionWithMetadata = CloudResource.builder()
+ .resourceId(DISTRIBUTION_ID)
+ .resourceName(DISTRIBUTION_NAME)
+ .displayName(DISTRIBUTION_NAME)
+ .resourceType(CloudResource.ResourceType.CDN_DISTRIBUTION)
+ .lifecycleState(CloudResource.LifecycleState.RUNNING)
+ .metadata("{\"origin\":{\"id\":\"origin-1\"}}")
+ .build();
+
+ when(managementPort.createDistribution(any(CreateDistributionCommand.class)))
+ .thenReturn(distributionWithMetadata);
+ when(resourceHelper.registerResource(any(ProviderType.class), anyString(), any(ResourceRegistrationRequest.class)))
+ .thenReturn(mockDistribution);
+ when(cloudResourceRepository.save(any(CloudResource.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // When
+ CloudResource result = cdnUseCaseService.createDistribution(command);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getResourceId()).isEqualTo(DISTRIBUTION_ID);
+ assertThat(result.getResourceName()).isEqualTo(DISTRIBUTION_NAME);
+
+ verify(capabilityGuard).ensureSupported(
+ eq(ProviderType.AWS), eq("CloudFront"), eq("CDN_DISTRIBUTION"),
+ eq(CapabilityGuard.Operation.TAGGING));
+ verify(accountCredentialManagementPort).getSession(
+ eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(ProviderType.AWS));
+ verify(managementPort).createDistribution(any(CreateDistributionCommand.class));
+ verify(resourceHelper).registerResource(
+ eq(ProviderType.AWS), eq("CloudFront"), any(ResourceRegistrationRequest.class));
+ // metadata 저장 검증
+ verify(cloudResourceRepository).save(argThat(resource ->
+ resource.getMetadata() != null && resource.getMetadata().equals("{\"origin\":{\"id\":\"origin-1\"}}")
+ ));
+ }
+
+ @Test
+ @DisplayName("DB 저장 실패 시 보상 트랜잭션 실행")
+ void createDistribution_DbSaveFailure_ExecutesCompensation() {
+ // Given
+ CreateDistributionCommand command = CreateDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .serviceKey("CloudFront")
+ .resourceType("CDN_DISTRIBUTION")
+ .distributionName(DISTRIBUTION_NAME)
+ .origin(originConfig)
+ .build();
+
+ // accountCredentialManagementPort는 @BeforeEach에서 설정됨
+ when(managementPort.createDistribution(any(CreateDistributionCommand.class)))
+ .thenReturn(mockDistribution);
+ when(resourceHelper.registerResource(any(), any(), any()))
+ .thenThrow(new RuntimeException("DB 저장 실패"));
+
+ // Distribution 조회를 위한 Mock (보상 트랜잭션에서 사용)
+ when(discoveryPort.getDistribution(eq(ACCOUNT_SCOPE), eq(DISTRIBUTION_ID)))
+ .thenReturn(Optional.of(mockDistribution));
+ doNothing().when(managementPort).deleteDistribution(any(DeleteDistributionCommand.class));
+
+ // When & Then
+ assertThatThrownBy(() -> cdnUseCaseService.createDistribution(command))
+ .isInstanceOf(BusinessException.class)
+ .extracting("errorCode")
+ .isEqualTo(CloudErrorCode.RESOURCE_CREATION_FAILED);
+
+ verify(managementPort).createDistribution(any(CreateDistributionCommand.class));
+ verify(resourceHelper).registerResource(any(), any(), any());
+ verify(discoveryPort).getDistribution(eq(ACCOUNT_SCOPE), eq(DISTRIBUTION_ID));
+ verify(managementPort).deleteDistribution(any(DeleteDistributionCommand.class));
+ }
+
+ @Test
+ @DisplayName("accountScope가 null일 때 예외 발생")
+ void createDistribution_NullAccountScope_ThrowsException() {
+ // Given
+ CreateDistributionCommand command = CreateDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(null)
+ .serviceKey("CloudFront")
+ .resourceType("CDN_DISTRIBUTION")
+ .distributionName(DISTRIBUTION_NAME)
+ .origin(originConfig)
+ .build();
+
+ // When & Then
+ assertThatThrownBy(() -> cdnUseCaseService.createDistribution(command))
+ .isInstanceOf(BusinessException.class)
+ .extracting("errorCode")
+ .isEqualTo(CloudErrorCode.ACCOUNT_SCOPE_REQUIRED);
+
+ verify(accountCredentialManagementPort, never()).getSession(any(), any(), any());
+ }
+
+ @Test
+ @DisplayName("자격증명을 찾을 수 없을 때 ACCOUNT_NOT_CONFIGURED 예외 발생")
+ void createDistribution_CredentialNotFound_ThrowsException() {
+ // Given
+ CreateDistributionCommand command = CreateDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .serviceKey("CloudFront")
+ .resourceType("CDN_DISTRIBUTION")
+ .distributionName(DISTRIBUTION_NAME)
+ .origin(originConfig)
+ .build();
+
+ when(accountCredentialManagementPort.getSession(
+ eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(ProviderType.AWS)))
+ .thenThrow(new BusinessException(
+ CredentialErrorCode.CREDENTIAL_NOT_FOUND,
+ "자격증명을 찾을 수 없습니다"
+ ));
+
+ // When & Then
+ assertThatThrownBy(() -> cdnUseCaseService.createDistribution(command))
+ .isInstanceOf(BusinessException.class)
+ .extracting("errorCode")
+ .isEqualTo(CloudErrorCode.ACCOUNT_NOT_CONFIGURED);
+ }
+ }
+
+ @Nested
+ @DisplayName("Distribution 조회 테스트")
+ class GetDistributionTest {
+
+ @Test
+ @DisplayName("정상적인 Distribution 조회")
+ void getDistribution_Success() {
+ // Given
+ when(discoveryPort.getDistribution(ACCOUNT_SCOPE, DISTRIBUTION_ID))
+ .thenReturn(Optional.of(mockDistribution));
+
+ // When
+ Optional result = cdnUseCaseService.getDistribution(
+ ACCOUNT_SCOPE, DISTRIBUTION_ID, ProviderType.AWS);
+
+ // Then
+ assertThat(result).isPresent();
+ assertThat(result.get().getResourceId()).isEqualTo(DISTRIBUTION_ID);
+
+ verify(discoveryPort).getDistribution(ACCOUNT_SCOPE, DISTRIBUTION_ID);
+ }
+
+ @Test
+ @DisplayName("Distribution이 존재하지 않을 때 Optional.empty 반환")
+ void getDistribution_NotFound_ReturnsEmpty() {
+ // Given
+ when(discoveryPort.getDistribution(ACCOUNT_SCOPE, DISTRIBUTION_ID))
+ .thenReturn(Optional.empty());
+
+ // When
+ Optional result = cdnUseCaseService.getDistribution(
+ ACCOUNT_SCOPE, DISTRIBUTION_ID, ProviderType.AWS);
+
+ // Then
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ @DisplayName("accountScope가 null일 때 예외 발생")
+ void getDistribution_NullAccountScope_ThrowsException() {
+ // When & Then
+ assertThatThrownBy(() -> cdnUseCaseService.getDistribution(
+ null, DISTRIBUTION_ID, ProviderType.AWS))
+ .isInstanceOf(BusinessException.class)
+ .extracting("errorCode")
+ .isEqualTo(CloudErrorCode.ACCOUNT_SCOPE_REQUIRED);
+ }
+ }
+
+ @Nested
+ @DisplayName("Distribution 목록 조회 테스트")
+ class ListDistributionsTest {
+
+ @Test
+ @DisplayName("정상적인 Distribution 목록 조회")
+ void listDistributions_Success() {
+ // Given
+ CDNDistributionQueryRequest query = CDNDistributionQueryRequest.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .tenantKey(TENANT_KEY)
+ .page(0)
+ .size(20)
+ .build();
+
+ Page expectedPage = new PageImpl<>(
+ List.of(mockDistribution),
+ PageRequest.of(0, 20),
+ 1
+ );
+
+ // accountCredentialManagementPort는 @BeforeEach에서 설정됨
+ when(discoveryPort.listDistributions(any(CDNDistributionQueryRequest.class)))
+ .thenReturn(expectedPage);
+
+ // When
+ Page result = cdnUseCaseService.listDistributions(query);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getTotalElements()).isEqualTo(1);
+ assertThat(result.getContent()).hasSize(1);
+
+ verify(accountCredentialManagementPort).getSession(
+ eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(ProviderType.AWS));
+ verify(discoveryPort).listDistributions(any(CDNDistributionQueryRequest.class));
+ }
+
+ @Test
+ @DisplayName("accountScope가 null일 때 예외 발생")
+ void listDistributions_NullAccountScope_ThrowsException() {
+ // Given
+ CDNDistributionQueryRequest query = CDNDistributionQueryRequest.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(null)
+ .build();
+
+ // When & Then
+ assertThatThrownBy(() -> cdnUseCaseService.listDistributions(query))
+ .isInstanceOf(BusinessException.class)
+ .extracting("errorCode")
+ .isEqualTo(CloudErrorCode.ACCOUNT_SCOPE_REQUIRED);
+ }
+ }
+
+ @Nested
+ @DisplayName("Distribution 수정 테스트")
+ class UpdateDistributionTest {
+
+ @Test
+ @DisplayName("정상적인 Distribution 수정")
+ void updateDistribution_Success() {
+ // Given
+ UpdateDistributionCommand command = UpdateDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionId(DISTRIBUTION_ID)
+ .etag(ETAG)
+ .comment("Updated comment")
+ .enabled(false)
+ .cacheBehaviors(List.of(cacheBehaviorConfig))
+ .tags(Map.of("Environment", "Production"))
+ .build();
+
+ CloudResource updatedDistribution = CloudResource.builder()
+ .resourceId(DISTRIBUTION_ID)
+ .resourceName(DISTRIBUTION_NAME)
+ .lifecycleState(CloudResource.LifecycleState.PENDING)
+ .build();
+
+ // accountCredentialManagementPort는 @BeforeEach에서 설정됨
+ when(managementPort.updateDistribution(any(UpdateDistributionCommand.class)))
+ .thenReturn(updatedDistribution);
+
+ // When
+ CloudResource result = cdnUseCaseService.updateDistribution(command);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getResourceId()).isEqualTo(DISTRIBUTION_ID);
+
+ verify(capabilityGuard).ensureSupported(
+ eq(ProviderType.AWS), eq("CloudFront"), eq("CDN_DISTRIBUTION"),
+ eq(CapabilityGuard.Operation.TAGGING));
+ verify(accountCredentialManagementPort).getSession(
+ eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(ProviderType.AWS));
+ verify(managementPort).updateDistribution(any(UpdateDistributionCommand.class));
+ }
+
+ @Test
+ @DisplayName("accountScope가 null일 때 예외 발생")
+ void updateDistribution_NullAccountScope_ThrowsException() {
+ // Given
+ UpdateDistributionCommand command = UpdateDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(null)
+ .distributionId(DISTRIBUTION_ID)
+ .etag(ETAG)
+ .build();
+
+ // When & Then
+ assertThatThrownBy(() -> cdnUseCaseService.updateDistribution(command))
+ .isInstanceOf(BusinessException.class)
+ .extracting("errorCode")
+ .isEqualTo(CloudErrorCode.ACCOUNT_SCOPE_REQUIRED);
+ }
+ }
+
+ @Nested
+ @DisplayName("Distribution 삭제 테스트")
+ class DeleteDistributionTest {
+
+ @Test
+ @DisplayName("정상적인 Distribution 삭제")
+ void deleteDistribution_Success() {
+ // Given
+ DeleteDistributionCommand command = DeleteDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionId(DISTRIBUTION_ID)
+ .etag(ETAG)
+ .build();
+
+ // accountCredentialManagementPort는 @BeforeEach에서 설정됨
+ doNothing().when(managementPort).deleteDistribution(any(DeleteDistributionCommand.class));
+ doNothing().when(resourceHelper).softDeleteResource(eq(DISTRIBUTION_ID));
+
+ // When
+ cdnUseCaseService.deleteDistribution(command);
+
+ // Then
+ verify(capabilityGuard).ensureSupported(
+ eq(ProviderType.AWS), eq("CloudFront"), eq("CDN_DISTRIBUTION"),
+ eq(CapabilityGuard.Operation.TERMINATE));
+ verify(accountCredentialManagementPort).getSession(
+ eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(ProviderType.AWS));
+ verify(managementPort).deleteDistribution(any(DeleteDistributionCommand.class));
+ verify(resourceHelper).softDeleteResource(DISTRIBUTION_ID);
+ }
+
+ @Test
+ @DisplayName("accountScope가 null일 때 예외 발생")
+ void deleteDistribution_NullAccountScope_ThrowsException() {
+ // Given
+ DeleteDistributionCommand command = DeleteDistributionCommand.builder()
+ .providerType(ProviderType.AWS)
+ .accountScope(null)
+ .distributionId(DISTRIBUTION_ID)
+ .etag(ETAG)
+ .build();
+
+ // When & Then
+ assertThatThrownBy(() -> cdnUseCaseService.deleteDistribution(command))
+ .isInstanceOf(BusinessException.class)
+ .extracting("errorCode")
+ .isEqualTo(CloudErrorCode.ACCOUNT_SCOPE_REQUIRED);
+ }
+ }
+
+ @Nested
+ @DisplayName("캐시 무효화 테스트")
+ class InvalidationTest {
+
+ @Test
+ @DisplayName("정상적인 캐시 무효화 생성")
+ void createInvalidation_Success() {
+ // Given
+ CreateInvalidationCommand command = CreateInvalidationCommand.builder()
+ .accountScope(ACCOUNT_SCOPE)
+ .distributionId(DISTRIBUTION_ID)
+ .paths(List.of("/*", "/images/*"))
+ .callerReference("test-ref")
+ .build();
+
+ InvalidationResult expectedResult = InvalidationResult.builder()
+ .invalidationId(INVALIDATION_ID)
+ .distributionId(DISTRIBUTION_ID)
+ .status("InProgress")
+ .createTime(LocalDateTime.now())
+ .paths(List.of("/*", "/images/*"))
+ .build();
+
+ // accountCredentialManagementPort는 @BeforeEach에서 설정됨
+ when(invalidationPort.createInvalidation(any(CreateInvalidationCommand.class)))
+ .thenReturn(expectedResult);
+
+ // When
+ InvalidationResult result = cdnUseCaseService.createInvalidation(command);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.invalidationId()).isEqualTo(INVALIDATION_ID);
+ assertThat(result.distributionId()).isEqualTo(DISTRIBUTION_ID);
+ assertThat(result.status()).isEqualTo("InProgress");
+
+ verify(accountCredentialManagementPort).getSession(
+ eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(ProviderType.AWS));
+ verify(invalidationPort).createInvalidation(any(CreateInvalidationCommand.class));
+ }
+
+ @Test
+ @DisplayName("정상적인 캐시 무효화 상태 조회")
+ void getInvalidation_Success() {
+ // Given
+ InvalidationResult expectedResult = InvalidationResult.builder()
+ .invalidationId(INVALIDATION_ID)
+ .distributionId(DISTRIBUTION_ID)
+ .status("Completed")
+ .createTime(LocalDateTime.now())
+ .paths(List.of("/*"))
+ .build();
+
+ when(invalidationPort.getInvalidation(ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID))
+ .thenReturn(Optional.of(expectedResult));
+
+ // When
+ Optional result = cdnUseCaseService.getInvalidation(
+ ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID, ProviderType.AWS);
+
+ // Then
+ assertThat(result).isPresent();
+ assertThat(result.get().invalidationId()).isEqualTo(INVALIDATION_ID);
+ assertThat(result.get().status()).isEqualTo("Completed");
+
+ verify(invalidationPort).getInvalidation(ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID);
+ }
+
+ @Test
+ @DisplayName("무효화가 존재하지 않을 때 Optional.empty 반환")
+ void getInvalidation_NotFound_ReturnsEmpty() {
+ // Given
+ when(invalidationPort.getInvalidation(ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID))
+ .thenReturn(Optional.empty());
+
+ // When
+ Optional result = cdnUseCaseService.getInvalidation(
+ ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID, ProviderType.AWS);
+
+ // Then
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ @DisplayName("accountScope가 null일 때 예외 발생")
+ void createInvalidation_NullAccountScope_ThrowsException() {
+ // Given
+ CreateInvalidationCommand command = CreateInvalidationCommand.builder()
+ .accountScope(null)
+ .distributionId(DISTRIBUTION_ID)
+ .paths(List.of("/*"))
+ .build();
+
+ // When & Then
+ assertThatThrownBy(() -> cdnUseCaseService.createInvalidation(command))
+ .isInstanceOf(BusinessException.class)
+ .extracting("errorCode")
+ .isEqualTo(CloudErrorCode.ACCOUNT_SCOPE_REQUIRED);
+ }
+ }
+}
+