diff --git a/apps/aggregator/README.md b/apps/aggregator/README.md index 9c054d7..e39e718 100644 --- a/apps/aggregator/README.md +++ b/apps/aggregator/README.md @@ -517,6 +517,88 @@ const results = this.normalizationService.normalizeMany(rawPrices); const { successful, failed } = this.normalizationService.normalizeManyWithErrors(rawPrices); ``` +## Testing Strategy + +The Aggregator service includes comprehensive test coverage for unit and integration testing. + +### Test Structure + +``` +test/ +├── integration/ +│ └── aggregation-pipeline.integration.spec.ts # Complete pipeline tests +``` + +### Running Tests + +**Unit Tests** (default, fast execution): +```bash +npm run test # Run all unit tests +npm run test:watch # Watch mode +npm run test:cov # With coverage report +``` + +**Integration Tests** (tests complete pipeline): +```bash +npm run test:integration # Run integration tests +npm run test:integration:cov # With coverage report +npm run test:all # Unit + Integration tests +``` + +### Test Coverage + +**Unit Tests** (src/**/*.spec.ts): +- Service isolation and correctness +- Strategy implementations (Weighted Average, Median, Trimmed Mean) +- Utility functions +- Edge cases and error handling +- Target: >85% coverage + +**Integration Tests** (test/integration/): +- End-to-end normalization pipeline +- Complete aggregation pipeline +- Multi-source consensus calculation +- Confidence metrics computation +- All aggregation methods comparison +- Edge cases (single source, empty arrays, many sources) +- Price spread and volatility scenarios + +### Test Scenarios Covered + +1. **High Confidence** - Closely agreeing prices from multiple sources +2. **Volatile Market** - High price spread scenarios +3. **Single Source** - Graceful handling of minimal data +4. **Many Sources** - Scalability with 20+ sources +5. **Different Methods** - Validation of all aggregation strategies +6. **Complete Pipeline** - Raw → Normalized → Aggregated flow + +### Running Integration Tests + +```bash +# Run integration tests with single-threaded execution +npm run test:integration + +# Run with coverage +npm run test:integration:cov + +# Run specific test file +npm run test:integration -- aggregation-pipeline.integration.spec + +# Watch mode +npm run test:integration -- --watch +``` + +### Test Output + +Integration tests validate: +- ✅ Normalization correctness +- ✅ Confidence scoring (high vs volatile scenarios) +- ✅ Weighted average aggregation +- ✅ Median aggregation +- ✅ Trimmed mean aggregation +- ✅ Multi-source extraction +- ✅ End-to-end pipeline + ## Status 🚧 Under construction - Aggregation and filtering logic will be implemented in subsequent issues. diff --git a/apps/aggregator/jest.integration.config.js b/apps/aggregator/jest.integration.config.js new file mode 100644 index 0000000..b0a34d9 --- /dev/null +++ b/apps/aggregator/jest.integration.config.js @@ -0,0 +1,15 @@ +module.exports = { + displayName: 'aggregator-integration', + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '.', + testMatch: [ + '**/test/integration/**/*.integration.spec.ts', + ], + moduleFileExtensions: ['js', 'json', 'ts'], + moduleNameMapper: { + '^@oracle-stocks/shared$': '/../../packages/shared/src', + '^@oracle-stocks/shared/(.*)$': '/../../packages/shared/src/$1', + }, + testTimeout: 30000, +}; diff --git a/apps/aggregator/package.json b/apps/aggregator/package.json index a3f0792..3c2da08 100644 --- a/apps/aggregator/package.json +++ b/apps/aggregator/package.json @@ -13,6 +13,10 @@ "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", + "test:integration": "jest --config jest.integration.config.js --runInBand", + "test:integration:cov": "jest --config jest.integration.config.js --coverage --runInBand", + "test:e2e": "jest --config jest.integration.config.js --testPathPattern=e2e --runInBand", + "test:all": "npm run test && npm run test:integration", "check-types": "tsc --noEmit" }, "dependencies": { @@ -63,6 +67,9 @@ ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", + "testPathIgnorePatterns": [ + "/../test/" + ], "transform": { "^.+\\.(t|j)s$": "ts-jest" }, diff --git a/apps/aggregator/src/app.module.ts b/apps/aggregator/src/app.module.ts index f527e73..722539c 100644 --- a/apps/aggregator/src/app.module.ts +++ b/apps/aggregator/src/app.module.ts @@ -2,9 +2,6 @@ import { Module } from '@nestjs/common'; import { NormalizationModule } from './modules/normalization.module'; import { ConfigModule } from '@nestjs/config'; import { AggregationService } from './services/aggregation.service'; -import { WeightedAverageAggregator } from './strategies/aggregators/weighted-average.aggregator'; -import { MedianAggregator } from './strategies/aggregators/median.aggregator'; -import { TrimmedMeanAggregator } from './strategies/aggregators/trimmed-mean.aggregator'; import { HealthModule } from './health/health.module'; import { MetricsModule } from './metrics/metrics.module'; import { DebugModule } from './debug/debug.module'; @@ -29,9 +26,6 @@ import { DataReceptionService } from './services/data-reception.service'; providers: [ DataReceptionService, AggregationService, - WeightedAverageAggregator, - MedianAggregator, - TrimmedMeanAggregator, ], exports: [AggregationService], }) diff --git a/apps/aggregator/test/integration/aggregation-pipeline.integration.spec.ts b/apps/aggregator/test/integration/aggregation-pipeline.integration.spec.ts new file mode 100644 index 0000000..46d4e35 --- /dev/null +++ b/apps/aggregator/test/integration/aggregation-pipeline.integration.spec.ts @@ -0,0 +1,578 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AppModule } from '../../src/app.module'; +import { NormalizationService } from '../../src/services/normalization.service'; +import { AggregationService } from '../../src/services/aggregation.service'; + +/** + * Integration Tests: Complete Normalization & Aggregation Pipeline + * + * Tests the complete flow from raw price data through normalization to aggregation. + * Uses only services and methods that are actually implemented. + */ +describe('Aggregation Pipeline Integration (e2e)', () => { + let app; + let normalizationService: NormalizationService; + let aggregationService: AggregationService; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + + normalizationService = moduleFixture.get(NormalizationService); + aggregationService = moduleFixture.get(AggregationService); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('Normalization Pipeline', () => { + it('should normalize a single AlphaVantage price', () => { + // Arrange + const rawPrice = { + symbol: 'AAPL', + price: 150.25, + timestamp: Date.now(), + source: 'AlphaVantage', + }; + + // Act + const result = normalizationService.normalize(rawPrice); + + // Assert + expect(result).toBeDefined(); + expect(result.symbol).toBe('AAPL'); + expect(result.price).toBe(150.25); + }); + + it('should normalize a single Finnhub price', () => { + // Arrange + const rawPrice = { + symbol: 'GOOGL', + price: 2800.5, + timestamp: Date.now(), + source: 'Finnhub', + }; + + // Act + const result = normalizationService.normalize(rawPrice); + + // Assert + expect(result).toBeDefined(); + expect(result.symbol).toBe('GOOGL'); + expect(result.price).toBe(2800.5); + }); + + it('should normalize multiple prices', () => { + // Arrange + const rawPrices = [ + { + symbol: 'AAPL', + price: 150.25, + timestamp: Date.now(), + source: 'AlphaVantage', + }, + { + symbol: 'AAPL', + price: 150.27, + timestamp: Date.now(), + source: 'YahooFinance', + }, + { + symbol: 'AAPL', + price: 150.23, + timestamp: Date.now(), + source: 'Finnhub', + }, + ]; + + // Act + const results = normalizationService.normalizeMany(rawPrices); + + // Assert + expect(results).toHaveLength(3); + expect(results.every((r) => r.symbol === 'AAPL')).toBe(true); + expect(results.every((r) => r.price > 0)).toBe(true); + }); + + it('should handle mixed sources', () => { + // Arrange + const rawPrices = [ + { + symbol: 'MSFT', + price: 375.85, + timestamp: Date.now(), + source: 'AlphaVantage', + }, + { + symbol: 'MSFT', + price: 375.90, + timestamp: Date.now(), + source: 'Finnhub', + }, + ]; + + // Act + const results = normalizationService.normalizeMany(rawPrices); + + // Assert + expect(results).toHaveLength(2); + results.forEach((r) => { + expect(r.symbol).toBe('MSFT'); + expect(r.price).toBeGreaterThan(0); + }); + }); + }); + + describe('Aggregation Pipeline', () => { + it('should aggregate prices from multiple sources', () => { + // Arrange - Create normalized prices + const timestamp = Date.now(); + const prices = [ + { + symbol: 'AAPL', + price: 150.25, + timestamp, + source: 'AlphaVantage', + }, + { + symbol: 'AAPL', + price: 150.27, + timestamp, + source: 'Finnhub', + }, + { + symbol: 'AAPL', + price: 150.23, + timestamp, + source: 'YahooFinance', + }, + ]; + + // Act + const result = aggregationService.aggregate('AAPL', prices); + + // Assert + expect(result).toBeDefined(); + expect(result.symbol).toBe('AAPL'); + expect(result.price).toBeCloseTo(150.25, 1); + expect(result.sources).toContain('AlphaVantage'); + expect(result.sources.length).toBeGreaterThan(0); + }); + + it('should calculate confidence metrics', () => { + // Arrange + const timestamp = Date.now(); + const prices = [ + { + symbol: 'GOOGL', + price: 2800.5, + timestamp, + source: 'AlphaVantage', + }, + { + symbol: 'GOOGL', + price: 2800.75, + timestamp, + source: 'Finnhub', + }, + { + symbol: 'GOOGL', + price: 2800.25, + timestamp, + source: 'YahooFinance', + }, + { + symbol: 'GOOGL', + price: 2800.60, + timestamp, + source: 'Bloomberg', + }, + { + symbol: 'GOOGL', + price: 2800.40, + timestamp, + source: 'Reuters', + }, + ]; + + // Act + const result = aggregationService.aggregate('GOOGL', prices); + + // Assert + expect(result.confidence).toBeGreaterThan(0); + expect(result.metrics).toBeDefined(); + expect(result.metrics.standardDeviation).toBeGreaterThanOrEqual(0); + expect(result.metrics.sourceCount).toBe(5); + }); + + it('should use weighted average method', () => { + // Arrange + const timestamp = Date.now(); + const prices = [ + { + symbol: 'MSFT', + price: 375.85, + timestamp, + source: 'Bloomberg', // High weight + }, + { + symbol: 'MSFT', + price: 375.90, + timestamp, + source: 'Reuters', // High weight + }, + { + symbol: 'MSFT', + price: 375.80, + timestamp, + source: 'AlphaVantage', // Lower weight + }, + ]; + + // Act + const result = aggregationService.aggregate('MSFT', prices, { + method: 'weighted-average', + }); + + // Assert + expect(result.method).toBe('weighted-average'); + expect(result.price).toBeGreaterThan(0); + }); + + it('should use median method', () => { + // Arrange + const timestamp = Date.now(); + const prices = [ + { + symbol: 'TSLA', + price: 250.0, + timestamp, + source: 'Bloomberg', + }, + { + symbol: 'TSLA', + price: 251.0, + timestamp, + source: 'Reuters', + }, + { + symbol: 'TSLA', + price: 250.5, + timestamp, + source: 'AlphaVantage', + }, + { + symbol: 'TSLA', + price: 250.8, + timestamp, + source: 'Finnhub', + }, + { + symbol: 'TSLA', + price: 250.2, + timestamp, + source: 'YahooFinance', + }, + ]; + + // Act + const result = aggregationService.aggregate('TSLA', prices, { + method: 'median', + }); + + // Assert + expect(result.method).toBe('median'); + expect(result.price).toBeCloseTo(250.5, 0); + }); + + it('should use trimmed mean method', () => { + // Arrange + const timestamp = Date.now(); + const prices = [ + { + symbol: 'AMZN', + price: 170.0, + timestamp, + source: 'Bloomberg', + }, + { + symbol: 'AMZN', + price: 171.0, + timestamp, + source: 'Reuters', + }, + { + symbol: 'AMZN', + price: 170.5, + timestamp, + source: 'AlphaVantage', + }, + { + symbol: 'AMZN', + price: 170.8, + timestamp, + source: 'Finnhub', + }, + { + symbol: 'AMZN', + price: 170.2, + timestamp, + source: 'YahooFinance', + }, + ]; + + // Act + const result = aggregationService.aggregate('AMZN', prices, { + method: 'trimmed-mean', + }); + + // Assert + expect(result.method).toBe('trimmed-mean'); + expect(result.price).toBeGreaterThan(0); + }); + }); + + describe('Complete Pipeline (Raw → Normalized → Aggregated)', () => { + it('should process prices end-to-end', () => { + // Arrange - Raw prices from ingestor + const rawPrices = [ + { + symbol: 'AAPL', + price: 150.25, + timestamp: Date.now(), + source: 'AlphaVantage', + }, + { + symbol: 'AAPL', + price: 150.27, + timestamp: Date.now(), + source: 'Finnhub', + }, + { + symbol: 'AAPL', + price: 150.23, + timestamp: Date.now(), + source: 'YahooFinance', + }, + ]; + + // Act 1: Normalize + const normalized = normalizationService.normalizeMany(rawPrices); + + // Assert normalization + expect(normalized).toHaveLength(3); + expect(normalized.every((n) => n.symbol === 'AAPL')).toBe(true); + + // Act 2: Convert to NormalizedPrice format (with numeric timestamp) + const normalizedPrices = normalized.map((n) => ({ + symbol: n.symbol, + price: n.price, + timestamp: n.originalTimestamp, + source: n.source, + })); + + // Act 3: Aggregate + const aggregated = aggregationService.aggregate('AAPL', normalizedPrices); + + // Assert aggregation + expect(aggregated).toBeDefined(); + expect(aggregated.symbol).toBe('AAPL'); + expect(aggregated.price).toBeCloseTo(150.25, 1); + expect(aggregated.confidence).toBeGreaterThan(0); + expect(aggregated.sources.length).toBeGreaterThan(0); + }); + + it('should handle high confidence scenario (closely agreeing prices)', () => { + // Arrange + const basePrice = 100.0; + const sources = ['AlphaVantage', 'Finnhub', 'YahooFinance', 'Bloomberg', 'Reuters']; + const rawPrices = sources.map((source) => ({ + symbol: 'TEST', + price: basePrice + (Math.random() - 0.5) * 0.5, // ±0.25 variation + timestamp: Date.now(), + source, + })); + + // Act + const normalized = normalizationService.normalizeMany(rawPrices); + const normalizedPrices = normalized.map((n) => ({ + symbol: n.symbol, + price: n.price, + timestamp: n.originalTimestamp, + source: n.source, + })); + const aggregated = aggregationService.aggregate('TEST', normalizedPrices); + + // Assert + expect(aggregated.confidence).toBeGreaterThan(70); + expect(aggregated.price).toBeCloseTo(basePrice, 0); + }); + + it('should handle volatile scenario (high price spread)', () => { + // Arrange + const rawPrices = [ + { + symbol: 'VOLATILE', + price: 100.0, + timestamp: Date.now(), + source: 'AlphaVantage', + }, + { + symbol: 'VOLATILE', + price: 110.0, + timestamp: Date.now(), + source: 'Finnhub', + }, + { + symbol: 'VOLATILE', + price: 105.0, + timestamp: Date.now(), + source: 'YahooFinance', + }, + { + symbol: 'VOLATILE', + price: 115.0, + timestamp: Date.now(), + source: 'Bloomberg', + }, + { + symbol: 'VOLATILE', + price: 95.0, + timestamp: Date.now(), + source: 'Reuters', + }, + ]; + + // Act + const normalized = normalizationService.normalizeMany(rawPrices); + const normalizedPrices = normalized.map((n) => ({ + symbol: n.symbol, + price: n.price, + timestamp: n.originalTimestamp, + source: n.source, + })); + const aggregated = aggregationService.aggregate('VOLATILE', normalizedPrices); + + // Assert + expect(aggregated.confidence).toBeLessThan(80); + expect(aggregated.price).toBeGreaterThan(90); + expect(aggregated.price).toBeLessThan(120); + }); + }); + + describe('Edge Cases', () => { + it('should handle single source', () => { + // Arrange + const timestamp = Date.now(); + const prices = [ + { + symbol: 'SINGLE', + price: 100.0, + timestamp, + source: 'AlphaVantage', + }, + ]; + + // Act + const result = aggregationService.aggregate('SINGLE', prices, { + minSources: 1, + }); + + // Assert + expect(result).toBeDefined(); + expect(result.price).toBe(100.0); + expect(result.sources).toHaveLength(1); + }); + + it('should handle empty price array', () => { + // Act & Assert - empty inputs should be rejected + expect(() => { + aggregationService.aggregate('EMPTY', []); + }).toThrow('Prices array cannot be empty'); + }); + + it('should handle many sources', () => { + // Arrange + const timestamp = Date.now(); + const prices = Array.from({ length: 20 }, (_, i) => ({ + symbol: 'MANY', + price: 100.0 + i * 0.1, + timestamp, + source: `Source${i}`, + })); + + // Act + const result = aggregationService.aggregate('MANY', prices); + + // Assert + expect(result).toBeDefined(); + expect(result.sources.length).toBeGreaterThan(10); + }); + + it('should handle different aggregation methods', () => { + // Arrange + const timestamp = Date.now(); + const prices = [ + { + symbol: 'METHOD', + price: 100.0, + timestamp, + source: 'S1', + }, + { + symbol: 'METHOD', + price: 101.0, + timestamp, + source: 'S2', + }, + { + symbol: 'METHOD', + price: 99.0, + timestamp, + source: 'S3', + }, + { + symbol: 'METHOD', + price: 100.5, + timestamp, + source: 'S4', + }, + { + symbol: 'METHOD', + price: 100.2, + timestamp, + source: 'S5', + }, + ]; + + // Act + const weighted = aggregationService.aggregate('METHOD', prices, { + method: 'weighted-average', + }); + const median = aggregationService.aggregate('METHOD', prices, { + method: 'median', + }); + const trimmed = aggregationService.aggregate('METHOD', prices, { + method: 'trimmed-mean', + }); + + // Assert + expect(weighted.method).toBe('weighted-average'); + expect(median.method).toBe('median'); + expect(trimmed.method).toBe('trimmed-mean'); + + // All should produce reasonable results + expect(weighted.price).toBeGreaterThan(99); + expect(weighted.price).toBeLessThan(102); + expect(median.price).toBeGreaterThan(99); + expect(median.price).toBeLessThan(102); + expect(trimmed.price).toBeGreaterThan(99); + expect(trimmed.price).toBeLessThan(102); + }); + }); +});