366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
|
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|||
|
|
import * as opendata from '../ts/index.js';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Test to verify we NEVER return stale intraday data
|
|||
|
|
* Even when cache hasn't expired, we should check for new data
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
class MockIntradayProvider implements opendata.IStockProvider {
|
|||
|
|
name = 'MockIntradayProvider';
|
|||
|
|
priority = 100;
|
|||
|
|
requiresAuth = false;
|
|||
|
|
|
|||
|
|
public fetchCount = 0;
|
|||
|
|
public lastRequestDate: Date | undefined;
|
|||
|
|
private currentDataCount = 10; // Start with 10 records
|
|||
|
|
private baseTime = new Date('2025-01-07T09:30:00.000Z');
|
|||
|
|
|
|||
|
|
async fetchData(request: opendata.IStockDataRequest): Promise<opendata.IStockPrice | opendata.IStockPrice[]> {
|
|||
|
|
this.fetchCount++;
|
|||
|
|
|
|||
|
|
if (request.type === 'intraday') {
|
|||
|
|
this.lastRequestDate = request.date;
|
|||
|
|
|
|||
|
|
const startTime = request.date || this.baseTime;
|
|||
|
|
const prices: opendata.IStockPrice[] = [];
|
|||
|
|
|
|||
|
|
// Simulate provider returning data AFTER the requested date
|
|||
|
|
for (let i = 0; i < this.currentDataCount; i++) {
|
|||
|
|
const timestamp = new Date(startTime.getTime() + i * 60 * 1000);
|
|||
|
|
|
|||
|
|
// Only return data AFTER request date if date filter is present
|
|||
|
|
if (request.date && timestamp <= request.date) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
prices.push({
|
|||
|
|
ticker: request.ticker,
|
|||
|
|
price: 100 + i,
|
|||
|
|
currency: 'USD',
|
|||
|
|
timestamp,
|
|||
|
|
fetchedAt: new Date(),
|
|||
|
|
provider: this.name,
|
|||
|
|
dataType: 'intraday',
|
|||
|
|
marketState: 'REGULAR',
|
|||
|
|
open: 100,
|
|||
|
|
high: 101,
|
|||
|
|
low: 99,
|
|||
|
|
volume: 1000000,
|
|||
|
|
change: 0,
|
|||
|
|
changePercent: 0,
|
|||
|
|
previousClose: 100
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return prices;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
throw new Error('Only intraday supported in this mock');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async isAvailable(): Promise<boolean> {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public addNewRecords(count: number): void {
|
|||
|
|
this.currentDataCount += count;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public advanceTime(minutes: number): void {
|
|||
|
|
this.baseTime = new Date(this.baseTime.getTime() + minutes * 60 * 1000);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let stockService: opendata.StockPriceService;
|
|||
|
|
let mockProvider: MockIntradayProvider;
|
|||
|
|
|
|||
|
|
tap.test('Stale Data Fix - Setup', async () => {
|
|||
|
|
// Use LONG TTL so cache doesn't expire during test
|
|||
|
|
stockService = new opendata.StockPriceService({
|
|||
|
|
ttl: 300000, // 5 minutes
|
|||
|
|
maxEntries: 1000
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
mockProvider = new MockIntradayProvider();
|
|||
|
|
stockService.register(mockProvider);
|
|||
|
|
|
|||
|
|
console.log('✓ Service initialized with 5-minute cache TTL');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
tap.test('Stale Data Fix - Check for New Data Even When Cache Valid', async () => {
|
|||
|
|
await tap.test('should return cached data if less than 1 minute old (freshness check)', async () => {
|
|||
|
|
stockService.clearCache();
|
|||
|
|
mockProvider.fetchCount = 0;
|
|||
|
|
mockProvider.currentDataCount = 10;
|
|||
|
|
|
|||
|
|
console.log('\n📊 Scenario: Request twice within 1 minute\n');
|
|||
|
|
|
|||
|
|
// First request - fetch 10 records
|
|||
|
|
console.log('⏰ First request (initial fetch)');
|
|||
|
|
const result1 = await stockService.getData({
|
|||
|
|
type: 'intraday',
|
|||
|
|
ticker: 'AAPL',
|
|||
|
|
interval: '1min',
|
|||
|
|
limit: 1000
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result1).toBeArray();
|
|||
|
|
expect((result1 as opendata.IStockPrice[]).length).toEqual(10);
|
|||
|
|
expect(mockProvider.fetchCount).toEqual(1);
|
|||
|
|
|
|||
|
|
const latestTimestamp1 = (result1 as opendata.IStockPrice[])[9].timestamp;
|
|||
|
|
console.log(` ✓ Fetched 10 records, latest: ${latestTimestamp1.toISOString()}`);
|
|||
|
|
|
|||
|
|
// Second request immediately - should return cache (data < 1min old)
|
|||
|
|
console.log('\n⏰ Second request (< 1 minute later)');
|
|||
|
|
mockProvider.fetchCount = 0;
|
|||
|
|
mockProvider.addNewRecords(10); // New data available, but won't fetch yet
|
|||
|
|
|
|||
|
|
const result2 = await stockService.getData({
|
|||
|
|
type: 'intraday',
|
|||
|
|
ticker: 'AAPL',
|
|||
|
|
interval: '1min',
|
|||
|
|
limit: 1000
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Should return cached data (freshness check prevents fetch)
|
|||
|
|
expect((result2 as opendata.IStockPrice[]).length).toEqual(10);
|
|||
|
|
expect(mockProvider.fetchCount).toEqual(0); // No provider call
|
|||
|
|
|
|||
|
|
console.log(` ✓ Returned cached 10 records (no provider call)`);
|
|||
|
|
console.log(` ✓ Freshness check: Data < 1min old, no fetch needed`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await tap.test('should fetch NEW data when cache is > 1 minute old', async () => {
|
|||
|
|
stockService.clearCache();
|
|||
|
|
mockProvider.fetchCount = 0;
|
|||
|
|
mockProvider.currentDataCount = 10;
|
|||
|
|
|
|||
|
|
console.log('\n📊 Scenario: Request after 2 minutes (data > 1min old)\n');
|
|||
|
|
|
|||
|
|
// First request - fetch 10 records at 9:30am
|
|||
|
|
console.log('⏰ 9:30:00 - First request (initial fetch)');
|
|||
|
|
const result1 = await stockService.getData({
|
|||
|
|
type: 'intraday',
|
|||
|
|
ticker: 'MSFT',
|
|||
|
|
interval: '1min',
|
|||
|
|
limit: 1000
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result1).toBeArray();
|
|||
|
|
expect((result1 as opendata.IStockPrice[]).length).toEqual(10);
|
|||
|
|
const latestTimestamp1 = (result1 as opendata.IStockPrice[])[9].timestamp;
|
|||
|
|
console.log(` ✓ Fetched 10 records, latest: ${latestTimestamp1.toISOString()}`);
|
|||
|
|
|
|||
|
|
// Advance time by 2 minutes - now data is > 1 minute old
|
|||
|
|
console.log('\n⏰ 9:32:00 - Second request (2 minutes later, data > 1min old)');
|
|||
|
|
console.log(' 📝 Advancing provider time by 2 minutes...');
|
|||
|
|
|
|||
|
|
mockProvider.fetchCount = 0;
|
|||
|
|
mockProvider.advanceTime(2); // Advance 2 minutes
|
|||
|
|
mockProvider.addNewRecords(10); // Now provider has 20 records total
|
|||
|
|
|
|||
|
|
const result2 = await stockService.getData({
|
|||
|
|
type: 'intraday',
|
|||
|
|
ticker: 'MSFT',
|
|||
|
|
interval: '1min',
|
|||
|
|
limit: 1000
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result2).toBeArray();
|
|||
|
|
const prices2 = result2 as opendata.IStockPrice[];
|
|||
|
|
|
|||
|
|
// Should have 20 records (10 cached + 10 new)
|
|||
|
|
expect(prices2.length).toEqual(20);
|
|||
|
|
|
|||
|
|
// Should have made a provider call (data was stale)
|
|||
|
|
expect(mockProvider.fetchCount).toBeGreaterThan(0);
|
|||
|
|
|
|||
|
|
const latestTimestamp2 = prices2[prices2.length - 1].timestamp;
|
|||
|
|
console.log(` ✓ Now have ${prices2.length} records, latest: ${latestTimestamp2.toISOString()}`);
|
|||
|
|
console.log(` ✓ Provider calls: ${mockProvider.fetchCount} (fetched new data)`);
|
|||
|
|
console.log(` ✓ Data was > 1min old, incremental fetch triggered!`);
|
|||
|
|
|
|||
|
|
// Verify we got NEW data
|
|||
|
|
expect(latestTimestamp2.getTime()).toBeGreaterThan(latestTimestamp1.getTime());
|
|||
|
|
|
|||
|
|
console.log('\n✅ SUCCESS: Fetched new data when cache was stale!');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await tap.test('should handle polling with > 1 minute intervals efficiently', async () => {
|
|||
|
|
stockService.clearCache();
|
|||
|
|
mockProvider.fetchCount = 0;
|
|||
|
|
mockProvider.currentDataCount = 100;
|
|||
|
|
|
|||
|
|
console.log('\n📊 Scenario: Dashboard polling every 2 minutes\n');
|
|||
|
|
|
|||
|
|
// Initial request at 9:30am
|
|||
|
|
console.log('⏰ 9:30:00 - Request 1 (initial fetch)');
|
|||
|
|
await stockService.getData({
|
|||
|
|
type: 'intraday',
|
|||
|
|
ticker: 'GOOGL',
|
|||
|
|
interval: '1min',
|
|||
|
|
limit: 1000
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(mockProvider.fetchCount).toEqual(1);
|
|||
|
|
console.log(` ✓ Fetched 100 records (provider calls: 1)`);
|
|||
|
|
|
|||
|
|
let totalProviderCalls = 1;
|
|||
|
|
let totalNewRecords = 0;
|
|||
|
|
|
|||
|
|
// Simulate 3 polling refreshes (2 minutes apart, 5 new records each)
|
|||
|
|
for (let i = 2; i <= 4; i++) {
|
|||
|
|
mockProvider.fetchCount = 0;
|
|||
|
|
mockProvider.advanceTime(2); // Advance 2 minutes (triggers freshness check)
|
|||
|
|
mockProvider.addNewRecords(5);
|
|||
|
|
totalNewRecords += 5;
|
|||
|
|
|
|||
|
|
const minutes = (i - 1) * 2;
|
|||
|
|
console.log(`\n⏰ 9:${30 + minutes}:00 - Request ${i} (${minutes} minutes later, +5 new records)`);
|
|||
|
|
|
|||
|
|
const result = await stockService.getData({
|
|||
|
|
type: 'intraday',
|
|||
|
|
ticker: 'GOOGL',
|
|||
|
|
interval: '1min',
|
|||
|
|
limit: 1000
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const expectedTotal = 100 + totalNewRecords;
|
|||
|
|
expect((result as opendata.IStockPrice[]).length).toEqual(expectedTotal);
|
|||
|
|
|
|||
|
|
// Should have made exactly 1 provider call (incremental fetch)
|
|||
|
|
expect(mockProvider.fetchCount).toEqual(1);
|
|||
|
|
totalProviderCalls++;
|
|||
|
|
|
|||
|
|
console.log(` ✓ Now have ${expectedTotal} records (incremental fetch: 1 call)`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log(`\n📊 Summary:`);
|
|||
|
|
console.log(` Total requests: 4`);
|
|||
|
|
console.log(` Total provider calls: ${totalProviderCalls}`);
|
|||
|
|
console.log(` New records fetched: ${totalNewRecords}`);
|
|||
|
|
console.log(` Without incremental cache: Would fetch 100 records × 3 refreshes = 300 records`);
|
|||
|
|
console.log(` With incremental cache: Only fetched ${totalNewRecords} new records`);
|
|||
|
|
console.log(` Data transfer reduction: ${Math.round((1 - (totalNewRecords / 300)) * 100)}%`);
|
|||
|
|
console.log('\n✅ SUCCESS: Only fetched NEW data on each refresh!');
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
tap.test('Stale Data Fix - Verify No Regression for Other Request Types', async () => {
|
|||
|
|
await tap.test('historical requests should still use simple cache', async () => {
|
|||
|
|
stockService.clearCache();
|
|||
|
|
|
|||
|
|
// Mock provider that counts calls
|
|||
|
|
let historicalCallCount = 0;
|
|||
|
|
const historicalProvider: opendata.IStockProvider = {
|
|||
|
|
name: 'HistoricalMock',
|
|||
|
|
priority: 100,
|
|||
|
|
requiresAuth: false,
|
|||
|
|
async fetchData() {
|
|||
|
|
historicalCallCount++;
|
|||
|
|
return [{
|
|||
|
|
ticker: 'TEST',
|
|||
|
|
price: 100,
|
|||
|
|
currency: 'USD',
|
|||
|
|
timestamp: new Date('2025-01-01'),
|
|||
|
|
fetchedAt: new Date(),
|
|||
|
|
provider: 'HistoricalMock',
|
|||
|
|
dataType: 'eod',
|
|||
|
|
marketState: 'CLOSED',
|
|||
|
|
open: 99,
|
|||
|
|
high: 101,
|
|||
|
|
low: 98,
|
|||
|
|
volume: 1000000,
|
|||
|
|
change: 1,
|
|||
|
|
changePercent: 1,
|
|||
|
|
previousClose: 99
|
|||
|
|
}];
|
|||
|
|
},
|
|||
|
|
async isAvailable() { return true; }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const testService = new opendata.StockPriceService({ ttl: 60000 });
|
|||
|
|
testService.register(historicalProvider);
|
|||
|
|
|
|||
|
|
// First request
|
|||
|
|
await testService.getData({
|
|||
|
|
type: 'historical',
|
|||
|
|
ticker: 'TEST',
|
|||
|
|
from: new Date('2025-01-01'),
|
|||
|
|
to: new Date('2025-01-31')
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(historicalCallCount).toEqual(1);
|
|||
|
|
|
|||
|
|
// Second request - should use cache (not incremental fetch)
|
|||
|
|
await testService.getData({
|
|||
|
|
type: 'historical',
|
|||
|
|
ticker: 'TEST',
|
|||
|
|
from: new Date('2025-01-01'),
|
|||
|
|
to: new Date('2025-01-31')
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Should still be 1 (used cache)
|
|||
|
|
expect(historicalCallCount).toEqual(1);
|
|||
|
|
|
|||
|
|
console.log('✓ Historical requests use simple cache (no incremental fetch)');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await tap.test('current price requests should still use simple cache', async () => {
|
|||
|
|
stockService.clearCache();
|
|||
|
|
|
|||
|
|
let currentCallCount = 0;
|
|||
|
|
const currentProvider: opendata.IStockProvider = {
|
|||
|
|
name: 'CurrentMock',
|
|||
|
|
priority: 100,
|
|||
|
|
requiresAuth: false,
|
|||
|
|
async fetchData() {
|
|||
|
|
currentCallCount++;
|
|||
|
|
return {
|
|||
|
|
ticker: 'TEST',
|
|||
|
|
price: 150,
|
|||
|
|
currency: 'USD',
|
|||
|
|
timestamp: new Date(),
|
|||
|
|
fetchedAt: new Date(),
|
|||
|
|
provider: 'CurrentMock',
|
|||
|
|
dataType: 'eod',
|
|||
|
|
marketState: 'CLOSED',
|
|||
|
|
open: 149,
|
|||
|
|
high: 151,
|
|||
|
|
low: 148,
|
|||
|
|
volume: 5000000,
|
|||
|
|
change: 1,
|
|||
|
|
changePercent: 0.67,
|
|||
|
|
previousClose: 149
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
async isAvailable() { return true; }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const testService = new opendata.StockPriceService({ ttl: 60000 });
|
|||
|
|
testService.register(currentProvider);
|
|||
|
|
|
|||
|
|
// First request
|
|||
|
|
await testService.getData({
|
|||
|
|
type: 'current',
|
|||
|
|
ticker: 'TEST'
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(currentCallCount).toEqual(1);
|
|||
|
|
|
|||
|
|
// Second request - should use cache
|
|||
|
|
await testService.getData({
|
|||
|
|
type: 'current',
|
|||
|
|
ticker: 'TEST'
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(currentCallCount).toEqual(1);
|
|||
|
|
|
|||
|
|
console.log('✓ Current price requests use simple cache');
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
export default tap.start();
|