# 에러/엣지 케이스 테스트 지침서 (Track A-2)

> **작성일**: 2026-06-10
> **목적**: Phase 4 (에러/엣지 케이스) 테스트 항목을 체계적으로 정의하고 실행 절차를 제공
> **선행 조건**: Phase 3 BSC E2E 완료 + Track A-1 TRON E2E 완료
> **참조**: `v2-docs/CRYPTOMENTS_V2_TEST_PLAN.md` Phase 4 (4-1 ~ 4-4)

---

## 1. 입금 에러/엣지 케이스 (4-1)

### 1-1. 동일 TX 중복 Webhook (멱등성)

**목적**: 같은 tx_hash로 Webhook이 2번 이상 도착해도 입금이 1건만 처리되는지 확인

**구현 포인트**: `DepositService.onTxDetected()` → `depositRepository.findByTxHash(txHash)` → 기존 반환

**테스트 절차**:

```bash
# 1. 정상 입금 Webhook 전송 (1번째)
ssh cryptoments-bastion "ssh app-01 'curl -s -X POST http://localhost:8082/api/v1/webhooks/blockchain \
  -H \"Content-Type: application/json\" \
  -d \"{
    \\\"chainId\\\": <CHAIN_ID>,
    \\\"txHash\\\": \\\"0xTEST_DUPLICATE_TX_001\\\",
    \\\"txStatus\\\": \\\"CONFIRMED\\\",
    \\\"fromAddress\\\": \\\"<FROM>\\\",
    \\\"toAddress\\\": \\\"<HOT_ADDRESS>\\\",
    \\\"amount\\\": \\\"1000000000000000000\\\",
    \\\"contractAddress\\\": \\\"<USDT_CONTRACT>\\\",
    \\\"confirmations\\\": 20
  }\"'"

# 2. 동일 txHash로 재전송 (2번째)
# (위와 동일한 curl 재실행)

# 3. 검증
```

**DB 검증**:

```sql
-- deposits에 1건만 있어야 함
SELECT COUNT(*) FROM deposits WHERE tx_hash = '0xTEST_DUPLICATE_TX_001';
-- 예상: 1

-- ledger_entries도 1세트(CREDIT + FEE)만 존재
SELECT * FROM ledger_entries WHERE reference LIKE '%0xTEST_DUPLICATE_TX_001%';
```

**기대 결과**: HTTP 200 반환 (에러 아님), deposits 1건만 존재, 원장 중복 없음

---

### 1-2. 미식별 입금 (등록되지 않은 주소)

**목적**: 등록되지 않은 외부 주소에서 MASTER 지갑으로 직접 USDT 전송 시 처리 확인

**구현 포인트**: 지갑 매칭 실패 → `UNIDENTIFIED` type, partnerId=null

**테스트 절차**:

```bash
# MASTER 지갑으로 직접 Webhook (HOT가 아닌 MASTER로)
ssh cryptoments-bastion "ssh app-01 'curl -s -X POST http://localhost:8082/api/v1/webhooks/blockchain \
  -H \"Content-Type: application/json\" \
  -d \"{
    \\\"chainId\\\": <CHAIN_ID>,
    \\\"txHash\\\": \\\"0xTEST_UNIDENTIFIED_001\\\",
    \\\"txStatus\\\": \\\"CONFIRMED\\\",
    \\\"fromAddress\\\": \\\"0xUNKNOWN_SENDER_ADDRESS\\\",
    \\\"toAddress\\\": \\\"<MASTER_ADDRESS>\\\",
    \\\"amount\\\": \\\"500000000000000000\\\",
    \\\"contractAddress\\\": \\\"<USDT_CONTRACT>\\\",
    \\\"confirmations\\\": 20
  }\"'"
```

**DB 검증**:

```sql
SELECT id, tx_hash, deposit_type, partner_id, status
FROM deposits WHERE tx_hash = '0xTEST_UNIDENTIFIED_001';
-- 예상: deposit_type=UNIDENTIFIED 또는 partner_id=NULL

-- 원장에 기록되지 않아야 함 (파트너 불명)
SELECT COUNT(*) FROM ledger_entries
WHERE reference LIKE '%0xTEST_UNIDENTIFIED_001%';
-- 예상: 0 (파트너 매칭 실패이므로)
```

