Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/aggregator/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ module.exports = {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
};
4 changes: 4 additions & 0 deletions apps/ingestor/.env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Server Configuration
PORT=3000

# Scheduled Price Fetching
FETCH_INTERVAL_MS=60000
STOCK_SYMBOLS=AAPL,GOOGL,MSFT,TSLA

# External API Keys
# ALPHA_VANTAGE_API_KEY=your_alpha_vantage_api_key
# YAHOO_FINANCE_API_KEY=your_yahoo_finance_api_key
Expand Down
2 changes: 2 additions & 0 deletions apps/ingestor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
"dependencies": {
"@oracle-stocks/shared": "*",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/schedule": "^4.0.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1"
},
Expand Down
11 changes: 10 additions & 1 deletion apps/ingestor/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { PricesModule } from './modules/prices.module';

@Module({
imports: [PricesModule],
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
ScheduleModule.forRoot(),
PricesModule,
],
controllers: [],
providers: [],
})
Expand Down
5 changes: 3 additions & 2 deletions apps/ingestor/src/modules/prices.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { PricesController } from '../controllers/prices.controller';
import { PriceFetcherService } from '../services/price-fetcher.service';
import { SchedulerService } from '../services/scheduler.service';

@Module({
controllers: [PricesController],
providers: [PriceFetcherService],
exports: [PriceFetcherService],
providers: [PriceFetcherService, SchedulerService],
exports: [PriceFetcherService, SchedulerService],
})
export class PricesModule {}
167 changes: 167 additions & 0 deletions apps/ingestor/src/services/price-fetcher.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { PriceFetcherService } from './price-fetcher.service';

describe('PriceFetcherService', () => {
let service: PriceFetcherService;
let configService: jest.Mocked<ConfigService>;

beforeEach(async () => {
const mockConfigService = {
get: jest.fn((key: string, defaultValue?: unknown) => {
const config: Record<string, unknown> = {
STOCK_SYMBOLS: 'AAPL,GOOGL,MSFT',
};
return config[key] ?? defaultValue;
}),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
PriceFetcherService,
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();

service = module.get<PriceFetcherService>(PriceFetcherService);
configService = module.get(ConfigService);
});

describe('constructor', () => {
it('should be defined', () => {
expect(service).toBeDefined();
});

it('should read symbols from config', () => {
expect(configService.get).toHaveBeenCalledWith('STOCK_SYMBOLS', 'AAPL,GOOGL,MSFT,TSLA');
});

it('should parse symbols correctly', () => {
expect(service.getSymbols()).toEqual(['AAPL', 'GOOGL', 'MSFT']);
});
});

describe('fetchRawPrices', () => {
it('should fetch prices for all configured symbols', async () => {
const prices = await service.fetchRawPrices();

expect(prices).toHaveLength(3); // One price per symbol from MockProvider
expect(prices.map(p => p.symbol)).toEqual(['AAPL', 'GOOGL', 'MSFT']);
});

it('should return prices with correct structure', async () => {
const prices = await service.fetchRawPrices();

prices.forEach(price => {
expect(price).toHaveProperty('symbol');
expect(price).toHaveProperty('price');
expect(price).toHaveProperty('timestamp');
expect(price).toHaveProperty('source');
expect(typeof price.symbol).toBe('string');
expect(typeof price.price).toBe('number');
expect(typeof price.timestamp).toBe('number');
expect(price.source).toBe('MockProvider');
});
});

it('should store fetched prices', async () => {
expect(service.getRawPrices()).toHaveLength(0);

await service.fetchRawPrices();

expect(service.getRawPrices().length).toBeGreaterThan(0);
});
});

describe('getRawPrices', () => {
it('should return empty array initially', () => {
expect(service.getRawPrices()).toEqual([]);
});

it('should return fetched prices', async () => {
await service.fetchRawPrices();
const prices = service.getRawPrices();

expect(prices.length).toBeGreaterThan(0);
});
});

describe('getSymbols', () => {
it('should return configured symbols', () => {
const symbols = service.getSymbols();

expect(Array.isArray(symbols)).toBe(true);
expect(symbols).toContain('AAPL');
expect(symbols).toContain('GOOGL');
expect(symbols).toContain('MSFT');
});

it('should return a copy of symbols array', () => {
const symbols1 = service.getSymbols();
const symbols2 = service.getSymbols();

expect(symbols1).not.toBe(symbols2);
expect(symbols1).toEqual(symbols2);
});
});

describe('symbol parsing', () => {
it('should handle symbols with extra whitespace', async () => {
const mockConfigWithSpaces = {
get: jest.fn((key: string, defaultValue?: unknown) => {
if (key === 'STOCK_SYMBOLS') {
return ' AAPL , GOOGL , MSFT ';
}
return defaultValue;
}),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
PriceFetcherService,
{ provide: ConfigService, useValue: mockConfigWithSpaces },
],
}).compile();

const serviceWithSpaces = module.get<PriceFetcherService>(PriceFetcherService);
expect(serviceWithSpaces.getSymbols()).toEqual(['AAPL', 'GOOGL', 'MSFT']);
});

it('should filter out empty symbols', async () => {
const mockConfigWithEmpty = {
get: jest.fn((key: string, defaultValue?: unknown) => {
if (key === 'STOCK_SYMBOLS') {
return 'AAPL,,GOOGL,,,MSFT';
}
return defaultValue;
}),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
PriceFetcherService,
{ provide: ConfigService, useValue: mockConfigWithEmpty },
],
}).compile();

const serviceWithEmpty = module.get<PriceFetcherService>(PriceFetcherService);
expect(serviceWithEmpty.getSymbols()).toEqual(['AAPL', 'GOOGL', 'MSFT']);
});

it('should use default symbols when env var is not set', async () => {
const mockConfigDefault = {
get: jest.fn((key: string, defaultValue?: unknown) => defaultValue),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
PriceFetcherService,
{ provide: ConfigService, useValue: mockConfigDefault },
],
}).compile();

const serviceDefault = module.get<PriceFetcherService>(PriceFetcherService);
expect(serviceDefault.getSymbols()).toEqual(['AAPL', 'GOOGL', 'MSFT', 'TSLA']);
});
});
});
22 changes: 17 additions & 5 deletions apps/ingestor/src/services/price-fetcher.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { RawPrice } from '@oracle-stocks/shared';
import { PriceProvider, MockProvider } from '../providers';

