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 { 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 { 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();