IRIS-AI_DEMO / tests /test_validation_flow.js
Brajmovech's picture
feat: wire end-to-end validation flow with error banners and loading states
6ff9f9f
'use strict';
/**
* Integration tests for the end-to-end validation β†’ analysis flow.
* Tests the frontend behaviour when the backend returns 200 / 422 / 429 / 5xx.
*
* Run with: node --test tests/test_validation_flow.js
*/
const { test, describe, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { JSDOM } = require('jsdom');
const { validateTickerFormat, validateTickerRemote } = require(
path.join(__dirname, '..', 'static', 'tickerValidation.js')
);
// ---------------------------------------------------------------------------
// Minimal DOM that mirrors the relevant parts of index.html
// ---------------------------------------------------------------------------
function buildDOM() {
return new JSDOM(`<!DOCTYPE html><html><body>
<form id="analyze-form">
<div class="search-box">
<div id="ticker-input-wrapper">
<input id="ticker-input" type="text" />
<button id="ticker-clear" class="hidden" type="button">Γ—</button>
<span id="ticker-val-indicator" class="hidden"></span>
</div>
<button id="analyze-btn" type="submit" disabled>
<span class="btn-text">Analyze Risk</span>
</button>
</div>
</form>
<div id="validation-hint" class="hidden">
<span id="validation-msg"></span>
<div id="suggestion-chips"></div>
</div>
<div id="error-banner" class="hidden"><div id="error-banner-body">
<span id="error-banner-msg"></span>
<div id="error-banner-chips"></div>
</div>
<button id="error-banner-retry" class="hidden" type="button">Retry</button>
<button id="error-banner-dismiss" type="button">Γ—</button>
</div>
<div id="analysis-progress" class="hidden">
<div class="progress-step" id="prog-step-1"></div>
<div class="progress-step" id="prog-step-2"></div>
<div class="progress-step" id="prog-step-3"></div>
<div class="progress-step" id="prog-step-4"></div>
</div>
<div id="analysis-skeleton" class="hidden"></div>
<section id="results-dashboard" class="dashboard hidden"></section>
<div id="error-message" class="hidden"></div>
</body></html>`, { pretendToBeVisual: true });
}
// ---------------------------------------------------------------------------
// Wire the analysis-flow logic (mirrors app.js without the full module)
// ---------------------------------------------------------------------------
function wireFlow(window, mockFetch) {
const { document } = window;
window.TickerValidation = { validateTickerFormat, validateTickerRemote };
window.fetch = mockFetch;
const input = document.getElementById('ticker-input');
const analyzeBtn = document.getElementById('analyze-btn');
const btnText = analyzeBtn.querySelector('.btn-text');
const dashboard = document.getElementById('results-dashboard');
const errorBanner = document.getElementById('error-banner');
const errorBannerMsg = document.getElementById('error-banner-msg');
const errorBannerChips = document.getElementById('error-banner-chips');
const errorBannerRetry = document.getElementById('error-banner-retry');
const analysisSkeleton = document.getElementById('analysis-skeleton');
const analysisProgress = document.getElementById('analysis-progress');
let _retryTicker = null;
let _validatedTicker = null;
let _progressTimers = [];
let _rateLimitTimer = null;
function _showErrorBanner(message, suggestions, showRetry) {
if (errorBannerMsg) errorBannerMsg.textContent = message;
if (errorBannerChips) {
errorBannerChips.innerHTML = '';
(suggestions || []).forEach((s) => {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'suggestion-chip';
chip.textContent = s;
chip.addEventListener('click', () => {
input.value = s;
// In real app this calls _triggerValidation(s).
// Here we emit a custom event so tests can observe it.
input.dispatchEvent(new window.CustomEvent('validation-triggered', { detail: s }));
});
errorBannerChips.appendChild(chip);
});
}
if (errorBannerRetry) errorBannerRetry.classList.toggle('hidden', !showRetry);
errorBanner.classList.remove('hidden');
errorBanner.classList.add('is-visible');
}
function _hideErrorBanner() {
errorBanner.classList.remove('is-visible');
errorBanner.classList.add('hidden');
}
function _showProgress() {
analysisProgress.classList.remove('hidden');
// Advance immediately to step 1
analysisProgress.querySelectorAll('.progress-step').forEach((el, i) => {
el.className = i === 0 ? 'progress-step is-active' : 'progress-step';
});
}
function _hideProgress() {
_progressTimers.forEach((t) => clearTimeout(t));
_progressTimers = [];
analysisProgress.classList.add('hidden');
}
function _showSkeleton() { analysisSkeleton.classList.remove('hidden'); }
function _hideSkeleton() { analysisSkeleton.classList.add('hidden'); }
function _startRateLimitCountdown(seconds) {
analyzeBtn.disabled = true;
const endTime = Date.now() + seconds * 1000;
function tick() {
const remaining = Math.ceil((endTime - Date.now()) / 1000);
if (remaining <= 0) {
if (btnText) btnText.textContent = 'Analyze Risk';
analyzeBtn.disabled = false;
return;
}
if (btnText) btnText.textContent = `Wait ${remaining}s…`;
_rateLimitTimer = setTimeout(tick, 500);
}
tick();
}
async function runAnalysis(ticker) {
const normalizedTicker = String(ticker || '').trim().toUpperCase();
_retryTicker = normalizedTicker;
const timeoutCtrl = new window.AbortController();
const timeoutId = setTimeout(() => timeoutCtrl.abort(), 30000);
analyzeBtn.disabled = true;
_hideErrorBanner();
_showProgress();
_showSkeleton();
dashboard.classList.add('hidden');
try {
const response = await window.fetch(`/api/analyze?ticker=${normalizedTicker}`, {
signal: timeoutCtrl.signal,
});
clearTimeout(timeoutId);
if (response.status === 422) {
const body = await response.json().catch(() => ({}));
_showErrorBanner(body.error || 'Invalid ticker.', body.suggestions || [], false);
return;
}
if (response.status === 429) {
_showErrorBanner(
"You're sending requests too quickly. Please wait a moment and try again.",
[], false
);
_startRateLimitCountdown(10);
return;
}
const data = await response.json();
if (!response.ok) {
_showErrorBanner(data.error || 'Something went wrong.', [], true);
return;
}
// Success
_hideSkeleton();
_hideProgress();
dashboard.classList.remove('hidden');
_validatedTicker = normalizedTicker;
analyzeBtn.disabled = false;
} catch (err) {
clearTimeout(timeoutId);
dashboard.classList.add('hidden');
if (err.name === 'AbortError') {
_showErrorBanner('The analysis is taking longer than expected. Please try again.', [], true);
} else {
_showErrorBanner('Something went wrong on our end. Please try again in a few seconds.', [], true);
}
} finally {
_hideSkeleton();
_hideProgress();
}
}
return { runAnalysis, input, analyzeBtn, btnText, dashboard, errorBanner,
errorBannerMsg, errorBannerChips, errorBannerRetry };
}
// Helpers
function makeFetch(status, body) {
return async () => ({
status,
ok: status >= 200 && status < 300,
json: async () => body,
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('End-to-end validation flow', () => {
test('test_valid_ticker_shows_analysis_result β€” 200 renders dashboard', async () => {
const { window } = buildDOM();
const { runAnalysis, dashboard, errorBanner } = wireFlow(window, makeFetch(200, {
meta: { symbol: 'AAPL' },
market: { predicted_price_next_session: 200, history: [] },
}));
await runAnalysis('AAPL');
assert.ok(!dashboard.classList.contains('hidden'),
'Dashboard should be visible after 200 response');
assert.ok(errorBanner.classList.contains('hidden'),
'Error banner should remain hidden after success');
});
test('test_invalid_ticker_shows_error_banner β€” 422 shows banner with message', async () => {
const { window } = buildDOM();
const { runAnalysis, errorBanner, errorBannerMsg, errorBannerChips } = wireFlow(
window,
makeFetch(422, {
valid: false,
error: 'Ticker "XYZQW" was not found. Please check the symbol and try again.',
suggestions: ['XYZ', 'XYZX'],
})
);
await runAnalysis('XYZQW');
assert.ok(!errorBanner.classList.contains('hidden'),
'Error banner should be visible after 422');
assert.ok(errorBannerMsg.textContent.includes('not found'),
'Banner should display the error message');
const chips = errorBannerChips.querySelectorAll('.suggestion-chip');
assert.equal(chips.length, 2, 'Should render suggestion chips');
});
test('test_rate_limit_shows_cooldown β€” 429 disables button with countdown', async () => {
const { window } = buildDOM();
const { runAnalysis, analyzeBtn, errorBanner, errorBannerMsg } = wireFlow(
window,
makeFetch(429, {})
);
await runAnalysis('AAPL');
assert.ok(!errorBanner.classList.contains('hidden'),
'Error banner should show on 429');
assert.ok(errorBannerMsg.textContent.includes('too quickly'),
'Message should mention rate limiting');
assert.ok(analyzeBtn.disabled,
'Submit button should be disabled during cooldown');
});
test('test_suggestion_click_in_banner_retriggers_validation β€” chip click fires event', async () => {
const { window } = buildDOM();
const { runAnalysis, input, errorBannerChips } = wireFlow(
window,
makeFetch(422, {
valid: false,
error: 'Not found.',
suggestions: ['AAPL', 'APD'],
})
);
let triggeredTicker = null;
input.addEventListener('validation-triggered', (e) => {
triggeredTicker = e.detail;
});
await runAnalysis('AAPX');
const chip = errorBannerChips.querySelector('.suggestion-chip');
assert.ok(chip, 'Suggestion chip should be rendered');
chip.click();
assert.equal(input.value, chip.textContent,
'Input should be filled with the clicked suggestion');
assert.equal(triggeredTicker, chip.textContent,
'Clicking suggestion should trigger validation for that ticker');
});
});