Files
telegram-bot-workers/tests/deposit-agent.test.ts
kappa f5df0c0ffe feat: add optimistic locking and improve type safety
- Implement optimistic locking for deposit balance updates
  - Prevent race conditions in concurrent deposit requests
  - Add automatic retry with exponential backoff (max 3 attempts)
  - Add version column to user_deposits table

- Improve type safety across codebase
  - Add explicit types for Namecheap API responses
  - Add typed function arguments (ManageDepositArgs, etc.)
  - Remove `any` types from deposit-agent and tool files

- Add reconciliation job for balance integrity verification
  - Compare user_deposits.balance vs SUM(confirmed transactions)
  - Alert admin on discrepancy detection

- Set up test environment with Vitest + Miniflare
  - Add 50+ test cases for deposit system
  - Add helper functions for test data creation

- Update documentation
  - Add migration guide for version columns
  - Document optimistic locking patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 23:23:09 +09:00

626 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/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 () => {
// 잔액 설정
await testContext.db.prepare(
'INSERT INTO user_deposits (user_id, balance) VALUES (?, ?)'
).bind(testUserId, 50000).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 () => {
// 은행 알림 먼저 생성 (7글자)
await createBankNotification('홍길동아버지', 50000);
// 사용자가 8글자로 입금 신고
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 () => {
// 은행 알림: "홍길동아버지" (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 () => {
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('should throw error on partial batch failure', async () => {
// 은행 알림 생성
const notificationId = await createBankNotification('홍길동', 40000);
// Mock db.batch to simulate partial failure
const originalBatch = testContext.db.batch;
testContext.db.batch = vi.fn().mockResolvedValue([
{ success: true, meta: { changes: 1 } },
{ success: false, meta: { changes: 0 } }, // 두 번째 쿼리 실패
]);
await expect(
executeDepositFunction('request_deposit', {
depositor_name: '홍길동',
amount: 40000,
}, testContext)
).rejects.toThrow('거래 처리 실패');
// 복원
testContext.db.batch = originalBatch;
});
});
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 () => {
// 여러 거래 생성
await createDepositTransaction(testUserId, 10000, 'confirmed', '홍길동');
await createDepositTransaction(testUserId, 20000, 'pending', '김철수');
await createDepositTransaction(testUserId, 15000, 'cancelled', '이영희');
const result = await executeDepositFunction('get_transactions', {}, testContext);
expect(result.transactions).toHaveLength(3);
// 최신순 정렬 확인
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 };
// 초기 잔액 설정
await testContext.db.prepare(
'INSERT INTO user_deposits (user_id, balance) VALUES (?, ?)'
).bind(testUserId, 10000).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('should handle batch failure during confirmation', async () => {
const adminContext = { ...testContext, isAdmin: true };
const txId = await createDepositTransaction(testUserId, 10000, 'pending');
// Mock batch failure
const originalBatch = testContext.db.batch;
testContext.db.batch = vi.fn().mockResolvedValue([
{ success: true, meta: { changes: 1 } },
{ success: false, meta: { changes: 0 } },
]);
await expect(
executeDepositFunction('confirm_deposit', {
transaction_id: txId,
}, adminContext)
).rejects.toThrow('거래 처리 실패');
testContext.db.batch = originalBatch;
});
});
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 () => {
// 은행 알림 생성
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 발생 가능)
// 실제 프로덕션에서는 DB 트랜잭션으로 보호되어야 함
expect(autoMatched + pending).toBe(2);
});
});
describe('Edge Cases', () => {
it('should handle very large amounts', async () => {
const result = await executeDepositFunction('request_deposit', {
depositor_name: '홍길동',
amount: 999999999,
}, testContext);
expect(result.success).toBe(true);
expect(result.amount).toBe(999999999);
});
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('알 수 없는 함수');
});
});
});