**기대 결과**: 입금 레코드는 생성되지만 파트너 귀속 없음, 원장 미기록, 관리자 수동 처리 대기

---

### 1-3. 서비스 재시작 후 미확인 TX 재처리

**목적**: open-api 재시작 시 CONFIRMING 상태의 입금이 누락되지 않는지 확인

**구현 포인트**: `StaleTxMonitorJob` (10분 폴링)이 `BROADCASTING` 상태 + `updatedAt < threshold` 건을 감지

**테스트 절차**:

1. CONFIRMING/BROADCASTING 상태 입금 존재 확인
2. open-api 또는 scheduler 재시작 (운영 시 주의)
3. StaleTxMonitorJob 실행 대기 (10분) 또는 수동 트리거

**DB 검증**:

```sql
-- BROADCASTING 상태 + 30분 이상 경과 건 확인
SELECT * FROM deposits
WHERE status = 'BROADCASTING' AND updated_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE);

-- collection_batches 중 stale
SELECT * FROM collection_batches
WHERE status = 'BROADCASTING' AND updated_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE);

-- StaleTxMonitorJob 결과: STALE 또는 CONFIRMED로 전환
SELECT * FROM withdrawals WHERE status IN ('STALE', 'FAILED')
ORDER BY updated_at DESC LIMIT 5;
```

**기대 결과**: StaleTxMonitorJob이 stale TX를 감지하고 blockchain-api로 TX 상태 조회 후 적절한 상태 전이 수행

---

## 2. 출금 에러/엣지 케이스 (4-2)

### 2-1. 잔액 부족 출금 요청

**목적**: MASTER 잔액보다 큰 금액 출금 요청 시 적절한 에러 반환 확인

**구현 포인트**: `SettlementService.freeze()` → `available = ledger_balance - frozen_amount` → `INSUFFICIENT_BALANCE`

**테스트 절차**:

```bash
# 현재 잔액 확인
ssh cryptoments-bastion "ssh db-01 'mysql -u cryptoments -p\"Crypt0m3nts!2026\" cryptoments_db -e \"
SELECT sb.* FROM settlement_balances sb WHERE partner_id = <PARTNER_ID>;
\"'"

# 잔액 초과 출금 요청
ssh cryptoments-bastion "ssh app-01 'curl -s -X POST http://localhost:8080/api/admin/withdrawals \
  -H \"Content-Type: application/json\" \
  -H \"Access-Token: <ADMIN_TOKEN>\" \
  -d \"{
    \\\"partnerId\\\": <PARTNER_ID>,
    \\\"networkId\\\": 2,
    \\\"currencyId\\\": <USDT_CURRENCY_ID>,
    \\\"amount\\\": \\\"999999.0\\\",
    \\\"toAddress\\\": \\\"<TO_ADDRESS>\\\"
  }\"'"
```

**기대 결과**: HTTP 4xx + `INSUFFICIENT_BALANCE` 에러 코드, withdrawals INSERT 없음, frozen_amount 변동 없음

---

### 2-2. 최소 금액 미달 출금 요청

**목적**: 10 USD 미만 출금 시 `MIN_AMOUNT_NOT_MET` 에러 확인

**구현 포인트**: `WithdrawalService.requestWithdrawal()` → amount < 10 → `MIN_AMOUNT_NOT_MET`

```bash
ssh cryptoments-bastion "ssh app-01 'curl -s -X POST http://localhost:8080/api/admin/withdrawals \
  -H \"Content-Type: application/json\" \
  -H \"Access-Token: <ADMIN_TOKEN>\" \
  -d \"{
    \\\"partnerId\\\": <PARTNER_ID>,
    \\\"networkId\\\": 2,
    \\\"currencyId\\\": <USDT_CURRENCY_ID>,
    \\\"amount\\\": \\\"0.5\\\",
    \\\"toAddress\\\": \\\"<TO_ADDRESS>\\\"
  }\"'"
```

