Files
telegram-bot-workers/tests/deposit-agent.test.ts
kappa bd25316fd3 fix: resolve all test failures after vitest 2.x upgrade
- Attach rejects handler before advancing timers (vitest 2.x strict mode)
- Fix FK constraint cleanup order in test setup
- Fix 7-char prefix matching test data
- Add INSERT OR IGNORE for deposit concurrency safety
- Add secondary ORDER BY for deterministic transaction ordering
- Update summary-service test assertions to match current prompt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 19:41:11 +09:00

614 lines
22 KiB
TypeScript

/**
* Comprehensive Unit Tests for deposit-agent.ts
*
* 테스트 범위:
* 1. 음수 금액 거부
* 2. 동시성 처리 안전성
* 3. 부분 배치 실패 처리
* 4. 입금자명 7글자 매칭
* 5. 잔액 부족 시나리오
* 6. 관리자 권한 검증
* 7. 거래 상태 검증
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { executeDepositFunction, DepositContext } from '../src/agents/deposit-agent';
import {
createTestUser,
createBankNotification,
createDepositTransaction,
getTestDB,
} from './setup';
describe('executeDepositFunction', () => {
let testUserId: number;
let testContext: DepositContext;
beforeEach(async () => {
// 각 테스트마다 새로운 사용자 생성
testUserId = await createTestUser('123456789', 'testuser');
testContext = {
userId: testUserId,
telegramUserId: '123456789',
isAdmin: false,
db: getTestDB(),
};
});
describe('get_balance', () => {
it('should return zero balance for new user', async () => {
const result = await executeDepositFunction('get_balance', {}, testContext);
expect(result).toHaveProperty('balance', 0);
expect(result).toHaveProperty('formatted', '0원');
});
it('should return current balance', async () => {
// 잔액 설정 - executeDepositFunction이 자동으로 생성하므로 기존 레코드 먼저 생성
await testContext.db.prepare(
'INSERT OR IGNORE INTO user_deposits (user_id, balance) VALUES (?, 0)'
).bind(testUserId).run();
// 잔액 업데이트
await testContext.db.prepare(
'UPDATE user_deposits SET balance = ? WHERE user_id = ?'
).bind(50000, testUserId).run();
const result = await executeDepositFunction('get_balance', {}, testContext);
expect(result.balance).toBe(50000);
expect(result.formatted).toBe('50,000원');
});
});
describe('get_account_info', () => {
it('should return bank account information', async () => {
const result = await executeDepositFunction('get_account_info', {}, testContext);
expect(result).toHaveProperty('bank', '하나은행');
expect(result).toHaveProperty('account', '427-910018-27104');
expect(result).toHaveProperty('holder', '주식회사 아이언클래드');
expect(result).toHaveProperty('instruction');
});
});
describe('request_deposit - Negative Amount Validation', () => {
it('should reject negative amounts', async () => {
const result = await executeDepositFunction('request_deposit', {
depositor_name: '홍길동',
amount: -10000,
}, testContext);
expect(result).toHaveProperty('error');
expect(result.error).toContain('충전 금액을 입력해주세요');
});
it('should reject zero amount', async () => {
const result = await executeDepositFunction('request_deposit', {
depositor_name: '홍길동',
amount: 0,
}, testContext);
expect(result).toHaveProperty('error');
expect(result.error).toContain('충전 금액을 입력해주세요');
});
it('should reject missing amount', async () => {
const result = await executeDepositFunction('request_deposit', {
depositor_name: '홍길동',
}, testContext);
expect(result).toHaveProperty('error');
expect(result.error).toContain('충전 금액을 입력해주세요');
});
it('should reject missing depositor name', async () => {
const result = await executeDepositFunction('request_deposit', {
amount: 10000,
}, testContext);
expect(result).toHaveProperty('error');
expect(result.error).toContain('입금자명을 입력해주세요');
});
});
describe('request_deposit - 7-Character Prefix Matching', () => {
it('should auto-match with 7-character prefix when bank notification exists', async () => {
// 은행 알림 먼저 생성: "홍길동아버지님어머니" → prefix "홍길동아버지님" (7글자)
await createBankNotification('홍길동아버지님어머니', 50000);
// 사용자가 "홍길동아버지님" (7글자)로 입금 신고 → 앞 7글자 매칭
const result = await executeDepositFunction('request_deposit', {
depositor_name: '홍길동아버지님',
amount: 50000,
}, testContext);
expect(result.success).toBe(true);
expect(result.auto_matched).toBe(true);
expect(result.new_balance).toBe(50000);
expect(result.message).toContain('자동 매칭');
});
it('should match exactly 7 characters from user input', async () => {
// 은행 알림: "홍길동아버지의부인이모" → prefix "홍길동아버지의" (7글자)
await createBankNotification('홍길동아버지의부인이모', 30000);
// 사용자: "홍길동아버지의부인" (9글자) → 앞 7글자 "홍길동아버지의" 매칭
const result = await executeDepositFunction('request_deposit', {
depositor_name: '홍길동아버지의부인',
amount: 30000,
}, testContext);
expect(result.success).toBe(true);
expect(result.auto_matched).toBe(true);
expect(result.new_balance).toBe(30000);
});
it('should not match if prefix differs', async () => {
// 은행 알림: "김철수씨" (5글자)
await createBankNotification('김철수씨', 20000);
// 사용자: "홍길동" (3글자) - 불일치
const result = await executeDepositFunction('request_deposit', {
depositor_name: '홍길동',
amount: 20000,
}, testContext);
expect(result.success).toBe(true);
expect(result.auto_matched).toBe(false);
expect(result.status).toBe('pending');
});
it('should not match if amount differs', async () => {
await createBankNotification('홍길동', 50000);
// 금액 불일치
const result = await executeDepositFunction('request_deposit', {
depositor_name: '홍길동',
amount: 30000,
}, testContext);
expect(result.auto_matched).toBe(false);
expect(result.status).toBe('pending');
});
});
describe('request_deposit - Pending Transaction', () => {
it('should create pending transaction when no bank notification', async () => {
const result = await executeDepositFunction('request_deposit', {
depositor_name: '홍길동',
amount: 10000,
}, testContext);
expect(result.success).toBe(true);
expect(result.auto_matched).toBe(false);
expect(result.status).toBe('pending');
expect(result).toHaveProperty('transaction_id');
expect(result).toHaveProperty('account_info');
// DB 확인
const tx = await testContext.db.prepare(
'SELECT * FROM deposit_transactions WHERE id = ?'
).bind(result.transaction_id).first();
expect(tx).toBeDefined();
expect(tx?.status).toBe('pending');
expect(tx?.depositor_name).toBe('홍길동');
expect(tx?.depositor_name_prefix).toBe('홍길동');
expect(tx?.amount).toBe(10000);
});
it('should store depositor_name_prefix correctly', async () => {
// 8글자 이름 → 앞 7글자만 prefix로 저장
const result = await executeDepositFunction('request_deposit', {
depositor_name: '홍길동아버지님이',
amount: 25000,
}, testContext);
const tx = await testContext.db.prepare(
'SELECT depositor_name_prefix FROM deposit_transactions WHERE id = ?'
).bind(result.transaction_id).first<{ depositor_name_prefix: string }>();
expect(tx?.depositor_name_prefix).toBe('홍길동아버지님');
});
});
describe('request_deposit - Batch Failure Handling', () => {
it.skip('DEPRECATED: batch 테스트 - 현재는 Optimistic Locking 사용', async () => {
// NOTE: 프로덕션 코드가 executeWithOptimisticLock()을 사용하도록 변경됨
// db.batch()를 직접 사용하지 않으므로 이 테스트는 더 이상 유효하지 않음
// TODO: Optimistic Locking 동시성 충돌 시뮬레이션 테스트 추가 필요
// - Version mismatch 시나리오
// - 재시도 로직 검증
// - OptimisticLockError 처리 확인
});
});
describe('get_transactions', () => {
it('should return empty list for new user', async () => {
const result = await executeDepositFunction('get_transactions', {}, testContext);
expect(result.transactions).toEqual([]);
expect(result.message).toContain('거래 내역이 없습니다');
});
it('should return transactions ordered by date DESC', async () => {
// 여러 거래 생성 (시간 순서 보장을 위해 순차 생성)
const tx1 = await createDepositTransaction(testUserId, 10000, 'confirmed', '홍길동');
// SQLite CURRENT_TIMESTAMP는 초 단위이므로 ID로 순서 확인
const tx2 = await createDepositTransaction(testUserId, 20000, 'pending', '김철수');
const tx3 = await createDepositTransaction(testUserId, 15000, 'cancelled', '이영희');
const result = await executeDepositFunction('get_transactions', {}, testContext);
expect(result.transactions).toHaveLength(3);
// 최신순 정렬 확인 (created_at DESC → 같은 초에 생성되면 id로 정렬)
// tx3(15000) > tx2(20000) > tx1(10000) 순서
expect(result.transactions[0].amount).toBe(15000);
expect(result.transactions[1].amount).toBe(20000);
expect(result.transactions[2].amount).toBe(10000);
});
it('should respect limit parameter', async () => {
// 5개 거래 생성
for (let i = 0; i < 5; i++) {
await createDepositTransaction(testUserId, 10000 * (i + 1), 'confirmed');
}
const result = await executeDepositFunction('get_transactions', {
limit: 3,
}, testContext);
expect(result.transactions).toHaveLength(3);
});
it('should enforce maximum limit of 100', async () => {
const result = await executeDepositFunction('get_transactions', {
limit: 500, // 과도한 limit
}, testContext);
// 실제로는 100개까지만 조회되어야 함 (로직 검증)
expect(result).toBeDefined();
});
it('should use default limit of 10 when not specified', async () => {
// 15개 거래 생성
for (let i = 0; i < 15; i++) {
await createDepositTransaction(testUserId, 1000, 'confirmed');
}
const result = await executeDepositFunction('get_transactions', {}, testContext);
expect(result.transactions).toHaveLength(10);
});
});
describe('cancel_transaction', () => {
it('should cancel pending transaction', async () => {
const txId = await createDepositTransaction(testUserId, 10000, 'pending', '홍길동');
const result = await executeDepositFunction('cancel_transaction', {
transaction_id: txId,
}, testContext);
expect(result.success).toBe(true);
expect(result.transaction_id).toBe(txId);
// DB 확인
const tx = await testContext.db.prepare(
'SELECT status FROM deposit_transactions WHERE id = ?'
).bind(txId).first<{ status: string }>();
expect(tx?.status).toBe('cancelled');
});
it('should reject canceling confirmed transaction', async () => {
const txId = await createDepositTransaction(testUserId, 10000, 'confirmed', '홍길동');
const result = await executeDepositFunction('cancel_transaction', {
transaction_id: txId,
}, testContext);
expect(result).toHaveProperty('error');
expect(result.error).toContain('대기 중인 거래만 취소');
});
it('should reject canceling other user transaction', async () => {
const otherUserId = await createTestUser('987654321', 'otheruser');
const txId = await createDepositTransaction(otherUserId, 10000, 'pending', '김철수');
const result = await executeDepositFunction('cancel_transaction', {
transaction_id: txId,
}, testContext);
expect(result).toHaveProperty('error');
expect(result.error).toContain('본인의 거래만 취소');
});
it('should allow admin to cancel any transaction', async () => {
const otherUserId = await createTestUser('987654321', 'otheruser');
const txId = await createDepositTransaction(otherUserId, 10000, 'pending', '김철수');
const adminContext = { ...testContext, isAdmin: true };
const result = await executeDepositFunction('cancel_transaction', {
transaction_id: txId,
}, adminContext);
expect(result.success).toBe(true);
});
it('should return error for non-existent transaction', async () => {
const result = await executeDepositFunction('cancel_transaction', {
transaction_id: 99999,
}, testContext);
expect(result).toHaveProperty('error');
expect(result.error).toContain('거래를 찾을 수 없습니다');
});
});
describe('Admin Functions - Permission Checks', () => {
it('should reject non-admin calling get_pending_list', async () => {
const result = await executeDepositFunction('get_pending_list', {}, testContext);
expect(result).toHaveProperty('error');
expect(result.error).toContain('관리자 권한이 필요합니다');
});
it('should reject non-admin calling confirm_deposit', async () => {
const txId = await createDepositTransaction(testUserId, 10000, 'pending');
const result = await executeDepositFunction('confirm_deposit', {
transaction_id: txId,
}, testContext);
expect(result).toHaveProperty('error');
expect(result.error).toContain('관리자 권한이 필요합니다');
});
it('should reject non-admin calling reject_deposit', async () => {
const txId = await createDepositTransaction(testUserId, 10000, 'pending');
const result = await executeDepositFunction('reject_deposit', {
transaction_id: txId,
}, testContext);
expect(result).toHaveProperty('error');
expect(result.error).toContain('관리자 권한이 필요합니다');
});
});
describe('Admin Functions - get_pending_list', () => {
it('should return pending deposits for admin', async () => {
const adminContext = { ...testContext, isAdmin: true };
// pending 거래 생성
await createDepositTransaction(testUserId, 10000, 'pending', '홍길동');
await createDepositTransaction(testUserId, 20000, 'confirmed', '김철수');
const result = await executeDepositFunction('get_pending_list', {}, adminContext);
expect(result.pending).toHaveLength(1);
expect(result.pending[0].amount).toBe(10000);
expect(result.pending[0].depositor_name).toBe('홍길동');
});
it('should return empty list when no pending deposits', async () => {
const adminContext = { ...testContext, isAdmin: true };
const result = await executeDepositFunction('get_pending_list', {}, adminContext);
expect(result.pending).toEqual([]);
expect(result.message).toContain('대기 중인 입금 요청이 없습니다');
});
});
describe('Admin Functions - confirm_deposit', () => {
it('should confirm pending deposit and increase balance', async () => {
const adminContext = { ...testContext, isAdmin: true };
// 초기 잔액 설정 - executeDepositFunction이 자동 생성하므로 먼저 확인
await testContext.db.prepare(
'INSERT OR IGNORE INTO user_deposits (user_id, balance) VALUES (?, 0)'
).bind(testUserId).run();
await testContext.db.prepare(
'UPDATE user_deposits SET balance = ? WHERE user_id = ?'
).bind(10000, testUserId).run();
const txId = await createDepositTransaction(testUserId, 15000, 'pending', '홍길동');
const result = await executeDepositFunction('confirm_deposit', {
transaction_id: txId,
}, adminContext);
expect(result.success).toBe(true);
expect(result.amount).toBe(15000);
// 거래 상태 확인
const tx = await testContext.db.prepare(
'SELECT status, confirmed_at FROM deposit_transactions WHERE id = ?'
).bind(txId).first<{ status: string; confirmed_at: string | null }>();
expect(tx?.status).toBe('confirmed');
expect(tx?.confirmed_at).toBeDefined();
// 잔액 확인
const deposit = await testContext.db.prepare(
'SELECT balance FROM user_deposits WHERE user_id = ?'
).bind(testUserId).first<{ balance: number }>();
expect(deposit?.balance).toBe(25000); // 10000 + 15000
});
it('should reject confirming already confirmed transaction', async () => {
const adminContext = { ...testContext, isAdmin: true };
const txId = await createDepositTransaction(testUserId, 10000, 'confirmed');
const result = await executeDepositFunction('confirm_deposit', {
transaction_id: txId,
}, adminContext);
expect(result).toHaveProperty('error');
expect(result.error).toContain('대기 중인 거래만 확인');
});
it.skip('DEPRECATED: batch 테스트 - 현재는 Optimistic Locking 사용', async () => {
// NOTE: confirm_deposit도 executeWithOptimisticLock()을 사용하도록 변경됨
// 더 이상 db.batch()를 직접 사용하지 않음
// TODO: 관리자 입금 확인 시 Optimistic Locking 테스트 추가 필요
// - 동시 확인 시도 시 하나만 성공 확인
// - Version mismatch 재시도 검증
});
});
describe('Admin Functions - reject_deposit', () => {
it('should reject pending deposit', async () => {
const adminContext = { ...testContext, isAdmin: true };
const txId = await createDepositTransaction(testUserId, 10000, 'pending', '홍길동');
const result = await executeDepositFunction('reject_deposit', {
transaction_id: txId,
}, adminContext);
expect(result.success).toBe(true);
// DB 확인
const tx = await testContext.db.prepare(
'SELECT status FROM deposit_transactions WHERE id = ?'
).bind(txId).first<{ status: string }>();
expect(tx?.status).toBe('rejected');
});
it('should reject rejecting already confirmed transaction', async () => {
const adminContext = { ...testContext, isAdmin: true };
const txId = await createDepositTransaction(testUserId, 10000, 'confirmed');
const result = await executeDepositFunction('reject_deposit', {
transaction_id: txId,
}, adminContext);
expect(result).toHaveProperty('error');
expect(result.error).toContain('대기 중인 거래만 거절');
});
});
describe('Concurrency Safety', () => {
it('should handle concurrent deposit requests from same user', async () => {
// 동시에 3개의 입금 요청
const promises = [
executeDepositFunction('request_deposit', {
depositor_name: '홍길동',
amount: 10000,
}, testContext),
executeDepositFunction('request_deposit', {
depositor_name: '김철수',
amount: 20000,
}, testContext),
executeDepositFunction('request_deposit', {
depositor_name: '이영희',
amount: 15000,
}, testContext),
];
const results = await Promise.all(promises);
// 모든 요청이 성공해야 함
expect(results).toHaveLength(3);
results.forEach(result => {
expect(result.success).toBe(true);
});
// DB에 3개의 거래가 모두 저장되어야 함
const transactions = await testContext.db.prepare(
'SELECT COUNT(*) as count FROM deposit_transactions WHERE user_id = ?'
).bind(testUserId).first<{ count: number }>();
expect(transactions?.count).toBe(3);
});
it('should handle race condition in auto-matching', async () => {
// 은행 알림 생성: 8글자 → prefix 7글자
await createBankNotification('홍길동아버지님', 30000);
// 동시에 2개의 매칭 시도 (같은 은행 알림, 같은 사용자)
const promises = [
executeDepositFunction('request_deposit', {
depositor_name: '홍길동아버지님',
amount: 30000,
}, testContext),
executeDepositFunction('request_deposit', {
depositor_name: '홍길동아버지님',
amount: 30000,
}, testContext),
];
const results = await Promise.all(promises);
// 첫 번째는 자동 매칭, 두 번째는 pending이어야 함
const autoMatched = results.filter(r => r.auto_matched).length;
const pending = results.filter(r => !r.auto_matched).length;
// 정확히 하나만 자동 매칭되어야 함 (RACE CONDITION 발생 가능)
// Optimistic Locking으로 보호되므로 하나는 성공, 하나는 pending
expect(autoMatched + pending).toBe(2);
});
});
describe('Edge Cases', () => {
it('should handle very large amounts', async () => {
// MAX_DEPOSIT_AMOUNT = 100,000,000 (1억원)
const result = await executeDepositFunction('request_deposit', {
depositor_name: '홍길동',
amount: 99999999, // 1억원 미만
}, testContext);
expect(result.success).toBe(true);
expect(result.amount).toBe(99999999);
});
it('should handle Korean names with special characters', async () => {
const result = await executeDepositFunction('request_deposit', {
depositor_name: '홍길동(주)',
amount: 10000,
}, testContext);
expect(result.success).toBe(true);
expect(result.depositor_name).toBe('홍길동(주)');
});
it('should handle very short names', async () => {
const result = await executeDepositFunction('request_deposit', {
depositor_name: '김',
amount: 10000,
}, testContext);
expect(result.success).toBe(true);
});
it('should handle exactly 7 character names', async () => {
const result = await executeDepositFunction('request_deposit', {
depositor_name: '1234567',
amount: 10000,
}, testContext);
expect(result.success).toBe(true);
const tx = await testContext.db.prepare(
'SELECT depositor_name_prefix FROM deposit_transactions WHERE id = ?'
).bind(result.transaction_id).first<{ depositor_name_prefix: string }>();
expect(tx?.depositor_name_prefix).toBe('1234567');
});
});
describe('Unknown Function', () => {
it('should return error for unknown function name', async () => {
const result = await executeDepositFunction('unknown_function', {}, testContext);
expect(result).toHaveProperty('error');
expect(result.error).toContain('알 수 없는 기능');
});
});
});