Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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
Loading