feat(stocks): Add Marketstack provider (EOD) with tests, exports and documentation updates
This commit is contained in:
302
test/test.marketstack.node.ts
Normal file
302
test/test.marketstack.node.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as opendata from '../ts/index.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
// Test data
|
||||
const testTickers = ['AAPL', 'MSFT', 'GOOGL'];
|
||||
const invalidTicker = 'INVALID_TICKER_XYZ';
|
||||
|
||||
let stockService: opendata.StockPriceService;
|
||||
let marketstackProvider: opendata.MarketstackProvider;
|
||||
let testQenv: plugins.qenv.Qenv;
|
||||
|
||||
tap.test('should create StockPriceService instance', async () => {
|
||||
stockService = new opendata.StockPriceService({
|
||||
ttl: 30000, // 30 seconds cache
|
||||
maxEntries: 100
|
||||
});
|
||||
expect(stockService).toBeInstanceOf(opendata.StockPriceService);
|
||||
});
|
||||
|
||||
tap.test('should create MarketstackProvider instance', async () => {
|
||||
try {
|
||||
// Create qenv and get API key
|
||||
testQenv = new plugins.qenv.Qenv(paths.packageDir, paths.nogitDir);
|
||||
const apiKey = await testQenv.getEnvVarOnDemand('MARKETSTACK_COM_TOKEN');
|
||||
|
||||
marketstackProvider = new opendata.MarketstackProvider(apiKey, {
|
||||
enabled: true,
|
||||
timeout: 10000,
|
||||
retryAttempts: 2,
|
||||
retryDelay: 500
|
||||
});
|
||||
expect(marketstackProvider).toBeInstanceOf(opendata.MarketstackProvider);
|
||||
expect(marketstackProvider.name).toEqual('Marketstack');
|
||||
expect(marketstackProvider.requiresAuth).toEqual(true);
|
||||
expect(marketstackProvider.priority).toEqual(80);
|
||||
} catch (error) {
|
||||
if (error.message.includes('MARKETSTACK_COM_TOKEN')) {
|
||||
console.log('⚠️ MARKETSTACK_COM_TOKEN not set - skipping Marketstack tests');
|
||||
tap.test('Marketstack token not available', async () => {
|
||||
expect(true).toEqual(true); // Skip gracefully
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should register Marketstack provider with the service', async () => {
|
||||
if (!marketstackProvider) {
|
||||
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
stockService.register(marketstackProvider);
|
||||
const providers = stockService.getAllProviders();
|
||||
expect(providers).toContainEqual(marketstackProvider);
|
||||
expect(stockService.getProvider('Marketstack')).toEqual(marketstackProvider);
|
||||
});
|
||||
|
||||
tap.test('should check provider health', async () => {
|
||||
if (!marketstackProvider) {
|
||||
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const health = await stockService.checkProvidersHealth();
|
||||
expect(health.get('Marketstack')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should fetch single stock price', async () => {
|
||||
if (!marketstackProvider) {
|
||||
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const price = await stockService.getPrice({ ticker: 'AAPL' });
|
||||
|
||||
expect(price).toHaveProperty('ticker');
|
||||
expect(price).toHaveProperty('price');
|
||||
expect(price).toHaveProperty('currency');
|
||||
expect(price).toHaveProperty('change');
|
||||
expect(price).toHaveProperty('changePercent');
|
||||
expect(price).toHaveProperty('previousClose');
|
||||
expect(price).toHaveProperty('timestamp');
|
||||
expect(price).toHaveProperty('provider');
|
||||
expect(price).toHaveProperty('marketState');
|
||||
|
||||
expect(price.ticker).toEqual('AAPL');
|
||||
expect(price.price).toBeGreaterThan(0);
|
||||
expect(price.provider).toEqual('Marketstack');
|
||||
expect(price.timestamp).toBeInstanceOf(Date);
|
||||
expect(price.marketState).toEqual('CLOSED'); // EOD data
|
||||
|
||||
console.log(`✓ Fetched AAPL: $${price.price} (${price.changePercent >= 0 ? '+' : ''}${price.changePercent.toFixed(2)}%)`);
|
||||
});
|
||||
|
||||
tap.test('should fetch multiple stock prices', async () => {
|
||||
if (!marketstackProvider) {
|
||||
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const prices = await stockService.getPrices({
|
||||
tickers: testTickers
|
||||
});
|
||||
|
||||
expect(prices).toBeArray();
|
||||
expect(prices.length).toBeGreaterThan(0);
|
||||
expect(prices.length).toBeLessThanOrEqual(testTickers.length);
|
||||
|
||||
for (const price of prices) {
|
||||
expect(testTickers).toContain(price.ticker);
|
||||
expect(price.price).toBeGreaterThan(0);
|
||||
expect(price.provider).toEqual('Marketstack');
|
||||
expect(price.marketState).toEqual('CLOSED');
|
||||
console.log(` ${price.ticker}: $${price.price}`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should serve cached prices on subsequent requests', async () => {
|
||||
if (!marketstackProvider) {
|
||||
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
// First request - should hit the API
|
||||
const firstRequest = await stockService.getPrice({ ticker: 'AAPL' });
|
||||
|
||||
// Second request - should be served from cache
|
||||
const secondRequest = await stockService.getPrice({ ticker: 'AAPL' });
|
||||
|
||||
expect(secondRequest.ticker).toEqual(firstRequest.ticker);
|
||||
expect(secondRequest.price).toEqual(firstRequest.price);
|
||||
expect(secondRequest.timestamp).toEqual(firstRequest.timestamp);
|
||||
|
||||
console.log('✓ Cache working correctly');
|
||||
});
|
||||
|
||||
tap.test('should handle invalid ticker gracefully', async () => {
|
||||
if (!marketstackProvider) {
|
||||
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await stockService.getPrice({ ticker: invalidTicker });
|
||||
throw new Error('Should have thrown an error for invalid ticker');
|
||||
} catch (error) {
|
||||
expect(error.message).toInclude('Failed to fetch price');
|
||||
console.log('✓ Invalid ticker handled correctly');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should support market checking', async () => {
|
||||
if (!marketstackProvider) {
|
||||
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
expect(marketstackProvider.supportsMarket('US')).toEqual(true);
|
||||
expect(marketstackProvider.supportsMarket('UK')).toEqual(true);
|
||||
expect(marketstackProvider.supportsMarket('DE')).toEqual(true);
|
||||
expect(marketstackProvider.supportsMarket('JP')).toEqual(true);
|
||||
expect(marketstackProvider.supportsMarket('INVALID')).toEqual(false);
|
||||
|
||||
console.log('✓ Market support check working');
|
||||
});
|
||||
|
||||
tap.test('should validate ticker format', async () => {
|
||||
if (!marketstackProvider) {
|
||||
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
expect(marketstackProvider.supportsTicker('AAPL')).toEqual(true);
|
||||
expect(marketstackProvider.supportsTicker('MSFT')).toEqual(true);
|
||||
expect(marketstackProvider.supportsTicker('BRK.B')).toEqual(true);
|
||||
expect(marketstackProvider.supportsTicker('123456789012')).toEqual(false);
|
||||
expect(marketstackProvider.supportsTicker('invalid@ticker')).toEqual(false);
|
||||
|
||||
console.log('✓ Ticker validation working');
|
||||
});
|
||||
|
||||
tap.test('should get provider statistics', async () => {
|
||||
if (!marketstackProvider) {
|
||||
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = stockService.getProviderStats();
|
||||
const marketstackStats = stats.get('Marketstack');
|
||||
|
||||
expect(marketstackStats).not.toEqual(undefined);
|
||||
expect(marketstackStats.successCount).toBeGreaterThan(0);
|
||||
expect(marketstackStats.errorCount).toBeGreaterThanOrEqual(0);
|
||||
|
||||
console.log(`✓ Provider stats: ${marketstackStats.successCount} successes, ${marketstackStats.errorCount} errors`);
|
||||
});
|
||||
|
||||
tap.test('should test direct provider methods', async () => {
|
||||
if (!marketstackProvider) {
|
||||
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n🔍 Testing direct provider methods:');
|
||||
|
||||
// Test isAvailable
|
||||
const available = await marketstackProvider.isAvailable();
|
||||
expect(available).toEqual(true);
|
||||
console.log(' ✓ isAvailable() returned true');
|
||||
|
||||
// Test fetchPrice directly
|
||||
const price = await marketstackProvider.fetchPrice({ ticker: 'MSFT' });
|
||||
expect(price.ticker).toEqual('MSFT');
|
||||
expect(price.provider).toEqual('Marketstack');
|
||||
expect(price.price).toBeGreaterThan(0);
|
||||
console.log(` ✓ fetchPrice() for MSFT: $${price.price}`);
|
||||
|
||||
// Test fetchPrices directly
|
||||
const prices = await marketstackProvider.fetchPrices({
|
||||
tickers: ['AAPL', 'GOOGL']
|
||||
});
|
||||
expect(prices.length).toBeGreaterThan(0);
|
||||
console.log(` ✓ fetchPrices() returned ${prices.length} prices`);
|
||||
|
||||
for (const p of prices) {
|
||||
console.log(` ${p.ticker}: $${p.price}`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should fetch sample EOD data', async () => {
|
||||
if (!marketstackProvider) {
|
||||
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n📊 Sample EOD Stock Data from Marketstack:');
|
||||
console.log('═'.repeat(65));
|
||||
|
||||
const sampleTickers = [
|
||||
{ ticker: 'AAPL', name: 'Apple Inc.' },
|
||||
{ ticker: 'MSFT', name: 'Microsoft Corp.' },
|
||||
{ ticker: 'GOOGL', name: 'Alphabet Inc.' },
|
||||
{ ticker: 'AMZN', name: 'Amazon.com Inc.' },
|
||||
{ ticker: 'TSLA', name: 'Tesla Inc.' }
|
||||
];
|
||||
|
||||
try {
|
||||
const prices = await marketstackProvider.fetchPrices({
|
||||
tickers: sampleTickers.map(t => t.ticker)
|
||||
});
|
||||
|
||||
const priceMap = new Map(prices.map(p => [p.ticker, p]));
|
||||
|
||||
for (const stock of sampleTickers) {
|
||||
const price = priceMap.get(stock.ticker);
|
||||
if (price) {
|
||||
const changeSymbol = price.change >= 0 ? '↑' : '↓';
|
||||
const changeColor = price.change >= 0 ? '\x1b[32m' : '\x1b[31m';
|
||||
const resetColor = '\x1b[0m';
|
||||
|
||||
console.log(
|
||||
`${stock.name.padEnd(20)} ${price.price.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).padStart(10)} ${changeColor}${changeSymbol} ${price.change >= 0 ? '+' : ''}${price.change.toFixed(2)} (${price.changePercent >= 0 ? '+' : ''}${price.changePercent.toFixed(2)}%)${resetColor}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('═'.repeat(65));
|
||||
console.log(`Provider: Marketstack (EOD Data)`);
|
||||
console.log(`Last updated: ${new Date().toLocaleString()}\n`);
|
||||
} catch (error) {
|
||||
console.log('Error fetching sample data:', error.message);
|
||||
}
|
||||
|
||||
expect(true).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should clear cache', async () => {
|
||||
if (!marketstackProvider) {
|
||||
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure we have something in cache
|
||||
await stockService.getPrice({ ticker: 'AAPL' });
|
||||
|
||||
// Clear cache
|
||||
stockService.clearCache();
|
||||
console.log('✓ Cache cleared');
|
||||
|
||||
// Next request should hit the API again
|
||||
const price = await stockService.getPrice({ ticker: 'AAPL' });
|
||||
expect(price).not.toEqual(undefined);
|
||||
});
|
||||
|
||||
export default tap.start();
|
Reference in New Issue
Block a user