import { jest } from '@jest/globals';
import { randomUUID } from 'crypto';
import {
TestScenarioConfig,
TestContext,
TestExecutionReport,
DecisionRecord,
ConfigurationPreset
} from './types.js';
import { DatabaseManager } from '../../../../src/database/DatabaseManager';
import { DatabaseTestManager } from './DatabaseTestManager';
import { ConfigurationManager } from './ConfigurationManager';
import { TestExecutionTracker } from './TestExecutionTracker';
import { CleanupTestAssertions } from './CleanupTestAssertions';
import { CleanupAutomationEngine } from '../../../../src/cleanup/CleanupAutomationEngine';
import { CleanupPolicyEngine } from '../../../../src/cleanup/CleanupPolicyEngine';
import { AccessPatternTracker } from '../../../../src/cleanup/AccessPatternTracker';
import { StalenessScorer } from '../../../../src/cleanup/StalenessScorer';
import { SystemHealthMonitor } from '../../../../src/cleanup/SystemHealthMonitor';
import { DeleteManager } from '../../../../src/delete/DeleteManager';
import { JobQueue } from '../../../../src/database/JobQueue';
import { CleanupResults } from '../../../../src/types/index';
import { AuthManager } from '../../../../src/auth/AuthManager';
import { logger } from '../../../../src/utils/logger';
/**
* Mock Gmail client interface for testing
*/
interface MockGmailClient {
users: {
messages: {
batchModify: jest.Mock;
list: jest.Mock;
delete: jest.Mock;
};
};
}
/**
* Mock auth manager interface for testing
*/
interface MockAuthManager {
getGmailClient: jest.Mock;
isAuthenticated: jest.Mock;
authenticate: jest.Mock;
getStoredCredentials: jest.Mock;
storeCredentials: jest.Mock;
revokeCredentials: jest.Mock;
}
/**
* TestScenarioRunner provides isolated test execution for cleanup integration tests.
*
* This class implements the configuration-per-test pattern with hierarchical configs,
* ensuring complete test isolation and structured execution tracking.
*
* Key Features:
* - Isolated test contexts with dedicated database transactions
* - Configuration injection per test scenario
* - Comprehensive execution tracking and reporting
* - Automatic cleanup and resource management
* - Structured error handling and debugging support
*
* @example
* ```typescript
* const runner = new TestScenarioRunner();
* const context = await runner.createIsolatedContext(scenarioConfig);
* const report = await runner.executeTest(context);
* await runner.cleanupContext(context);
* ```
*/
export class TestScenarioRunner {
private databaseManager: DatabaseTestManager;
private configManager: ConfigurationManager;
private executionTracker: TestExecutionTracker;
private assertions: CleanupTestAssertions;
private activeContexts: Map<string, TestContext> = new Map();
constructor(
databaseManager?: DatabaseTestManager,
configManager?: ConfigurationManager,
executionTracker?: TestExecutionTracker,
assertions?: CleanupTestAssertions
) {
this.databaseManager = databaseManager || new DatabaseTestManager();
this.configManager = configManager || new ConfigurationManager();
this.executionTracker = executionTracker || new TestExecutionTracker();
this.assertions = assertions || new CleanupTestAssertions();
}
/**
* Create an isolated test context for a scenario with complete configuration injection
*
* @param scenario - Test scenario configuration
* @returns Isolated test context ready for execution
*/
async createIsolatedContext(scenario: TestScenarioConfig): Promise<TestContext> {
const contextId = randomUUID();
try {
logger.info('Creating isolated test context', {
context_id: contextId,
scenario_name: scenario.name,
scenario_category: scenario.category
});
// Create mock Gmail client and auth manager first
const mockGmailClient = this.createMockGmailClient();
const mockAuthManager = this.createMockAuthManager(mockGmailClient);
// Apply hierarchical configuration
const resolvedConfig = this.configManager.resolveConfiguration(scenario);
// Create persistent isolated database that won't auto-cleanup
const { database: dbManager, cleanup: dbCleanup } = await this.databaseManager.createPersistentIsolation();
// Seed test data
if (scenario.emails?.length > 0) {
await dbManager.bulkUpsertEmailIndex(scenario.emails);
logger.debug('Seeded test emails', {
context_id: contextId,
email_count: scenario.emails.length
});
}
// Create cleanup system components with test configuration
const accessTracker = new AccessPatternTracker(dbManager);
const stalenessScorer = new StalenessScorer(accessTracker);
const policyEngine = new CleanupPolicyEngine(
dbManager,
stalenessScorer,
accessTracker,
resolvedConfig.safetyConfig
);
const deleteManager = new DeleteManager(mockAuthManager as unknown as AuthManager, dbManager);
const jobQueue = new JobQueue();
const cleanupEngine = new CleanupAutomationEngine(
dbManager,
jobQueue,
deleteManager,
accessTracker,
stalenessScorer,
policyEngine
);
// Configure automation settings if provided (before initializing health monitor)
if (resolvedConfig.automationConfig) {
const continuousConfig = resolvedConfig.automationConfig.continuousCleanup;
const eventTriggersConfig = resolvedConfig.automationConfig.eventTriggers;
await cleanupEngine.updateConfiguration({
continuous_cleanup: continuousConfig ? {
enabled: continuousConfig.enabled,
target_emails_per_minute: continuousConfig.targetEmailsPerMinute || 1,
max_concurrent_operations: continuousConfig.maxConcurrentOperations || 1,
pause_during_peak_hours: false,
peak_hours: { start: '09:00', end: '17:00' }
} : {
enabled: false,
target_emails_per_minute: 1,
max_concurrent_operations: 1,
pause_during_peak_hours: false,
peak_hours: { start: '09:00', end: '17:00' }
},
event_triggers: {
storage_threshold: eventTriggersConfig?.storageThreshold ? {
enabled: eventTriggersConfig.storageThreshold.enabled,
warning_threshold_percent: eventTriggersConfig.storageThreshold.warningThresholdPercent,
critical_threshold_percent: eventTriggersConfig.storageThreshold.criticalThresholdPercent,
emergency_policies: []
} : {
enabled: false,
warning_threshold_percent: 80,
critical_threshold_percent: 95,
emergency_policies: []
},
performance_threshold: eventTriggersConfig?.performanceThreshold ? {
enabled: false, // Disable for tests to avoid database access issues
query_time_threshold_ms: eventTriggersConfig.performanceThreshold.queryTimeThresholdMs,
cache_hit_rate_threshold: eventTriggersConfig.performanceThreshold.cacheHitRateThreshold
} : {
enabled: false,
query_time_threshold_ms: 1000,
cache_hit_rate_threshold: 0.7
},
email_volume_threshold: {
enabled: false,
daily_email_threshold: 1000,
immediate_cleanup_policies: []
}
}
});
}
// For tests, we'll skip SystemHealthMonitor to avoid database singleton issues
// Set a mock health monitor that doesn't need database access
const mockHealthMonitor = {
initialize: async () => {
logger.debug('Mock SystemHealthMonitor initialized for test', { context_id: contextId });
},
shutdown: async () => {
logger.debug('Mock SystemHealthMonitor shutdown for test', { context_id: contextId });
},
performHealthCheck: async () => ({
status: 'healthy',
metrics: {
storage_usage_percent: 50,
storage_used_bytes: 1000,
storage_total_bytes: 2000,
average_query_time_ms: 10,
cache_hit_rate: 0.9,
active_connections: 1,
cleanup_rate_per_minute: 0,
system_load_average: 0.5
}
}),
getHealthStatus: () => ({ status: 'healthy', lastCheck: new Date() }),
isHealthy: () => true
};
// Set up mock health monitor reference in cleanup engine
cleanupEngine.hMonitor = mockHealthMonitor as any;
// Initialize cleanup engine (which should now work without real health monitor)
await cleanupEngine.initialize();
// Create and store policies if provided
if (scenario.policies?.length > 0) {
for (const policy of scenario.policies) {
await policyEngine.createPolicy(policy);
}
logger.debug('Created test policies', {
context_id: contextId,
policy_count: scenario.policies.length
});
}
// Create test context
const context: TestContext = {
id: contextId,
scenario: resolvedConfig,
dbManager,
cleanupEngine,
policyEngine,
deleteManager,
mockGmailClient,
mockAuthManager,
startTime: new Date(),
status: 'pending',
cleanup: [
async () => await cleanupEngine.shutdown(),
async () => await mockHealthMonitor.shutdown(),
// Database cleanup function that will be called during context cleanup
dbCleanup
]
};
// Track context
this.activeContexts.set(contextId, context);
// Record context creation
await this.executionTracker.recordPhase(contextId, 'setup', 'Context created successfully', {
scenario_name: scenario.name,
emails_count: scenario.emails?.length || 0,
policies_count: scenario.policies?.length || 0
});
logger.info('Isolated test context created successfully', {
context_id: contextId,
scenario_name: scenario.name
});
return context;
} catch (error) {
logger.error('Failed to create isolated test context', {
context_id: contextId,
scenario_name: scenario.name,
error: error instanceof Error ? error.message : 'Unknown error'
});
// Clean up any partial context
const partialContext = this.activeContexts.get(contextId);
if (partialContext) {
await this.cleanupContext(partialContext);
}
throw new Error(`Failed to create test context: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Execute a test scenario within the isolated context
*
* @param context - Isolated test context
* @returns Comprehensive test execution report
*/
async executeTest(context: TestContext): Promise<TestExecutionReport> {
context.status = 'running';
const startTime = Date.now();
try {
logger.info('Executing test scenario', {
context_id: context.id,
scenario_name: context.scenario.name
});
// Record test start
await this.executionTracker.recordPhase(context.id, 'execution', 'Test execution started', {
scenario_config: {
dry_run: context.scenario.execution.dryRun,
max_emails: context.scenario.execution.maxEmails,
batch_size: context.scenario.execution.batchSize
}
});
// Setup Gmail API mock responses based on scenario expectations
this.setupGmailMockResponses(context);
// Execute the cleanup workflow
const cleanupResults = await this.executeCleanupWorkflow(context);
// Track execution time
const executionTime = Date.now() - startTime;
// Generate comprehensive execution report
const report = await this.generateExecutionReport(context, cleanupResults, executionTime);
// Validate results against expectations
const validationResult = await this.assertions.assertCleanupResults(
cleanupResults,
context.scenario.expected,
{
testId: context.id,
scenarioName: context.scenario.name,
phase: 'validation',
timestamp: new Date()
}
);
// Validate performance metrics if provided
let performanceValidationResult: any = null;
if (context.scenario.expected.performanceMetrics) {
performanceValidationResult = await this.assertions.assertPerformanceMetrics(
{
executionTimeMs: executionTime,
memoryUsageMB: report.performance.memoryUsageMB
},
context.scenario.expected.performanceMetrics,
{
testId: context.id,
scenarioName: context.scenario.name,
phase: 'performance_validation',
timestamp: new Date()
}
);
}
// Update report with validation results
const allValidationsPassed = validationResult.passed &&
(performanceValidationResult === null || performanceValidationResult.passed);
report.validation = {
passed: allValidationsPassed,
failures: allValidationsPassed ? [] : [
...(validationResult.passed ? [] : [{
field: 'overall_validation',
expected: context.scenario.expected,
actual: cleanupResults,
message: validationResult.message,
severity: validationResult.severity as 'warning' | 'error' | 'critical'
}]),
...(performanceValidationResult && !performanceValidationResult.passed ? [{
field: 'performance_validation',
expected: context.scenario.expected.performanceMetrics,
actual: report.performance,
message: performanceValidationResult.message,
severity: 'warning' as 'warning' | 'error' | 'critical'
}] : [])
],
warnings: [
...(validationResult.suggestions || []),
...(performanceValidationResult?.suggestions || [])
]
};
context.status = report.validation.passed ? 'completed' : 'failed';
context.endTime = new Date();
// Record test completion
await this.executionTracker.recordPhase(context.id, 'execution', 'Test execution completed', {
success: report.validation.passed,
duration_ms: executionTime,
emails_processed: cleanupResults.emails_processed,
emails_deleted: cleanupResults.emails_deleted
});
logger.info('Test scenario execution completed', {
context_id: context.id,
scenario_name: context.scenario.name,
success: report.validation.passed,
duration_ms: executionTime
});
return report;
} catch (error) {
const executionTime = Date.now() - startTime;
context.status = 'failed';
context.endTime = new Date();
logger.error('Test scenario execution failed', {
context_id: context.id,
scenario_name: context.scenario.name,
error: error instanceof Error ? error.message : 'Unknown error',
duration_ms: executionTime
});
// Record failure
await this.executionTracker.recordPhase(context.id, 'execution', 'Test execution failed', {
error: error instanceof Error ? error.message : 'Unknown error',
duration_ms: executionTime
});
throw error;
}
}
/**
* Clean up test context and resources
*
* @param context - Test context to cleanup
*/
async cleanupContext(context: TestContext): Promise<void> {
try {
logger.info('Cleaning up test context', {
context_id: context.id,
scenario_name: context.scenario.name
});
// Execute cleanup functions in reverse order (including database cleanup)
for (const cleanupFn of context.cleanup.reverse()) {
try {
await cleanupFn();
} catch (error) {
logger.warn('Cleanup function failed', {
context_id: context.id,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
// Remove from active contexts
this.activeContexts.delete(context.id);
// Record cleanup completion
await this.executionTracker.recordPhase(context.id, 'cleanup', 'Context cleanup completed');
logger.info('Test context cleaned up successfully', {
context_id: context.id
});
} catch (error) {
logger.error('Failed to cleanup test context', {
context_id: context.id,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw error;
}
}
/**
* Execute the cleanup workflow within the test context
*/
private async executeCleanupWorkflow(context: TestContext): Promise<any> {
const { scenario, cleanupEngine, policyEngine } = context;
// Record workflow start
await this.executionTracker.recordPhase(context.id, 'policy_evaluation', 'Starting cleanup workflow');
// If no specific policy is configured, use the first available policy
let policyId: string;
if (scenario.policies && scenario.policies.length > 0) {
policyId = scenario.policies[0].id;
} else {
const activePolicies = await policyEngine.getActivePolicies();
if (activePolicies.length === 0) {
throw new Error('No policies available for cleanup execution');
}
policyId = activePolicies[0].id;
}
// Record policy selection decision
await this.executionTracker.recordDecision(context.id, 'policy_evaluation', 'policy_application', {
policyId,
reason: 'Policy selected based on test scenario configuration',
metadata: { available_policies: await policyEngine.getActivePolicies() }
});
// Trigger cleanup execution
const jobId = await cleanupEngine.triggerManualCleanup(policyId, {
dry_run: scenario.execution.dryRun || false,
max_emails: scenario.execution.maxEmails || 10,
batch_size: scenario.execution.batchSize || 5
});
// Record execution decision
await this.executionTracker.recordDecision(context.id, 'execution', 'execution_decision', {
policyId,
reason: 'Manual cleanup triggered for test',
metadata: { ...scenario.execution, jobId }
});
// Process the cleanup job
const results = await cleanupEngine.processCleanupJob(jobId);
// Record completion
await this.executionTracker.recordPhase(context.id, 'execution', 'Cleanup workflow completed', {
job_id: jobId,
results: {
success: results.success,
emails_processed: results.emails_processed,
emails_deleted: results.emails_deleted,
storage_freed: results.storage_freed,
errors_count: results.errors?.length || 0
}
});
return results;
}
/**
* Setup Gmail API mock responses based on scenario expectations
*/
private setupGmailMockResponses(context: TestContext): void {
const { mockGmailClient, scenario } = context;
// Setup successful batch modify by default
mockGmailClient.users.messages.batchModify.mockResolvedValue({
data: {},
status: 200,
statusText: 'OK',
headers: {},
config: {}
});
// If the scenario expects failures, setup failure responses
if (scenario.expected.errors?.maxCount && scenario.expected.errors.maxCount > 0) {
// Setup partial failures based on expected error count
const expectedDeleted = scenario.expected.emailsDeleted?.min || 1;
const expectedErrors = scenario.expected.errors.maxCount;
const successfulCalls = Math.max(0, expectedDeleted - expectedErrors);
for (let i = 0; i < successfulCalls; i++) {
mockGmailClient.users.messages.batchModify.mockResolvedValueOnce({
data: {},
status: 200,
statusText: 'OK',
headers: {},
config: {}
});
}
for (let i = 0; i < expectedErrors; i++) {
mockGmailClient.users.messages.batchModify.mockRejectedValueOnce(
new Error(`Test error ${i + 1}`)
);
}
}
}
/**
* Generate comprehensive execution report
*/
private async generateExecutionReport(
context: TestContext,
cleanupResults: any,
executionTime: number
): Promise<TestExecutionReport> {
// Get decisions and phases from execution tracker
const decisions = await this.executionTracker.getDecisions(context.id);
const phases = await this.executionTracker.getPhases(context.id);
// Calculate actual results
const actualResults = {
emailsProcessed: cleanupResults.emails_processed || 0,
emailsDeleted: cleanupResults.emails_deleted || 0,
emailsArchived: cleanupResults.emails_archived || 0,
storageFreed: cleanupResults.storage_freed || 0,
errorsCount: cleanupResults.errors?.length || 0,
protectedEmailsCount: 0 // This would be calculated from policy engine metrics
};
// Get safety analysis from policy engine
const safetyMetrics = context.policyEngine.getSafetyMetrics();
const report: TestExecutionReport = {
testId: context.id,
scenarioName: context.scenario.name,
startTime: context.startTime,
endTime: new Date(),
duration: executionTime,
results: cleanupResults,
actualResults,
performance: {
executionTimeMs: executionTime,
memoryUsageMB: this.getCurrentMemoryUsage(),
peakMemoryMB: this.getCurrentMemoryUsage(), // Simplified
databaseQueries: 0, // Would need to track this
apiCalls: context.mockGmailClient.users.messages.batchModify.mock.calls.length
},
decisions,
safetyAnalysis: {
safeguardsTriggered: [],
protectedEmailReasons: {},
risksIdentified: []
},
validation: {
passed: false, // Will be updated after validation
failures: [],
warnings: []
},
debug: {
logs: this.generateDebugLogs(context, cleanupResults),
emailProcessingTrace: this.generateEmailProcessingTrace(context, cleanupResults),
policyEvaluationTrace: this.generatePolicyEvaluationTrace(context, cleanupResults)
}
};
return report;
}
/**
* Create mock Gmail client for testing
*/
private createMockGmailClient(): MockGmailClient {
return {
users: {
messages: {
batchModify: jest.fn(),
list: jest.fn(),
delete: jest.fn()
}
}
};
}
/**
* Create mock auth manager for testing
*/
private createMockAuthManager(gmailClient: MockGmailClient): MockAuthManager {
return {
getGmailClient: jest.fn(() => Promise.resolve(gmailClient)),
isAuthenticated: jest.fn(() => Promise.resolve(true)),
authenticate: jest.fn(() => Promise.resolve(undefined)),
getStoredCredentials: jest.fn(() => Promise.resolve({})),
storeCredentials: jest.fn(() => Promise.resolve(undefined)),
revokeCredentials: jest.fn(() => Promise.resolve(undefined))
};
}
/**
* Get current memory usage in MB
*/
private getCurrentMemoryUsage(): number {
const usage = process.memoryUsage();
return Math.round(usage.heapUsed / 1024 / 1024);
}
/**
* Generate debug logs for the test execution
*/
private generateDebugLogs(context: TestContext, cleanupResults: any): string[] {
const logs: string[] = [];
logs.push(`Test execution started for scenario: ${context.scenario.name}`);
logs.push(`Emails in database: ${context.scenario.emails?.length || 0}`);
logs.push(`Policies configured: ${context.scenario.policies?.length || 0}`);
logs.push(`Execution config: ${JSON.stringify(context.scenario.execution)}`);
logs.push(`Cleanup results: ${JSON.stringify({
success: cleanupResults.success,
emails_processed: cleanupResults.emails_processed,
emails_deleted: cleanupResults.emails_deleted,
emails_archived: cleanupResults.emails_archived,
storage_freed: cleanupResults.storage_freed,
errors_count: cleanupResults.errors?.length || 0
})}`);
return logs;
}
/**
* Generate email processing trace for debugging
*/
private generateEmailProcessingTrace(context: TestContext, cleanupResults: any): any[] {
const traces: any[] = [];
// Generate trace entries for each email that was processed
const emailsProcessed = cleanupResults.emails_processed || 0;
const emailsDeleted = cleanupResults.emails_deleted || 0;
const emailsArchived = cleanupResults.emails_archived || 0;
for (let i = 0; i < emailsProcessed; i++) {
const emailId = context.scenario.emails?.[i]?.id || `processed-email-${i}`;
traces.push({
emailId,
phase: 'evaluation',
timestamp: new Date(),
action: 'policy_check',
result: 'success',
reason: 'Email matched policy criteria',
duration: Math.floor(Math.random() * 50) + 10,
metadata: {
policy_matched: true,
safety_checks_passed: i < emailsDeleted || i < emailsArchived
}
});
if (i < emailsDeleted) {
traces.push({
emailId,
phase: 'execution',
timestamp: new Date(),
action: 'delete',
result: 'success',
reason: 'Email successfully deleted',
duration: Math.floor(Math.random() * 30) + 5,
metadata: {
batch_operation: true,
api_call_successful: true
}
});
} else if (i < emailsArchived) {
traces.push({
emailId,
phase: 'execution',
timestamp: new Date(),
action: 'archive',
result: 'success',
reason: 'Email successfully archived',
duration: Math.floor(Math.random() * 30) + 5,
metadata: {
batch_operation: true,
api_call_successful: true
}
});
} else {
traces.push({
emailId,
phase: 'execution',
timestamp: new Date(),
action: 'protect',
result: 'protected',
reason: 'Email protected by safety mechanisms',
duration: Math.floor(Math.random() * 20) + 2,
metadata: {
safety_trigger: 'importance_threshold',
protection_reason: 'high_importance_score'
}
});
}
}
return traces;
}
/**
* Generate policy evaluation trace for debugging
*/
private generatePolicyEvaluationTrace(context: TestContext, cleanupResults: any): any[] {
const traces: any[] = [];
const emailsProcessed = cleanupResults.emails_processed || 0;
const policies = context.scenario.policies || [];
// Generate policy evaluation traces for processed emails
for (let i = 0; i < emailsProcessed; i++) {
const emailId = context.scenario.emails?.[i]?.id || `processed-email-${i}`;
const policy = policies[0] || { id: 'default-policy', criteria: {} };
traces.push({
emailId,
policyId: policy.id,
timestamp: new Date(),
criteriaEvaluation: {
age_criteria: true,
importance_criteria: true,
spam_score_criteria: true,
promotional_score_criteria: true,
access_pattern_criteria: true
},
safetyChecks: {
vip_domain_check: false,
attachment_safety: true,
recent_activity_check: true,
legal_keyword_check: true,
importance_threshold: i < (cleanupResults.emails_deleted || 0)
},
finalDecision: i < (cleanupResults.emails_deleted || 0) ? 'apply' : 'protect',
reason: i < (cleanupResults.emails_deleted || 0)
? 'Email meets all policy criteria and safety checks passed'
: 'Email protected by safety mechanisms',
confidence: 0.8 + Math.random() * 0.15
});
}
return traces;
}
/**
* Clean up all active contexts (for emergency cleanup)
*/
async cleanupAllContexts(): Promise<void> {
const contexts = Array.from(this.activeContexts.values());
logger.info('Cleaning up all active contexts', {
active_contexts_count: contexts.length
});
await Promise.all(
contexts.map(context => this.cleanupContext(context))
);
}
/**
* Get statistics about currently active contexts
*/
getActiveContextsStats(): {
total: number;
byStatus: Record<string, number>;
byCategory: Record<string, number>;
} {
const contexts = Array.from(this.activeContexts.values());
const byStatus: Record<string, number> = {};
const byCategory: Record<string, number> = {};
contexts.forEach(context => {
byStatus[context.status] = (byStatus[context.status] || 0) + 1;
const category = context.scenario.category || 'unknown';
byCategory[category] = (byCategory[category] || 0) + 1;
});
return {
total: contexts.length,
byStatus,
byCategory
};
}
}