Fix out-of-order pagination responses in the treatment table
pg-prev/pg-next deliberately skip comCarregando (it would fight the page-boundary disabled-state logic), so nothing stopped two rapid clicks from firing overlapping GET /api/leads requests. If the earlier page's response resolved after the later one, it silently overwrote the table, pager text and prev/next disabled state with a page the operator was no longer viewing. Adds a per-call sequence number in carregarTabela(); a response is only applied if no newer call has started since, covering every caller (pager clicks, filter changes, tab activation) with one guard. Found via a full-project audit; verified the regression test fails against the pre-fix code and passes against the fix.
This commit is contained in:
@@ -4,6 +4,7 @@ const Tratamento = (() => {
|
||||
let totalPages = 1;
|
||||
let carregado = false;
|
||||
let refreshTimer = null;
|
||||
let tabelaRequestSeq = 0;
|
||||
|
||||
const CLASSIFICACAO_LABELS = {
|
||||
excelente: 'Excelente', bom: 'Bom', medio: 'Médio', baixo: 'Baixo', muito_baixo: 'Muito Baixo'
|
||||
@@ -364,7 +365,13 @@ const Tratamento = (() => {
|
||||
}
|
||||
|
||||
async function carregarTabela() {
|
||||
const requestId = ++tabelaRequestSeq;
|
||||
const data = await API.get(`/api/leads?${filtroQuery({ page, pageSize: 50 })}`);
|
||||
// Descarta respostas fora de ordem: pg-prev/pg-next nao usam comCarregando
|
||||
// (ver bindEvents), entao um clique rapido pode disparar uma nova consulta
|
||||
// antes da anterior responder. Sem isso, a resposta mais lenta podia
|
||||
// sobrescrever a tabela/paginacao com uma pagina que ja nao e mais a atual.
|
||||
if (requestId !== tabelaRequestSeq) return;
|
||||
totalPages = Math.max(1, Math.ceil(data.total / data.pageSize));
|
||||
el('leads-total').textContent = `— ${fmt.format(data.total)} encontrados`;
|
||||
el('pg-info').textContent = `página ${data.page} de ${fmt.format(totalPages)}`;
|
||||
|
||||
@@ -254,6 +254,70 @@ test('launcher carrega APP_PORT do .env do root sem sobrescrever ambiente real',
|
||||
assert.match(source, /StartsWith\("#"\)/);
|
||||
});
|
||||
|
||||
// pg-prev/pg-next nao usam comCarregando de proposito (ver comentario em
|
||||
// bindEvents), entao nada impede dois cliques rapidos de disparar duas
|
||||
// consultas concorrentes. Antes, se a resposta da pagina anterior chegasse
|
||||
// DEPOIS da pagina mais recente, ela sobrescrevia a tabela com dados de uma
|
||||
// pagina que o operador ja nao esta mais vendo.
|
||||
test('clique duplo em pg-next nao deixa resposta atrasada sobrescrever a pagina mais recente', async () => {
|
||||
const app = await executeApp();
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
const page2 = deferred();
|
||||
const page3 = deferred();
|
||||
const rowFor = (label) => [{
|
||||
id: 1, empresa: label, whatsapp: '', segmento: '', segmento_label: '',
|
||||
prioridade: 1, score: 0, classificacao: 'bom', produto_principal: '', exported_at: null
|
||||
}];
|
||||
|
||||
app.context.API.get = async (url) => {
|
||||
if (url === '/api/leads/facets') return { ufs: [], interesses: [], produtos: [], segmentos: [], cidades: [] };
|
||||
if (url.startsWith('/api/dashboard/summary')) return {
|
||||
total: 1, com_whatsapp: 1, p1: 1, p2: 0, p3: 0, p4: 0,
|
||||
potencial_pct: 100, cobertura_pct: 0, exportados: 0, disponiveis: 1,
|
||||
p1_pct: 100, p2_pct: 0, p3_pct: 0, p4_pct: 0, meta_diaria: 10,
|
||||
meta_pct: 0, hoje_exportados: 0, dias_estimados: 1
|
||||
};
|
||||
if (url.startsWith('/api/dashboard/evolution')) return { evolution: [] };
|
||||
if (url.startsWith('/api/dashboard/oportunidade')) return { oportunidade: null };
|
||||
if (url.startsWith('/api/dashboard/rankings')) return { ranking: [] };
|
||||
if (url === '/api/exports?limit=12') return { exports: [] };
|
||||
if (url === '/api/settings') return { settings: {} };
|
||||
if (url === '/api/outreach/templates') return { templates: [] };
|
||||
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
|
||||
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
|
||||
if (url === '/api/whatsapp/status') return { state: 'READY' };
|
||||
if (url.startsWith('/api/leads?')) {
|
||||
const page = new URLSearchParams(url.split('?')[1]).get('page');
|
||||
if (page === '1') return { rows: rowFor('Pagina 1'), total: 150, page: 1, pageSize: 50 };
|
||||
if (page === '2') { await page2.promise; return { rows: rowFor('Pagina 2 (atrasada)'), total: 150, page: 2, pageSize: 50 }; }
|
||||
if (page === '3') { await page3.promise; return { rows: rowFor('Pagina 3'), total: 150, page: 3, pageSize: 50 }; }
|
||||
throw new Error(`pagina inesperada: ${page}`);
|
||||
}
|
||||
throw new Error(`GET inesperado no tratamento: ${url}`);
|
||||
};
|
||||
|
||||
vm.runInContext(await read('public/js/tratamento.js'), app.context);
|
||||
await app.context.window.Tratamento.ativar();
|
||||
app.context.window.Tratamento.bindEvents();
|
||||
|
||||
app.element('pg-next').click(); // pagina 1 -> 2, fica presa em page2.promise
|
||||
await tick();
|
||||
app.element('pg-next').click(); // pagina 2 -> 3, fica presa em page3.promise
|
||||
await tick();
|
||||
|
||||
// Resolve fora de ordem: a pagina 3 (mais recente) responde primeiro.
|
||||
page3.resolve();
|
||||
await tick();
|
||||
await tick();
|
||||
page2.resolve();
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const t = nodeText(app.element('tabela-leads-tbody'));
|
||||
assert.match(t, /Pagina 3/);
|
||||
assert.doesNotMatch(t, /atrasada/);
|
||||
});
|
||||
|
||||
test('tratamento separa validade do telefone da verificacao de WhatsApp', async () => {
|
||||
const html = await read('public/index.html');
|
||||
const treatment = await read('public/js/tratamento.js');
|
||||
|
||||
Reference in New Issue
Block a user