**기대 결과**: HTTP 4xx + `MIN_AMOUNT_NOT_MET` 에러

---

### 2-3. 관리자 거부 (REJECTED)

**목적**: 출금을 REJECTED 처리 시 frozen_amount 복원(unfreeze) 확인

**테스트 절차**:

```bash
# 1. 소액 출금 요청 (정상)
# → withdrawals INSERT (REQUESTED), frozen_amount 증가

# 2. 거부
ssh cryptoments-bastion "ssh app-01 'curl -s -X POST http://localhost:8080/api/admin/withdrawals/<WITHDRAWAL_ID>/reject \
  -H \"Access-Token: <ADMIN_TOKEN>\" \
  -H \"Content-Type: application/json\" \
  -d \"{\\\"reason\\\": \\\"테스트 거부\\\"}\"'"
```

**DB 검증**:

```sql
-- 출금 상태
SELECT id, status, amount FROM withdrawals WHERE id = <WITHDRAWAL_ID>;
-- 예상: REJECTED

-- frozen_amount 복원 (요청 전과 동일해야 함)
SELECT frozen_amount FROM settlement_balances WHERE partner_id = <PARTNER_ID>;

-- 상태 이력
SELECT * FROM withdrawal_approval_logs WHERE withdrawal_id = <WITHDRAWAL_ID>;
```

**기대 결과**: status=REJECTED, frozen_amount 원복, approval_logs에 REJECTED 기록

---

### 2-4. TX 실패 → 자동 재시도

**목적**: 블록체인 TX 실패 시 FAILED → RetryFailedWithdrawalsJob이 APPROVED로 복원 → 재시도

**구현 포인트**:
- `WithdrawalService.onTxFailed()` → status=FAILED
- `RetryFailedWithdrawalsJob` (5분 간격) → FAILED → APPROVED

**테스트 절차**: TX 실패를 시뮬레이션하기 어려우므로 DB에서 직접 확인

```sql
-- 현재 FAILED 상태 출금
SELECT * FROM withdrawals WHERE status = 'FAILED' ORDER BY updated_at DESC;

-- RetryFailedWithdrawalsJob 실행 후 APPROVED 전환 확인
SELECT * FROM withdrawals
WHERE status IN ('FAILED', 'APPROVED')
ORDER BY updated_at DESC LIMIT 5;
```

**기대 결과**: FAILED → APPROVED 자동 전환 (retry 시점에서 WithdrawalPoller가 다시 처리)

---

### 2-5. 출금 만료 (24시간 미처리)

**목적**: REQUESTED/PENDING_APPROVAL 상태로 24시간 경과 시 자동 CANCELLED

**구현 포인트**: `ExpireStaleWithdrawalsJob` (1시간 간격)
- 조건: `status IN ('REQUESTED', 'PENDING_APPROVAL') AND created_at < NOW() - 24h`
- 동작: CANCELLED + unfreeze

**DB 검증**:

```sql
-- 24시간 이상 경과한 REQUESTED/PENDING_APPROVAL 확인
SELECT * FROM withdrawals
WHERE status IN ('REQUESTED', 'PENDING_APPROVAL')
AND created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR);
-- 정상이면 0건 (Job이 처리했으므로)

-- 만료 처리된 건 확인
SELECT * FROM withdrawals
WHERE status = 'CANCELLED'
AND updated_at > DATE_SUB(NOW(), INTERVAL 2 HOUR)
ORDER BY updated_at DESC;
```

---

### 2-6. 일일 한도 초과

**목적**: 파트너 daily_limit 초과 시 `DAILY_LIMIT_EXCEEDED` 에러 확인

```bash
# 파트너 출금 정책 확인
ssh cryptoments-bastion "ssh db-01 'mysql -u cryptoments -p\"Crypt0m3nts!2026\" cryptoments_db -e \"
SELECT * FROM withdrawal_policies WHERE partner_id = <PARTNER_ID>;
\"'"

# daily_limit 초과 요청 (정책이 있는 경우)
```

**기대 결과**: `DAILY_LIMIT_EXCEEDED` 에러 (정책이 없으면 한도 검증 skip)

---

