shouldReceive('isEnabled')->andReturn($enabled); $extractor->shouldReceive('getFeatureType')->andReturn(FeatureType::FREQUENCY); $extractor->shouldReceive('getPriority')->andReturn(10); $extractor->shouldReceive('canExtract')->andReturn(true); $extractor->shouldReceive('extractFeatures')->andReturn($features); return $extractor; } // Helper function to create mock detector function createMockQueryDetector(bool $enabled = true, array $supportedTypes = [], array $anomalies = []): MockInterface { $supportedTypes = $supportedTypes ?: [FeatureType::FREQUENCY]; $detector = Mockery::mock(\App\Framework\MachineLearning\Core\AnomalyDetectorInterface::class); $detector->shouldReceive('isEnabled')->andReturn($enabled); $detector->shouldReceive('getName')->andReturn('MockDetector'); $detector->shouldReceive('getSupportedFeatureTypes')->andReturn($supportedTypes); $detector->shouldReceive('canAnalyze')->andReturn(true); $detector->shouldReceive('detectAnomalies')->andReturn($anomalies); return $detector; } describe('NPlusOneDetectionEngine', function () { beforeEach(function () { $this->clock = new SystemClock(); }); afterEach(function () { Mockery::close(); }); it('is disabled when constructed with enabled: false', function () { $engine = new NPlusOneDetectionEngine( enabled: false, extractors: [], detectors: [], clock: $this->clock, analysisTimeout: Duration::fromSeconds(5), confidenceThreshold: Percentage::from(60.0) ); expect($engine->isEnabled())->toBeFalse(); }); it('is enabled when constructed with enabled: true', function () { $engine = new NPlusOneDetectionEngine( enabled: true, extractors: [], detectors: [], clock: $this->clock, analysisTimeout: Duration::fromSeconds(5), confidenceThreshold: Percentage::from(60.0) ); expect($engine->isEnabled())->toBeTrue(); }); it('returns configuration', function () { $engine = new NPlusOneDetectionEngine( enabled: true, extractors: [new QueryFeatureExtractor()], detectors: [], clock: $this->clock, analysisTimeout: Duration::fromSeconds(3), confidenceThreshold: Percentage::from(75.0) ); $config = $engine->getConfiguration(); expect($config)->toBeArray(); expect($config['enabled'])->toBeTrue(); expect($config['extractor_count'])->toBe(1); expect($config['detector_count'])->toBe(0); expect($config['analysis_timeout_ms'])->toBe(3000); expect($config['confidence_threshold'])->toBe(75.0); }); it('returns disabled result when engine is disabled', function () { $engine = new NPlusOneDetectionEngine( enabled: false, extractors: [], detectors: [], clock: $this->clock, analysisTimeout: Duration::fromSeconds(5), confidenceThreshold: Percentage::from(60.0) ); $context = QueryExecutionContext::minimal(); $result = $engine->analyzeQueryContext($context); expect($result->enabled)->toBeFalse(); expect($result->features)->toBeEmpty(); expect($result->anomalies)->toBeEmpty(); expect($result->confidence->getValue())->toBe(0.0); }); it('extracts features from query context', function () { $feature = new Feature( type: FeatureType::FREQUENCY, name: 'query_frequency', value: 10.0, unit: 'queries/second' ); $extractor = createMockQueryExtractor(true, [$feature]); $engine = new NPlusOneDetectionEngine( enabled: true, extractors: [$extractor], detectors: [], clock: $this->clock, analysisTimeout: Duration::fromSeconds(5), confidenceThreshold: Percentage::from(60.0) ); $context = QueryExecutionContext::minimal(); $result = $engine->analyzeQueryContext($context); expect($result->features)->toHaveCount(1); expect($result->features[0]->name)->toBe('query_frequency'); }); it('detects anomalies using configured detectors', function () { $feature = new Feature( type: FeatureType::FREQUENCY, name: 'query_frequency', value: 100.0, // High frequency unit: 'queries/second' ); $anomaly = AnomalyDetection::frequencySpike( currentRate: 100.0, baseline: 10.0, threshold: 3.0 ); $extractor = createMockQueryExtractor(true, [$feature]); $detector = createMockQueryDetector(true, [FeatureType::FREQUENCY], [$anomaly]); $engine = new NPlusOneDetectionEngine( enabled: true, extractors: [$extractor], detectors: [$detector], clock: $this->clock, analysisTimeout: Duration::fromSeconds(5), confidenceThreshold: Percentage::from(60.0) ); $context = QueryExecutionContext::minimal(); $result = $engine->analyzeQueryContext($context); expect($result->anomalies)->not->toBeEmpty(); expect($result->confidence->getValue())->toBeGreaterThan(0.0); }); it('filters anomalies by confidence threshold', function () { $feature = new Feature( type: FeatureType::FREQUENCY, name: 'query_frequency', value: 50.0, unit: 'queries/second' ); // Low confidence anomaly (should be filtered out) $lowConfidenceAnomaly = AnomalyDetection::create( type: AnomalyType::FREQUENCY_SPIKE, featureType: FeatureType::FREQUENCY, anomalyScore: 0.5, // 50% confidence description: 'Low confidence anomaly', features: [$feature], evidence: [] ); $extractor = createMockQueryExtractor(true, [$feature]); $detector = createMockQueryDetector(true, [FeatureType::FREQUENCY], [$lowConfidenceAnomaly]); $engine = new NPlusOneDetectionEngine( enabled: true, extractors: [$extractor], detectors: [$detector], clock: $this->clock, analysisTimeout: Duration::fromSeconds(5), confidenceThreshold: Percentage::from(75.0) // High threshold ); $context = QueryExecutionContext::minimal(); $result = $engine->analyzeQueryContext($context); // Anomaly should be filtered out due to low confidence expect($result->anomalies)->toBeEmpty(); }); it('skips disabled extractors', function () { $enabledExtractor = createMockQueryExtractor(true, [ new Feature(FeatureType::FREQUENCY, 'feature1', 10.0, 'unit') ]); $disabledExtractor = createMockQueryExtractor(false, [ new Feature(FeatureType::FREQUENCY, 'feature2', 20.0, 'unit') ]); $engine = new NPlusOneDetectionEngine( enabled: true, extractors: [$enabledExtractor, $disabledExtractor], detectors: [], clock: $this->clock, analysisTimeout: Duration::fromSeconds(5), confidenceThreshold: Percentage::from(60.0) ); $context = QueryExecutionContext::minimal(); $result = $engine->analyzeQueryContext($context); // Should only have features from enabled extractor expect($result->features)->toHaveCount(1); expect($result->features[0]->name)->toBe('feature1'); }); it('skips disabled detectors', function () { $feature = new Feature( type: FeatureType::FREQUENCY, name: 'query_frequency', value: 100.0, unit: 'queries/second' ); $anomaly = AnomalyDetection::frequencySpike(100.0, 10.0, 3.0); $extractor = createMockQueryExtractor(true, [$feature]); $enabledDetector = createMockQueryDetector(true, [FeatureType::FREQUENCY], [$anomaly]); $disabledDetector = createMockQueryDetector(false, [FeatureType::FREQUENCY], [$anomaly]); $engine = new NPlusOneDetectionEngine( enabled: true, extractors: [$extractor], detectors: [$enabledDetector, $disabledDetector], clock: $this->clock, analysisTimeout: Duration::fromSeconds(5), confidenceThreshold: Percentage::from(60.0) ); $context = QueryExecutionContext::minimal(); $result = $engine->analyzeQueryContext($context); // Should only have anomalies from enabled detector expect($result->detectorResults)->toHaveCount(1); }); it('includes extractor results in output', function () { $extractor = createMockQueryExtractor(true, [ new Feature(FeatureType::FREQUENCY, 'feature1', 10.0, 'unit') ]); $engine = new NPlusOneDetectionEngine( enabled: true, extractors: [$extractor], detectors: [], clock: $this->clock, analysisTimeout: Duration::fromSeconds(5), confidenceThreshold: Percentage::from(60.0) ); $context = QueryExecutionContext::minimal(); $result = $engine->analyzeQueryContext($context); expect($result->extractorResults)->not->toBeEmpty(); expect($result->extractorResults[0]->featuresExtracted)->toBe(1); expect($result->extractorResults[0]->success)->toBeTrue(); }); it('includes detector results in output', function () { $feature = new Feature( type: FeatureType::FREQUENCY, name: 'query_frequency', value: 100.0, unit: 'queries/second' ); $anomaly = AnomalyDetection::frequencySpike(100.0, 10.0, 3.0); $extractor = createMockQueryExtractor(true, [$feature]); $detector = createMockQueryDetector(true, [FeatureType::FREQUENCY], [$anomaly]); $engine = new NPlusOneDetectionEngine( enabled: true, extractors: [$extractor], detectors: [$detector], clock: $this->clock, analysisTimeout: Duration::fromSeconds(5), confidenceThreshold: Percentage::from(60.0) ); $context = QueryExecutionContext::minimal(); $result = $engine->analyzeQueryContext($context); expect($result->detectorResults)->not->toBeEmpty(); expect($result->detectorResults[0]->anomaliesDetected)->toBe(1); expect($result->detectorResults[0]->success)->toBeTrue(); }); it('calculates overall confidence from anomalies', function () { $feature = new Feature( type: FeatureType::FREQUENCY, name: 'query_frequency', value: 100.0, unit: 'queries/second' ); $anomaly1 = AnomalyDetection::frequencySpike(100.0, 10.0, 3.0); // ~95% confidence $anomaly2 = AnomalyDetection::frequencySpike(90.0, 10.0, 3.0); // ~95% confidence $extractor = createMockQueryExtractor(true, [$feature]); $detector = createMockQueryDetector(true, [FeatureType::FREQUENCY], [$anomaly1, $anomaly2]); $engine = new NPlusOneDetectionEngine( enabled: true, extractors: [$extractor], detectors: [$detector], clock: $this->clock, analysisTimeout: Duration::fromSeconds(5), confidenceThreshold: Percentage::from(60.0) ); $context = QueryExecutionContext::minimal(); $result = $engine->analyzeQueryContext($context); // Overall confidence should be average of anomalies expect($result->confidence->getValue())->toBeGreaterThan(90.0); }); it('handles exceptions gracefully', function () { $extractor = Mockery::mock(\App\Framework\MachineLearning\Core\FeatureExtractorInterface::class); $extractor->shouldReceive('isEnabled')->andReturn(true); $extractor->shouldReceive('canExtract')->andReturn(true); $extractor->shouldReceive('extractFeatures')->andThrow(new \RuntimeException('Extraction failed')); $engine = new NPlusOneDetectionEngine( enabled: true, extractors: [$extractor], detectors: [], clock: $this->clock, analysisTimeout: Duration::fromSeconds(5), confidenceThreshold: Percentage::from(60.0) ); $context = QueryExecutionContext::minimal(); $result = $engine->analyzeQueryContext($context); // Should not throw, should return error in result expect($result->error)->not->toBeNull(); expect($result->error)->toContain('Extraction failed'); }); it('tracks analysis time', function () { $extractor = createMockQueryExtractor(true, [ new Feature(FeatureType::FREQUENCY, 'feature1', 10.0, 'unit') ]); $engine = new NPlusOneDetectionEngine( enabled: true, extractors: [$extractor], detectors: [], clock: $this->clock, analysisTimeout: Duration::fromSeconds(5), confidenceThreshold: Percentage::from(60.0) ); $context = QueryExecutionContext::minimal(); $result = $engine->analyzeQueryContext($context); // Analysis time should be measured expect($result->analysisTime->toMilliseconds())->toBeGreaterThanOrEqual(0); }); });