Merge branch 'master' into feature/crypto-module

This commit is contained in:
curry772
2026-05-12 16:33:59 +09:00
23 changed files with 1611 additions and 514 deletions
@@ -0,0 +1,319 @@
package com.eactive.eai.adapter.handler;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import com.eactive.eai.common.property.PropManager;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.message.StandardItem;
import com.eactive.eai.message.StandardMessage;
/**
* 템플릿 기반 어댑터 에러 메시지 핸들러.
*
* [프로퍼티 설정]
* AdapterPropManager 의 "AdapterErrorMessageHandler" 프로퍼티 그룹에서
* "{adapterGroupId}.template" 키로 등록된 템플릿 문자열을 읽어
* StandardMessage 의 필드 값으로 치환한 결과를 반환한다.
*
* [변수 치환 규칙]
* 1. "${callprop.키}" → callProp.getProperty("키") 로 치환
* "${callprop.키:기본값}" → 키 없거나 callProp null 이면 기본값 반환
* 2. "${callprop[경로]}" → StandardMessage 경로 값을 키로 callProp 간접 조회
* "${callprop[경로]:기본값}" → 키 결정 실패 또는 키 없으면 기본값 반환
* 3. "${경로}" → StandardMessage.findItemValue("경로") 로 치환
* "${경로:기본값}" → 경로 값이 null/공백이면 기본값 반환
* 모든 형태에서 기본값 미지정 시 빈 문자열을 반환한다.
*
* [배열 반복 문법] (MSG_LIST 등 GRID 타입)
* {{#foreach MSG.MSG_LIST}}
* { "code":"${outp_msg_cd}", "msg":"${outp_msg_ctnt}" }
* {{/foreach}}
* → MSG_LIST 각 행을 반복하며 행 내 필드명(접두어 없이)으로 치환,
* 반복 결과를 쉼표(,)로 이어 붙임
*
* [사용 예 JSON 템플릿]
* {
* "adapterName": "${callprop.ADAPTER_NAME}",
* "resultCode": "${MSG.MAIN_MSG.outp_msg_cd}",
* "resultMsg": "${MSG.MAIN_MSG.outp_msg_ctnt}",
* "errors": [
* {{#foreach MSG.MSG_LIST}}
* {
* "code": "${outp_msg_cd}",
* "msg": "${outp_msg_ctnt}",
* "desc": "${outp_msg_desc}",
* "field": "${err_occu_item_nm}"
* }
* {{/foreach}}
* ]
* }
*
* [사용 예 – XML 템플릿]
* <error>
* <adapter>${callprop.ADAPTER_NAME}</adapter>
* <code>${MSG.MAIN_MSG.outp_msg_cd}</code>
* <errors>
* {{#foreach MSG.MSG_LIST}}
* <item><code>${outp_msg_cd}</code><msg>${outp_msg_ctnt}</msg></item>
* {{/foreach}}
* </errors>
* </error>
*
* [callProp 중첩 Properties 참조]
* callProp 의 값이 Properties 인 경우 점(.) 으로 체인 탐색이 가능하다.
* callProp.put("OUTBOUND", subProps) // subProps.setProperty("IF_ID", "SVC001")
* → 템플릿에서 ${callprop.OUTBOUND.IF_ID} 로 참조
* 중간 값이 Properties 가 아닌 경우 전체 keyPath 를 키로 폴백 조회한다.
*/
public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandler {
private static final Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
/** AdapterPropManager 에서 템플릿을 조회할 프로퍼티 그룹 이름 */
static final String PROP_GROUP = "AdapterErrorMessageHandler";
/** callProp 참조 변수 접두어: ${callprop.키} */
static final String CALLPROP_PREFIX = "callprop.";
/** callProp 간접 참조 접두어: ${callprop[표준전문경로]} */
static final String CALLPROP_INDIRECT_PREFIX = "callprop[";
/** ${path} 또는 ${callprop.key} 스칼라 변수 패턴 */
private static final Pattern VAR_PATTERN =
Pattern.compile("\\$\\{([^}]+)\\}");
/** {{#foreach path}}...{{/foreach}} 배열 반복 블록 패턴 */
private static final Pattern FOREACH_PATTERN =
Pattern.compile("\\{\\{#foreach\\s+([^}]+)\\}\\}(.*?)\\{\\{/foreach\\}\\}",
Pattern.DOTALL);
@Override
public Object generateNonStandardErrorResponseMessage(
String inboundAdapterGroupName,
String inboundAdapterName,
Properties callProp,
Object outboundRequestData,
StandardMessage resStandardMessage) throws Exception {
String templateKey = inboundAdapterGroupName + ".template";
String template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey);
if (StringUtils.isBlank(template)) {
if (logger.isWarn()) {
logger.warn("TemplateAdapterErrorMsgHandler] template not found. group=" + PROP_GROUP
+ ", key=" + templateKey);
}
return null;
}
return render(template, resStandardMessage, callProp);
}
/**
* 템플릿에 StandardMessage 및 callProp 값을 치환해 최종 문자열을 반환한다.
* package-private: 단위 테스트에서 직접 호출 가능
*/
String render(String template, StandardMessage msg, Properties callProp) {
// 1단계: {{#foreach path}}...{{/foreach}} 배열 반복 처리
StringBuffer sb = new StringBuffer();
Matcher foreachMatcher = FOREACH_PATTERN.matcher(template);
while (foreachMatcher.find()) {
String arrayPath = foreachMatcher.group(1).trim();
String blockContent = foreachMatcher.group(2);
String rendered = renderForeach(arrayPath, blockContent, msg);
foreachMatcher.appendReplacement(sb, Matcher.quoteReplacement(rendered));
}
foreachMatcher.appendTail(sb);
// 2단계: 나머지 ${path} / ${callprop.키} 스칼라 변수 치환
String intermediate = sb.toString();
StringBuffer result = new StringBuffer();
Matcher varMatcher = VAR_PATTERN.matcher(intermediate);
while (varMatcher.find()) {
String expr = varMatcher.group(1).trim();
String value = resolveScalar(expr, msg, callProp);
varMatcher.appendReplacement(result, Matcher.quoteReplacement(value));
}
varMatcher.appendTail(result);
return result.toString();
}
/**
* 변수 표현식을 해석해 값을 반환한다.
* 모든 형태에서 ':기본값' 접미사를 지원한다. 기본값 미지정 시 빈 문자열.
* - "callprop[경로][:기본값]" → 표준전문 경로 값을 키로 callProp 간접 조회
* - "callprop.키[:기본값]" → callProp 직접/중첩 조회
* - "경로[:기본값]" → StandardMessage.findItemValue(경로)
*/
private String resolveScalar(String expr, StandardMessage msg, Properties callProp) {
if (expr.startsWith(CALLPROP_INDIRECT_PREFIX)) {
return resolveIndirectCallProp(expr, msg, callProp);
}
if (expr.startsWith(CALLPROP_PREFIX)) {
String rest = expr.substring(CALLPROP_PREFIX.length());
int colonIdx = rest.indexOf(':');
String keyPath = colonIdx >= 0 ? rest.substring(0, colonIdx) : rest;
String defaultVal = colonIdx >= 0 ? rest.substring(colonIdx + 1) : "";
if (callProp == null) return defaultVal;
String value = resolveCallProp(callProp, keyPath);
return value != null ? value : defaultVal;
}
int colonIdx = expr.indexOf(':');
String path = colonIdx >= 0 ? expr.substring(0, colonIdx) : expr;
String defaultVal = colonIdx >= 0 ? expr.substring(colonIdx + 1) : "";
String value = resolveMessagePath(msg, path);
return StringUtils.isNotEmpty(value) ? value : defaultVal;
}
/**
* StandardMessage 경로를 해석한다.
* 1. findItemValue(path) 로 직접 조회한다.
* 2. null 이면 경로를 뒤에서부터 분리하며, 부모 경로 값이 JSON 문자열인 경우
* 나머지 경로(subPath)를 키로 JSON 필드를 추출한다.
* 예) DATA.BIZDATA.acctNo → findItemValue("DATA.BIZDATA") → JSON 파싱 → acctNo
* DATA.BIZDATA.addr.city → findItemValue("DATA.BIZDATA") → JSON 파싱 → addr.city
*/
private String resolveMessagePath(StandardMessage msg, String path) {
String value = msg.findItemValue(path);
if (value != null) return value;
int dotIdx = path.lastIndexOf('.');
while (dotIdx > 0) {
String parentPath = path.substring(0, dotIdx);
String subPath = path.substring(dotIdx + 1);
String parentVal = msg.findItemValue(parentPath);
if (StringUtils.isNotBlank(parentVal)) {
String extracted = extractFromJson(parentVal, subPath);
if (extracted != null) return extracted;
}
dotIdx = parentPath.lastIndexOf('.');
}
return null;
}
/**
* JSON 문자열에서 dot 구분 경로로 필드 값을 추출한다.
* 중간 경로가 JSON 오브젝트면 계속 탐색하고, 최종 값이 primitive 면 문자열로,
* 오브젝트/배열이면 JSON 문자열 그대로 반환한다. 파싱 실패 시 null 반환.
*/
private String extractFromJson(String json, String fieldPath) {
try {
JsonElement el = new JsonParser().parse(json);
for (String part : fieldPath.split("\\.", -1)) {
if (!el.isJsonObject()) return null;
el = el.getAsJsonObject().get(part);
if (el == null) return null;
}
return el.isJsonPrimitive() ? el.getAsString() : el.toString();
} catch (Exception e) {
return null;
}
}
/**
* ${callprop[경로]} 또는 ${callprop[경로]:기본값} 처리.
* 1. 표준전문 경로로 callProp 키를 동적으로 결정한다.
* 2. 결정된 키로 callProp 에서 값을 조회한다.
* 3. 키 또는 값을 찾지 못하면 기본값(없으면 빈 문자열)을 반환한다.
*/
private String resolveIndirectCallProp(String expr, StandardMessage msg, Properties callProp) {
int closeIdx = expr.indexOf(']');
if (closeIdx < 0) return "";
String msgPath = expr.substring(CALLPROP_INDIRECT_PREFIX.length(), closeIdx);
String defaultVal = (closeIdx + 1 < expr.length() && expr.charAt(closeIdx + 1) == ':')
? expr.substring(closeIdx + 2)
: "";
String key = msg.findItemValue(msgPath);
if (StringUtils.isBlank(key)) return defaultVal;
if (callProp == null) return defaultVal;
String value = resolveCallProp(callProp, key);
return value != null ? value : defaultVal;
}
/**
* Properties 체인을 점(.) 구분자로 재귀 탐색한다.
* - 첫 세그먼트의 값이 Properties 이면 나머지 경로로 재귀
* - String 이면 반환, 그 외 Object 이면 toString() 반환
* - 첫 세그먼트 값이 Properties 가 아닌 경우 전체 keyPath 로 폴백 조회
*/
private String resolveCallProp(Properties props, String keyPath) {
int dotIdx = keyPath.indexOf('.');
if (dotIdx < 0) {
Object val = props.get(keyPath);
if (val == null) return null;
if (val instanceof String) return (String) val;
return val.toString();
}
String first = keyPath.substring(0, dotIdx);
String rest = keyPath.substring(dotIdx + 1);
Object val = props.get(first);
if (val instanceof Properties) {
return resolveCallProp((Properties) val, rest);
}
// 중간 값이 Properties 가 아닌 경우: 전체 keyPath 를 키로 폴백
Object direct = props.get(keyPath);
if (direct == null) return null;
if (direct instanceof String) return (String) direct;
return direct.toString();
}
/**
* GRID 타입 배열 아이템을 반복하며 blockContent 를 각 행으로 치환,
* 결과를 쉼표로 이어 반환한다. (foreach 블록 내부는 callProp 참조 미지원)
*/
private String renderForeach(String arrayPath, String blockContent, StandardMessage msg) {
StandardItem arrayItem = msg.findItem(arrayPath);
if (arrayItem == null) {
if (logger.isWarn()) logger.warn("TemplateAdapterErrorMsgHandler] foreach path not found: " + arrayPath);
return "";
}
List<LinkedHashMap<String, StandardItem>> rows = arrayItem.getList();
if (rows == null || rows.isEmpty()) {
return "";
}
List<String> renderedItems = new ArrayList<>();
for (LinkedHashMap<String, StandardItem> row : rows) {
String rowText = renderRow(blockContent, row);
if (StringUtils.isNotBlank(rowText)) {
renderedItems.add(rowText);
}
}
return String.join(",", renderedItems);
}
/**
* 배열 한 행(LinkedHashMap) 기준으로 blockContent 의 ${fieldName} 을 치환한다.
* fieldName 은 경로 없이 해당 행의 컬럼명만 사용한다.
*/
private String renderRow(String blockContent, LinkedHashMap<String, StandardItem> row) {
StringBuffer result = new StringBuffer();
Matcher varMatcher = VAR_PATTERN.matcher(blockContent);
while (varMatcher.find()) {
String fieldName = varMatcher.group(1).trim();
String value = "";
StandardItem item = row.get(fieldName);
if (item != null) {
value = StringUtils.defaultString(item.getValue());
}
varMatcher.appendReplacement(result, Matcher.quoteReplacement(value));
}
varMatcher.appendTail(result);
return result.toString().trim();
}
}
@@ -40,7 +40,9 @@ import com.eactive.eai.adapter.AdapterManager;
import com.eactive.eai.adapter.Keys;
import com.eactive.eai.adapter.http.secure.HttpClient5SSLContextFactory;
import com.eactive.eai.common.TransactionContextKeys;
import com.eactive.eai.common.message.EAIMessageKeys;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.server.EAIServerManager;
import com.eactive.eai.common.util.HttpAdapterExtraLogUtil;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.httpouttlsinfo.HttpOutTlsInfoVO;
@@ -566,4 +568,10 @@ public abstract class HttpClient5AdapterServiceSupport implements HttpClientAdap
}
public abstract Object execute(Properties prop, Object message, Properties tempProp) throws Exception;
protected boolean isTas(Properties tempProp) {
String simYn = tempProp.getProperty("SIM_YN");
return EAIServerManager.getInstance().isTASEnabledEAIServer()
&& StringUtils.equals(simYn, EAIMessageKeys.TRANTYPE_TAS);
}
}
@@ -41,14 +41,11 @@ public class HttpClient5AdapterServiceBody extends HttpClient5AdapterServiceSupp
boolean useForwardProxy = StringUtils.equalsIgnoreCase(prop.getProperty("FORWARD_PROXY_USE_YN", "N"), "Y");
String forwardProxyUrl = prop.getProperty("FORWARD_PROXY_URL");
if(useForwardProxy) {
if(useForwardProxy && !isTas(tempProp)) {
java.net.URL url = new java.net.URL(forwardProxyUrl);
// 프로토콜, 호스트, 포트 추출
String protocol = url.getProtocol();
String host = url.getHost();
int port = url.getPort();
HttpHost proxy = new HttpHost(protocol,host, port);
HttpHost proxy = new HttpHost(url.getProtocol(), url.getHost(), url.getPort());
requestConfigBuilder.setProxy(proxy);
}
@@ -11,6 +11,7 @@ import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Date;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -294,14 +295,11 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
RequestConfig.Builder requestConfigBuilder = RequestConfig.custom();
if(useForwardProxy) {
if(useForwardProxy && !isTas(tempProp)) {
java.net.URL url = new java.net.URL(forwardProxyUrl);
// 프로토콜, 호스트, 포트 추출
String protocol = url.getProtocol();
String host = url.getHost();
int port = url.getPort();
HttpHost proxy = new HttpHost(protocol,host, port);
HttpHost proxy = new HttpHost(url.getProtocol(), url.getHost(), url.getPort());
requestConfigBuilder.setProxy(proxy);
}
@@ -708,12 +706,19 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
return new String(responseMessage, encode);
}
String[] relayKeyArr = org.springframework.util.StringUtils.tokenizeToStringArray(relayHeaderKeys, ",");
JSONObject headerJson = new JSONObject();
for (String key : relayKeyArr) {
String value = responseHeaderProp.getProperty(key);
if (StringUtils.isNotBlank(value)) {
headerJson.put(key, value);
if (StringUtils.equalsIgnoreCase(relayHeaderKeys, "ALL")) {
for (Enumeration<Object> e = responseHeaderProp.keys(); e.hasMoreElements(); ) {
String key = (String)e.nextElement();
headerJson.put(StringUtils.lowerCase(key), responseHeaderProp.getProperty(key));
}
} else {
String[] relayKeyArr = org.springframework.util.StringUtils.tokenizeToStringArray(relayHeaderKeys, ",");
for (String key : relayKeyArr) {
String value = responseHeaderProp.getProperty(key);
if (StringUtils.isNotBlank(value)) {
headerJson.put(StringUtils.lowerCase(key), value);
}
}
}
@@ -4,7 +4,7 @@ import com.eactive.eai.agent.command.Command;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager;
import com.eactive.eai.common.util.Logger;
public class ReloadInflowClientControlCommand extends Command {
@@ -31,10 +31,10 @@ public class ReloadInflowClientControlCommand extends Command {
String keyName = (String) args;
try {
AbstractInflowControlManager base = InflowControlUtil.getInflowControlManager();
if (!(base instanceof DualInflowControlManager)) {
throw new IllegalStateException("DualInflowControlManager 가 활성화되지 않았습니다.");
if (!(base instanceof ClientDualInflowControlManager)) {
throw new IllegalStateException("ClientDualInflowControlManager 가 활성화되지 않았습니다.");
}
DualInflowControlManager manager = (DualInflowControlManager) base;
ClientDualInflowControlManager manager = (ClientDualInflowControlManager) base;
if (keyName != null) {
if ("ALL".equals(keyName)) {
@@ -4,7 +4,7 @@ import com.eactive.eai.agent.command.Command;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager;
import com.eactive.eai.common.util.Logger;
public class RemoveInflowClientControlCommand extends Command {
@@ -31,10 +31,10 @@ public class RemoveInflowClientControlCommand extends Command {
String key = (String) args;
try {
AbstractInflowControlManager base = InflowControlUtil.getInflowControlManager();
if (!(base instanceof DualInflowControlManager)) {
throw new IllegalStateException("DualInflowControlManager 가 활성화되지 않았습니다.");
if (!(base instanceof ClientDualInflowControlManager)) {
throw new IllegalStateException("ClientDualInflowControlManager 가 활성화되지 않았습니다.");
}
DualInflowControlManager manager = (DualInflowControlManager) base;
ClientDualInflowControlManager manager = (ClientDualInflowControlManager) base;
manager.removeClient(key);
if (logger.isWarn())
logger.warn(this.name + "] " + key + " removed.");
@@ -21,6 +21,7 @@ import com.eactive.eai.common.util.Logger;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.Refill;
import io.github.bucket4j.local.LocalBucket;
import io.github.bucket4j.local.LocalBucketBuilder;
@@ -345,12 +346,14 @@ public abstract class AbstractInflowControlManager implements Lifecycle, Bucket
if (AbstractInflowControlManager.isInflowTarget(inflowTarget)) {
LocalBucketBuilder builder = Bucket4j.builder();
if (inflowTarget.getThresholdPerSecond() > 0) {
builder.addLimit(Bandwidth.simple(inflowTarget.getThresholdPerSecond(), Duration.ofSeconds(1)));
builder.addLimit(Bandwidth.classic(inflowTarget.getThresholdPerSecond(),
Refill.greedy(inflowTarget.getThresholdPerSecond(), Duration.ofSeconds(1))));
}
if (inflowTarget.getThreshold() > 0 && StringUtils.isNotBlank(inflowTarget.getThresholdTimeUnit())) {
builder.addLimit(Bandwidth.simple(inflowTarget.getThreshold(),
AbstractInflowControlManager.getPeriod(inflowTarget.getThresholdTimeUnit())));
builder.addLimit(Bandwidth.classic(inflowTarget.getThreshold(),
Refill.intervally(inflowTarget.getThreshold(),
AbstractInflowControlManager.getPeriod(inflowTarget.getThresholdTimeUnit()))));
}
return new CustomBucket(builder.build(), inflowTarget);
@@ -369,14 +372,16 @@ public abstract class AbstractInflowControlManager implements Lifecycle, Bucket
if (groupVo.getThresholdPerSecond() > 0) {
perSecondBucket = Bucket4j.builder()
.addLimit(Bandwidth.simple(groupVo.getThresholdPerSecond(), Duration.ofSeconds(1)))
.addLimit(Bandwidth.classic(groupVo.getThresholdPerSecond(),
Refill.greedy(groupVo.getThresholdPerSecond(), Duration.ofSeconds(1))))
.build();
}
if (groupVo.getThreshold() > 0 && StringUtils.isNotBlank(groupVo.getThresholdTimeUnit())) {
thresholdBucket = Bucket4j.builder()
.addLimit(Bandwidth.simple(groupVo.getThreshold(),
AbstractInflowControlManager.getPeriod(groupVo.getThresholdTimeUnit())))
.addLimit(Bandwidth.classic(groupVo.getThreshold(),
Refill.intervally(groupVo.getThreshold(),
AbstractInflowControlManager.getPeriod(groupVo.getThresholdTimeUnit()))))
.build();
}
@@ -0,0 +1,22 @@
package com.eactive.eai.common.inflow.dual;
import com.eactive.eai.common.inflow.InflowTargetVO;
/**
* DualBucket에 클라이언트 유량제어 메서드를 추가한 인터페이스.
* {@link ClientDualInflowControlManager} 구현한다.
*/
public interface ClientDualBucket extends DualBucket {
/**
* 클라이언트 유량제어 체크 차단 원인 포함.
* @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단
*/
String isClientPassDetail(String clientId);
/**
* 클라이언트 유량제어 설정 조회 에러 메시지 생성용.
* @return 등록된 설정이 없으면 null
*/
InflowTargetVO getClientInflowThreshold(String clientId);
}
@@ -0,0 +1,112 @@
package com.eactive.eai.common.inflow.dual;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
import com.eactive.eai.common.inflow.Bucket;
import com.eactive.eai.common.inflow.InflowTargetVO;
import com.eactive.eai.common.inflow.InflowType;
import com.eactive.eai.common.lifecycle.LifecycleException;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.common.util.Logger;
/**
* 어댑터/인터페이스 이중 버킷(DualInflowControlManager) 클라이언트 유량제어를 추가한 관리자.
*
* <p>적용 방법: INFLOW.properties에 아래 추가.
* <pre>inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager</pre>
*/
@Component
public class ClientDualInflowControlManager extends DualInflowControlManager implements ClientDualBucket {
private static final Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
public static synchronized Bucket getBucket() {
return ApplicationContextProvider.getContext().getBean(ClientDualInflowControlManager.class);
}
private volatile Map<String, DualCustomBucket> clientBucketMap = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, TargetMetrics> clientMetricsMap = new ConcurrentHashMap<>();
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
@Override
public void start() throws LifecycleException {
super.start();
try {
initClients();
} catch (Exception e) {
throw new LifecycleException("ClientDualInflowControlManager init failed: " + e.getMessage());
}
}
// -------------------------------------------------------------------------
// 초기화 / 리로드
// -------------------------------------------------------------------------
private void initClients() throws Exception {
this.clientBucketMap = loadBucketMap(InflowType.CLIENT);
if (logger.isInfo()) logger.info("ClientDualInflowControlManager] initClients: " + clientBucketMap.size() + " buckets");
}
public synchronized void reloadClient() throws Exception {
initClients();
clientMetricsMap.clear();
}
public synchronized void reloadClient(String clientId) throws Exception {
reloadBucketMap(clientBucketMap, InflowType.CLIENT, clientId);
TargetMetrics m = clientMetricsMap.get(clientId);
if (m != null) m.reset();
}
public synchronized void removeClient(String clientId) {
clientBucketMap.remove(clientId);
clientMetricsMap.remove(clientId);
}
// -------------------------------------------------------------------------
// 클라이언트 메트릭 접근자
// -------------------------------------------------------------------------
public TargetMetrics getClientMetrics(String clientId) { return clientMetricsMap.get(clientId); }
public Map<String, TargetMetrics> getAllClientMetrics() { return Collections.unmodifiableMap(clientMetricsMap); }
public void resetClientMetrics(String clientId) { TargetMetrics m = clientMetricsMap.get(clientId); if (m != null) m.reset(); }
public void resetAllClientMetrics() { clientMetricsMap.values().forEach(TargetMetrics::reset); }
// -------------------------------------------------------------------------
// ClientDualBucket 클라이언트 유량제어
// -------------------------------------------------------------------------
@Override
public String isClientPassDetail(String clientId) {
DualCustomBucket b = clientBucketMap.get(clientId);
if (b == null) return DualCustomBucket.RESULT_PASS;
String result = b.tryConsume();
recordMetrics(clientMetricsMap, clientId, result);
return result;
}
@Override
public InflowTargetVO getClientInflowThreshold(String clientId) {
DualCustomBucket b = clientBucketMap.get(clientId);
return (b == null) ? null : b.getInflowTargetVo();
}
// -------------------------------------------------------------------------
// 모니터링용 접근자
// -------------------------------------------------------------------------
public DualCustomBucket getClientBucket(String clientId) {
return clientBucketMap.get(clientId);
}
public Map<String, DualCustomBucket> getClientBucketMap() {
return clientBucketMap;
}
}
@@ -1,13 +1,13 @@
package com.eactive.eai.common.inflow.dual;
import com.eactive.eai.common.inflow.Bucket;
import com.eactive.eai.common.inflow.InflowTargetVO;
/**
* Bucket 인터페이스 확장.
* 기존 isAdapterPass/isInterfacePass(boolean) 더해
* 어느 버킷(PER_SECOND/THRESHOLD)에서 차단됐는지 반환하는 메서드 추가.
* 클라이언트별 유량제어 메서드도 포함.
*
* <p>클라이언트 유량제어는 {@link ClientDualBucket}으로 분리됨.
*/
public interface DualBucket extends Bucket {
@@ -22,16 +22,4 @@ public interface DualBucket extends Bucket {
* @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단
*/
String isInterfacePassDetail(String inter);
/**
* 클라이언트 유량제어 체크 차단 원인 포함.
* @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단
*/
String isClientPassDetail(String clientId);
/**
* 클라이언트 유량제어 설정 조회 에러 메시지 생성용.
* @return 등록된 설정이 없으면 null
*/
InflowTargetVO getClientInflowThreshold(String clientId);
}
@@ -9,7 +9,6 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
import com.eactive.eai.common.inflow.Bucket;
@@ -22,21 +21,17 @@ import com.eactive.eai.common.util.Logger;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.Refill;
import io.github.bucket4j.local.LocalBucket;
/**
* 어댑터/인터페이스/클라이언트 버킷을 이중 구조(perSecond + threshold) 관리하는 유량제어 관리자.
* 어댑터/인터페이스 버킷을 이중 구조(perSecond + threshold) 관리하는 유량제어 관리자.
*
* <p>기존 InflowControlManager는 어댑터/인터페이스에 단일 LocalBucket(복수 Bandwidth) 사용하여
* 차단 어느 한도(초당/기간) 초과했는지 없었다.
* 클래스는 그룹 버킷(CustomGroupBucket) 동일하게 버킷을 분리하고
* {@link DualBucket#isAdapterPassDetail}/{@link DualBucket#isInterfacePassDetail}
* 차단 원인을 반환한다. 그룹 버킷 통과 여부는 {@link TargetMetrics} 카운팅된다.
* <p>클라이언트 유량제어는 {@link ClientDualInflowControlManager} 분리됨.
*
* <p>적용 방법: INFLOW.properties에 아래 추가.
* <pre>inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.DualInflowControlManager</pre>
* <pre>inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager</pre>
*/
@Component
public class DualInflowControlManager extends AbstractInflowControlManager implements DualBucket {
private static final Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
@@ -48,11 +43,9 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
private volatile Map<String, DualCustomBucket> adapterBucketMap = new ConcurrentHashMap<>();
private volatile Map<String, DualCustomBucket> interfaceBucketMap = new ConcurrentHashMap<>();
private volatile Map<String, DualCustomBucket> clientBucketMap = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, TargetMetrics> adapterMetricsMap = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, TargetMetrics> interfaceMetricsMap = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, TargetMetrics> clientMetricsMap = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, TargetMetrics> groupMetricsMap = new ConcurrentHashMap<>();
public static class TargetMetrics {
@@ -79,7 +72,6 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
try {
initAdapters();
initInterfaces();
initClients();
} catch (Exception e) {
throw new LifecycleException("DualInflowControlManager init failed: " + e.getMessage());
}
@@ -99,12 +91,7 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
if (logger.isInfo()) logger.info("DualInflowControlManager] initInterfaces: " + interfaceBucketMap.size() + " buckets");
}
private void initClients() throws Exception {
this.clientBucketMap = loadBucketMap(InflowType.CLIENT);
if (logger.isInfo()) logger.info("DualInflowControlManager] initClients: " + clientBucketMap.size() + " buckets");
}
private Map<String, DualCustomBucket> loadBucketMap(InflowType type) throws Exception {
protected Map<String, DualCustomBucket> loadBucketMap(InflowType type) throws Exception {
Map<String, DualCustomBucket> newMap = new HashMap<>();
for (InflowTargetVO vo : inflowControlDAO.getInflowList(type)) {
DualCustomBucket bucket = makeBucket(vo);
@@ -139,22 +126,6 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
if (m != null) m.reset();
}
public synchronized void reloadClient() throws Exception {
initClients();
clientMetricsMap.clear();
}
public synchronized void reloadClient(String clientId) throws Exception {
reloadBucketMap(clientBucketMap, InflowType.CLIENT, clientId);
TargetMetrics m = clientMetricsMap.get(clientId);
if (m != null) m.reset();
}
public synchronized void removeClient(String clientId) {
clientBucketMap.remove(clientId);
clientMetricsMap.remove(clientId);
}
@Override
public synchronized void reloadGroup() throws Exception {
super.reloadGroup();
@@ -168,7 +139,7 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
if (m != null) m.reset();
}
private void reloadBucketMap(Map<String, DualCustomBucket> targetMap, InflowType type, String name) throws Exception {
protected void reloadBucketMap(Map<String, DualCustomBucket> targetMap, InflowType type, String name) throws Exception {
Map<String, InflowTargetVO> updated = inflowControlDAO.getInflowMap(type, name);
if (updated == null) return;
for (InflowTargetVO vo : updated.values()) {
@@ -179,24 +150,20 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
}
// -------------------------------------------------------------------------
// 어댑터/인터페이스/클라이언트 메트릭 접근자
// 어댑터/인터페이스 메트릭 접근자
// -------------------------------------------------------------------------
public TargetMetrics getAdapterMetrics(String adapter) { return adapterMetricsMap.get(adapter); }
public TargetMetrics getInterfaceMetrics(String inter) { return interfaceMetricsMap.get(inter); }
public TargetMetrics getClientMetrics(String clientId) { return clientMetricsMap.get(clientId); }
public Map<String, TargetMetrics> getAllAdapterMetrics() { return Collections.unmodifiableMap(adapterMetricsMap); }
public Map<String, TargetMetrics> getAllInterfaceMetrics() { return Collections.unmodifiableMap(interfaceMetricsMap); }
public Map<String, TargetMetrics> getAllClientMetrics() { return Collections.unmodifiableMap(clientMetricsMap); }
public void resetAdapterMetrics(String adapter) { TargetMetrics m = adapterMetricsMap.get(adapter); if (m != null) m.reset(); }
public void resetInterfaceMetrics(String inter) { TargetMetrics m = interfaceMetricsMap.get(inter); if (m != null) m.reset(); }
public void resetClientMetrics(String clientId) { TargetMetrics m = clientMetricsMap.get(clientId); if (m != null) m.reset(); }
public void resetAllAdapterMetrics() { adapterMetricsMap.values().forEach(TargetMetrics::reset); }
public void resetAllInterfaceMetrics() { interfaceMetricsMap.values().forEach(TargetMetrics::reset); }
public void resetAllClientMetrics() { clientMetricsMap.values().forEach(TargetMetrics::reset); }
// -------------------------------------------------------------------------
// 그룹 메트릭 isGroupPass() 카운팅 접근자
@@ -255,32 +222,15 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
return result;
}
@Override
public String isClientPassDetail(String clientId) {
DualCustomBucket b = clientBucketMap.get(clientId);
if (b == null) return DualCustomBucket.RESULT_PASS;
String result = b.tryConsume();
recordMetrics(clientMetricsMap, clientId, result);
return result;
}
private void recordMetrics(ConcurrentHashMap<String, TargetMetrics> metricsMap, String key, String result) {
protected void recordMetrics(ConcurrentHashMap<String, TargetMetrics> metricsMap, String key, String result) {
TargetMetrics m = metricsMap.computeIfAbsent(key, k -> new TargetMetrics());
if (result == null) m.allowed.incrementAndGet();
else if (DualCustomBucket.RESULT_BLOCKED_PER_SECOND.equals(result)) m.rejectedPerSecond.incrementAndGet();
else m.rejectedThreshold.incrementAndGet();
}
@Override
public InflowTargetVO getClientInflowThreshold(String clientId) {
DualCustomBucket b = clientBucketMap.get(clientId);
return (b == null) ? null : b.getInflowTargetVo();
}
// -------------------------------------------------------------------------
// Bucket 인터페이스 오버라이드 이중 버킷으로 교체 (boolean 반환 유지)
// isAdapterPassDetail/isInterfacePassDetail이 토큰을 소비하므로
// 메서드를 동일 요청에서 중복 호출하면 토큰이 이중 소비됨에 주의.
// -------------------------------------------------------------------------
@Override
@@ -295,7 +245,6 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
// -------------------------------------------------------------------------
// 어댑터/인터페이스 ·VO·삭제 Dual 기준으로 오버라이드
// (base의 adapterBucketList / interfaceBucketList 읽지 않아 DAO 이중 호출 제거)
// -------------------------------------------------------------------------
@Override
@@ -356,19 +305,16 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
return interfaceBucketMap;
}
public DualCustomBucket getClientBucket(String clientId) {
return clientBucketMap.get(clientId);
}
public Map<String, DualCustomBucket> getClientBucketMap() {
return clientBucketMap;
}
// -------------------------------------------------------------------------
// 버킷 팩토리
// -------------------------------------------------------------------------
private DualCustomBucket makeBucket(InflowTargetVO vo) {
/**
* perSecond 버킷: greedy refill 초당 N개 요청을 균등하게 허용.
* threshold 버킷: intervally refill 기간(// ) 단위 쿼터를 기간 시작 일괄 충전.
* Bandwidth.simple은 버킷 모두 greedy로 생성되어 시간 단위 구분이 없어지므로 classic으로 교체.
*/
protected DualCustomBucket makeBucket(InflowTargetVO vo) {
if (!AbstractInflowControlManager.isInflowTarget(vo)) {
return null;
}
@@ -378,14 +324,16 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
if (vo.getThresholdPerSecond() > 0) {
perSecondBucket = Bucket4j.builder()
.addLimit(Bandwidth.simple(vo.getThresholdPerSecond(), Duration.ofSeconds(1)))
.addLimit(Bandwidth.classic(vo.getThresholdPerSecond(),
Refill.greedy(vo.getThresholdPerSecond(), Duration.ofSeconds(1))))
.build();
}
if (vo.getThreshold() > 0 && StringUtils.isNotBlank(vo.getThresholdTimeUnit())) {
Duration period = AbstractInflowControlManager.getPeriod(vo.getThresholdTimeUnit());
thresholdBucket = Bucket4j.builder()
.addLimit(Bandwidth.simple(vo.getThreshold(),
AbstractInflowControlManager.getPeriod(vo.getThresholdTimeUnit())))
.addLimit(Bandwidth.classic(vo.getThreshold(),
Refill.intervally(vo.getThreshold(), period)))
.build();
}
@@ -1,79 +0,0 @@
package com.eactive.eai.inbound.processor;
import org.apache.commons.lang3.StringUtils;
import com.eactive.eai.common.inflow.Bucket;
import com.eactive.eai.common.inflow.InflowTargetVO;
import com.eactive.eai.common.inflow.dual.DualBucket;
import com.eactive.eai.common.inflow.dual.DualCustomBucket;
/**
* 어댑터/인터페이스 유량제어 차단 어느 버킷(PER_SECOND/THRESHOLD)에서 차단됐는지
* 에러 메시지에 포함하는 RequestProcessor 확장.
*
* <p>DualInflowControlManager와 함께 사용:
* <pre>
* inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.DualInflowControlManager
* </pre>
*
* <p>Spring Bean 등록 기존 RequestProcessor 대신 클래스를 어댑터에 설정한다.
*/
public class DualRequestProcessor extends RequestProcessor {
@Override
protected String checkClientInflow(Bucket bucket, String clientId) {
if (StringUtils.isBlank(clientId)) return null;
if (bucket instanceof DualBucket) {
return ((DualBucket) bucket).isClientPassDetail(clientId);
}
return null;
}
@Override
protected InflowTargetVO resolveClientInflowThreshold(Bucket bucket, String clientId) {
if (bucket instanceof DualBucket) {
return ((DualBucket) bucket).getClientInflowThreshold(clientId);
}
return null;
}
@Override
protected String checkAdapterInflow(Bucket bucket, String adapterGroupName) {
if (bucket instanceof DualBucket) {
return ((DualBucket) bucket).isAdapterPassDetail(adapterGroupName);
}
return super.checkAdapterInflow(bucket, adapterGroupName);
}
@Override
protected String checkInterfaceInflow(Bucket bucket, String eaiSvcCd) {
if (bucket instanceof DualBucket) {
return ((DualBucket) bucket).isInterfacePassDetail(eaiSvcCd);
}
return super.checkInterfaceInflow(bucket, eaiSvcCd);
}
/**
* 차단 원인에 따라 에러 메시지에 한도 정보를 포함.
* <ul>
* <li>PER_SECOND: "API 호출 한도 초과 [name: Nreq/sec]"</li>
* <li>THRESHOLD: "API 호출 한도 초과 [name: Nreq/UNIT]"</li>
* <li> : "API 호출 한도 초과 [name]" (기존 동작)</li>
* </ul>
*/
@Override
protected String buildInflowTargetErrorMsg(InflowTargetVO inflowTargetVO, String blockedType) {
if (DualCustomBucket.RESULT_BLOCKED_PER_SECOND.equals(blockedType)) {
return String.format("API 호출 한도 초과 [%s: %dreq/sec]",
inflowTargetVO.getName(),
inflowTargetVO.getThresholdPerSecond());
}
if (DualCustomBucket.RESULT_BLOCKED_THRESHOLD.equals(blockedType)) {
return String.format("API 호출 한도 초과 [%s: %dreq/%s]",
inflowTargetVO.getName(),
inflowTargetVO.getThreshold(),
inflowTargetVO.getThresholdTimeUnit());
}
return super.buildInflowTargetErrorMsg(inflowTargetVO, blockedType);
}
}
@@ -17,6 +17,7 @@ import com.eactive.eai.adapter.AdapterPropManager;
import com.eactive.eai.adapter.AdapterVO;
import com.eactive.eai.adapter.http.HttpStatusException;
import com.eactive.eai.adapter.http.client.HttpClientAdapterServiceKey;
import com.eactive.eai.adapter.http.dynamic.HttpAdapterServiceKey;
import com.eactive.eai.common.EAIKeys;
import com.eactive.eai.common.exception.ExceptionUtil;
import com.eactive.eai.common.header.HeaderAction;
@@ -352,24 +353,26 @@ public class HTTPProcess extends DefaultProcess {
this.tempProp.put(HttpClientAdapterServiceKey.INSTANCE_ID, instanceId);
}
EAIServerManager eaiServerManager = EAIServerManager.getInstance();
AdapterManager adapterManager = AdapterManager.getInstance();
AdapterGroupVO adptGrpVO = adapterManager.getAdapterGroupVO(reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp());
if (eaiServerManager.isTASEnabledEAIServer() && EAIMessageKeys.TRANTYPE_TAS.equals(reqEaiMsg.getTranType())
// RESTAdapter는 교체하지 않는다
&& !StringUtils.equals(adptGrpVO.getType(), com.eactive.eai.adapter.Keys.TYPE_REST)) {
PropManager manager = PropManager.getInstance();
Properties prop = manager.getProperties(RouteKeys.SIM);
if (reqEaiMsg.getCurrentSvcMsg().isSyncItfTp()) {
this.adapterGroupName = prop.getProperty(RouteKeys.SIM_ADAPTER_SYNC, "_SIM_OU_HTT_SyC");
} else {
this.adapterGroupName = prop.getProperty(RouteKeys.SIM_ADAPTER_ASYNC, "_SIM_OU_HTT_AsC");
}
this.reqEaiMsg.getSvcMsg(0).setPsvSysItfTp(this.adapterGroupName);
} else {
this.adapterGroupName = reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp();
}
// EAIServerManager eaiServerManager = EAIServerManager.getInstance();
// AdapterManager adapterManager = AdapterManager.getInstance();
// AdapterGroupVO adptGrpVO = adapterManager.getAdapterGroupVO(reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp());
// if (eaiServerManager.isTASEnabledEAIServer() && EAIMessageKeys.TRANTYPE_TAS.equals(reqEaiMsg.getTranType())
// // RESTAdapter는 교체하지 않는다
// && !StringUtils.equals(adptGrpVO.getType(), com.eactive.eai.adapter.Keys.TYPE_REST)) {
// PropManager manager = PropManager.getInstance();
// Properties prop = manager.getProperties(RouteKeys.SIM);
//
// if (reqEaiMsg.getCurrentSvcMsg().isSyncItfTp()) {
// this.adapterGroupName = prop.getProperty(RouteKeys.SIM_ADAPTER_SYNC, "_SIM_OU_HTT_SyC");
// } else {
// this.adapterGroupName = prop.getProperty(RouteKeys.SIM_ADAPTER_ASYNC, "_SIM_OU_HTT_AsC");
// }
// this.reqEaiMsg.getSvcMsg(0).setPsvSysItfTp(this.adapterGroupName);
// } else {
// this.adapterGroupName = reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp();
// }
this.adapterGroupName = reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp();
this.svcTsmtUsgTp = this.reqEaiMsg.getSvcTsmtUsgTp(); // Inbound 호출방식
this.psvItfTp = this.reqEaiMsg.getCurrentSvcMsg().getPsvItfTp(); // 호출방식
@@ -404,6 +407,7 @@ public class HTTPProcess extends DefaultProcess {
}
// Inbound Adapter mesageType
AdapterManager adapterManager = AdapterManager.getInstance();
AdapterGroupVO inAdptGrpVO = adapterManager.getAdapterGroupVO(this.reqEaiMsg.getSngSysItfTp());
this.inAdapterMsgType = MessageType.ASC;
@@ -592,6 +596,22 @@ public class HTTPProcess extends DefaultProcess {
logger.debug(guidLogPrefix + " HTTP NONSTANDARD Request Message : "
+ MessageUtil.toAdapterTypeString(this.reqObject, this.inboundCharset));
}
// TAS 선택시 시뮬레이터 url로 변경, HTT 어댑터도 RST 처럼 방식을 변경하였다.
EAIServerManager eaiServerManager = EAIServerManager.getInstance();
if (eaiServerManager.isTASEnabledEAIServer() && EAIMessageKeys.TRANTYPE_TAS.equals(reqEaiMsg.getTranType())) {
PropManager manager = PropManager.getInstance();
Properties prop = manager.getProperties(RouteKeys.SIM);
String simAddr = prop.getProperty(RouteKeys.SIM_REST_MOCKSERVER);
if (StringUtils.isBlank(simAddr)) {
throw new Exception(String.format("Properties %s > %s not setted", RouteKeys.SIM,
RouteKeys.SIM_REST_MOCKSERVER));
}
String url = StringUtils.removeEnd(simAddr, "/") + "/mockapi/" + this.reqEaiMsg.getEAISvcCd();
this.outboundProp.put(HttpClientAdapterServiceKey.URL, url);
this.tempProp.remove(HttpAdapterServiceKey.INBOUND_REWRITE_PATH);
logger.debug("simUrl=" + url);
}
} catch (Exception e) {
String[] msgArgs = new String[1];
msgArgs[0] = this.reqEaiMsg.getEAISvcCd();
@@ -0,0 +1,725 @@
package com.eactive.eai.adapter.handler;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import java.lang.reflect.Field;
import java.util.LinkedHashMap;
import java.util.Properties;
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.mockito.Mockito;
import org.springframework.context.ApplicationContext;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.message.StandardItem;
import com.eactive.eai.message.StandardMessage;
import com.eactive.eai.message.StandardType;
/**
* TemplateAdapterErrorMsgHandler 단위테스트
*
* - render() : package-private 이므로 같은 패키지에서 직접 호출
* - generateNonStandardErrorResponseMessage() : ApplicationContextProvider
* mock ApplicationContext 리플렉션으로 주입하여 PropManager 격리
*/
class TemplateAdapterErrorMsgHandlerTest {
private TemplateAdapterErrorMsgHandler handler;
@BeforeEach
void setUp() {
handler = new TemplateAdapterErrorMsgHandler();
}
// ================================================================
// StandardMessage 빌더 헬퍼
// ================================================================
/** FIELD 아이템 (type=2) */
private static StandardItem field(String name, String value) {
return new StandardItem(name, 3, StandardType.FIELD, 1, 0, 300, 1, "", "", value, name);
}
/** GROUP 아이템 (type=3, size=1 → 활성) */
private static StandardItem group(String name) {
return new StandardItem(name, 2, StandardType.GROUP, 1, 0, 0, 1, "", "", null, name);
}
/**
* DJB 표준전문 MSG 영역을 모사한 StandardMessage 생성.
*
* 구조:
* MSG (GROUP)
* MAIN_MSG (GROUP)
* outp_msg_cd, outp_msg_ctnt, outp_msg_desc
* MSG_LIST (GRID)
* row[n]: outp_msg_cd, outp_msg_ctnt, outp_msg_desc, err_occu_item_nm
*
* @param listRows null 또는 배열이면 MSG_LIST 없음
*/
private StandardMessage buildMsg(String errCode, String errMsg, String errDesc,
String[][] listRows) {
StandardMessage msg = new StandardMessage();
StandardItem msgGroup = group("MSG");
StandardItem mainMsg = group("MAIN_MSG");
mainMsg.addItem(field("outp_msg_cd", errCode));
mainMsg.addItem(field("outp_msg_ctnt", errMsg));
mainMsg.addItem(field("outp_msg_desc", errDesc));
msgGroup.addItem(mainMsg);
StandardItem msgList = new StandardItem(
"MSG_LIST", 2, StandardType.GRID, 0, 0, 0, 1, "", "", null, "MSG_LIST");
msgList.addItem(field("outp_msg_cd", ""));
msgList.addItem(field("outp_msg_ctnt", ""));
msgList.addItem(field("outp_msg_desc", ""));
msgList.addItem(field("err_occu_item_nm", ""));
if (listRows != null) {
for (String[] row : listRows) {
LinkedHashMap<String, StandardItem> rowMap = new LinkedHashMap<>();
rowMap.put("outp_msg_cd", field("outp_msg_cd", get(row, 0)));
rowMap.put("outp_msg_ctnt", field("outp_msg_ctnt", get(row, 1)));
rowMap.put("outp_msg_desc", field("outp_msg_desc", get(row, 2)));
rowMap.put("err_occu_item_nm", field("err_occu_item_nm", get(row, 3)));
msgList.addArray(rowMap);
}
}
msgGroup.addItem(msgList);
msg.addItem(msgGroup);
return msg;
}
private static String get(String[] arr, int idx) {
return (arr != null && arr.length > idx) ? arr[idx] : "";
}
private static Properties props(String... keyValues) {
Properties p = new Properties();
for (int i = 0; i < keyValues.length - 1; i += 2) {
p.setProperty(keyValues[i], keyValues[i + 1]);
}
return p;
}
// ================================================================
// 스칼라 변수 치환 ${path}
// ================================================================
@Nested
@DisplayName("StandardMessage 스칼라 변수 치환 ${path}")
class ScalarSubstitution {
@Test
@DisplayName("단순 JSON 템플릿 치환")
void 단순_치환() {
StandardMessage msg = buildMsg("E001", "오류발생", "상세설명", null);
String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"msg\":\"${MSG.MAIN_MSG.outp_msg_ctnt}\"}";
String result = handler.render(template, msg, null);
assertEquals("{\"code\":\"E001\",\"msg\":\"오류발생\"}", result);
}
@Test
@DisplayName("동일 변수 중복 치환")
void 동일_변수_중복_치환() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
String template = "${MSG.MAIN_MSG.outp_msg_cd}/${MSG.MAIN_MSG.outp_msg_cd}";
assertEquals("E001/E001", handler.render(template, msg, null));
}
@Test
@DisplayName("존재하지 않는 경로는 빈 문자열로 치환")
void 없는_경로_빈_문자열() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("", handler.render("${NONE.PATH}", msg, null));
}
@Test
@DisplayName("변수 없는 템플릿은 원본 그대로 반환")
void 변수_없는_템플릿_원본() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("{\"static\":\"value\"}", handler.render("{\"static\":\"value\"}", msg, null));
}
@Test
@DisplayName("outp_msg_desc 필드 치환")
void outp_msg_desc_치환() {
StandardMessage msg = buildMsg("E001", "오류", "상세설명입니다", null);
assertEquals("상세설명입니다", handler.render("${MSG.MAIN_MSG.outp_msg_desc}", msg, null));
}
@Test
@DisplayName("경로:기본값 – 경로 존재 시 값 반환")
void 표준전문_기본값_경로존재() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("E001", handler.render("${MSG.MAIN_MSG.outp_msg_cd:DEFAULT}", msg, null));
}
@Test
@DisplayName("경로:기본값 – 경로 없으면 기본값 반환")
void 표준전문_기본값_경로없음() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("UNKNOWN", handler.render("${NONE.PATH:UNKNOWN}", msg, null));
}
@Test
@DisplayName("경로:기본값 기본값이 빈 문자열")
void 표준전문_기본값_빈문자열() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("", handler.render("${NONE.PATH:}", msg, null));
}
}
// ================================================================
// callProp 변수 치환 ${callprop.}
// ================================================================
@Nested
@DisplayName("callProp 변수 치환 ${callprop.키}")
class CallPropSubstitution {
@Test
@DisplayName("callProp 값을 템플릿에서 참조")
void callProp_단순_참조() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER", "IF_ID", "SVC001");
String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"ifId\":\"${callprop.IF_ID}\"}";
String result = handler.render(template, msg, callProp);
assertEquals("{\"adapter\":\"MY_ADAPTER\",\"ifId\":\"SVC001\"}", result);
}
@Test
@DisplayName("callProp 과 StandardMessage 혼합 참조")
void callProp_stdmsg_혼합() {
StandardMessage msg = buildMsg("E001", "오류발생", "", null);
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}";
String result = handler.render(template, msg, callProp);
assertEquals("{\"adapter\":\"MY_ADAPTER\",\"code\":\"E001\"}", result);
}
@Test
@DisplayName("callProp 에 없는 키는 빈 문자열")
void callProp_없는_키_빈문자열() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
Properties callProp = new Properties();
assertEquals("", handler.render("${callprop.NONE_KEY}", msg, callProp));
}
@Test
@DisplayName("callProp 이 null 이면 빈 문자열")
void callProp_null_빈문자열() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("", handler.render("${callprop.ADAPTER_NAME}", msg, null));
}
@Test
@DisplayName("callProp 값이 Properties 인 경우 중첩 탐색")
void callProp_중첩_Properties() {
Properties sub = new Properties();
sub.setProperty("IF_ID", "SVC001");
sub.setProperty("SVC_NM", "조회서비스");
Properties callProp = new Properties();
callProp.put("OUTBOUND", sub);
callProp.setProperty("ADAPTER_NAME", "MY_ADAPTER");
StandardMessage msg = buildMsg("E001", "오류", "", null);
String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"ifId\":\"${callprop.OUTBOUND.IF_ID}\",\"svcNm\":\"${callprop.OUTBOUND.SVC_NM}\"}";
String result = handler.render(template, msg, callProp);
assertEquals("{\"adapter\":\"MY_ADAPTER\",\"ifId\":\"SVC001\",\"svcNm\":\"조회서비스\"}", result);
}
@Test
@DisplayName("중첩 Properties 3단계 탐색")
void callProp_3단계_중첩() {
Properties level2 = new Properties();
level2.setProperty("CODE", "L2CODE");
Properties level1 = new Properties();
level1.put("LEVEL2", level2);
Properties callProp = new Properties();
callProp.put("LEVEL1", level1);
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("L2CODE", handler.render("${callprop.LEVEL1.LEVEL2.CODE}", msg, callProp));
}
@Test
@DisplayName("중간 값이 Properties 가 아니면 전체 keyPath 로 폴백 조회")
void callProp_중간값_비Properties_폴백() {
Properties callProp = new Properties();
callProp.setProperty("A.B", "FALLBACK_VALUE"); // 포함
callProp.setProperty("A", "NOT_PROPS"); // String
StandardMessage msg = buildMsg("E001", "오류", "", null);
// A String 이므로 Properties 아님 "A.B" 전체 키로 폴백
assertEquals("FALLBACK_VALUE", handler.render("${callprop.A.B}", msg, callProp));
}
@Test
@DisplayName("중첩 Properties 에서 없는 키는 빈 문자열")
void callProp_중첩_없는키() {
Properties sub = new Properties();
sub.setProperty("EXIST", "VALUE");
Properties callProp = new Properties();
callProp.put("SUB", sub);
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("", handler.render("${callprop.SUB.NONE_KEY}", msg, callProp));
}
@Test
@DisplayName("callprop.키:기본값 – 키 존재 시 값 반환")
void callProp_직접_기본값_키존재() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
assertEquals("MY_ADAPTER", handler.render("${callprop.ADAPTER_NAME:DEFAULT}", msg, callProp));
}
@Test
@DisplayName("callprop.키:기본값 – 키 없으면 기본값 반환")
void callProp_직접_기본값_키없음() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
Properties callProp = new Properties();
assertEquals("DEFAULT_VAL", handler.render("${callprop.NONE_KEY:DEFAULT_VAL}", msg, callProp));
}
@Test
@DisplayName("callprop.키:기본값 callProp null 이면 기본값 반환")
void callProp_직접_기본값_null() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("FALLBACK", handler.render("${callprop.ADAPTER_NAME:FALLBACK}", msg, null));
}
}
// ================================================================
// callProp 간접 참조 ${callprop[표준전문경로]} / ${callprop[경로]:기본값}
// ================================================================
@Nested
@DisplayName("callProp 간접 참조 ${callprop[경로]}")
class IndirectCallPropSubstitution {
/** 표준전문에 cp_key 필드가 있는 메시지 */
private StandardMessage buildMsgWithKey(String cpKey, String errCode) {
StandardMessage msg = buildMsg(errCode, "오류", "", null);
// MSG 그룹 아래 cp_key 필드 추가
StandardItem msgGroup = msg.findItem("MSG");
if (msgGroup != null) {
msgGroup.addItem(field("cp_key", cpKey));
}
return msg;
}
@Test
@DisplayName("표준전문 경로 값으로 callProp 키를 결정해 값 조회")
void 간접_참조_정상() {
StandardMessage msg = buildMsgWithKey("ADAPTER_NAME", "E001");
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
String template = "${callprop[MSG.cp_key]}";
assertEquals("MY_ADAPTER", handler.render(template, msg, callProp));
}
@Test
@DisplayName("표준전문 경로 값이 없으면 기본값 반환")
void 표준전문_경로_없으면_기본값() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
String template = "${callprop[MSG.NONE_KEY]:DEFAULT_ADAPTER}";
assertEquals("DEFAULT_ADAPTER", handler.render(template, msg, callProp));
}
@Test
@DisplayName("callProp 에 해당 키 없으면 기본값 반환")
void callProp_키_없으면_기본값() {
StandardMessage msg = buildMsgWithKey("MISSING_KEY", "E001");
Properties callProp = props("OTHER_KEY", "OTHER_VALUE");
String template = "${callprop[MSG.cp_key]:FALLBACK}";
assertEquals("FALLBACK", handler.render(template, msg, callProp));
}
@Test
@DisplayName("기본값 미지정 시 빈 문자열")
void 기본값_없으면_빈문자열() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
String template = "${callprop[MSG.NONE_KEY]}";
assertEquals("", handler.render(template, msg, callProp));
}
@Test
@DisplayName("callProp 이 null 이면 기본값 반환")
void callProp_null_기본값() {
StandardMessage msg = buildMsgWithKey("ADAPTER_NAME", "E001");
String template = "${callprop[MSG.cp_key]:NO_PROP}";
assertEquals("NO_PROP", handler.render(template, msg, null));
}
@Test
@DisplayName("기본값이 빈 문자열인 경우")
void 기본값이_빈문자열() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
Properties callProp = new Properties();
String template = "${callprop[MSG.NONE_KEY]:}";
assertEquals("", handler.render(template, msg, callProp));
}
@Test
@DisplayName("간접 참조와 직접 참조 혼합")
void 간접_직접_혼합() {
StandardMessage msg = buildMsgWithKey("IF_ID_KEY", "E001");
Properties callProp = props("IF_ID_KEY", "SVC_001", "ADAPTER_NAME", "MY_ADAPTER");
String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"ifId\":\"${callprop[MSG.cp_key]}\"}";
assertEquals("{\"adapter\":\"MY_ADAPTER\",\"ifId\":\"SVC_001\"}", handler.render(template, msg, callProp));
}
}
// ================================================================
// 배열 반복 {{#foreach path}}...{{/foreach}}
// ================================================================
// ================================================================
// DATA 영역 변수 치환 ${DATA.xxx}
// ================================================================
@Nested
@DisplayName("DATA 영역 변수 치환 ${DATA.xxx}")
class DataSectionSubstitution {
/**
* DATA > BIZDATA(FIELD, JSON String value) 구조의 StandardMessage 생성.
* BIZDATA 파싱 없이 JSON 문자열 그대로 세팅되는 형태.
*/
private StandardMessage buildMsgWithBizData(String bizDataJson) {
StandardMessage msg = buildMsg("E001", "오류", "", null);
StandardItem dataGroup = group("DATA");
dataGroup.addItem(field("BIZDATA", bizDataJson));
msg.addItem(dataGroup);
return msg;
}
@Test
@DisplayName("DATA.BIZDATA JSON 필드 치환 단일 depth")
void DATA_BIZDATA_JSON_필드_치환() {
StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"1234567890\",\"custNm\":\"홍길동\"}");
String template = "{\"acctNo\":\"${DATA.BIZDATA.acctNo}\",\"custNm\":\"${DATA.BIZDATA.custNm}\"}";
assertEquals("{\"acctNo\":\"1234567890\",\"custNm\":\"홍길동\"}",
handler.render(template, msg, null));
}
@Test
@DisplayName("DATA.BIZDATA JSON 필드 치환 중첩 depth")
void DATA_BIZDATA_JSON_중첩_필드_치환() {
StandardMessage msg = buildMsgWithBizData("{\"addr\":{\"city\":\"서울\",\"zip\":\"12345\"}}");
assertEquals("서울", handler.render("${DATA.BIZDATA.addr.city}", msg, null));
}
@Test
@DisplayName("DATA.BIZDATA 원본 JSON 문자열 치환")
void DATA_BIZDATA_원본_치환() {
String rawJson = "{\"acctNo\":\"1234567890\"}";
StandardMessage msg = buildMsgWithBizData(rawJson);
assertEquals(rawJson, handler.render("${DATA.BIZDATA}", msg, null));
}
@Test
@DisplayName("DATA.BIZDATA 와 MSG 영역 혼합 치환")
void DATA_MSG_혼합_치환() {
StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"9876543210\"}");
String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"acctNo\":\"${DATA.BIZDATA.acctNo}\"}";
assertEquals("{\"code\":\"E001\",\"acctNo\":\"9876543210\"}",
handler.render(template, msg, null));
}
@Test
@DisplayName("DATA.BIZDATA JSON 에 없는 필드는 빈 문자열")
void DATA_BIZDATA_없는_JSON_필드_빈문자열() {
StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"1234567890\"}");
assertEquals("", handler.render("${DATA.BIZDATA.noneField}", msg, null));
}
@Test
@DisplayName("DATA.BIZDATA JSON 에 없는 필드에 기본값 지정")
void DATA_BIZDATA_없는_JSON_필드_기본값() {
StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"1234567890\"}");
assertEquals("UNKNOWN", handler.render("${DATA.BIZDATA.noneField:UNKNOWN}", msg, null));
}
@Test
@DisplayName("DATA 영역 미설정 시 기본값 반환")
void DATA_없으면_기본값() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("{}", handler.render("${DATA.BIZDATA:{}}", msg, null));
}
}
@Nested
@DisplayName("배열 반복 블록 {{#foreach}}")
class ForeachBlock {
@Test
@DisplayName("MSG_LIST 1건 렌더링")
void 단건_렌더링() {
StandardMessage msg = buildMsg("E001", "대표오류", "",
new String[][]{{"E001", "메시지A", "설명A", "fieldA"}});
String template = "[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\",\"m\":\"${outp_msg_ctnt}\"}{{/foreach}}]";
assertEquals("[{\"c\":\"E001\",\"m\":\"메시지A\"}]", handler.render(template, msg, null));
}
@Test
@DisplayName("MSG_LIST 2건 → 쉼표로 연결")
void 다건_쉼표_연결() {
StandardMessage msg = buildMsg("E001", "대표오류", "",
new String[][]{
{"E001", "오류A", "설명A", "fieldA"},
{"E002", "오류B", "설명B", "fieldB"}
});
String template = "[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]";
assertEquals("[{\"c\":\"E001\"},{\"c\":\"E002\"}]", handler.render(template, msg, null));
}
@Test
@DisplayName("MSG_LIST 가 비어있으면 foreach 결과는 빈 문자열")
void 빈_배열() {
StandardMessage msg = buildMsg("E001", "오류", "", new String[][]{});
String template = "[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]";
assertEquals("[]", handler.render(template, msg, null));
}
@Test
@DisplayName("foreach 경로가 없으면 빈 문자열")
void 없는_경로() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
String template = "[{{#foreach NONE.LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]";
assertEquals("[]", handler.render(template, msg, null));
}
@Test
@DisplayName("err_occu_item_nm 필드 포함 렌더링")
void err_occu_item_nm_포함() {
StandardMessage msg = buildMsg("E001", "오류", "",
new String[][]{{"E001", "오류A", "설명A", "accountNo"}});
String template = "{{#foreach MSG.MSG_LIST}}{\"field\":\"${err_occu_item_nm}\"}{{/foreach}}";
assertEquals("{\"field\":\"accountNo\"}", handler.render(template, msg, null));
}
}
// ================================================================
// 혼합 템플릿 (스칼라 + callProp + 배열)
// ================================================================
@Nested
@DisplayName("혼합 템플릿")
class MixedTemplate {
@Test
@DisplayName("JSON MAIN_MSG + callProp + MSG_LIST 2건")
void JSON_전체_혼합() {
StandardMessage msg = buildMsg("E001", "대표오류", "대표설명",
new String[][]{
{"E001", "오류A", "설명A", "fieldA"},
{"E002", "오류B", "설명B", "fieldB"}
});
Properties callProp = props("ADAPTER_NAME", "TEST_ADAPTER");
String template =
"{" +
"\"adapter\":\"${callprop.ADAPTER_NAME}\"," +
"\"resultCode\":\"${MSG.MAIN_MSG.outp_msg_cd}\"," +
"\"resultMsg\":\"${MSG.MAIN_MSG.outp_msg_ctnt}\"," +
"\"errors\":[{{#foreach MSG.MSG_LIST}}" +
"{\"code\":\"${outp_msg_cd}\",\"msg\":\"${outp_msg_ctnt}\",\"field\":\"${err_occu_item_nm}\"}" +
"{{/foreach}}]" +
"}";
String result = handler.render(template, msg, callProp);
assertEquals(
"{\"adapter\":\"TEST_ADAPTER\"," +
"\"resultCode\":\"E001\",\"resultMsg\":\"대표오류\"," +
"\"errors\":[{\"code\":\"E001\",\"msg\":\"오류A\",\"field\":\"fieldA\"}," +
"{\"code\":\"E002\",\"msg\":\"오류B\",\"field\":\"fieldB\"}]}",
result);
}
@Test
@DisplayName("XML 스칼라 + callProp 치환")
void XML_스칼라_callProp() {
StandardMessage msg = buildMsg("E001", "오류발생", "", null);
Properties callProp = props("IF_ID", "IF_001");
String template = "<error><ifId>${callprop.IF_ID}</ifId><code>${MSG.MAIN_MSG.outp_msg_cd}</code></error>";
assertEquals("<error><ifId>IF_001</ifId><code>E001</code></error>",
handler.render(template, msg, callProp));
}
@Test
@DisplayName("XML foreach 배열")
void XML_foreach_배열() {
StandardMessage msg = buildMsg("E001", "오류", "",
new String[][]{{"E001", "오류A", "", ""}, {"E002", "오류B", "", ""}});
String template =
"<errors>{{#foreach MSG.MSG_LIST}}" +
"<item><code>${outp_msg_cd}</code><msg>${outp_msg_ctnt}</msg></item>" +
"{{/foreach}}</errors>";
assertEquals(
"<errors>" +
"<item><code>E001</code><msg>오류A</msg></item>," +
"<item><code>E002</code><msg>오류B</msg></item>" +
"</errors>",
handler.render(template, msg, null));
}
}
// ================================================================
// generateNonStandardErrorResponseMessage PropManager mock
// ================================================================
@Nested
@DisplayName("generateNonStandardErrorResponseMessage")
class GenerateMethod {
private void injectMockPropManager(PropManager mockPropManager) throws Exception {
ApplicationContext mockCtx = Mockito.mock(ApplicationContext.class);
Mockito.when(mockCtx.getBean(PropManager.class)).thenReturn(mockPropManager);
Field ctxField = ApplicationContextProvider.class.getDeclaredField("context");
ctxField.setAccessible(true);
ctxField.set(null, mockCtx);
}
@Test
@DisplayName("템플릿 미설정 시 null 반환")
void 템플릿_없으면_null() throws Exception {
PropManager mockProp = Mockito.mock(PropManager.class);
Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(null);
injectMockPropManager(mockProp);
StandardMessage msg = buildMsg("E001", "오류", "", null);
Object result = handler.generateNonStandardErrorResponseMessage(
"TEST_GROUP", "TEST_ADAPTER", new Properties(), null, msg);
assertNull(result);
}
@Test
@DisplayName("빈 템플릿 시 null 반환")
void 빈_템플릿_null() throws Exception {
PropManager mockProp = Mockito.mock(PropManager.class);
Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(" ");
injectMockPropManager(mockProp);
StandardMessage msg = buildMsg("E001", "오류", "", null);
Object result = handler.generateNonStandardErrorResponseMessage(
"TEST_GROUP", "TEST_ADAPTER", new Properties(), null, msg);
assertNull(result);
}
@Test
@DisplayName("템플릿 설정 시 StandardMessage 치환 결과 반환")
void 템플릿_stdmsg_치환() throws Exception {
String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}";
PropManager mockProp = Mockito.mock(PropManager.class);
Mockito.when(mockProp.getProperty(
eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
eq("MY_GROUP.template"))).thenReturn(template);
injectMockPropManager(mockProp);
StandardMessage msg = buildMsg("E999", "테스트오류", "", null);
Object result = handler.generateNonStandardErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", new Properties(), null, msg);
assertEquals("{\"code\":\"E999\"}", result);
}
@Test
@DisplayName("callProp 값이 템플릿에 치환되어 반환")
void 템플릿_callProp_치환() throws Exception {
String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}";
PropManager mockProp = Mockito.mock(PropManager.class);
Mockito.when(mockProp.getProperty(
eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
eq("MY_GROUP.template"))).thenReturn(template);
injectMockPropManager(mockProp);
StandardMessage msg = buildMsg("E999", "테스트오류", "", null);
Properties callProp = props("ADAPTER_NAME", "REAL_ADAPTER");
Object result = handler.generateNonStandardErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", callProp, null, msg);
assertEquals("{\"adapter\":\"REAL_ADAPTER\",\"code\":\"E999\"}", result);
}
@Test
@DisplayName("템플릿 키는 {adapterGroupId}.template 형식으로 조회")
void 템플릿_키_형식_검증() throws Exception {
PropManager mockProp = Mockito.mock(PropManager.class);
Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(null);
injectMockPropManager(mockProp);
StandardMessage msg = buildMsg("E001", "오류", "", null);
handler.generateNonStandardErrorResponseMessage(
"SAMPLE_GROUP", "ANY_ADAPTER", new Properties(), null, msg);
Mockito.verify(mockProp).getProperty(
TemplateAdapterErrorMsgHandler.PROP_GROUP,
"SAMPLE_GROUP.template");
}
}
}
@@ -11,7 +11,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.InflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager;
/**
* ReloadInflowClientControlCommand 단위 테스트.
@@ -21,11 +21,11 @@ import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
*/
class ReloadInflowClientControlCommandTest {
private DualInflowControlManager mockDualManager;
private ClientDualInflowControlManager mockDualManager;
@BeforeEach
void setUp() {
mockDualManager = mock(DualInflowControlManager.class);
mockDualManager = mock(ClientDualInflowControlManager.class);
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", null);
}
@@ -51,11 +51,11 @@ class ReloadInflowClientControlCommandTest {
}
// =========================================================================
// DualInflowControlManager 비활성
// ClientDualInflowControlManager 비활성
// =========================================================================
@Test
void execute_notDualManager_throwsCommandException() {
void execute_notClientDualManager_throwsCommandException() {
InflowControlManager baseMgr = mock(InflowControlManager.class);
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", baseMgr);
@@ -11,7 +11,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.InflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager;
/**
* RemoveInflowClientControlCommand 단위 테스트.
@@ -21,11 +21,11 @@ import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
*/
class RemoveInflowClientControlCommandTest {
private DualInflowControlManager mockDualManager;
private ClientDualInflowControlManager mockDualManager;
@BeforeEach
void setUp() {
mockDualManager = mock(DualInflowControlManager.class);
mockDualManager = mock(ClientDualInflowControlManager.class);
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", null);
}
@@ -50,11 +50,11 @@ class RemoveInflowClientControlCommandTest {
}
// =========================================================================
// DualInflowControlManager 비활성
// ClientDualInflowControlManager 비활성
// =========================================================================
@Test
void execute_notDualManager_throwsCommandException() {
void execute_notClientDualManager_throwsCommandException() {
InflowControlManager baseMgr = mock(InflowControlManager.class);
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", baseMgr);
@@ -1,5 +1,7 @@
package com.eactive.eai.common.inflow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -36,9 +38,13 @@ class InflowControlManagerTest {
InflowControlManager inflowControlManager;
@BeforeEach
void setUp() throws LifecycleException {
void setUp() throws Exception {
if (!inflowControlManager.isStarted()) {
inflowControlManager.start();
} else {
inflowControlManager.reloadAdapter();
inflowControlManager.reloadInterface();
inflowControlManager.reloadGroup();
}
}
@@ -57,4 +63,22 @@ class InflowControlManagerTest {
assertNull(inflowControlManager.getGroupBucket("non-existent-group"));
}
@Test
void adapter_spikePasses_thenBurstBlocked() {
for (int i = 0; i < 5; i++) assertTrue(inflowControlManager.isAdapterPass("TEST_ADAPTER"));
assertFalse(inflowControlManager.isAdapterPass("TEST_ADAPTER"));
}
@Test
void interface_spikePasses_thenBurstBlocked() {
for (int i = 0; i < 5; i++) assertTrue(inflowControlManager.isInterfacePass("TEST_INTERFACE"));
assertFalse(inflowControlManager.isInterfacePass("TEST_INTERFACE"));
}
@Test
void group_spikePasses_thenBurstBlocked() {
for (int i = 0; i < 5; i++) assertNull(inflowControlManager.isGroupPass("test-group-quota"));
assertEquals(CustomGroupBucket.RESULT_BLOCKED_THRESHOLD, inflowControlManager.isGroupPass("test-group-quota"));
}
}
@@ -0,0 +1,190 @@
package com.eactive.eai.common.inflow.dual;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import com.eactive.eai.common.inflow.InflowTargetVO;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.local.LocalBucket;
/**
* ClientDualInflowControlManager 클라이언트 메트릭 단위 테스트.
*/
class ClientDualInflowControlManagerMetricsTest {
private ClientDualInflowControlManager manager;
@BeforeEach
void setUp() {
manager = new ClientDualInflowControlManager();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private InflowTargetVO makeTargetVO(String name) {
InflowTargetVO vo = new InflowTargetVO();
vo.setName(name);
vo.setThresholdPerSecond(100);
vo.setThreshold(10000);
vo.setThresholdTimeUnit("DAY");
vo.setActivate(true);
return vo;
}
private LocalBucket stableBucket(long capacity) {
return Bucket4j.builder()
.addLimit(Bandwidth.simple(capacity, Duration.ofHours(1)))
.build();
}
private LocalBucket exhaustedBucket(long capacity) {
LocalBucket b = stableBucket(capacity);
b.tryConsume(capacity);
return b;
}
private DualCustomBucket passDualBucket(String name) {
return new DualCustomBucket(stableBucket(100), null, makeTargetVO(name));
}
private DualCustomBucket blockedPerSecondDualBucket(String name) {
return new DualCustomBucket(exhaustedBucket(1), null, makeTargetVO(name));
}
private DualCustomBucket blockedThresholdDualBucket(String name) {
return new DualCustomBucket(stableBucket(100), exhaustedBucket(1), makeTargetVO(name));
}
private void injectClientMap(Map<String, DualCustomBucket> map) {
ReflectionTestUtils.setField(manager, "clientBucketMap", map);
}
// =========================================================================
// isClientPassDetail() 카운터 증가
// =========================================================================
@Test
void isClientPassDetail_bucketNotRegistered_noMetricsCreated() {
manager.isClientPassDetail("UNKNOWN");
assertNull(manager.getClientMetrics("UNKNOWN"));
}
@Test
void isClientPassDetail_pass_incrementsAllowed() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
assertEquals(1, manager.getClientMetrics("C1").allowed.get());
}
@Test
void isClientPassDetail_blockedThreshold_incrementsRejectedThreshold() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", blockedThresholdDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
assertEquals(1, manager.getClientMetrics("C1").rejectedThreshold.get());
}
@Test
void isClientPassDetail_blockedPerSecond_incrementsRejectedPerSecond() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", blockedPerSecondDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
assertEquals(1, manager.getClientMetrics("C1").rejectedPerSecond.get());
}
// =========================================================================
// getAllClientMetrics 조회 불변성
// =========================================================================
@Test
void getAllClientMetrics_empty_returnsEmptyMap() {
assertTrue(manager.getAllClientMetrics().isEmpty());
}
@Test
void getAllClientMetrics_afterCalls_containsEntries() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
map.put("C2", passDualBucket("C2"));
injectClientMap(map);
manager.isClientPassDetail("C1");
manager.isClientPassDetail("C2");
Map<String, DualInflowControlManager.TargetMetrics> all = manager.getAllClientMetrics();
assertEquals(2, all.size());
assertTrue(all.containsKey("C1"));
assertTrue(all.containsKey("C2"));
}
// =========================================================================
// resetClientMetrics / resetAllClientMetrics
// =========================================================================
@Test
void resetClientMetrics_resetsTargetClient() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
manager.resetClientMetrics("C1");
assertEquals(0, manager.getClientMetrics("C1").allowed.get());
}
@Test
void resetAllClientMetrics_resetsAll() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
map.put("C2", passDualBucket("C2"));
injectClientMap(map);
manager.isClientPassDetail("C1");
manager.isClientPassDetail("C2");
manager.resetAllClientMetrics();
assertEquals(0, manager.getClientMetrics("C1").allowed.get());
assertEquals(0, manager.getClientMetrics("C2").allowed.get());
}
// =========================================================================
// removeClient 메트릭 자동 삭제
// =========================================================================
@Test
void removeClient_cleansUpMetrics() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
assertNotNull(manager.getClientMetrics("C1"));
manager.removeClient("C1");
assertNull(manager.getClientMetrics("C1"));
}
}
@@ -0,0 +1,92 @@
package com.eactive.eai.common.inflow.dual;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import com.eactive.eai.common.inflow.InflowTargetVO;
/**
* DualInflowControlManager.makeBucket() 버킷 생성 동작 검증.
*
* <p>검증 포인트:
* - threshold/perSecond 버킷이 실제로 생성되는지
* - 할당량 이내 spike는 통과하고 초과 burst는 차단되는지
* - perSecond/threshold 어느 쪽이 binding 제약인지 구분되는지
*/
class DualInflowControlManagerMakeBucketTest {
private final DualInflowControlManager manager = new DualInflowControlManager();
private InflowTargetVO vo(long perSecond, long threshold, String timeUnit) {
InflowTargetVO vo = new InflowTargetVO();
vo.setName("TEST");
vo.setThresholdPerSecond(perSecond);
vo.setThreshold(threshold);
vo.setThresholdTimeUnit(timeUnit);
vo.setActivate(true);
return vo;
}
// =========================================================================
// threshold 단독 (5건/)
// =========================================================================
@Test
void thresholdOnly_spikePasses_thenBurstBlocked() {
DualCustomBucket bucket = manager.makeBucket(vo(0, 5, "MIN"));
for (int i = 0; i < 5; i++) assertNull(bucket.tryConsume());
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD, bucket.tryConsume());
}
// =========================================================================
// perSecond 단독
// =========================================================================
@Test
void perSecondOnly_spikePasses_thenBurstBlocked() {
DualCustomBucket bucket = manager.makeBucket(vo(5, 0, null));
for (int i = 0; i < 5; i++) assertNull(bucket.tryConsume());
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND, bucket.tryConsume());
}
// =========================================================================
// perSecond + threshold 동시 설정 binding 제약 구분
// =========================================================================
@Test
void both_perSecondIsBinding_blockedByPerSecond() {
// perSecond=3 binding: threshold=100/MIN은 여유 있음
DualCustomBucket bucket = manager.makeBucket(vo(3, 100, "MIN"));
for (int i = 0; i < 3; i++) assertNull(bucket.tryConsume());
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND, bucket.tryConsume());
}
@Test
void both_thresholdIsBinding_blockedByThreshold() {
// threshold=3/MIN binding: perSecond=100은 여유 있음
DualCustomBucket bucket = manager.makeBucket(vo(100, 3, "MIN"));
for (int i = 0; i < 3; i++) assertNull(bucket.tryConsume());
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD, bucket.tryConsume());
}
// =========================================================================
// 비활성 / 조건 미충족 null 반환
// =========================================================================
@Test
void inactiveVO_returnsNull() {
InflowTargetVO vo = vo(5, 0, null);
vo.setActivate(false);
assertNull(manager.makeBucket(vo));
}
@Test
void noLimitsSet_returnsNull() {
assertNull(manager.makeBucket(vo(0, 0, null)));
}
}
@@ -21,9 +21,8 @@ import io.github.bucket4j.local.LocalBucket;
/**
* DualInflowControlManager 메트릭 단위 테스트.
*
* <p>start() 없이 버킷 맵을 ReflectionTestUtils로 직접 주입하여
* isAdapterPassDetail / isInterfacePassDetail / isClientPassDetail / isGroupPass()
* 카운팅 동작과 getXxxMetrics / resetXxxMetrics 메서드를 검증한다.
* <p>어댑터/인터페이스/그룹 메트릭을 검증한다.
* 클라이언트 메트릭은 ClientDualInflowControlManagerMetricsTest 에서 검증한다.
*/
class DualInflowControlManagerMetricsTest {
@@ -100,10 +99,6 @@ class DualInflowControlManagerMetricsTest {
ReflectionTestUtils.setField(manager, "interfaceBucketMap", map);
}
private void injectClientMap(Map<String, DualCustomBucket> map) {
ReflectionTestUtils.setField(manager, "clientBucketMap", map);
}
private void injectGroupBucketList(Map<String, CustomGroupBucket> map) {
ReflectionTestUtils.setField(manager, "groupBucketList", map);
}
@@ -358,7 +353,6 @@ class DualInflowControlManagerMetricsTest {
@Test
void isAdapterPassDetail_bucketNotRegistered_noMetricsCreated() {
// 버킷 없는 어댑터는 메트릭 맵에 항목을 생성하지 않음
manager.isAdapterPassDetail("UNKNOWN");
assertNull(manager.getAdapterMetrics("UNKNOWN"));
@@ -473,39 +467,6 @@ class DualInflowControlManagerMetricsTest {
assertEquals(1, manager.getInterfaceMetrics("IF2").rejectedPerSecond.get());
}
// =========================================================================
// isClientPassDetail() 카운터 증가
// =========================================================================
@Test
void isClientPassDetail_bucketNotRegistered_noMetricsCreated() {
manager.isClientPassDetail("UNKNOWN");
assertNull(manager.getClientMetrics("UNKNOWN"));
}
@Test
void isClientPassDetail_pass_incrementsAllowed() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
assertEquals(1, manager.getClientMetrics("C1").allowed.get());
}
@Test
void isClientPassDetail_blockedThreshold_incrementsRejectedThreshold() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", blockedThresholdDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
assertEquals(1, manager.getClientMetrics("C1").rejectedThreshold.get());
}
// =========================================================================
// getAllXxxMetrics 조회 불변성
// =========================================================================
@@ -537,11 +498,6 @@ class DualInflowControlManagerMetricsTest {
assertTrue(manager.getAllInterfaceMetrics().isEmpty());
}
@Test
void getAllClientMetrics_empty_returnsEmptyMap() {
assertTrue(manager.getAllClientMetrics().isEmpty());
}
// =========================================================================
// resetXxxMetrics / resetAllXxxMetrics
// =========================================================================
@@ -560,7 +516,7 @@ class DualInflowControlManagerMetricsTest {
manager.resetAdapterMetrics("A1");
assertEquals(0, manager.getAdapterMetrics("A1").allowed.get());
assertEquals(1, manager.getAdapterMetrics("A2").allowed.get()); // 영향 없음
assertEquals(1, manager.getAdapterMetrics("A2").allowed.get());
}
@Test
@@ -601,33 +557,6 @@ class DualInflowControlManagerMetricsTest {
assertDoesNotThrow(() -> manager.resetAllInterfaceMetrics());
}
@Test
void resetClientMetrics_resetsTargetClient() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
manager.resetClientMetrics("C1");
assertEquals(0, manager.getClientMetrics("C1").allowed.get());
}
@Test
void resetAllClientMetrics_resetsAll() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
map.put("C2", passDualBucket("C2"));
injectClientMap(map);
manager.isClientPassDetail("C1");
manager.isClientPassDetail("C2");
manager.resetAllClientMetrics();
assertEquals(0, manager.getClientMetrics("C1").allowed.get());
assertEquals(0, manager.getClientMetrics("C2").allowed.get());
}
// =========================================================================
// remove 메트릭 자동 삭제
// =========================================================================
@@ -659,18 +588,4 @@ class DualInflowControlManagerMetricsTest {
assertNull(manager.getInterfaceMetrics("IF1"));
}
@Test
void removeClient_cleansUpMetrics() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
ReflectionTestUtils.setField(manager, "clientBucketMap", map);
manager.isClientPassDetail("C1");
assertNotNull(manager.getClientMetrics("C1"));
manager.removeClient("C1");
assertNull(manager.getClientMetrics("C1"));
}
}
@@ -1,197 +0,0 @@
package com.eactive.eai.inbound.processor;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import com.eactive.eai.common.inflow.Bucket;
import com.eactive.eai.common.inflow.InflowTargetVO;
import com.eactive.eai.common.inflow.dual.DualBucket;
import com.eactive.eai.common.inflow.dual.DualCustomBucket;
import com.eactive.eai.common.server.EAIServerManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
/**
* DualRequestProcessor 단위 테스트.
*
* <p>RequestProcessor 클래스는 static 초기화 블록에서 EAIServerManager.getInstance() 호출한다.
* @BeforeAll에서 mock ApplicationContext를 주입한 DualRequestProcessor를 최초 로딩시킨다.
*
* <p>주의: DualRequestProcessor를 필드 타입으로 선언하면 테스트 클래스 로딩 함께 로딩되어
* @BeforeAll 실행 전에 NPE가 발생한다. 반드시 메서드 본문 내에서만 참조해야 한다.
*/
class DualRequestProcessorTest {
@BeforeAll
static void setupMockApplicationContext() {
ApplicationContext mockContext = mock(ApplicationContext.class);
EAIServerManager mockServerManager = mock(EAIServerManager.class);
when(mockContext.getBean(EAIServerManager.class)).thenReturn(mockServerManager);
when(mockServerManager.getLocalServerName()).thenReturn("TEST-SERVER");
when(mockServerManager.getGroupInstId()).thenReturn("TEST-INST");
ReflectionTestUtils.setField(ApplicationContextProvider.class, "context", mockContext);
}
private InflowTargetVO makeVO(String name, long perSec, long threshold, String unit) {
InflowTargetVO vo = new InflowTargetVO();
vo.setName(name);
vo.setThresholdPerSecond(perSec);
vo.setThreshold(threshold);
vo.setThresholdTimeUnit(unit);
vo.setActivate(true);
return vo;
}
// -------------------------------------------------------------------------
// checkAdapterInflow
// -------------------------------------------------------------------------
@Test
void checkAdapterInflow_dualBucket_pass_returnsNull() {
DualRequestProcessor processor = new DualRequestProcessor();
DualBucket mockBucket = mock(DualBucket.class);
when(mockBucket.isAdapterPassDetail("ADAPTER_A")).thenReturn(null);
assertNull(processor.checkAdapterInflow(mockBucket, "ADAPTER_A"));
verify(mockBucket).isAdapterPassDetail("ADAPTER_A");
}
@Test
void checkAdapterInflow_dualBucket_blockedPerSecond() {
DualRequestProcessor processor = new DualRequestProcessor();
DualBucket mockBucket = mock(DualBucket.class);
when(mockBucket.isAdapterPassDetail("ADAPTER_A")).thenReturn(DualCustomBucket.RESULT_BLOCKED_PER_SECOND);
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND,
processor.checkAdapterInflow(mockBucket, "ADAPTER_A"));
}
@Test
void checkAdapterInflow_dualBucket_blockedThreshold() {
DualRequestProcessor processor = new DualRequestProcessor();
DualBucket mockBucket = mock(DualBucket.class);
when(mockBucket.isAdapterPassDetail("ADAPTER_A")).thenReturn(DualCustomBucket.RESULT_BLOCKED_THRESHOLD);
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD,
processor.checkAdapterInflow(mockBucket, "ADAPTER_A"));
}
@Test
void checkAdapterInflow_nonDualBucket_pass_returnsNull() {
DualRequestProcessor processor = new DualRequestProcessor();
Bucket mockBucket = mock(Bucket.class);
when(mockBucket.isAdapterPass("ADAPTER_A")).thenReturn(true);
assertNull(processor.checkAdapterInflow(mockBucket, "ADAPTER_A"));
}
@Test
void checkAdapterInflow_nonDualBucket_blocked_returnsBlocked() {
DualRequestProcessor processor = new DualRequestProcessor();
Bucket mockBucket = mock(Bucket.class);
when(mockBucket.isAdapterPass("ADAPTER_A")).thenReturn(false);
assertEquals("BLOCKED", processor.checkAdapterInflow(mockBucket, "ADAPTER_A"));
}
// -------------------------------------------------------------------------
// checkInterfaceInflow
// -------------------------------------------------------------------------
@Test
void checkInterfaceInflow_dualBucket_pass_returnsNull() {
DualRequestProcessor processor = new DualRequestProcessor();
DualBucket mockBucket = mock(DualBucket.class);
when(mockBucket.isInterfacePassDetail("IF_001")).thenReturn(null);
assertNull(processor.checkInterfaceInflow(mockBucket, "IF_001"));
verify(mockBucket).isInterfacePassDetail("IF_001");
}
@Test
void checkInterfaceInflow_dualBucket_blockedPerSecond() {
DualRequestProcessor processor = new DualRequestProcessor();
DualBucket mockBucket = mock(DualBucket.class);
when(mockBucket.isInterfacePassDetail("IF_001")).thenReturn(DualCustomBucket.RESULT_BLOCKED_PER_SECOND);
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND,
processor.checkInterfaceInflow(mockBucket, "IF_001"));
}
@Test
void checkInterfaceInflow_nonDualBucket_pass_returnsNull() {
DualRequestProcessor processor = new DualRequestProcessor();
Bucket mockBucket = mock(Bucket.class);
when(mockBucket.isInterfacePass("IF_001")).thenReturn(true);
assertNull(processor.checkInterfaceInflow(mockBucket, "IF_001"));
}
@Test
void checkInterfaceInflow_nonDualBucket_blocked_returnsBlocked() {
DualRequestProcessor processor = new DualRequestProcessor();
Bucket mockBucket = mock(Bucket.class);
when(mockBucket.isInterfacePass("IF_001")).thenReturn(false);
assertEquals("BLOCKED", processor.checkInterfaceInflow(mockBucket, "IF_001"));
}
// -------------------------------------------------------------------------
// buildInflowTargetErrorMsg
// -------------------------------------------------------------------------
@Test
void buildInflowTargetErrorMsg_perSecond_includesReqPerSec() {
DualRequestProcessor processor = new DualRequestProcessor();
InflowTargetVO vo = makeVO("결제API", 30, 1000, "MIN");
String msg = processor.buildInflowTargetErrorMsg(vo, DualCustomBucket.RESULT_BLOCKED_PER_SECOND);
assertEquals("API 호출 한도 초과 [결제API: 30req/sec]", msg);
}
@Test
void buildInflowTargetErrorMsg_threshold_includesReqPerUnit() {
DualRequestProcessor processor = new DualRequestProcessor();
InflowTargetVO vo = makeVO("결제API", 30, 1000, "MIN");
String msg = processor.buildInflowTargetErrorMsg(vo, DualCustomBucket.RESULT_BLOCKED_THRESHOLD);
assertEquals("API 호출 한도 초과 [결제API: 1000req/MIN]", msg);
}
@Test
void buildInflowTargetErrorMsg_blockedFallback_simpleFormat() {
DualRequestProcessor processor = new DualRequestProcessor();
InflowTargetVO vo = makeVO("결제API", 30, 1000, "MIN");
String msg = processor.buildInflowTargetErrorMsg(vo, "BLOCKED");
assertEquals("API 호출 한도 초과 [결제API]", msg);
}
@Test
void buildInflowTargetErrorMsg_nullBlockedType_simpleFormat() {
DualRequestProcessor processor = new DualRequestProcessor();
InflowTargetVO vo = makeVO("결제API", 30, 1000, "MIN");
String msg = processor.buildInflowTargetErrorMsg(vo, null);
assertEquals("API 호출 한도 초과 [결제API]", msg);
}
@Test
void buildInflowTargetErrorMsg_hourUnit() {
DualRequestProcessor processor = new DualRequestProcessor();
InflowTargetVO vo = makeVO("조회API", 10, 500, "HOUR");
String msg = processor.buildInflowTargetErrorMsg(vo, DualCustomBucket.RESULT_BLOCKED_THRESHOLD);
assertEquals("API 호출 한도 초과 [조회API: 500req/HOUR]", msg);
}
}
@@ -1,2 +1,3 @@
INSERT INTO inflow_control_group (group_id, group_name, threshold, threshold_per_second, threshold_time_unit, use_yn) VALUES
('test-group-001', '테스트 그룹', 100, 10, 'MIN', '1');
('test-group-001', '테스트 그룹', 100, 10, 'MIN', '1'),
('test-group-quota', '쿼터전용 그룹', 5, 0, 'MIN', '1');
@@ -1,6 +1,8 @@
INSERT INTO tseaifr09 (type,name,threshold,useyn,thresholdpersecond,thresholdtimeunit) VALUES
('01','_AA0_IN_NET_AsS',10,'1',0,NULL),
('01','_AB0_IN_NET_AsS',3,'1',0,NULL),
('01','_TST_IN_NET_AsS',1,'1',0,NULL),
('02','FASSFEP00000004S2',10,'1',0,NULL),
('02','FDASFEP00000009S2',2,'1',0,NULL);
INSERT INTO tseaifr09 (type,name,threshold,useyn,thresholdpersecond,thresholdtimeunit) VALUES
('01','_AA0_IN_NET_AsS',10,'1',0,NULL),
('01','_AB0_IN_NET_AsS',3,'1',0,NULL),
('01','_TST_IN_NET_AsS',1,'1',0,NULL),
('02','FASSFEP00000004S2',10,'1',0,NULL),
('02','FDASFEP00000009S2',2,'1',0,NULL),
('01','TEST_ADAPTER',5,'1',0,'MIN'),
('02','TEST_INTERFACE',5,'1',0,'MIN');