## 3. 집금 에러/엣지 케이스 (4-3)

### 3-1. approve 미완료 HOT → 집금 스킵

**목적**: approve가 PENDING/FAILED 상태인 HOT 지갑의 집금이 스킵되는지 확인

**구현 포인트**: `CollectionBatchRunner`가 transferFrom 전 allowance 확인. allowance=0이면 TX revert.

**테스트 절차**:

```sql
-- approve 미완료 지갑 확인
SELECT wa.id, wa.address, wap.status
FROM wallet_addresses wa
LEFT JOIN wallet_approvals wap ON wa.id = wap.wallet_address_id
WHERE wa.wallet_type = 'HOT' AND (wap.status IS NULL OR wap.status != 'APPROVED');

-- 해당 지갑의 집금 큐 상태
SELECT cq.* FROM collection_queue cq
JOIN deposits d ON cq.deposit_id = d.id
WHERE d.wallet_address_id IN (<위에서 찾은 ID>);
-- 예상: QUEUED 상태 유지 (처리 안 됨)
```

**기대 결과**: approve 미완료 HOT의 집금은 TX 실행하지 않거나 FAILED 처리

---

### 3-2. Relayer 가스 부족

**목적**: Relayer 지갑의 네이티브 토큰(BNB/TRX) 부족 시 에러 로그 + 알림 확인

**구현 포인트**:
- EVM: gasEstimate 실패 → TX revert
- TRON: 에너지 렌탈 실패 또는 feeLimit 초과

**DB 검증**:

```sql
-- Relayer 잔액 확인
SELECT wa.address, wa.wallet_type, wb.available_balance
FROM wallet_addresses wa
JOIN wallet_balances wb ON wa.id = wb.wallet_address_id
WHERE wa.wallet_type = 'RELAYER';

-- 실패 집금 확인
SELECT * FROM collection_queue WHERE status = 'FAILED' ORDER BY updated_at DESC LIMIT 5;

-- 가스비 잔액 감시 알림 (BalanceLowCheckJob)
SELECT * FROM notification_events
WHERE event_type LIKE '%BALANCE%' ORDER BY id DESC LIMIT 5;
```

---

### 3-3. 집금 타임아웃 (BROADCASTING 상태 장기 유지)

**목적**: 집금 TX가 BROADCASTING 후 30분 이상 확인 안 되면 STALE 처리

**구현 포인트**: `StaleTxMonitorJob` (10분 간격)
- `collection_batches WHERE status='BROADCASTING' AND updated_at < threshold`
- TX 조회 → not found = STALE, reverted = FAILED, success = CONFIRMED

**DB 검증**:

```sql
SELECT * FROM collection_batches
WHERE status IN ('BROADCASTING', 'STALE', 'FAILED')
ORDER BY updated_at DESC LIMIT 5;
```

---

## 4. 인프라 에러/엣지 케이스 (4-4)

### 4-1. GAS 지갑 잔액 부족 알림

**목적**: GAS/FEE 지갑 잔액이 임계값 미만이면 알림 발송 확인

**구현 포인트**: `BalanceLowCheckJob` (30분 간격) → `WalletService.monitorGasWallets()` → threshold 0.1 미만 필터

**테스트 절차**:

```sql
-- GAS 지갑 잔액 확인
SELECT wa.id, wa.address, wa.network_id, wb.available_balance
FROM wallet_addresses wa
JOIN wallet_balances wb ON wa.id = wb.wallet_address_id
WHERE wa.wallet_type = 'GAS';

-- 알림 이벤트 확인
SELECT * FROM notification_events
WHERE event_type LIKE '%LOW_BALANCE%' OR event_type LIKE '%GAS%'
ORDER BY id DESC LIMIT 5;
```

**기대 결과**: 잔액 < 임계값이면 Telegram 알림 + notification_events 기록

---

### 4-2. Relayer Nonce Stuck → 수동 동기화

**목적**: Nonce 불일치 시 수동 동기화로 복구 가능한지 확인

**테스트 절차**:

