Initial implementation of Telegram AI customer support bot
Cloudflare Workers + Hono + D1 + KV + R2 stack with 4 specialized AI agents (onboarding, troubleshoot, asset, billing), OpenAI function calling with 7 tool definitions, human escalation, pending action approval workflow, feedback collection, audit logging, i18n (ko/en), and Workers AI fallback. 43 source files, 45 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
60
tests/security.test.ts
Normal file
60
tests/security.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { timingSafeEqual, isAdmin } from '../src/security';
|
||||
|
||||
describe('timingSafeEqual', () => {
|
||||
it('returns true for equal strings', () => {
|
||||
expect(timingSafeEqual('abc123', 'abc123')).toBe(true);
|
||||
expect(timingSafeEqual('secret-token', 'secret-token')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for different strings', () => {
|
||||
expect(timingSafeEqual('abc123', 'abc124')).toBe(false);
|
||||
expect(timingSafeEqual('short', 'longer')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for null/undefined', () => {
|
||||
expect(timingSafeEqual(null, 'abc')).toBe(false);
|
||||
expect(timingSafeEqual('abc', null)).toBe(false);
|
||||
expect(timingSafeEqual(null, null)).toBe(false);
|
||||
expect(timingSafeEqual(undefined, 'abc')).toBe(false);
|
||||
expect(timingSafeEqual('abc', undefined)).toBe(false);
|
||||
expect(timingSafeEqual(undefined, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty string vs non-empty', () => {
|
||||
expect(timingSafeEqual('', 'abc')).toBe(false);
|
||||
expect(timingSafeEqual('abc', '')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAdmin', () => {
|
||||
const adminIds = '123456,789012,345678';
|
||||
|
||||
it('returns true for admin IDs', () => {
|
||||
expect(isAdmin('123456', adminIds)).toBe(true);
|
||||
expect(isAdmin('789012', adminIds)).toBe(true);
|
||||
expect(isAdmin('345678', adminIds)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for numeric admin ID', () => {
|
||||
expect(isAdmin(123456, adminIds)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-admin IDs', () => {
|
||||
expect(isAdmin('999999', adminIds)).toBe(false);
|
||||
expect(isAdmin('000000', adminIds)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when adminIds is undefined', () => {
|
||||
expect(isAdmin('123456', undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when adminIds is empty', () => {
|
||||
expect(isAdmin('123456', '')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles whitespace in admin ID list', () => {
|
||||
expect(isAdmin('123', '123, 456, 789')).toBe(true);
|
||||
expect(isAdmin('456', '123, 456, 789')).toBe(true);
|
||||
});
|
||||
});
|
||||
120
tests/setup.ts
Normal file
120
tests/setup.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Vitest test setup
|
||||
*
|
||||
* Miniflare-based D1 + KV simulation for integration tests.
|
||||
*/
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { beforeAll, afterEach } from 'vitest';
|
||||
import { Miniflare } from 'miniflare';
|
||||
|
||||
let mf: Miniflare | null = null;
|
||||
let db: D1Database | null = null;
|
||||
|
||||
declare global {
|
||||
var getMiniflareBindings: () => {
|
||||
DB: D1Database;
|
||||
RATE_LIMIT_KV: KVNamespace;
|
||||
SESSION_KV: KVNamespace;
|
||||
CACHE_KV: KVNamespace;
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
mf = new Miniflare({
|
||||
modules: true,
|
||||
script: 'export default { fetch() { return new Response("test"); } }',
|
||||
d1Databases: {
|
||||
DB: '__test_db__',
|
||||
},
|
||||
kvNamespaces: ['RATE_LIMIT_KV', 'SESSION_KV', 'CACHE_KV'],
|
||||
});
|
||||
|
||||
db = await mf.getD1Database('DB');
|
||||
|
||||
(global as any).getMiniflareBindings = () => ({
|
||||
DB: db as D1Database,
|
||||
RATE_LIMIT_KV: {} as KVNamespace,
|
||||
SESSION_KV: {} as KVNamespace,
|
||||
CACHE_KV: {} as KVNamespace,
|
||||
});
|
||||
|
||||
// Schema initialization
|
||||
const schemaPath = join(__dirname, '../schema.sql');
|
||||
const schema = readFileSync(schemaPath, 'utf-8');
|
||||
|
||||
const cleanSchema = schema
|
||||
.split('\n')
|
||||
.filter(line => !line.trim().startsWith('--'))
|
||||
.join('\n');
|
||||
|
||||
const statements = cleanSchema
|
||||
.split(';')
|
||||
.map(s => s.replace(/\s+/g, ' ').trim())
|
||||
.filter(s => s.length > 0);
|
||||
|
||||
try {
|
||||
for (const statement of statements) {
|
||||
await db.exec(statement + ';');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Schema initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (!db) return;
|
||||
|
||||
// Delete child tables first, then parent tables (FK order)
|
||||
// 1. No FK dependencies between each other
|
||||
await db.exec('DELETE FROM feedback');
|
||||
await db.exec('DELETE FROM pending_actions');
|
||||
await db.exec('DELETE FROM audit_logs');
|
||||
// 2. bank_notifications refs transactions
|
||||
await db.exec('DELETE FROM bank_notifications');
|
||||
// 3. transactions refs users
|
||||
await db.exec('DELETE FROM transactions');
|
||||
// 4. wallets refs users
|
||||
await db.exec('DELETE FROM wallets');
|
||||
// 5. Asset tables ref users
|
||||
await db.exec('DELETE FROM domains');
|
||||
await db.exec('DELETE FROM servers');
|
||||
await db.exec('DELETE FROM services_ddos');
|
||||
await db.exec('DELETE FROM services_vpn');
|
||||
// 6. Conversation tables ref users
|
||||
await db.exec('DELETE FROM conversations');
|
||||
await db.exec('DELETE FROM conversation_archives');
|
||||
// 7. Standalone tables (no FKs)
|
||||
await db.exec('DELETE FROM knowledge_articles');
|
||||
await db.exec('DELETE FROM d2_cache');
|
||||
// 8. Session tables (no FKs to users)
|
||||
await db.exec('DELETE FROM onboarding_sessions');
|
||||
await db.exec('DELETE FROM troubleshoot_sessions');
|
||||
await db.exec('DELETE FROM asset_sessions');
|
||||
await db.exec('DELETE FROM billing_sessions');
|
||||
// 9. users last (parent table)
|
||||
await db.exec('DELETE FROM users');
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a test user and return its auto-incremented id.
|
||||
*/
|
||||
export async function createTestUser(
|
||||
telegramId: string,
|
||||
username?: string
|
||||
): Promise<number> {
|
||||
const bindings = getMiniflareBindings();
|
||||
const result = await bindings.DB.prepare(
|
||||
'INSERT INTO users (telegram_id, username) VALUES (?, ?)'
|
||||
).bind(telegramId, username || null).run();
|
||||
|
||||
return Number(result.meta?.last_row_id || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the test D1Database binding.
|
||||
*/
|
||||
export function getTestDB(): D1Database {
|
||||
return getMiniflareBindings().DB;
|
||||
}
|
||||
43
tests/tools/index.test.ts
Normal file
43
tests/tools/index.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { selectToolsForMessage, executeTool } from '../../src/tools/index';
|
||||
|
||||
describe('selectToolsForMessage', () => {
|
||||
it('returns knowledge tool for unknown/generic patterns', () => {
|
||||
const tools = selectToolsForMessage('안녕하세요');
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].function.name).toBe('search_knowledge');
|
||||
});
|
||||
|
||||
it('returns domain tool for domain-related messages', () => {
|
||||
const tools = selectToolsForMessage('도메인 등록하고 싶어요');
|
||||
const names = tools.map(t => t.function.name);
|
||||
expect(names).toContain('manage_domain');
|
||||
expect(names).toContain('search_knowledge');
|
||||
});
|
||||
|
||||
it('returns wallet tool for billing messages', () => {
|
||||
const tools = selectToolsForMessage('잔액 확인해주세요');
|
||||
const names = tools.map(t => t.function.name);
|
||||
expect(names).toContain('manage_wallet');
|
||||
expect(names).toContain('search_knowledge');
|
||||
});
|
||||
|
||||
it('returns server tool for server-related messages', () => {
|
||||
const tools = selectToolsForMessage('서버 목록 보여줘');
|
||||
const names = tools.map(t => t.function.name);
|
||||
expect(names).toContain('manage_server');
|
||||
});
|
||||
|
||||
it('returns security tools for DDoS/VPN messages', () => {
|
||||
const tools = selectToolsForMessage('DDoS 방어 서비스 현황');
|
||||
const names = tools.map(t => t.function.name);
|
||||
expect(names).toContain('check_service');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeTool', () => {
|
||||
it('returns error message for unknown tool name', async () => {
|
||||
const result = await executeTool('nonexistent_tool', {});
|
||||
expect(result).toContain('알 수 없는 도구');
|
||||
});
|
||||
});
|
||||
117
tests/utils/circuit-breaker.test.ts
Normal file
117
tests/utils/circuit-breaker.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CircuitBreaker, CircuitBreakerError, CircuitState } from '../../src/utils/circuit-breaker';
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe('CircuitBreaker', () => {
|
||||
it('starts in CLOSED state', () => {
|
||||
const cb = new CircuitBreaker({ serviceName: 'test' });
|
||||
expect(cb.getState()).toBe(CircuitState.CLOSED);
|
||||
});
|
||||
|
||||
it('passes through successful executions', async () => {
|
||||
const cb = new CircuitBreaker({ serviceName: 'test' });
|
||||
const result = await cb.execute(async () => 'ok');
|
||||
expect(result).toBe('ok');
|
||||
expect(cb.getState()).toBe(CircuitState.CLOSED);
|
||||
});
|
||||
|
||||
it('opens after failure threshold is exceeded', async () => {
|
||||
const cb = new CircuitBreaker({
|
||||
serviceName: 'test',
|
||||
failureThreshold: 3,
|
||||
resetTimeoutMs: 100,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow('fail');
|
||||
}
|
||||
|
||||
expect(cb.getState()).toBe(CircuitState.OPEN);
|
||||
});
|
||||
|
||||
it('rejects requests while OPEN', async () => {
|
||||
const cb = new CircuitBreaker({
|
||||
serviceName: 'test',
|
||||
failureThreshold: 2,
|
||||
resetTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
// Trip the breaker
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow();
|
||||
}
|
||||
|
||||
expect(cb.getState()).toBe(CircuitState.OPEN);
|
||||
|
||||
// Should throw CircuitBreakerError
|
||||
await expect(cb.execute(async () => 'ok')).rejects.toThrow(CircuitBreakerError);
|
||||
});
|
||||
|
||||
it('transitions to HALF_OPEN after reset timeout', async () => {
|
||||
const cb = new CircuitBreaker({
|
||||
serviceName: 'test',
|
||||
failureThreshold: 2,
|
||||
resetTimeoutMs: 100,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow();
|
||||
}
|
||||
expect(cb.getState()).toBe(CircuitState.OPEN);
|
||||
|
||||
await sleep(150);
|
||||
|
||||
// Next execute call checks the timeout and transitions to HALF_OPEN
|
||||
const result = await cb.execute(async () => 'recovered');
|
||||
expect(result).toBe('recovered');
|
||||
// Successful test in HALF_OPEN closes the circuit
|
||||
expect(cb.getState()).toBe(CircuitState.CLOSED);
|
||||
});
|
||||
|
||||
it('closes after successful test in HALF_OPEN', async () => {
|
||||
const cb = new CircuitBreaker({
|
||||
serviceName: 'test',
|
||||
failureThreshold: 2,
|
||||
resetTimeoutMs: 100,
|
||||
});
|
||||
|
||||
// Open the circuit
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow();
|
||||
}
|
||||
expect(cb.getState()).toBe(CircuitState.OPEN);
|
||||
|
||||
// Wait for reset timeout
|
||||
await sleep(150);
|
||||
|
||||
// Successful execution transitions HALF_OPEN -> CLOSED
|
||||
await cb.execute(async () => 'success');
|
||||
expect(cb.getState()).toBe(CircuitState.CLOSED);
|
||||
|
||||
// Verify circuit is fully operational again
|
||||
const result = await cb.execute(async () => 'working');
|
||||
expect(result).toBe('working');
|
||||
});
|
||||
|
||||
it('re-opens if HALF_OPEN test fails', async () => {
|
||||
const cb = new CircuitBreaker({
|
||||
serviceName: 'test',
|
||||
failureThreshold: 2,
|
||||
resetTimeoutMs: 100,
|
||||
});
|
||||
|
||||
// Open the circuit
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow();
|
||||
}
|
||||
|
||||
await sleep(150);
|
||||
|
||||
// Fail during HALF_OPEN
|
||||
await expect(cb.execute(async () => { throw new Error('still broken'); })).rejects.toThrow();
|
||||
expect(cb.getState()).toBe(CircuitState.OPEN);
|
||||
});
|
||||
});
|
||||
51
tests/utils/logger.test.ts
Normal file
51
tests/utils/logger.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createLogger, Logger, maskUserId } from '../../src/utils/logger';
|
||||
|
||||
describe('Logger', () => {
|
||||
it('createLogger returns a Logger instance', () => {
|
||||
const logger = createLogger('test-service');
|
||||
expect(logger).toBeInstanceOf(Logger);
|
||||
});
|
||||
|
||||
it('info() does not throw', () => {
|
||||
const logger = createLogger('test-service');
|
||||
expect(() => logger.info('test message')).not.toThrow();
|
||||
expect(() => logger.info('with context', { key: 'value' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('warn() does not throw', () => {
|
||||
const logger = createLogger('test-service');
|
||||
expect(() => logger.warn('warning message')).not.toThrow();
|
||||
expect(() => logger.warn('with context', { count: 42 })).not.toThrow();
|
||||
});
|
||||
|
||||
it('error() does not throw', () => {
|
||||
const logger = createLogger('test-service');
|
||||
expect(() => logger.error('error message')).not.toThrow();
|
||||
expect(() => logger.error('with error', new Error('boom'))).not.toThrow();
|
||||
expect(() => logger.error('full', new Error('boom'), { extra: true })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maskUserId', () => {
|
||||
it('masks a normal user ID correctly', () => {
|
||||
expect(maskUserId('821596605')).toBe('8215****');
|
||||
});
|
||||
|
||||
it('masks a short ID (<=4 chars) as all asterisks', () => {
|
||||
expect(maskUserId('1234')).toBe('****');
|
||||
expect(maskUserId('abc')).toBe('****');
|
||||
});
|
||||
|
||||
it('returns "unknown" for undefined', () => {
|
||||
expect(maskUserId(undefined)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns "unknown" for empty string', () => {
|
||||
expect(maskUserId('')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('handles numeric input', () => {
|
||||
expect(maskUserId(821596605)).toBe('8215****');
|
||||
});
|
||||
});
|
||||
66
tests/utils/retry.test.ts
Normal file
66
tests/utils/retry.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { retryWithBackoff, RetryError } from '../../src/utils/retry';
|
||||
|
||||
describe('retryWithBackoff', () => {
|
||||
it('succeeds on first attempt without retrying', async () => {
|
||||
let callCount = 0;
|
||||
const result = await retryWithBackoff(async () => {
|
||||
callCount++;
|
||||
return 'ok';
|
||||
}, { maxRetries: 3, initialDelayMs: 1, jitter: false });
|
||||
|
||||
expect(result).toBe('ok');
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it('retries and succeeds on 2nd attempt', async () => {
|
||||
let callCount = 0;
|
||||
const result = await retryWithBackoff(async () => {
|
||||
callCount++;
|
||||
if (callCount < 2) throw new Error('transient');
|
||||
return 'recovered';
|
||||
}, { maxRetries: 3, initialDelayMs: 1, jitter: false });
|
||||
|
||||
expect(result).toBe('recovered');
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
it('throws RetryError after all attempts exhausted', async () => {
|
||||
let callCount = 0;
|
||||
await expect(
|
||||
retryWithBackoff(async () => {
|
||||
callCount++;
|
||||
throw new Error('permanent');
|
||||
}, { maxRetries: 2, initialDelayMs: 1, jitter: false })
|
||||
).rejects.toThrow(RetryError);
|
||||
|
||||
// 1 initial + 2 retries = 3 total
|
||||
expect(callCount).toBe(3);
|
||||
});
|
||||
|
||||
it('respects maxRetries option', async () => {
|
||||
let callCount = 0;
|
||||
await expect(
|
||||
retryWithBackoff(async () => {
|
||||
callCount++;
|
||||
throw new Error('fail');
|
||||
}, { maxRetries: 1, initialDelayMs: 1, jitter: false })
|
||||
).rejects.toThrow(RetryError);
|
||||
|
||||
// 1 initial + 1 retry = 2 total
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
it('RetryError contains attempt count and last error', async () => {
|
||||
try {
|
||||
await retryWithBackoff(async () => {
|
||||
throw new Error('specific failure');
|
||||
}, { maxRetries: 2, initialDelayMs: 1, jitter: false });
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(RetryError);
|
||||
const retryErr = error as RetryError;
|
||||
expect(retryErr.attempts).toBe(3);
|
||||
expect(retryErr.lastError.message).toBe('specific failure');
|
||||
}
|
||||
});
|
||||
});
|
||||
137
tests/utils/session-manager.test.ts
Normal file
137
tests/utils/session-manager.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
|
||||
import { SessionManager, BaseSession } from '../../src/utils/session-manager';
|
||||
import { createTestUser, getTestDB } from '../setup';
|
||||
|
||||
// Use onboarding_sessions table for testing
|
||||
const manager = new SessionManager<BaseSession>({
|
||||
tableName: 'onboarding_sessions',
|
||||
ttlMs: 30 * 60 * 1000, // 30 minutes
|
||||
maxMessages: 5,
|
||||
});
|
||||
|
||||
describe('SessionManager', () => {
|
||||
describe('create()', () => {
|
||||
it('returns session with correct fields', () => {
|
||||
const session = manager.create('user123', 'greeting');
|
||||
|
||||
expect(session.user_id).toBe('user123');
|
||||
expect(session.status).toBe('greeting');
|
||||
expect(session.collected_info).toEqual({});
|
||||
expect(session.messages).toEqual([]);
|
||||
expect(session.created_at).toBeTypeOf('number');
|
||||
expect(session.updated_at).toBeTypeOf('number');
|
||||
expect(session.expires_at).toBeGreaterThan(session.created_at);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save() and get() round-trip', () => {
|
||||
it('saves and retrieves session from DB', async () => {
|
||||
const db = getTestDB();
|
||||
const session = manager.create('user456', 'gathering');
|
||||
session.collected_info = { purpose: 'hosting' };
|
||||
session.messages = [{ role: 'user', content: 'hello' }];
|
||||
|
||||
await manager.save(db, session);
|
||||
|
||||
const retrieved = await manager.get(db, 'user456');
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved!.user_id).toBe('user456');
|
||||
expect(retrieved!.status).toBe('gathering');
|
||||
expect(retrieved!.collected_info).toEqual({ purpose: 'hosting' });
|
||||
expect(retrieved!.messages).toEqual([{ role: 'user', content: 'hello' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete()', () => {
|
||||
it('removes session from DB', async () => {
|
||||
const db = getTestDB();
|
||||
const session = manager.create('user789', 'greeting');
|
||||
await manager.save(db, session);
|
||||
|
||||
// Verify it exists
|
||||
const exists = await manager.has(db, 'user789');
|
||||
expect(exists).toBe(true);
|
||||
|
||||
// Delete
|
||||
await manager.delete(db, 'user789');
|
||||
|
||||
// Verify it's gone
|
||||
const afterDelete = await manager.get(db, 'user789');
|
||||
expect(afterDelete).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('has()', () => {
|
||||
it('returns true for existing session', async () => {
|
||||
const db = getTestDB();
|
||||
const session = manager.create('user_has_test', 'greeting');
|
||||
await manager.save(db, session);
|
||||
|
||||
expect(await manager.has(db, 'user_has_test')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for missing session', async () => {
|
||||
const db = getTestDB();
|
||||
expect(await manager.has(db, 'nonexistent_user')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMessage()', () => {
|
||||
it('adds message to session', () => {
|
||||
const session = manager.create('user_msg', 'greeting');
|
||||
manager.addMessage(session, 'user', 'hello');
|
||||
|
||||
expect(session.messages).toHaveLength(1);
|
||||
expect(session.messages[0]).toEqual({ role: 'user', content: 'hello' });
|
||||
});
|
||||
|
||||
it('trims old messages when over limit', () => {
|
||||
const session = manager.create('user_trim', 'greeting');
|
||||
|
||||
// maxMessages is 5, add 7 messages
|
||||
for (let i = 0; i < 7; i++) {
|
||||
manager.addMessage(session, 'user', `message ${i}`);
|
||||
}
|
||||
|
||||
expect(session.messages).toHaveLength(5);
|
||||
// Should keep the last 5 messages (2..6)
|
||||
expect(session.messages[0].content).toBe('message 2');
|
||||
expect(session.messages[4].content).toBe('message 6');
|
||||
});
|
||||
});
|
||||
|
||||
describe('expired sessions', () => {
|
||||
it('get() returns null for expired session', async () => {
|
||||
const db = getTestDB();
|
||||
|
||||
// Create a session that's already expired
|
||||
const now = Date.now();
|
||||
const expiredSession: BaseSession = {
|
||||
user_id: 'expired_user',
|
||||
status: 'greeting',
|
||||
collected_info: {},
|
||||
messages: [],
|
||||
created_at: now - 60000,
|
||||
updated_at: now - 60000,
|
||||
expires_at: now - 1000, // expired 1 second ago
|
||||
};
|
||||
|
||||
// Insert directly with past expires_at
|
||||
await db.prepare(
|
||||
`INSERT INTO onboarding_sessions (user_id, status, collected_info, messages, created_at, updated_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).bind(
|
||||
expiredSession.user_id,
|
||||
expiredSession.status,
|
||||
JSON.stringify(expiredSession.collected_info),
|
||||
JSON.stringify(expiredSession.messages),
|
||||
expiredSession.created_at,
|
||||
expiredSession.updated_at,
|
||||
expiredSession.expires_at
|
||||
).run();
|
||||
|
||||
const result = await manager.get(db, 'expired_user');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user