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>
This commit is contained in:
@@ -438,16 +438,25 @@ export async function executeDepositFunction(
|
|||||||
): Promise<DepositFunctionResult> {
|
): Promise<DepositFunctionResult> {
|
||||||
const { userId, isAdmin, db } = context;
|
const { userId, isAdmin, db } = context;
|
||||||
|
|
||||||
// 예치금 계정 조회 또는 생성
|
// 예치금 계정 조회 또는 생성 (동시성 안전)
|
||||||
let deposit = await db.prepare(
|
let deposit = await db.prepare(
|
||||||
'SELECT id, balance FROM user_deposits WHERE user_id = ?'
|
'SELECT id, balance FROM user_deposits WHERE user_id = ?'
|
||||||
).bind(userId).first<{ id: number; balance: number }>();
|
).bind(userId).first<{ id: number; balance: number }>();
|
||||||
|
|
||||||
if (!deposit) {
|
if (!deposit) {
|
||||||
|
// INSERT OR IGNORE로 동시 요청 시 UNIQUE 제약 위반 방지
|
||||||
await db.prepare(
|
await db.prepare(
|
||||||
'INSERT INTO user_deposits (user_id, balance) VALUES (?, 0)'
|
'INSERT OR IGNORE INTO user_deposits (user_id, balance) VALUES (?, 0)'
|
||||||
).bind(userId).run();
|
).bind(userId).run();
|
||||||
deposit = { id: 0, balance: 0 };
|
|
||||||
|
// 재조회 (다른 요청이 먼저 생성했을 수 있음)
|
||||||
|
deposit = await db.prepare(
|
||||||
|
'SELECT id, balance FROM user_deposits WHERE user_id = ?'
|
||||||
|
).bind(userId).first<{ id: number; balance: number }>();
|
||||||
|
|
||||||
|
if (!deposit) {
|
||||||
|
deposit = { id: 0, balance: 0 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (funcName) {
|
switch (funcName) {
|
||||||
@@ -595,7 +604,7 @@ export async function executeDepositFunction(
|
|||||||
`SELECT id, type, amount, status, depositor_name, description, created_at, confirmed_at
|
`SELECT id, type, amount, status, depositor_name, description, created_at, confirmed_at
|
||||||
FROM deposit_transactions
|
FROM deposit_transactions
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC, id DESC
|
||||||
LIMIT ?`
|
LIMIT ?`
|
||||||
).bind(userId, limit).all<{
|
).bind(userId, limit).all<{
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -8,10 +8,6 @@
|
|||||||
* 4. 타임아웃 처리
|
* 4. 타임아웃 처리
|
||||||
* 5. 재시도 로직 통합
|
* 5. 재시도 로직 통합
|
||||||
* 6. 커스텀 헤더 및 옵션
|
* 6. 커스텀 헤더 및 옵션
|
||||||
*
|
|
||||||
* Note: Vitest may report "unhandled errors" due to timing issues with
|
|
||||||
* fake timers and async promise rejections. All tests pass successfully
|
|
||||||
* (25/25 passing) - these are expected error logs, not test failures.
|
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -144,13 +140,12 @@ describe('callApi', () => {
|
|||||||
json: async () => ({ error: 'Resource not found' }),
|
json: async () => ({ error: 'Resource not found' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(async () => {
|
const promise = callApi('https://api.example.com/missing', {
|
||||||
const promise = callApi('https://api.example.com/missing', {
|
retries: 0, // 재시도 없음
|
||||||
retries: 0, // 재시도 없음
|
});
|
||||||
});
|
const expectPromise = expect(promise).rejects.toThrow();
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
await promise;
|
await expectPromise;
|
||||||
}).rejects.toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error on 5xx server error', async () => {
|
it('should throw error on 5xx server error', async () => {
|
||||||
@@ -161,13 +156,12 @@ describe('callApi', () => {
|
|||||||
json: async () => ({ error: 'Server crashed' }),
|
json: async () => ({ error: 'Server crashed' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(async () => {
|
const promise = callApi('https://api.example.com/error', {
|
||||||
const promise = callApi('https://api.example.com/error', {
|
retries: 0,
|
||||||
retries: 0,
|
});
|
||||||
});
|
const expectPromise = expect(promise).rejects.toThrow();
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
await promise;
|
await expectPromise;
|
||||||
}).rejects.toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include status code in error message', async () => {
|
it('should include status code in error message', async () => {
|
||||||
@@ -178,13 +172,12 @@ describe('callApi', () => {
|
|||||||
json: async () => ({}),
|
json: async () => ({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(async () => {
|
const promise = callApi('https://api.example.com/forbidden', {
|
||||||
const promise = callApi('https://api.example.com/forbidden', {
|
retries: 0,
|
||||||
retries: 0,
|
});
|
||||||
});
|
const expectPromise = expect(promise).rejects.toThrow(/403/);
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
await promise;
|
await expectPromise;
|
||||||
}).rejects.toThrow(/403/);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -230,14 +223,13 @@ describe('callApi', () => {
|
|||||||
json: async () => invalidData,
|
json: async () => invalidData,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(async () => {
|
const promise = callApi('https://api.example.com/user', {
|
||||||
const promise = callApi('https://api.example.com/user', {
|
schema: UserSchema,
|
||||||
schema: UserSchema,
|
retries: 0,
|
||||||
retries: 0,
|
});
|
||||||
});
|
const expectPromise = expect(promise).rejects.toThrow();
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
await promise;
|
await expectPromise;
|
||||||
}).rejects.toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error on invalid schema (wrong type)', async () => {
|
it('should throw error on invalid schema (wrong type)', async () => {
|
||||||
@@ -252,14 +244,13 @@ describe('callApi', () => {
|
|||||||
json: async () => invalidData,
|
json: async () => invalidData,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(async () => {
|
const promise = callApi('https://api.example.com/user', {
|
||||||
const promise = callApi('https://api.example.com/user', {
|
schema: UserSchema,
|
||||||
schema: UserSchema,
|
retries: 0,
|
||||||
retries: 0,
|
});
|
||||||
});
|
const expectPromise = expect(promise).rejects.toThrow();
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
await promise;
|
await expectPromise;
|
||||||
}).rejects.toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error on invalid email format', async () => {
|
it('should throw error on invalid email format', async () => {
|
||||||
@@ -274,14 +265,13 @@ describe('callApi', () => {
|
|||||||
json: async () => invalidData,
|
json: async () => invalidData,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(async () => {
|
const promise = callApi('https://api.example.com/user', {
|
||||||
const promise = callApi('https://api.example.com/user', {
|
schema: UserSchema,
|
||||||
schema: UserSchema,
|
retries: 0,
|
||||||
retries: 0,
|
});
|
||||||
});
|
const expectPromise = expect(promise).rejects.toThrow();
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
await promise;
|
await expectPromise;
|
||||||
}).rejects.toThrow();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -301,19 +291,18 @@ describe('callApi', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(async () => {
|
const promise = callApi('https://api.example.com/slow', {
|
||||||
const promise = callApi('https://api.example.com/slow', {
|
retries: 0,
|
||||||
retries: 0,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// 타임아웃 전에는 에러 없음
|
// 타임아웃 전에는 에러 없음
|
||||||
await vi.advanceTimersByTimeAsync(29000);
|
await vi.advanceTimersByTimeAsync(29000);
|
||||||
|
|
||||||
// 타임아웃 발생
|
// 타임아웃 발생
|
||||||
await vi.advanceTimersByTimeAsync(2000);
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
await vi.runAllTimersAsync();
|
const expectPromise = expect(promise).rejects.toThrow();
|
||||||
await promise;
|
await vi.runAllTimersAsync();
|
||||||
}).rejects.toThrow();
|
await expectPromise;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use custom timeout value', async () => {
|
it('should use custom timeout value', async () => {
|
||||||
@@ -330,20 +319,19 @@ describe('callApi', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(async () => {
|
const promise = callApi('https://api.example.com/slow', {
|
||||||
const promise = callApi('https://api.example.com/slow', {
|
timeout: 5000, // 5초 타임아웃
|
||||||
timeout: 5000, // 5초 타임아웃
|
retries: 0,
|
||||||
retries: 0,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// 타임아웃 전
|
// 타임아웃 전
|
||||||
await vi.advanceTimersByTimeAsync(4000);
|
await vi.advanceTimersByTimeAsync(4000);
|
||||||
|
|
||||||
// 타임아웃 발생
|
// 타임아웃 발생
|
||||||
await vi.advanceTimersByTimeAsync(2000);
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
await vi.runAllTimersAsync();
|
const expectPromise = expect(promise).rejects.toThrow();
|
||||||
await promise;
|
await vi.runAllTimersAsync();
|
||||||
}).rejects.toThrow();
|
await expectPromise;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should complete before timeout', async () => {
|
it('should complete before timeout', async () => {
|
||||||
@@ -435,13 +423,12 @@ describe('callApi', () => {
|
|||||||
json: async () => ({}),
|
json: async () => ({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(async () => {
|
const promise = callApi('https://api.example.com/always-fails', {
|
||||||
const promise = callApi('https://api.example.com/always-fails', {
|
retries: 2,
|
||||||
retries: 2,
|
});
|
||||||
});
|
const expectPromise = expect(promise).rejects.toThrow();
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
await promise;
|
await expectPromise;
|
||||||
}).rejects.toThrow();
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(3); // 초기 + 2회 재시도
|
expect(mockFetch).toHaveBeenCalledTimes(3); // 초기 + 2회 재시도
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -453,13 +440,12 @@ describe('callApi', () => {
|
|||||||
json: async () => ({}),
|
json: async () => ({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(async () => {
|
const promise = callApi('https://api.example.com/missing', {
|
||||||
const promise = callApi('https://api.example.com/missing', {
|
retries: 3,
|
||||||
retries: 3,
|
});
|
||||||
});
|
const expectPromise = expect(promise).rejects.toThrow();
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
await promise;
|
await expectPromise;
|
||||||
}).rejects.toThrow();
|
|
||||||
// 재시도 로직은 4xx 에러를 재시도하지 않음 (retryWithBackoff 구현에 따라 다름)
|
// 재시도 로직은 4xx 에러를 재시도하지 않음 (retryWithBackoff 구현에 따라 다름)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,10 +43,15 @@ describe('executeDepositFunction', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return current balance', async () => {
|
it('should return current balance', async () => {
|
||||||
// 잔액 설정
|
// 잔액 설정 - executeDepositFunction이 자동으로 생성하므로 기존 레코드 먼저 생성
|
||||||
await testContext.db.prepare(
|
await testContext.db.prepare(
|
||||||
'INSERT INTO user_deposits (user_id, balance) VALUES (?, ?)'
|
'INSERT OR IGNORE INTO user_deposits (user_id, balance) VALUES (?, 0)'
|
||||||
).bind(testUserId, 50000).run();
|
).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);
|
const result = await executeDepositFunction('get_balance', {}, testContext);
|
||||||
|
|
||||||
@@ -108,10 +113,10 @@ describe('executeDepositFunction', () => {
|
|||||||
|
|
||||||
describe('request_deposit - 7-Character Prefix Matching', () => {
|
describe('request_deposit - 7-Character Prefix Matching', () => {
|
||||||
it('should auto-match with 7-character prefix when bank notification exists', async () => {
|
it('should auto-match with 7-character prefix when bank notification exists', async () => {
|
||||||
// 은행 알림 먼저 생성 (7글자)
|
// 은행 알림 먼저 생성: "홍길동아버지님어머니" → prefix "홍길동아버지님" (7글자)
|
||||||
await createBankNotification('홍길동아버지', 50000);
|
await createBankNotification('홍길동아버지님어머니', 50000);
|
||||||
|
|
||||||
// 사용자가 8글자로 입금 신고
|
// 사용자가 "홍길동아버지님" (7글자)로 입금 신고 → 앞 7글자 매칭
|
||||||
const result = await executeDepositFunction('request_deposit', {
|
const result = await executeDepositFunction('request_deposit', {
|
||||||
depositor_name: '홍길동아버지님',
|
depositor_name: '홍길동아버지님',
|
||||||
amount: 50000,
|
amount: 50000,
|
||||||
@@ -124,10 +129,10 @@ describe('executeDepositFunction', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should match exactly 7 characters from user input', async () => {
|
it('should match exactly 7 characters from user input', async () => {
|
||||||
// 은행 알림: "홍길동아버지" (7글자)
|
// 은행 알림: "홍길동아버지의부인이모" → prefix "홍길동아버지의" (7글자)
|
||||||
await createBankNotification('홍길동아버지', 30000);
|
await createBankNotification('홍길동아버지의부인이모', 30000);
|
||||||
|
|
||||||
// 사용자: "홍길동아버지의부인" (9글자, 앞 7글자 일치)
|
// 사용자: "홍길동아버지의부인" (9글자) → 앞 7글자 "홍길동아버지의" 매칭
|
||||||
const result = await executeDepositFunction('request_deposit', {
|
const result = await executeDepositFunction('request_deposit', {
|
||||||
depositor_name: '홍길동아버지의부인',
|
depositor_name: '홍길동아버지의부인',
|
||||||
amount: 30000,
|
amount: 30000,
|
||||||
@@ -193,8 +198,9 @@ describe('executeDepositFunction', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should store depositor_name_prefix correctly', async () => {
|
it('should store depositor_name_prefix correctly', async () => {
|
||||||
|
// 8글자 이름 → 앞 7글자만 prefix로 저장
|
||||||
const result = await executeDepositFunction('request_deposit', {
|
const result = await executeDepositFunction('request_deposit', {
|
||||||
depositor_name: '홍길동아버지님',
|
depositor_name: '홍길동아버지님이',
|
||||||
amount: 25000,
|
amount: 25000,
|
||||||
}, testContext);
|
}, testContext);
|
||||||
|
|
||||||
@@ -202,7 +208,7 @@ describe('executeDepositFunction', () => {
|
|||||||
'SELECT depositor_name_prefix FROM deposit_transactions WHERE id = ?'
|
'SELECT depositor_name_prefix FROM deposit_transactions WHERE id = ?'
|
||||||
).bind(result.transaction_id).first<{ depositor_name_prefix: string }>();
|
).bind(result.transaction_id).first<{ depositor_name_prefix: string }>();
|
||||||
|
|
||||||
expect(tx?.depositor_name_prefix).toBe('홍길동아버지');
|
expect(tx?.depositor_name_prefix).toBe('홍길동아버지님');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,15 +232,17 @@ describe('executeDepositFunction', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return transactions ordered by date DESC', async () => {
|
it('should return transactions ordered by date DESC', async () => {
|
||||||
// 여러 거래 생성
|
// 여러 거래 생성 (시간 순서 보장을 위해 순차 생성)
|
||||||
await createDepositTransaction(testUserId, 10000, 'confirmed', '홍길동');
|
const tx1 = await createDepositTransaction(testUserId, 10000, 'confirmed', '홍길동');
|
||||||
await createDepositTransaction(testUserId, 20000, 'pending', '김철수');
|
// SQLite CURRENT_TIMESTAMP는 초 단위이므로 ID로 순서 확인
|
||||||
await createDepositTransaction(testUserId, 15000, 'cancelled', '이영희');
|
const tx2 = await createDepositTransaction(testUserId, 20000, 'pending', '김철수');
|
||||||
|
const tx3 = await createDepositTransaction(testUserId, 15000, 'cancelled', '이영희');
|
||||||
|
|
||||||
const result = await executeDepositFunction('get_transactions', {}, testContext);
|
const result = await executeDepositFunction('get_transactions', {}, testContext);
|
||||||
|
|
||||||
expect(result.transactions).toHaveLength(3);
|
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[0].amount).toBe(15000);
|
||||||
expect(result.transactions[1].amount).toBe(20000);
|
expect(result.transactions[1].amount).toBe(20000);
|
||||||
expect(result.transactions[2].amount).toBe(10000);
|
expect(result.transactions[2].amount).toBe(10000);
|
||||||
@@ -398,10 +406,14 @@ describe('executeDepositFunction', () => {
|
|||||||
it('should confirm pending deposit and increase balance', async () => {
|
it('should confirm pending deposit and increase balance', async () => {
|
||||||
const adminContext = { ...testContext, isAdmin: true };
|
const adminContext = { ...testContext, isAdmin: true };
|
||||||
|
|
||||||
// 초기 잔액 설정
|
// 초기 잔액 설정 - executeDepositFunction이 자동 생성하므로 먼저 확인
|
||||||
await testContext.db.prepare(
|
await testContext.db.prepare(
|
||||||
'INSERT INTO user_deposits (user_id, balance) VALUES (?, ?)'
|
'INSERT OR IGNORE INTO user_deposits (user_id, balance) VALUES (?, 0)'
|
||||||
).bind(testUserId, 10000).run();
|
).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 txId = await createDepositTransaction(testUserId, 15000, 'pending', '홍길동');
|
||||||
|
|
||||||
@@ -516,17 +528,17 @@ describe('executeDepositFunction', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle race condition in auto-matching', async () => {
|
it('should handle race condition in auto-matching', async () => {
|
||||||
// 은행 알림 생성
|
// 은행 알림 생성: 8글자 → prefix 7글자
|
||||||
await createBankNotification('홍길동', 30000);
|
await createBankNotification('홍길동아버지님', 30000);
|
||||||
|
|
||||||
// 동시에 2개의 매칭 시도 (같은 은행 알림)
|
// 동시에 2개의 매칭 시도 (같은 은행 알림, 같은 사용자)
|
||||||
const promises = [
|
const promises = [
|
||||||
executeDepositFunction('request_deposit', {
|
executeDepositFunction('request_deposit', {
|
||||||
depositor_name: '홍길동',
|
depositor_name: '홍길동아버지님',
|
||||||
amount: 30000,
|
amount: 30000,
|
||||||
}, testContext),
|
}, testContext),
|
||||||
executeDepositFunction('request_deposit', {
|
executeDepositFunction('request_deposit', {
|
||||||
depositor_name: '홍길동',
|
depositor_name: '홍길동아버지님',
|
||||||
amount: 30000,
|
amount: 30000,
|
||||||
}, testContext),
|
}, testContext),
|
||||||
];
|
];
|
||||||
@@ -538,20 +550,21 @@ describe('executeDepositFunction', () => {
|
|||||||
const pending = results.filter(r => !r.auto_matched).length;
|
const pending = results.filter(r => !r.auto_matched).length;
|
||||||
|
|
||||||
// 정확히 하나만 자동 매칭되어야 함 (RACE CONDITION 발생 가능)
|
// 정확히 하나만 자동 매칭되어야 함 (RACE CONDITION 발생 가능)
|
||||||
// 실제 프로덕션에서는 DB 트랜잭션으로 보호되어야 함
|
// Optimistic Locking으로 보호되므로 하나는 성공, 하나는 pending
|
||||||
expect(autoMatched + pending).toBe(2);
|
expect(autoMatched + pending).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Edge Cases', () => {
|
describe('Edge Cases', () => {
|
||||||
it('should handle very large amounts', async () => {
|
it('should handle very large amounts', async () => {
|
||||||
|
// MAX_DEPOSIT_AMOUNT = 100,000,000 (1억원)
|
||||||
const result = await executeDepositFunction('request_deposit', {
|
const result = await executeDepositFunction('request_deposit', {
|
||||||
depositor_name: '홍길동',
|
depositor_name: '홍길동',
|
||||||
amount: 999999999,
|
amount: 99999999, // 1억원 미만
|
||||||
}, testContext);
|
}, testContext);
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.amount).toBe(999999999);
|
expect(result.amount).toBe(99999999);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle Korean names with special characters', async () => {
|
it('should handle Korean names with special characters', async () => {
|
||||||
@@ -594,7 +607,7 @@ describe('executeDepositFunction', () => {
|
|||||||
const result = await executeDepositFunction('unknown_function', {}, testContext);
|
const result = await executeDepositFunction('unknown_function', {}, testContext);
|
||||||
|
|
||||||
expect(result).toHaveProperty('error');
|
expect(result).toHaveProperty('error');
|
||||||
expect(result.error).toContain('알 수 없는 함수');
|
expect(result.error).toContain('알 수 없는 기능');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ describe('executeWithOptimisticLock', () => {
|
|||||||
|
|
||||||
// Start operation with 5 max retries
|
// Start operation with 5 max retries
|
||||||
const promise = executeWithOptimisticLock(mockDb, mockOperation, 5);
|
const promise = executeWithOptimisticLock(mockDb, mockOperation, 5);
|
||||||
|
// Attach error handler before advancing timers (vitest 2.x strict mode)
|
||||||
|
const expectPromise = expect(promise).rejects.toThrow('처리 중 동시성 충돌이 발생했습니다');
|
||||||
|
|
||||||
// Advance through all retry delays
|
// Advance through all retry delays
|
||||||
await vi.advanceTimersByTimeAsync(100); // 1st retry
|
await vi.advanceTimersByTimeAsync(100); // 1st retry
|
||||||
@@ -125,7 +127,7 @@ describe('executeWithOptimisticLock', () => {
|
|||||||
await vi.advanceTimersByTimeAsync(800); // 4th retry
|
await vi.advanceTimersByTimeAsync(800); // 4th retry
|
||||||
|
|
||||||
// Wait for final rejection
|
// Wait for final rejection
|
||||||
await expect(promise).rejects.toThrow('처리 중 동시성 충돌이 발생했습니다');
|
await expectPromise;
|
||||||
expect(mockOperation).toHaveBeenCalledTimes(5);
|
expect(mockOperation).toHaveBeenCalledTimes(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -136,15 +138,17 @@ describe('executeWithOptimisticLock', () => {
|
|||||||
|
|
||||||
// Start operation (default 3 retries)
|
// Start operation (default 3 retries)
|
||||||
const promise = executeWithOptimisticLock(mockDb, mockOperation);
|
const promise = executeWithOptimisticLock(mockDb, mockOperation);
|
||||||
|
// Attach error handler before advancing timers (vitest 2.x strict mode)
|
||||||
|
const expectPromise = expect(promise).rejects.toThrow(
|
||||||
|
'처리 중 동시성 충돌이 발생했습니다. 다시 시도해주세요. (3회 재시도 실패)'
|
||||||
|
);
|
||||||
|
|
||||||
// Advance through all retry delays
|
// Advance through all retry delays
|
||||||
await vi.advanceTimersByTimeAsync(100); // 1st retry
|
await vi.advanceTimersByTimeAsync(100); // 1st retry
|
||||||
await vi.advanceTimersByTimeAsync(200); // 2nd retry
|
await vi.advanceTimersByTimeAsync(200); // 2nd retry
|
||||||
|
|
||||||
// Wait for final rejection
|
// Wait for final rejection
|
||||||
await expect(promise).rejects.toThrow(
|
await expectPromise;
|
||||||
'처리 중 동시성 충돌이 발생했습니다. 다시 시도해주세요. (3회 재시도 실패)'
|
|
||||||
);
|
|
||||||
expect(mockOperation).toHaveBeenCalledTimes(3);
|
expect(mockOperation).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -145,9 +145,12 @@ describe('retryWithBackoff', () => {
|
|||||||
|
|
||||||
const promise = retryWithBackoff(mockFn, { maxRetries: 2 });
|
const promise = retryWithBackoff(mockFn, { maxRetries: 2 });
|
||||||
|
|
||||||
await vi.runAllTimersAsync();
|
// Attach error handler before advancing timers
|
||||||
|
const expectPromise = expect(promise).rejects.toThrow(RetryError);
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await expectPromise;
|
||||||
|
|
||||||
await expect(promise).rejects.toThrow(RetryError);
|
|
||||||
expect(mockFn).toHaveBeenCalledTimes(3); // Initial + 2 retries
|
expect(mockFn).toHaveBeenCalledTimes(3); // Initial + 2 retries
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,21 +162,20 @@ describe('retryWithBackoff', () => {
|
|||||||
|
|
||||||
const promise = retryWithBackoff(mockFn, { maxRetries: 1 });
|
const promise = retryWithBackoff(mockFn, { maxRetries: 1 });
|
||||||
|
|
||||||
|
// Attach catch handler before advancing timers
|
||||||
|
const errorPromise = promise.catch(error => error);
|
||||||
|
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
try {
|
const error = await errorPromise;
|
||||||
await promise;
|
expect(error).toBeInstanceOf(RetryError);
|
||||||
expect.fail('Should have thrown RetryError');
|
|
||||||
} catch (error) {
|
|
||||||
expect(error).toBeInstanceOf(RetryError);
|
|
||||||
|
|
||||||
const retryError = error as RetryError;
|
const retryError = error as RetryError;
|
||||||
expect(retryError.name).toBe('RetryError');
|
expect(retryError.name).toBe('RetryError');
|
||||||
expect(retryError.attempts).toBe(2); // Initial + 1 retry
|
expect(retryError.attempts).toBe(2); // Initial + 1 retry
|
||||||
expect(retryError.lastError).toBe(originalError);
|
expect(retryError.lastError).toBe(originalError);
|
||||||
expect(retryError.lastError.message).toBe('Original failure');
|
expect(retryError.lastError.message).toBe('Original failure');
|
||||||
expect(retryError.lastError.stack).toBe('Original stack trace');
|
expect(retryError.lastError.stack).toBe('Original stack trace');
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include attempts count in RetryError message', async () => {
|
it('should include attempts count in RetryError message', async () => {
|
||||||
@@ -181,16 +183,15 @@ describe('retryWithBackoff', () => {
|
|||||||
|
|
||||||
const promise = retryWithBackoff(mockFn, { maxRetries: 2 });
|
const promise = retryWithBackoff(mockFn, { maxRetries: 2 });
|
||||||
|
|
||||||
|
// Attach catch handler before advancing timers
|
||||||
|
const errorPromise = promise.catch(error => error);
|
||||||
|
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
try {
|
const error = await errorPromise;
|
||||||
await promise;
|
const retryError = error as RetryError;
|
||||||
expect.fail('Should have thrown RetryError');
|
expect(retryError.message).toContain('3 attempts'); // Initial + 2 retries
|
||||||
} catch (error) {
|
expect(retryError.message).toContain('API error');
|
||||||
const retryError = error as RetryError;
|
|
||||||
expect(retryError.message).toContain('3 attempts'); // Initial + 2 retries
|
|
||||||
expect(retryError.message).toContain('API error');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -294,11 +295,14 @@ describe('retryWithBackoff', () => {
|
|||||||
jitter: false,
|
jitter: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Attach error handler before advancing timers
|
||||||
|
const expectPromise = expect(promise).rejects.toThrow(RetryError);
|
||||||
|
|
||||||
// After several retries, delay should cap at 10000ms
|
// After several retries, delay should cap at 10000ms
|
||||||
// Let's verify it doesn't exceed 10000ms by checking timer behavior
|
// Let's verify it doesn't exceed 10000ms by checking timer behavior
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
await expect(promise).rejects.toThrow(RetryError);
|
await expectPromise;
|
||||||
expect(mockFn).toHaveBeenCalledTimes(21); // Initial + 20 retries
|
expect(mockFn).toHaveBeenCalledTimes(21); // Initial + 20 retries
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -395,9 +399,12 @@ describe('retryWithBackoff', () => {
|
|||||||
maxRetries: customMaxRetries,
|
maxRetries: customMaxRetries,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Attach error handler before advancing timers
|
||||||
|
const expectPromise = expect(promise).rejects.toThrow(RetryError);
|
||||||
|
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
await expect(promise).rejects.toThrow(RetryError);
|
await expectPromise;
|
||||||
expect(mockFn).toHaveBeenCalledTimes(customMaxRetries + 1); // Initial + retries
|
expect(mockFn).toHaveBeenCalledTimes(customMaxRetries + 1); // Initial + retries
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -470,9 +477,12 @@ describe('retryWithBackoff', () => {
|
|||||||
|
|
||||||
const promise = retryWithBackoff(mockFn, { maxRetries: 0 });
|
const promise = retryWithBackoff(mockFn, { maxRetries: 0 });
|
||||||
|
|
||||||
|
// Attach error handler before advancing timers
|
||||||
|
const expectPromise = expect(promise).rejects.toThrow(RetryError);
|
||||||
|
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
await expect(promise).rejects.toThrow(RetryError);
|
await expectPromise;
|
||||||
expect(mockFn).toHaveBeenCalledTimes(1); // Only initial attempt
|
expect(mockFn).toHaveBeenCalledTimes(1); // Only initial attempt
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -483,17 +493,16 @@ describe('retryWithBackoff', () => {
|
|||||||
|
|
||||||
const promise = retryWithBackoff(mockFn, { maxRetries: 1 });
|
const promise = retryWithBackoff(mockFn, { maxRetries: 1 });
|
||||||
|
|
||||||
|
// Attach catch handler before advancing timers
|
||||||
|
const errorPromise = promise.catch(error => error);
|
||||||
|
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
try {
|
const error = await errorPromise;
|
||||||
await promise;
|
expect(error).toBeInstanceOf(RetryError);
|
||||||
expect.fail('Should have thrown RetryError');
|
const retryError = error as RetryError;
|
||||||
} catch (error) {
|
expect(retryError.lastError).toBeInstanceOf(Error);
|
||||||
expect(error).toBeInstanceOf(RetryError);
|
expect(retryError.lastError.message).toBe('String error');
|
||||||
const retryError = error as RetryError;
|
|
||||||
expect(retryError.lastError).toBeInstanceOf(Error);
|
|
||||||
expect(retryError.lastError.message).toBe('String error');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle undefined rejections', async () => {
|
it('should handle undefined rejections', async () => {
|
||||||
@@ -501,17 +510,16 @@ describe('retryWithBackoff', () => {
|
|||||||
|
|
||||||
const promise = retryWithBackoff(mockFn, { maxRetries: 1 });
|
const promise = retryWithBackoff(mockFn, { maxRetries: 1 });
|
||||||
|
|
||||||
|
// Attach catch handler before advancing timers
|
||||||
|
const errorPromise = promise.catch(error => error);
|
||||||
|
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
try {
|
const error = await errorPromise;
|
||||||
await promise;
|
expect(error).toBeInstanceOf(RetryError);
|
||||||
expect.fail('Should have thrown RetryError');
|
const retryError = error as RetryError;
|
||||||
} catch (error) {
|
expect(retryError.lastError).toBeInstanceOf(Error);
|
||||||
expect(error).toBeInstanceOf(RetryError);
|
expect(retryError.lastError.message).toBe('undefined');
|
||||||
const retryError = error as RetryError;
|
|
||||||
expect(retryError.lastError).toBeInstanceOf(Error);
|
|
||||||
expect(retryError.lastError.message).toBe('undefined');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work with serviceName for metrics tracking', async () => {
|
it('should work with serviceName for metrics tracking', async () => {
|
||||||
@@ -567,22 +575,21 @@ describe('retryWithBackoff', () => {
|
|||||||
|
|
||||||
const promise = retryWithBackoff(mockFn, { maxRetries: 2 });
|
const promise = retryWithBackoff(mockFn, { maxRetries: 2 });
|
||||||
|
|
||||||
|
// Attach catch handler before advancing timers
|
||||||
|
const errorPromise = promise.catch(error => error);
|
||||||
|
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
try {
|
const error = await errorPromise;
|
||||||
await promise;
|
expect(error).toBeInstanceOf(RetryError);
|
||||||
expect.fail('Should have thrown RetryError');
|
expect(error).toBeInstanceOf(Error);
|
||||||
} catch (error) {
|
|
||||||
expect(error).toBeInstanceOf(RetryError);
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
|
||||||
|
|
||||||
const retryError = error as RetryError;
|
const retryError = error as RetryError;
|
||||||
expect(retryError.name).toBe('RetryError');
|
expect(retryError.name).toBe('RetryError');
|
||||||
expect(retryError.attempts).toBe(3); // Initial + 2 retries
|
expect(retryError.attempts).toBe(3); // Initial + 2 retries
|
||||||
expect(retryError.lastError).toBe(originalError);
|
expect(retryError.lastError).toBe(originalError);
|
||||||
expect(retryError.message).toContain('3 attempts');
|
expect(retryError.message).toContain('3 attempts');
|
||||||
expect(retryError.message).toContain('Test error');
|
expect(retryError.message).toContain('Test error');
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be catchable as Error', async () => {
|
it('should be catchable as Error', async () => {
|
||||||
@@ -590,17 +597,17 @@ describe('retryWithBackoff', () => {
|
|||||||
|
|
||||||
const promise = retryWithBackoff(mockFn, { maxRetries: 0 });
|
const promise = retryWithBackoff(mockFn, { maxRetries: 0 });
|
||||||
|
|
||||||
await vi.runAllTimersAsync();
|
// Attach catch handler before advancing timers
|
||||||
|
|
||||||
let caughtError: Error | null = null;
|
let caughtError: Error | null = null;
|
||||||
|
const errorPromise = promise.catch(error => {
|
||||||
try {
|
|
||||||
await promise;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
caughtError = error;
|
caughtError = error;
|
||||||
}
|
}
|
||||||
}
|
return error;
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await errorPromise;
|
||||||
|
|
||||||
expect(caughtError).not.toBeNull();
|
expect(caughtError).not.toBeNull();
|
||||||
expect(caughtError).toBeInstanceOf(Error);
|
expect(caughtError).toBeInstanceOf(Error);
|
||||||
|
|||||||
@@ -72,13 +72,18 @@ beforeAll(async () => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
// DB가 있을 경우만 정리
|
// DB가 있을 경우만 정리
|
||||||
if (db) {
|
if (db) {
|
||||||
// 각 테스트 후 데이터 정리 (스키마는 유지)
|
// 각 테스트 후 데이터 정리 (FK 제약 순서 고려)
|
||||||
await db.exec('DELETE FROM deposit_transactions');
|
// 1. bank_notifications (참조: deposit_transactions)
|
||||||
await db.exec('DELETE FROM bank_notifications');
|
await db.exec('DELETE FROM bank_notifications');
|
||||||
|
// 2. deposit_transactions (참조: users)
|
||||||
|
await db.exec('DELETE FROM deposit_transactions');
|
||||||
|
// 3. user_deposits (참조: users)
|
||||||
await db.exec('DELETE FROM user_deposits');
|
await db.exec('DELETE FROM user_deposits');
|
||||||
|
// 4. 기타 테이블 (참조: users)
|
||||||
await db.exec('DELETE FROM user_domains');
|
await db.exec('DELETE FROM user_domains');
|
||||||
await db.exec('DELETE FROM summaries');
|
await db.exec('DELETE FROM summaries');
|
||||||
await db.exec('DELETE FROM message_buffer');
|
await db.exec('DELETE FROM message_buffer');
|
||||||
|
// 5. users (마지막)
|
||||||
await db.exec('DELETE FROM users');
|
await db.exec('DELETE FROM users');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,9 +29,18 @@ import {
|
|||||||
import { Env } from '../src/types';
|
import { Env } from '../src/types';
|
||||||
|
|
||||||
// Mock OpenAI service
|
// Mock OpenAI service
|
||||||
|
// Store captured arguments for inspection in tests
|
||||||
|
let capturedSystemPrompt: string | undefined;
|
||||||
|
let capturedRecentContext: Array<{ role: string; content: string }> | undefined;
|
||||||
|
|
||||||
vi.mock('../src/openai-service', () => ({
|
vi.mock('../src/openai-service', () => ({
|
||||||
generateProfileWithOpenAI: vi.fn(async () => '테스트 프로필'),
|
generateProfileWithOpenAI: vi.fn(async () => '테스트 프로필'),
|
||||||
generateOpenAIResponse: vi.fn(async () => 'AI 응답 테스트'),
|
generateOpenAIResponse: vi.fn(async (env, userMessage, systemPrompt, recentContext) => {
|
||||||
|
// Capture arguments for test inspection
|
||||||
|
capturedSystemPrompt = systemPrompt as string;
|
||||||
|
capturedRecentContext = recentContext as Array<{ role: string; content: string }>;
|
||||||
|
return 'AI 응답 테스트';
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('summary-service', () => {
|
describe('summary-service', () => {
|
||||||
@@ -348,8 +357,15 @@ describe('summary-service', () => {
|
|||||||
it('should include profile in system prompt when available', async () => {
|
it('should include profile in system prompt when available', async () => {
|
||||||
await createSummary(testUserId, testChatId, 1, '사용자는 개발자입니다', 20);
|
await createSummary(testUserId, testChatId, 1, '사용자는 개발자입니다', 20);
|
||||||
|
|
||||||
|
// Verify summary was created correctly
|
||||||
|
const summaries = await getAllSummaries(testEnv.DB, testUserId, testChatId);
|
||||||
|
expect(summaries).toHaveLength(1);
|
||||||
|
|
||||||
const { generateOpenAIResponse } = await import('../src/openai-service');
|
const { generateOpenAIResponse } = await import('../src/openai-service');
|
||||||
|
|
||||||
|
// Reset captured values
|
||||||
|
capturedSystemPrompt = undefined;
|
||||||
|
|
||||||
await generateAIResponse(
|
await generateAIResponse(
|
||||||
testEnv,
|
testEnv,
|
||||||
testUserId,
|
testUserId,
|
||||||
@@ -358,18 +374,28 @@ describe('summary-service', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(generateOpenAIResponse).toHaveBeenCalled();
|
expect(generateOpenAIResponse).toHaveBeenCalled();
|
||||||
const callArgs = vi.mocked(generateOpenAIResponse).mock.calls[0];
|
|
||||||
const systemPrompt = callArgs[2] as string;
|
|
||||||
|
|
||||||
expect(systemPrompt).toContain('사용자 프로필');
|
// Use captured system prompt from mock
|
||||||
|
expect(capturedSystemPrompt).toBeDefined();
|
||||||
|
|
||||||
|
// When summaries exist, system prompt should include profile content
|
||||||
|
// Format: "## 사용자 프로필 (N개 버전 통합)" followed by versioned profile
|
||||||
|
if (summaries.length > 0 && capturedSystemPrompt) {
|
||||||
|
expect(capturedSystemPrompt).toContain('사용자는 개발자입니다');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include recent messages in context', async () => {
|
it('should include recent messages in context', async () => {
|
||||||
|
// Note: We need to use the same userId and chatId as testUserId and testChatId
|
||||||
|
// because generateAIResponse uses those for context lookup
|
||||||
await createMessageBuffer(testUserId, testChatId, 'user', '이전 메시지');
|
await createMessageBuffer(testUserId, testChatId, 'user', '이전 메시지');
|
||||||
await createMessageBuffer(testUserId, testChatId, 'bot', '이전 응답');
|
await createMessageBuffer(testUserId, testChatId, 'bot', '이전 응답');
|
||||||
|
|
||||||
const { generateOpenAIResponse } = await import('../src/openai-service');
|
const { generateOpenAIResponse } = await import('../src/openai-service');
|
||||||
|
|
||||||
|
// Reset captured values
|
||||||
|
capturedRecentContext = undefined;
|
||||||
|
|
||||||
await generateAIResponse(
|
await generateAIResponse(
|
||||||
testEnv,
|
testEnv,
|
||||||
testUserId,
|
testUserId,
|
||||||
@@ -378,10 +404,19 @@ describe('summary-service', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(generateOpenAIResponse).toHaveBeenCalled();
|
expect(generateOpenAIResponse).toHaveBeenCalled();
|
||||||
const callArgs = vi.mocked(generateOpenAIResponse).mock.calls[0];
|
|
||||||
const recentContext = callArgs[3] as Array<{ role: string; content: string }>;
|
|
||||||
|
|
||||||
expect(recentContext.length).toBeGreaterThan(0);
|
// Use captured recent context from mock
|
||||||
|
expect(capturedRecentContext).toBeDefined();
|
||||||
|
|
||||||
|
// recentContext uses getSmartContext() when telegramUserId is provided.
|
||||||
|
// Without telegramUserId parameter, it returns empty [], then falls back
|
||||||
|
// to getConversationContext().recentMessages
|
||||||
|
// Since we created buffer messages above, we should have at least 2 messages
|
||||||
|
if (capturedRecentContext) {
|
||||||
|
expect(capturedRecentContext.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(capturedRecentContext[0].role).toBe('user');
|
||||||
|
expect(capturedRecentContext[0].content).toBe('이전 메시지');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use OpenAI when API key is available', async () => {
|
it('should use OpenAI when API key is available', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user