```bash
# 현재 nonce 확인
ssh cryptoments-bastion "ssh node-02 'curl -s http://localhost:3002/api/nonce-trackers?networkId=2'"

# nonce 수동 동기화
ssh cryptoments-bastion "ssh node-02 'curl -s -X POST http://localhost:3002/api/nonce-trackers/<NONCE_TRACKER_ID>/sync'"
```

**DB 검증**:

```sql
SELECT * FROM nonce_trackers WHERE network_id = 2;
-- current_nonce가 온체인 nonce와 일치해야 함
```

---

### 4-3. 동시 다발 approve 요청 → Nonce 직렬화

**목적**: 여러 HOT 지갑의 approve가 동시 요청될 때 nonce가 충돌하지 않는지 확인

**구현 포인트**: `SELECT ... FOR UPDATE` 비관적 잠금 (nonce_trackers 테이블)

**검증 방법**:

```sql
-- wallet_approvals에서 GAS_SUPPORTING/APPROVING 상태 동시 존재 확인
SELECT status, COUNT(*) FROM wallet_approvals
WHERE status IN ('GAS_SUPPORTING', 'APPROVING')
GROUP BY status;

-- 동일 relayer의 nonce 연속성 확인
SELECT gcr.* FROM gas_cost_records gcr
WHERE gcr.tx_type IN ('GAS_SUPPORT', 'APPROVE')
ORDER BY gcr.id DESC LIMIT 10;
-- nonce 값이 연속적이어야 함 (빈 번호 없이)
```

---

## 5. Webhook 에러 케이스

### 5-1. Webhook 배달 실패 → 재시도

**목적**: 파트너 Webhook URL 응답 실패 시 지수 백오프 재시도 확인

**구현 포인트**: `NotificationService.recordWebhookResult()`
- 재시도 간격: 30s → 60s → 300s → 900s → 3600s (최대 5회)
- RETRYING → nextRetryAt 기록 → Job이 다시 PENDING으로 전환

**DB 검증**:

```sql
-- 배달 로그 확인
SELECT nd.id, nd.status, nd.retry_count, nd.next_retry_at,
       ne.event_type, ne.partner_id
FROM notification_deliveries nd
JOIN notification_events ne ON nd.notification_event_id = ne.id
WHERE nd.status IN ('RETRYING', 'FAILED', 'PENDING')
ORDER BY nd.id DESC LIMIT 10;
```

**기대 결과**: retry_count 증가, next_retry_at 간격 확장, 5회 초과 시 FAILED 고정

---

### 5-2. Webhook 수신 시 HTTP 200 항상 반환

**목적**: 내부 에러 발생해도 blockchain_monitor에 200 반환 → 무한 재전송 방지

**구현 포인트**: `BlockchainWebhookController` — try-catch로 감싸고 항상 200 반환

**테스트 절차**: 잘못된 데이터로 Webhook 전송

```bash
# 잘못된 contractAddress로 Webhook 전송
ssh cryptoments-bastion "ssh app-01 'curl -s -w \"\n%{http_code}\" -X POST http://localhost:8082/api/v1/webhooks/blockchain \
  -H \"Content-Type: application/json\" \
  -d \"{
    \\\"chainId\\\": <CHAIN_ID>,
    \\\"txHash\\\": \\\"0xTEST_BAD_CONTRACT_001\\\",
    \\\"txStatus\\\": \\\"CONFIRMED\\\",
    \\\"fromAddress\\\": \\\"0xSOME_ADDRESS\\\",
    \\\"toAddress\\\": \\\"0xSOME_ADDRESS\\\",
    \\\"amount\\\": \\\"1\\\",
    \\\"contractAddress\\\": \\\"0xINVALID_CONTRACT\\\",
    \\\"confirmations\\\": 20
  }\"'"
```

**기대 결과**: HTTP 200 (내부 처리 결과 관계없이), 로그에 warn/error 기록

---

## 6. 정산 에러 케이스

### 6-1. 실현 최소 임계값 (10 USD)

**목적**: 미실현 수수료가 10 USD 미만이면 실현 처리 skip

**구현 포인트**: `SettlementService.realizeFees()` → 10 USD 미만 skip

**DB 검증**:

