import { describe, test, expect, beforeAll, it, vi } from 'vitest';
import { spawn, ChildProcess } from 'child_process';
import path from 'path';
import { TestHelper, getTestAccountName, checkTestPrerequisites, getTestDateRange, getTestEnvironment } from '../utils/helpers.js';
import { AccountManager } from '../../src/services/account-manager';
import { ImapFlowHandler } from '../../src/services/imapflow-handler.js';
import { ImapAccount } from '../../src/types';
import { decrypt } from '../../src/crypto.js';
import { GmailHandler } from '../../src/services/gmail.js';
import McpEmailServer from '../../src/index.js';
import { ConnectionManager } from '../../src/connection-manager.js';
describe('IMAP Tools Timeout Prevention', () => {
let helper: TestHelper;
let configuredAccounts: { gmail: string[]; imap: string[]; xserver: string[] };
const serverPath = path.join(process.cwd(), 'scripts/run-email-server.ts');
const TIMEOUT_MS = 10000; // 10秒タイムアウト
let gmailHandler: GmailHandler;
let testEnv: ReturnType<typeof getTestEnvironment>;
let mcpServer: McpEmailServer;
beforeAll(() => {
const { canRun, message } = checkTestPrerequisites();
console.log(`テスト環境チェック: ${message}`);
if (!canRun) {
throw new Error(message);
}
helper = new TestHelper();
configuredAccounts = helper.getConfiguredAccounts();
gmailHandler = new GmailHandler([]);
testEnv = getTestEnvironment();
mcpServer = new McpEmailServer();
});
// Test helper function to run MCP command with timeout
async function runMCPCommand(command: any, timeoutMs: number = TIMEOUT_MS): Promise<{
success: boolean;
response?: any;
error?: string;
timedOut: boolean;
}> {
return new Promise((resolve) => {
const timeoutId = setTimeout(() => {
resolve({
success: false,
timedOut: true,
error: 'Command timed out'
});
}, timeoutMs);
mcpServer.handleRequest(command).then(response => {
clearTimeout(timeoutId);
resolve({
success: !response.error,
response: response,
timedOut: false,
error: response.error?.message
});
}).catch(error => {
clearTimeout(timeoutId);
resolve({
success: false,
error: error.message,
timedOut: false
});
});
});
}
test('should respond within timeout for list_emails', async () => {
const encryptionKey = process.env.EMAIL_ENCRYPTION_KEY;
if (!encryptionKey) {
console.log('テストスキップ: EMAIL_ENCRYPTION_KEY が .env に設定されていません。');
return;
}
const imapAccounts = [...configuredAccounts.imap, ...configuredAccounts.xserver];
if (imapAccounts.length === 0) {
console.log(`テストスキップ: IMAP Tools Timeout Prevention テストには、.env ファイルにIMAPアカウントの設定が必要です。`);
return;
}
const accountManager = new AccountManager();
const targetAccountName = imapAccounts[0];
const originalImapAccount = accountManager.getAccount(targetAccountName) as ImapAccount;
if (!originalImapAccount) {
console.log(`テストスキップ: IMAPアカウント ${targetAccountName} の詳細が AccountManager から取得できませんでした。`);
return;
}
// IMAPHandlerを直接インスタンス化(内部で復号化される)
const imapHandler = new ImapFlowHandler([originalImapAccount], encryptionKey);
try {
const emails = await imapHandler.listEmails(originalImapAccount.name, { limit: 1 });
console.log(`✅ IMAP connection successful for ${originalImapAccount.name}, found ${emails.length} emails`);
expect(Array.isArray(emails)).toBe(true);
expect(emails.length).toBeGreaterThanOrEqual(0);
} catch (error: any) {
console.log(`❌ IMAP connection failed for ${originalImapAccount.name}: ${error.message}`);
// ログイン失敗または接続エラーを期待
expect(error.message).toMatch(/Login failed|connection failed|Failed to decrypt password/);
}
}, 10000);
test('should respond within timeout for list_emails with unread_only', async () => {
const encryptionKey = process.env.EMAIL_ENCRYPTION_KEY;
if (!encryptionKey) {
console.log('テストスキップ: EMAIL_ENCRYPTION_KEY が .env に設定されていません。');
return;
}
const imapAccounts = [...configuredAccounts.imap, ...configuredAccounts.xserver];
if (imapAccounts.length === 0) {
console.log(`テストスキップ: IMAP Tools Timeout Prevention テストには、.env ファイルにIMAPアカウントの設定が必要です。`);
return;
}
const accountManager = new AccountManager();
const targetAccountName = imapAccounts[0];
const originalImapAccount = accountManager.getAccount(targetAccountName) as ImapAccount;
if (!originalImapAccount) {
console.log(`テストスキップ: IMAPアカウント ${targetAccountName} の詳細が AccountManager から取得できませんでした。`);
return;
}
// IMAPHandlerを直接インスタンス化(内部で復号化される)
const imapHandler = new ImapFlowHandler([originalImapAccount], encryptionKey);
try {
const emails = await imapHandler.listEmails(originalImapAccount.name, { unread_only: true, limit: 1 });
console.log(`✅ IMAP unread emails successful for ${originalImapAccount.name}, found ${emails.length} emails`);
expect(Array.isArray(emails)).toBe(true);
expect(emails.length).toBeGreaterThanOrEqual(0);
} catch (error: any) {
console.log(`❌ IMAP unread emails failed for ${originalImapAccount.name}: ${error.message}`);
// ログイン失敗または接続エラーを期待
expect(error.message).toMatch(/Login failed|connection failed|Failed to decrypt password/);
}
}, 10000);
it('should handle invalid account gracefully', async () => {
const command = {
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: {
name: 'list_emails',
arguments: {
account_name: 'invalid_account',
limit: 1
}
}
};
const result = await runMCPCommand(command, 5000);
// デバッグ用ログ
if (!result.success) {
console.log('invalid account test failed:', result.error);
console.log('TimedOut:', result.timedOut);
console.log('Response:', result.response);
}
expect(result.timedOut).toBe(false);
expect(result.success).toBe(false);
expect(result.response).toBeDefined();
expect(result.response.error).toBeDefined();
}, 10000);
it.skipIf(() => getTestEnvironment().allImapAccounts.length < 2)('should handle multiple accounts without timeout', async () => {
const imapAccounts = testEnv.allImapAccounts;
const commands = [
{
jsonrpc: '2.0',
id: 4,
method: 'tools/call',
params: {
name: 'list_emails',
arguments: { account_name: imapAccounts[0], limit: 1 }
}
},
{
jsonrpc: '2.0',
id: 5,
method: 'tools/call',
params: {
name: 'list_emails',
arguments: { account_name: imapAccounts[1] || imapAccounts[0], unread_only: true, limit: 10 }
}
}
];
const results = await Promise.all(
commands.map(cmd => runMCPCommand(cmd, 8000))
);
results.forEach((result) => {
expect(result.timedOut).toBe(false);
expect(result.success).toBe(true);
});
}, 20000);
it.skipIf(!getTestAccountName('gmail'))('should search emails with date range (previous day)', async () => {
const { dateAfter } = getTestDateRange();
console.log(`Testing date range search for: ${dateAfter}`);
const command = {
jsonrpc: '2.0',
id: 6,
method: 'tools/call',
params: {
name: 'search_emails',
arguments: {
account_name: 'MAIN',
query: '*', // Search all emails
limit: 50,
date_after: dateAfter // 前日以降のメールを検索(beforeは指定しない)
}
}
};
const result = await runMCPCommand(command, 8000);
// デバッグ用ログ
if (!result.success) {
console.log('Test failed with error:', result.error);
console.log('TimedOut:', result.timedOut);
}
expect(result.timedOut).toBe(false);
expect(result.response).toBeDefined();
if (result.response.error) {
// Account not found is acceptable for this test
expect(result.response.error.message).toContain('Account not found');
expect(result.success).toBe(false);
console.log('Account MAIN not found - this is expected in this test setup');
} else {
// If no error, the search was successful
expect(result.success).toBe(true);
// Direct access to result data
const searchResult = result.response.result;
expect(searchResult.emails).toBeDefined();
expect(Array.isArray(searchResult.emails)).toBe(true);
// 前日以降のメールが存在することを確認(現実的な期待値)
expect(searchResult.emails.length).toBeGreaterThan(0);
console.log(`前日(${dateAfter})以降受信メール件数: ${searchResult.emails.length}件`);
// Log first few email subjects for verification
if (searchResult.emails.length > 0) {
console.log('前日以降受信メールの例:');
searchResult.emails.slice(0, 3).forEach((email: any, index: number) => {
console.log(` ${index + 1}. ${email.subject} (${email.date})`);
});
}
// 期間指定が正しく動作していることを確認(前日以降のメールのみ)
const emailDates = searchResult.emails.map((email: any) => new Date(email.date));
const yesterdayDate = new Date(dateAfter.split(' ')[0]);
yesterdayDate.setHours(0, 0, 0, 0);
const validEmails = emailDates.filter(date => date >= yesterdayDate);
expect(validEmails.length).toBe(emailDates.length); // 全てのメールが前日以降であることを確認
}
}, 15000);
it('should use ConnectionManager without duplicate instances', () => {
// 重複インスタンス作成の完全解消確認
expect((mcpServer as any).connectionManager).toBeInstanceOf(ConnectionManager);
expect((mcpServer as any).gmailHandler).toBeUndefined();
expect((mcpServer as any).imapHandler).toBeUndefined();
});
it('should maintain ConnectionManager consistency during timeout scenarios', async () => {
const mockConnectionManager = {
getImapHandler: vi.fn().mockResolvedValue({
listEmails: vi.fn().mockResolvedValue([])
}),
testConnection: vi.fn().mockResolvedValue({
success: true,
accountName: 'test-timeout-account',
accountType: 'imap',
message: 'Connection successful'
})
};
(mcpServer as any).connectionManager = mockConnectionManager;
(mcpServer as any).accountManager = {
getAccount: vi.fn().mockReturnValue({
name: 'test-timeout-account',
type: 'imap',
config: {}
})
};
// test_connection実行
const testResponse = await mcpServer.handleRequest({
jsonrpc: '2.0',
method: 'tools/call',
params: {
name: 'test_connection',
arguments: { account_name: 'test-timeout-account' }
},
id: 1
});
// list_emails実行
const listResponse = await mcpServer.handleRequest({
jsonrpc: '2.0',
method: 'tools/call',
params: {
name: 'list_emails',
arguments: { account_name: 'test-timeout-account', limit: 1 }
},
id: 2
});
// 一貫性確認
expect(testResponse.result.status).toBe('connected');
expect(listResponse.result).toBeDefined();
expect(Array.isArray(listResponse.result)).toBe(true);
// 同じConnectionManagerが使用されることを確認
expect(mockConnectionManager.testConnection).toHaveBeenCalledWith('test-timeout-account');
expect(mockConnectionManager.getImapHandler).toHaveBeenCalledWith('test-timeout-account');
});
});
describe.skip('Timezone Handling', () => {
let gmailHandlerForTz: GmailHandler;
beforeAll(() => {
gmailHandlerForTz = new GmailHandler([]);
});
it('should handle Unix timestamp correctly', () => {
// Access private method for testing
const parseDateTime = (gmailHandlerForTz as any).parseDateTime.bind(gmailHandlerForTz);
const unixTimestamp = '1640995200'; // 2022-01-01 00:00:00 UTC
const result = parseDateTime(unixTimestamp);
expect(result).toBe('1640995200');
});
it('should handle ISO 8601 with timezone correctly', () => {
const parseDateTime = (gmailHandlerForTz as any).parseDateTime.bind(gmailHandlerForTz);
// ISO 8601 with timezone offset
const isoWithTz = '2024-01-01T10:00:00+09:00';
const result = parseDateTime(isoWithTz);
// Should convert to Unix timestamp
const expectedDate = new Date(isoWithTz);
const expectedTimestamp = Math.floor(expectedDate.getTime() / 1000).toString();
expect(result).toBe(expectedTimestamp);
});
it('should handle ISO 8601 with Z timezone correctly', () => {
const parseDateTime = (gmailHandlerForTz as any).parseDateTime.bind(gmailHandlerForTz);
// ISO 8601 with Z (UTC)
const isoWithZ = '2024-01-01T01:00:00Z';
const result = parseDateTime(isoWithZ);
const expectedDate = new Date(isoWithZ);
const expectedTimestamp = Math.floor(expectedDate.getTime() / 1000).toString();
expect(result).toBe(expectedTimestamp);
});
it('should handle date format correctly', () => {
const parseDateTime = (gmailHandlerForTz as any).parseDateTime.bind(gmailHandlerForTz);
// Date format (Gmail API format)
const dateFormat = '2024/01/01';
const result = parseDateTime(dateFormat);
expect(result).toBe('2024/01/01');
});
it('should handle datetime format with default timezone', () => {
const parseDateTime = (gmailHandlerForTz as any).parseDateTime.bind(gmailHandlerForTz);
// Datetime format without timezone
const datetimeFormat = '2024/01/01 10:00:00';
const result = parseDateTime(datetimeFormat);
// Should be a Unix timestamp
expect(result).toMatch(/^\d+$/);
expect(parseInt(result)).toBeGreaterThan(0);
});
it('should detect system timezone correctly', () => {
const detectTimezone = (gmailHandlerForTz as any).detectTimezone.bind(gmailHandlerForTz);
// Test without environment variables
const originalTZ = process.env.TZ;
const originalEmailTZ = process.env.EMAIL_DEFAULT_TIMEZONE;
delete process.env.TZ;
delete process.env.EMAIL_DEFAULT_TIMEZONE;
const detectedTz = detectTimezone();
// Should detect system timezone or use default
expect(detectedTz).toBeTruthy();
expect(typeof detectedTz).toBe('string');
// Restore environment variables
if (originalTZ) process.env.TZ = originalTZ;
if (originalEmailTZ) process.env.EMAIL_DEFAULT_TIMEZONE = originalEmailTZ;
});
it('should prioritize TZ environment variable', () => {
const detectTimezone = (gmailHandlerForTz as any).detectTimezone.bind(gmailHandlerForTz);
// Set test environment variables
const originalTZ = process.env.TZ;
const originalEmailTZ = process.env.EMAIL_DEFAULT_TIMEZONE;
process.env.TZ = 'America/New_York';
process.env.EMAIL_DEFAULT_TIMEZONE = 'Europe/London';
const detectedTz = detectTimezone();
// Should prioritize TZ over EMAIL_DEFAULT_TIMEZONE
expect(detectedTz).toBe('America/New_York');
// Restore environment variables
if (originalTZ) process.env.TZ = originalTZ;
else delete process.env.TZ;
if (originalEmailTZ) process.env.EMAIL_DEFAULT_TIMEZONE = originalEmailTZ;
else delete process.env.EMAIL_DEFAULT_TIMEZONE;
});
it('should use EMAIL_DEFAULT_TIMEZONE when TZ is not set', () => {
const detectTimezone = (gmailHandlerForTz as any).detectTimezone.bind(gmailHandlerForTz);
// Set test environment variables
const originalTZ = process.env.TZ;
const originalEmailTZ = process.env.EMAIL_DEFAULT_TIMEZONE;
delete process.env.TZ;
process.env.EMAIL_DEFAULT_TIMEZONE = 'Europe/London';
const detectedTz = detectTimezone();
// Should use EMAIL_DEFAULT_TIMEZONE
expect(detectedTz).toBe('Europe/London');
// Restore environment variables
if (originalTZ) process.env.TZ = originalTZ;
if (originalEmailTZ) process.env.EMAIL_DEFAULT_TIMEZONE = originalEmailTZ;
else delete process.env.EMAIL_DEFAULT_TIMEZONE;
});
});