284 lines
8.2 KiB
JavaScript
284 lines
8.2 KiB
JavaScript
/**
|
|
* Ship Infrastructure Tests — VYNDR v5.1
|
|
* Tests data contracts, configurations, and infrastructure logic
|
|
* for the production ship build.
|
|
*/
|
|
|
|
// ── Inline constants (JS-side contracts for Python service configs) ──
|
|
|
|
const RETRY_CONFIG = {
|
|
maxRetries: 3,
|
|
baseDelayMs: 1000,
|
|
backoffMultiplier: 2,
|
|
};
|
|
|
|
const DATA_FRESHNESS = {
|
|
odds: { default_ttl: 0.25, game_day_ttl: 0.083 },
|
|
weather: { default_ttl: 1.0, game_day_ttl: 0.5 },
|
|
park_factors: { default_ttl: 720, game_day_ttl: 720 },
|
|
reporter_feed: { default_ttl: 0.017, game_day_ttl: 0.017 },
|
|
};
|
|
|
|
const CONTEXT_FACTORS = [
|
|
'park_factor', 'weather_wind', 'weather_temp', 'weather_humidity',
|
|
'platoon_split', 'bullpen_fatigue', 'umpire_tendency', 'lineup_confirmed',
|
|
'rest_days', 'travel_distance', 'rivalry_flag', 'injury_report',
|
|
'recent_form', 'season_avg', 'home_away_split',
|
|
];
|
|
|
|
const RATE_LIMITS = {
|
|
default: { windowMs: 60000, max: 60 },
|
|
grade: { windowMs: 60000, max: 20 },
|
|
};
|
|
|
|
const HEALTH_RESPONSE_FIELDS = ['status', 'version', 'services', 'timestamp'];
|
|
|
|
const BOOT_SEQUENCE = [
|
|
'database',
|
|
'park_factors',
|
|
'archetypes',
|
|
'reporter_seed',
|
|
'api_server',
|
|
];
|
|
|
|
const FAILURE_MONITOR = {
|
|
threshold: 3,
|
|
windowMinutes: 30,
|
|
};
|
|
|
|
const CORS_CONFIG = {
|
|
pattern: '/api/*',
|
|
enabled: true,
|
|
};
|
|
|
|
const FLASK_DOCS = {
|
|
version: '5.1',
|
|
endpointKeys: [
|
|
'scan', 'grade', 'health', 'props', 'stats',
|
|
'tracker', 'waitlist', 'auth', 'payments', 'docs',
|
|
],
|
|
};
|
|
|
|
// ── Helpers (inline logic under test) ──
|
|
|
|
function retryWithBackoff(fn, config = RETRY_CONFIG) {
|
|
let attempts = 0;
|
|
const delays = [];
|
|
return {
|
|
async execute() {
|
|
while (attempts < config.maxRetries) {
|
|
try {
|
|
return await fn();
|
|
} catch (e) {
|
|
attempts++;
|
|
const delay = config.baseDelayMs * Math.pow(config.backoffMultiplier, attempts - 1);
|
|
delays.push(delay);
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
getDelays() { return delays; },
|
|
getAttempts() { return attempts; },
|
|
};
|
|
}
|
|
|
|
function isFresh(lastFetched, ttlHours) {
|
|
const ageHours = (Date.now() - lastFetched) / (1000 * 60 * 60);
|
|
return ageHours < ttlHours;
|
|
}
|
|
|
|
function getTtl(dataType, isGameDay) {
|
|
const entry = DATA_FRESHNESS[dataType];
|
|
if (!entry) return null;
|
|
return isGameDay ? entry.game_day_ttl : entry.default_ttl;
|
|
}
|
|
|
|
function aggregateContext(factors) {
|
|
if (!factors || Object.keys(factors).length === 0) return 0;
|
|
return CONTEXT_FACTORS.reduce((sum, key) => sum + (factors[key] || 0), 0);
|
|
}
|
|
|
|
function buildHealthResponse(serviceStatuses) {
|
|
const degraded = Object.values(serviceStatuses).some(s => s !== 'ok');
|
|
return {
|
|
status: degraded ? 'degraded' : 'ok',
|
|
version: '5.1',
|
|
services: serviceStatuses,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function checkFailureAlert(failures, windowMinutes) {
|
|
const cutoff = Date.now() - windowMinutes * 60 * 1000;
|
|
const recentFailures = failures.filter(ts => ts > cutoff);
|
|
return {
|
|
count: recentFailures.length,
|
|
alert: recentFailures.length >= FAILURE_MONITOR.threshold,
|
|
};
|
|
}
|
|
|
|
// ── Tests ──
|
|
|
|
describe('Retry Logic', () => {
|
|
test('retries up to 3 times before giving up', async () => {
|
|
let callCount = 0;
|
|
const failing = () => { callCount++; throw new Error('fail'); };
|
|
const runner = retryWithBackoff(failing);
|
|
const result = await runner.execute();
|
|
expect(callCount).toBe(3);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test('exponential backoff doubles each delay', async () => {
|
|
const failing = () => { throw new Error('fail'); };
|
|
const runner = retryWithBackoff(failing);
|
|
await runner.execute();
|
|
const delays = runner.getDelays();
|
|
expect(delays).toEqual([1000, 2000, 4000]);
|
|
});
|
|
|
|
test('returns null after all retries exhausted', async () => {
|
|
const failing = () => { throw new Error('fail'); };
|
|
const runner = retryWithBackoff(failing);
|
|
const result = await runner.execute();
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Data Warehouse + Game-Day TTL Override', () => {
|
|
test('default TTL lookup returns non-game-day value', () => {
|
|
expect(getTtl('odds', false)).toBe(0.25);
|
|
expect(getTtl('weather', false)).toBe(1.0);
|
|
});
|
|
|
|
test('game-day TTL is shorter than default for weather', () => {
|
|
const defaultTtl = getTtl('weather', false);
|
|
const gameDayTtl = getTtl('weather', true);
|
|
expect(gameDayTtl).toBeLessThan(defaultTtl);
|
|
});
|
|
|
|
test('cache freshness check passes for recent data', () => {
|
|
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
|
|
expect(isFresh(fiveMinutesAgo, 0.25)).toBe(true); // 5min < 15min
|
|
});
|
|
|
|
test('stale data flagged when TTL exceeded', () => {
|
|
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
|
expect(isFresh(twoHoursAgo, 0.25)).toBe(false); // 2hr > 15min
|
|
});
|
|
});
|
|
|
|
describe('Health Check Endpoint Shape', () => {
|
|
test('health response contains all required fields', () => {
|
|
const response = buildHealthResponse({ db: 'ok', redis: 'ok', oddsApi: 'ok' });
|
|
HEALTH_RESPONSE_FIELDS.forEach(field => {
|
|
expect(response).toHaveProperty(field);
|
|
});
|
|
});
|
|
|
|
test('status is degraded when any service is unavailable', () => {
|
|
const response = buildHealthResponse({ db: 'ok', redis: 'down', oddsApi: 'ok' });
|
|
expect(response.status).toBe('degraded');
|
|
});
|
|
});
|
|
|
|
describe('Rate Limiting Config', () => {
|
|
test('default rate limit is 60 requests per minute', () => {
|
|
expect(RATE_LIMITS.default.max).toBe(60);
|
|
expect(RATE_LIMITS.default.windowMs).toBe(60000);
|
|
});
|
|
|
|
test('grade endpoints limited to 20 requests per minute', () => {
|
|
expect(RATE_LIMITS.grade.max).toBe(20);
|
|
expect(RATE_LIMITS.grade.windowMs).toBe(60000);
|
|
});
|
|
});
|
|
|
|
describe('Context Aggregator', () => {
|
|
test('sums all 15 context factors correctly', () => {
|
|
const factors = {};
|
|
CONTEXT_FACTORS.forEach(key => { factors[key] = 1; });
|
|
expect(aggregateContext(factors)).toBe(15);
|
|
});
|
|
|
|
test('missing factors default to 0', () => {
|
|
const partial = { park_factor: 3, weather_wind: 2 };
|
|
expect(aggregateContext(partial)).toBe(5);
|
|
});
|
|
|
|
test('empty factors object returns 0', () => {
|
|
expect(aggregateContext({})).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('Cold Start Boot Sequence Order', () => {
|
|
test('park_factors loaded before archetypes', () => {
|
|
const parkIdx = BOOT_SEQUENCE.indexOf('park_factors');
|
|
const archIdx = BOOT_SEQUENCE.indexOf('archetypes');
|
|
expect(parkIdx).toBeLessThan(archIdx);
|
|
expect(parkIdx).not.toBe(-1);
|
|
});
|
|
|
|
test('reporter_seed happens after database is loaded', () => {
|
|
const dbIdx = BOOT_SEQUENCE.indexOf('database');
|
|
const reporterIdx = BOOT_SEQUENCE.indexOf('reporter_seed');
|
|
expect(reporterIdx).toBeGreaterThan(dbIdx);
|
|
});
|
|
});
|
|
|
|
describe('API Failure Monitoring', () => {
|
|
test('failure count threshold is 3', () => {
|
|
expect(FAILURE_MONITOR.threshold).toBe(3);
|
|
});
|
|
|
|
test('alert triggered at 3+ failures within 30 minutes', () => {
|
|
const now = Date.now();
|
|
const failures = [
|
|
now - 20 * 60 * 1000,
|
|
now - 10 * 60 * 1000,
|
|
now - 5 * 60 * 1000,
|
|
];
|
|
const result = checkFailureAlert(failures, FAILURE_MONITOR.windowMinutes);
|
|
expect(result.alert).toBe(true);
|
|
expect(result.count).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('Data Freshness TTLs', () => {
|
|
test('odds default TTL is 0.25 hours (15 minutes)', () => {
|
|
expect(DATA_FRESHNESS.odds.default_ttl).toBe(0.25);
|
|
});
|
|
|
|
test('weather game-day TTL is 0.5 hours (30 minutes)', () => {
|
|
expect(DATA_FRESHNESS.weather.game_day_ttl).toBe(0.5);
|
|
});
|
|
|
|
test('park_factors TTL is 720 hours (30 days)', () => {
|
|
expect(DATA_FRESHNESS.park_factors.default_ttl).toBe(720);
|
|
});
|
|
|
|
test('reporter_feed TTL is 0.017 hours (~1 minute)', () => {
|
|
expect(DATA_FRESHNESS.reporter_feed.default_ttl).toBe(0.017);
|
|
});
|
|
});
|
|
|
|
describe('Flask App Docs Endpoint Shape', () => {
|
|
test('/api/docs returns all expected endpoint keys', () => {
|
|
FLASK_DOCS.endpointKeys.forEach(key => {
|
|
expect(FLASK_DOCS.endpointKeys).toContain(key);
|
|
});
|
|
expect(FLASK_DOCS.endpointKeys.length).toBe(10);
|
|
});
|
|
|
|
test('version is 5.1', () => {
|
|
expect(FLASK_DOCS.version).toBe('5.1');
|
|
});
|
|
});
|
|
|
|
describe('CORS Config', () => {
|
|
test('/api/* pattern is enabled', () => {
|
|
expect(CORS_CONFIG.pattern).toBe('/api/*');
|
|
expect(CORS_CONFIG.enabled).toBe(true);
|
|
});
|
|
});
|