```sql
-- 미실현 수수료 확인
SELECT sdf.partner_id, SUM(sdf.share_amount) as total_unrealized
FROM settlement_daily_fees sdf
WHERE sdf.is_realized = false
GROUP BY sdf.partner_id;

-- 10 USD 미만 파트너는 settlement_realizations에 없어야 함
```

---

### 6-2. freeze/unfreeze 이중 실행 방지

**목적**: 동일 출금의 freeze가 2번 호출되어도 frozen_amount가 2배가 되지 않는지 확인

**구현 포인트**: freeze는 출금 요청 시 1회만 호출. 상태 머신이 이중 요청을 방지.

**DB 검증**:

```sql
-- frozen_amount가 음수가 아닌지, 비정상적으로 크지 않은지
SELECT partner_id, ledger_balance, frozen_amount,
       (ledger_balance - frozen_amount) AS available
FROM settlement_balances
WHERE frozen_amount < 0 OR frozen_amount > ledger_balance;
-- 예상: 0건 (정상이면)
```

---

### 6-3. 가스비 기록 — partnerId null/0 처리

**목적**: 시스템 지갑(ADMIN, GAS)의 가스비가 파트너 정산에 포함되지 않는지 확인

**구현 포인트**: `SettlementService.recordGasCost()` → partnerId null/0이면 return null

**DB 검증**:

```sql
-- partnerId가 null인 gas_cost_records 존재 확인
SELECT COUNT(*) FROM gas_cost_records WHERE partner_id IS NULL OR partner_id = 0;
-- 이들은 정산에 포함되면 안 됨

-- settlement_daily_fees에서 system gas 비용 미포함 확인
SELECT * FROM settlement_daily_fees WHERE fee_type = 'GAS' AND partner_id = 0;
-- 예상: 0건
```

---

## 7. 실행 우선순위

| 순위 | 테스트 ID | 항목 | 위험도 |
|------|-----------|------|--------|
| 1 | 1-1 | 중복 Webhook 멱등성 | **Critical** — 이중 입금 방지 |
| 2 | 2-1 | 잔액 부족 출금 | **Critical** — 자금 보호 |
| 3 | 2-3 | 출금 거부 unfreeze | **Critical** — 잔액 정합성 |
| 4 | 5-2 | Webhook 200 항상 반환 | **High** — 모니터 재전송 방지 |
| 5 | 2-4 | TX 실패 자동 재시도 | **High** — 운영 자동화 |
| 6 | 4-1 | GAS 잔액 부족 알림 | **High** — 운영 모니터링 |
| 7 | 3-1 | approve 미완료 집금 스킵 | **Medium** — 방어적 처리 |
| 8 | 1-2 | 미식별 입금 | **Medium** — CS 대응 |
| 9 | 6-1 | 실현 최소 임계값 | **Low** — 비용 최적화 |
| 10 | 4-2 | Nonce 수동 동기화 | **Low** — 복구 절차 |

---

## 8. 완료 기준

- [ ] 1-1: 중복 tx_hash Webhook → deposits 1건만 존재
- [ ] 1-2: 미식별 입금 → partner_id=null, 원장 미기록
- [ ] 2-1: 잔액 초과 출금 → `INSUFFICIENT_BALANCE` 에러
- [ ] 2-2: 최소 금액 미달 → `MIN_AMOUNT_NOT_MET` 에러
- [ ] 2-3: REJECTED → frozen_amount 원복
- [ ] 2-4: FAILED → RetryJob → APPROVED 자동 전환
- [ ] 2-5: 24시간 미처리 → CANCELLED + unfreeze
- [ ] 3-1: approve 미완료 HOT → 집금 미실행
- [ ] 4-1: GAS 잔액 부족 → 알림 발송
- [ ] 4-2: Nonce sync → 온체인 nonce 동기화
- [ ] 5-1: Webhook 실패 → 5회 재시도 (30s~3600s 간격)
- [ ] 5-2: 잘못된 Webhook → HTTP 200 반환
- [ ] 6-1: 미실현 < 10 USD → 실현 skip
- [ ] 6-2: frozen_amount ≥ 0, ≤ ledger_balance
