refactor: improve pollSessions error handling and test coverage
This commit is contained in:
@@ -173,7 +173,9 @@ async function api(method, path, body, baseUrl) {
|
|||||||
}
|
}
|
||||||
const res = await fetch(url, opts);
|
const res = await fetch(url, opts);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
const err = new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -248,7 +250,7 @@ function setConnectionStatus(level) {
|
|||||||
*/
|
*/
|
||||||
async function pollSessions() {
|
async function pollSessions() {
|
||||||
// Falls back to local-only if _sources is empty
|
// Falls back to local-only if _sources is empty
|
||||||
if (!_sources || _sources.length === 0) {
|
if (_sources.length === 0) {
|
||||||
try {
|
try {
|
||||||
const res = await api('GET', '/api/sessions');
|
const res = await api('GET', '/api/sessions');
|
||||||
const sessions = await res.json();
|
const sessions = await res.json();
|
||||||
@@ -284,8 +286,7 @@ async function pollSessions() {
|
|||||||
delete source.nextRetryAt;
|
delete source.nextRetryAt;
|
||||||
return { source, sessions };
|
return { source, sessions };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err.message || '';
|
if (err.status === 401 || err.status === 403) {
|
||||||
if (msg.includes('401') || msg.includes('403')) {
|
|
||||||
source.status = 'auth_required';
|
source.status = 'auth_required';
|
||||||
} else {
|
} else {
|
||||||
source.status = 'unreachable';
|
source.status = 'unreachable';
|
||||||
|
|||||||
@@ -2190,4 +2190,149 @@ test('pollSessions fetches from all sources and merges results', async () => {
|
|||||||
app._setSources([]);
|
app._setSources([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('pollSessions skips sources with nextRetryAt in the future', async () => {
|
||||||
|
const mockStatusEl = { textContent: '', className: '' };
|
||||||
|
const mockGrid = { innerHTML: '' };
|
||||||
|
const mockEmptyState = { style: {}, classList: { add: () => {}, remove: () => {} } };
|
||||||
|
|
||||||
|
const origGetById = globalThis.document.getElementById;
|
||||||
|
const origQSA = globalThis.document.querySelectorAll;
|
||||||
|
globalThis.document.getElementById = (id) => {
|
||||||
|
if (id === 'connection-status') return mockStatusEl;
|
||||||
|
if (id === 'session-grid') return mockGrid;
|
||||||
|
if (id === 'empty-state') return mockEmptyState;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
globalThis.document.querySelectorAll = () => [];
|
||||||
|
|
||||||
|
const futureRetry = Date.now() + 60000; // 60s in the future
|
||||||
|
app._setSources([
|
||||||
|
{ url: '', name: 'Local', type: 'local', status: 'authenticated', backoffMs: 2000 },
|
||||||
|
{
|
||||||
|
url: 'https://remote.example.com',
|
||||||
|
name: 'Remote',
|
||||||
|
type: 'remote',
|
||||||
|
status: 'unreachable',
|
||||||
|
backoffMs: 4000,
|
||||||
|
nextRetryAt: futureRetry,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fetchCalls = [];
|
||||||
|
globalThis.fetch = async (url) => {
|
||||||
|
fetchCalls.push(url);
|
||||||
|
return { ok: true, json: async () => [{ name: 'local-session' }] };
|
||||||
|
};
|
||||||
|
|
||||||
|
await app.pollSessions();
|
||||||
|
|
||||||
|
assert.ok(fetchCalls.some((url) => url === '/api/sessions'), 'should fetch local sessions');
|
||||||
|
assert.ok(
|
||||||
|
!fetchCalls.some((url) => url === 'https://remote.example.com/api/sessions'),
|
||||||
|
'should skip remote source still in backoff',
|
||||||
|
);
|
||||||
|
|
||||||
|
globalThis.document.getElementById = origGetById;
|
||||||
|
globalThis.document.querySelectorAll = origQSA;
|
||||||
|
globalThis.fetch = undefined;
|
||||||
|
app._setSources([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pollSessions sets auth_required on 401 response', async () => {
|
||||||
|
const mockStatusEl = { textContent: '', className: '' };
|
||||||
|
const mockGrid = { innerHTML: '' };
|
||||||
|
const mockEmptyState = { style: {}, classList: { add: () => {}, remove: () => {} } };
|
||||||
|
|
||||||
|
const origGetById = globalThis.document.getElementById;
|
||||||
|
const origQSA = globalThis.document.querySelectorAll;
|
||||||
|
globalThis.document.getElementById = (id) => {
|
||||||
|
if (id === 'connection-status') return mockStatusEl;
|
||||||
|
if (id === 'session-grid') return mockGrid;
|
||||||
|
if (id === 'empty-state') return mockEmptyState;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
globalThis.document.querySelectorAll = () => [];
|
||||||
|
|
||||||
|
const sources = [
|
||||||
|
{ url: '', name: 'Local', type: 'local', status: 'authenticated', backoffMs: 2000 },
|
||||||
|
{
|
||||||
|
url: 'https://remote.example.com',
|
||||||
|
name: 'Remote',
|
||||||
|
type: 'remote',
|
||||||
|
status: 'authenticated',
|
||||||
|
backoffMs: 2000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
app._setSources(sources);
|
||||||
|
|
||||||
|
globalThis.fetch = async (url) => {
|
||||||
|
if (url === '/api/sessions') {
|
||||||
|
return { ok: true, json: async () => [{ name: 'local-session' }] };
|
||||||
|
}
|
||||||
|
// Remote returns 401
|
||||||
|
return { ok: false, status: 401, statusText: 'Unauthorized' };
|
||||||
|
};
|
||||||
|
|
||||||
|
await app.pollSessions();
|
||||||
|
|
||||||
|
const remoteSrc = sources.find((s) => s.url === 'https://remote.example.com');
|
||||||
|
assert.strictEqual(remoteSrc.status, 'auth_required', 'remote source should be marked auth_required');
|
||||||
|
assert.ok(!remoteSrc.nextRetryAt, 'auth_required should not set nextRetryAt');
|
||||||
|
|
||||||
|
globalThis.document.getElementById = origGetById;
|
||||||
|
globalThis.document.querySelectorAll = origQSA;
|
||||||
|
globalThis.fetch = undefined;
|
||||||
|
app._setSources([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pollSessions sets unreachable and applies exponential backoff on network error', async () => {
|
||||||
|
const mockStatusEl = { textContent: '', className: '' };
|
||||||
|
const mockGrid = { innerHTML: '' };
|
||||||
|
const mockEmptyState = { style: {}, classList: { add: () => {}, remove: () => {} } };
|
||||||
|
|
||||||
|
const origGetById = globalThis.document.getElementById;
|
||||||
|
const origQSA = globalThis.document.querySelectorAll;
|
||||||
|
globalThis.document.getElementById = (id) => {
|
||||||
|
if (id === 'connection-status') return mockStatusEl;
|
||||||
|
if (id === 'session-grid') return mockGrid;
|
||||||
|
if (id === 'empty-state') return mockEmptyState;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
globalThis.document.querySelectorAll = () => [];
|
||||||
|
|
||||||
|
const sources = [
|
||||||
|
{ url: '', name: 'Local', type: 'local', status: 'authenticated', backoffMs: 2000 },
|
||||||
|
{
|
||||||
|
url: 'https://remote.example.com',
|
||||||
|
name: 'Remote',
|
||||||
|
type: 'remote',
|
||||||
|
status: 'authenticated',
|
||||||
|
backoffMs: 2000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
app._setSources(sources);
|
||||||
|
|
||||||
|
const beforePoll = Date.now();
|
||||||
|
globalThis.fetch = async (url) => {
|
||||||
|
if (url === '/api/sessions') {
|
||||||
|
return { ok: true, json: async () => [] };
|
||||||
|
}
|
||||||
|
return { ok: false, status: 500, statusText: 'Internal Server Error' };
|
||||||
|
};
|
||||||
|
|
||||||
|
await app.pollSessions();
|
||||||
|
|
||||||
|
const remoteSrc = sources.find((s) => s.url === 'https://remote.example.com');
|
||||||
|
assert.strictEqual(remoteSrc.status, 'unreachable', 'remote source should be marked unreachable');
|
||||||
|
// backoffMs doubles from 2000 → 4000
|
||||||
|
assert.strictEqual(remoteSrc.backoffMs, 4000, 'backoff should double from 2000 to 4000');
|
||||||
|
assert.ok(remoteSrc.nextRetryAt > beforePoll, 'nextRetryAt should be set to a future timestamp');
|
||||||
|
assert.ok(remoteSrc.nextRetryAt <= beforePoll + 4000 + 100, 'nextRetryAt should be roughly now + 4000ms');
|
||||||
|
|
||||||
|
globalThis.document.getElementById = origGetById;
|
||||||
|
globalThis.document.querySelectorAll = origQSA;
|
||||||
|
globalThis.fetch = undefined;
|
||||||
|
app._setSources([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user