Expand All @@ -7,22 +8,29 @@ export class PriceFetcherService {
private readonly logger = new Logger(PriceFetcherService.name);
private rawPrices: RawPrice[] = [];
private readonly providers: PriceProvider[] = [];
private readonly symbols: string[];

constructor() {
constructor(private readonly configService: ConfigService) {
this.providers.push(new MockProvider());

const symbolsEnv = this.configService.get<string>('STOCK_SYMBOLS', 'AAPL,GOOGL,MSFT,TSLA');
this.symbols = symbolsEnv
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0);

this.logger.log(`Configured symbols: ${this.symbols.join(', ')}`);
}

async fetchRawPrices(): Promise<RawPrice[]> {
const symbols = ['AAPL', 'GOOGL', 'MSFT', 'TSLA'];

const pricePromises = this.providers.map(provider => provider.fetchPrices(symbols));
const pricePromises = this.providers.map(provider => provider.fetchPrices(this.symbols));
const results = await Promise.all(pricePromises);
this.rawPrices = results.flat();

this.logger.log(`Fetched ${this.rawPrices.length} raw prices from ${this.providers.length} provider(s)`);
this.rawPrices.forEach(price => {
this.logger.debug(
`${price.source} - ${price.symbol}: $${price.price.toFixed(2)} at ${new Date(price.timestamp).toISOString()}`
`${price.source} - ${price.symbol}: $${price.price.toFixed(2)} at ${new Date(price.timestamp).toISOString()}`,
);
});

Expand All @@ -32,4 +40,8 @@ export class PriceFetcherService {
getRawPrices(): RawPrice[] {
return this.rawPrices;
}

getSymbols(): string[] {
return [...this.symbols];
}
}
Loading