const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../../static/js/admin.js'), 'utf8');
const sharedSource = fs.readFileSync(path.join(__dirname, '../../static/js/support.js'), 'utf8');
const toolsSource = fs.readFileSync(path.join(__dirname, '../../static/js/admin_tools.js'), 'utf8');
const ticketsSource = fs.readFileSync(path.join(__dirname, '../../static/js/admin_tickets.js'), 'utf8');
const flush = () => new Promise(setImmediate);
function response(data, status = 200){
return {ok: status >= 200 && status < 300, status, json: async () => data};
}
async function workspace(compact = false, initialHooks = {}){
const elements = new Map();
const documentListeners = {};
let document;
class Element {
constructor(){
this.value = ''; this.children = []; this.attributes = {}; this.listeners = {};
this.style = {}; this.hidden = false; this.scrollHeight = 70;
this.scrollTop = 0; this.clientHeight = 300;
const classes = new Set();
this.classList = {
toggle: (name, enabled) => { enabled ? classes.add(name) : classes.delete(name); },
contains: name => classes.has(name),
};
}
set innerHTML(value){ this.html = value; this.children = []; }
get innerHTML(){ return this.html || ''; }
setAttribute(name, value){ this.attributes[name] = value; }
toggleAttribute(name, enabled){ if(enabled) this.attributes[name] = ''; else delete this.attributes[name]; }
appendChild(element){ this.children.push(element); }
contains(target){ return this === target || this.children.some(child=>child.contains(target)); }
addEventListener(name, callback){ this.listeners[name] = callback; }
querySelectorAll(){ return []; }
querySelector(selector){
if(selector === '.meta') return this.meta || (this.meta = new Element());
return null;
}
setSelectionRange(start, end){ this.selectionStart = start; this.selectionEnd = end; }
showModal(){ this.open = true; }
close(){ this.open = false; }
focus(){ document.activeElement = this; }
scrollIntoView(){}
click(){ if(this.onclick) return this.onclick(); }
}
const quickButtons = [new Element(), new Element()];
document = {
body: new Element(), activeElement: null,
getElementById(id){ if(!elements.has(id)){ const value=new Element(); if(id==='agent-emoji-panel') value.hidden=true; elements.set(id,value); } return elements.get(id); },
createElement(){ return new Element(); },
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
querySelector(selector){
if(selector === 'meta[name="staff-csrf-token"]') return {content:'fixture-csrf'};
if(selector === 'meta[name="staff-username"]') return {content:'alice'};
const match = selector.match(/data-message-id="(\d+)"/);
if(!match) return null;
return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null;
},
addEventListener(name, callback){ const previous=documentListeners[name]; documentListeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
};
const calls = [], dialogs = [];
const hooks = initialHooks;
const timers = new Map(), intervals = new Map();
let timerId = 0;
const context = vm.createContext({
document, console, AbortController,
window: {matchMedia: () => ({matches: compact}), addEventListener(){}, confirm(){ return true; },
location:{replace(url){ calls.push({redirect:url}); }}},
setInterval(callback, delay){ const id=++timerId; intervals.set(id,{callback,delay}); return id; },
setTimeout(callback, delay){ const id=++timerId; timers.set(id,{callback,delay}); return id; },
clearTimeout(id){ timers.delete(id); },
clearInterval(id){ intervals.delete(id); },
fetch: async (url, options) => {
const body = options && options.body ? JSON.parse(options.body) : undefined;
calls.push({url, body, signal:options && options.signal, headers:options && options.headers});
if(url.startsWith('/api/tickets/summary')) return hooks.ticketSummary ? hooks.ticketSummary(url, options) : response({pending_count:0,latest_id:0,new_count:0});
if(/^\/api\/tickets\/\d+\/events/.test(url)) return hooks.ticketEvents ? hooks.ticketEvents(url, options) : response([]);
if(url === '/admin/logout' && hooks.logout) return hooks.logout(options);
if(url.startsWith('/api/tickets') && hooks.tickets) return hooks.tickets(url, body, options);
if(url === '/api/human/send' && hooks.send) return hooks.send(body, options);
if(url === '/api/human/close' && hooks.close) return hooks.close(body);
if(url === '/api/human/transfer' && hooks.transfer) return hooks.transfer(body);
if(url === '/api/human/note' && hooks.note) return hooks.note(body);
if(url === '/api/human/queue' && hooks.queue) return hooks.queue(options);
if(url === '/api/human/active' && hooks.active) return hooks.active(options);
if(url.includes('/api/human/poll') && hooks.poll) return hooks.poll(url, options);
if(url.includes('/api/search_chat') && hooks.search) return hooks.search(url);
if(url.includes('/api/human/poll')) return response({status:'active', messages:[]});
if(url === '/api/admin/stats') return response({total_conv:0, total_msg:0, today_conv:0, good:0, bad:0});
return response(options && options.method ? {ok:true} : []);
},
});
vm.runInContext(sharedSource, context, {filename:'support.js'});
context.SupportUI.confirm = async (message, options) => { dialogs.push({message, options}); return initialHooks.confirmResult ?? true; };
vm.runInContext(toolsSource, context, {filename:'admin_tools.js'});
vm.runInContext(ticketsSource, context, {filename:'admin_tickets.js'});
vm.runInContext(source, context, {filename:'admin.js'});
await flush();
await context.accept(7);
await flush();
return {context, element: id => document.getElementById(id), calls, hooks, dialogs, document, documentListeners, timers,
runTimers:async delay=>{
for(const [id,timer] of Array.from(timers)){
if(timer.delay===delay){ timers.delete(id); timer.callback(); }
}
await flush();
},
tick:async delay=>{
for(const timer of intervals.values()) if(timer.delay===delay) timer.callback();
await flush();
},
};
}
function waitForAbort(options){
return new Promise((resolve,reject)=>{
if(!options || !options.signal) return;
options.signal.addEventListener('abort',()=>{
const error=new Error('Request aborted'); error.name='AbortError'; reject(error);
},{once:true});
});
}
test('ticket polling preserves drafts and stale versions require explicit review', async () => {
const app = await workspace();
let ticket = {id:10,content:'查询派送',phone:'123',ctime:'now',status:'processing',owner_username:'alice',owner_name:'Alice',note:'原备注',version:1};
app.hooks.tickets = async url => response(url.includes('?') ? [ticket] : {ok:true,ticket});
await app.context.loadTickets();
app.element('ticket-note').value='我的未保存备注';
app.element('ticket-note').listeners.input();
ticket = {...ticket, note:'另一页面更新', version:2};
await app.context.loadTickets();
assert.equal(app.element('ticket-note').value,'我的未保存备注');
assert.equal(app.element('ticket-save').disabled,true);
assert.match(app.element('ticket-server-note').textContent,/另一页面更新/);
app.element('ticket-rebase').onclick();
assert.equal(app.element('ticket-save').disabled,false);
});
test('human queue displays five-minute overdue state and notifies once when it crosses the threshold', async () => {
const app=await workspace();
let overdue=false;
app.hooks.queue=async()=>response([{id:119,ctime:'2026-11-02 16:47:39',waiting_minutes:overdue?5:4,overdue,overdue_minutes:overdue?1:0}]);
const notices=[];
app.context.SupportUI.notify=(...args)=>notices.push(args);
await app.context.loadQueue();
assert.match(app.element('queue').innerHTML,/等待 4 分钟/);
assert.equal(app.element('queue-overdue-count').textContent,'超时 0');
assert.equal(notices.filter(item=>item[0]==='新客户排队').length,1);
overdue=true;
await app.context.loadQueue();
assert.match(app.element('queue').innerHTML,/排队超时 1 分钟/);
assert.equal(app.element('queue-overdue-count').textContent,'超时 1');
assert.equal(notices.filter(item=>item[0]==='人工排队超时').length,1);
await app.context.loadQueue();
assert.equal(notices.filter(item=>item[0]==='人工排队超时').length,1);
});
test('ticket writes use current owner, CSRF and preserve the draft on network failure', async () => {
const app = await workspace();
const ticket = {id:10,content:'待处理留言',status:'processing',owner_username:'alice',note:'',version:1};
app.hooks.tickets = async (url, body, options) => options && options.method ? response({ok:false,error:'版本冲突'},409) : response(url.includes('?') ? [ticket] : {ok:true,ticket});
await app.context.loadTickets();
app.element('ticket-note').value='处理结果';
app.element('ticket-note').listeners.input();
await app.element('ticket-complete').onclick();
const call=app.calls.find(call=>call.url==='/api/tickets/10' && call.body);
assert.equal(call.headers['X-CSRF-Token'],'fixture-csrf');
assert.equal(call.body.status,'completed');
assert.equal(call.body.version,1);
assert.equal(app.element('ticket-note').value,'处理结果');
assert.match(app.element('ticket-status-message').textContent,/版本冲突/);
assert.equal(app.element('ticket-complete').disabled,false);
});
test('tickets assigned to other staff are read-only and escaped in the list', async () => {
const app=await workspace();
app.hooks.tickets=async()=>response([{id:12,content:'
',status:'processing',owner_username:'bob',owner_name:'Bob',version:1,note:'备注'}]);
await app.context.loadTickets();
assert.equal(app.element('ticket-note').readOnly,true);
assert.equal(app.element('ticket-save').hidden,true);
assert.match(app.element('ticketList').innerHTML,/<img/);
assert.doesNotMatch(app.element('ticketList').innerHTML,/
{
const app=await workspace();
let resolveOld, count=0;
app.hooks.tickets=async()=>{
if(++count===1) return new Promise(resolve=>{resolveOld=resolve;});
return response([{id:20,content:'已完成留言',status:'completed',owner_username:'alice',note:'处理结果',version:2}]);
};
const pending=app.context.loadTickets();
app.element('ticket-filter').value='completed';
app.element('ticket-filter').listeners.change();
await flush();
resolveOld(response([{id:10,content:'旧列表',status:'pending',version:0}]));
await pending;
assert.equal(app.element('ticket-state').textContent,'已完成');
assert.doesNotMatch(app.element('ticketList').innerHTML,/旧列表/);
});
test('a hung ticket save releases controls and retains notes after the request timeout', async () => {
const app=await workspace();
const ticket={id:10,content:'留言',status:'processing',owner_username:'alice',note:'',version:1};
app.hooks.tickets=async(url,body,options)=>options && options.method ? waitForAbort(options) : response([ticket]);
await app.context.loadTickets();
app.element('ticket-note').value='不能丢失的备注';
app.element('ticket-note').listeners.input();
const pending=app.element('ticket-save').onclick();
assert.equal(app.element('ticket-save').disabled,true);
await app.runTimers(10000);
await pending;
assert.equal(app.element('ticket-save').disabled,false);
assert.equal(app.element('ticket-note').value,'不能丢失的备注');
assert.match(app.element('ticket-status-message').textContent,/未确认成功.*备注已保留/);
});
test('ticket search is sent to the server and retained for pagination and status changes', async () => {
const app=await workspace();
app.hooks.tickets=async()=>response(Array.from({length:20},(_,index)=>({id:100-index,content:'查询派送',status:'pending',version:0})));
app.element('ticket-search').value='138 &小林';
await app.element('ticket-search-form').listeners.submit({preventDefault(){}});
let listCalls=app.calls.filter(call=>call.url.startsWith('/api/tickets?'));
assert.equal(new URL(listCalls.at(-1).url,'http://local').searchParams.get('q'),'138 &小林');
await app.element('ticket-more').onclick();
listCalls=app.calls.filter(call=>call.url.startsWith('/api/tickets?'));
assert.match(listCalls.at(-1).url,/before=81/);
assert.equal(new URL(listCalls.at(-1).url,'http://local').searchParams.get('q'),'138 &小林');
app.element('ticket-filter').value='pending';
app.element('ticket-filter').listeners.change();
await flush();
assert.equal(new URL(app.calls.filter(call=>call.url.startsWith('/api/tickets?')).at(-1).url,'http://local').searchParams.get('q'),'138 &小林');
});
test('new ticket alerts skip old records, deduplicate new arrivals and keep polling on older pages', async () => {
let version=1;
const app=await workspace(false,{ticketSummary:async()=>response({pending_count:24,latest_id:version,new_count:version===2?1:0})});
const notices=[];
app.context.SupportUI.notify=(...args)=>notices.push(args);
await app.context.loadTickets();
assert.equal(app.element('ticket-pending-count').textContent,'24');
assert.equal(notices.length,0);
app.hooks.tickets=async()=>response(Array.from({length:20},(_,index)=>({id:40-index,content:'旧留言',status:'pending',version:0})));
await app.context.loadTickets();
await app.element('ticket-more').onclick();
version=2;
await app.context.loadTickets();
assert.match(app.element('ticket-alert-text').textContent,/1 条新留言/);
assert.equal(app.element('ticket-alert').hidden,false);
assert.equal(notices.length,1);
await app.context.loadTickets();
assert.equal(notices.length,1);
version=3;
await app.context.loadTickets();
assert.equal(notices.length,1);
await app.element('ticket-alert-view').onclick();
assert.equal(app.element('ticket-filter').value,'pending');
assert.equal(app.element('ticket-search').value,'');
assert.equal(app.element('ticket-alert').hidden,true);
});
test('ticket header entry focuses the search field and IME confirmation does not submit', async () => {
const app=await workspace(true);
await app.element('ticket-inbox-toggle').onclick();
assert.equal(app.document.body.classList.contains('tools-open'),true);
assert.equal(app.document.activeElement,app.element('ticket-search'));
let prevented=false;
app.element('ticket-search').listeners.keydown({key:'Enter',isComposing:true,preventDefault(){prevented=true;}});
assert.equal(prevented,true);
});
test('overdue count stays global and the header opens all overdue records', async () => {
const app=await workspace(true,{ticketSummary:async()=>response({pending_count:8,latest_id:10,new_count:0,overdue_count:3})});
assert.equal(app.element('ticket-overdue-count').textContent,'3');
app.hooks.tickets=async()=>response([{id:10,content:'超时件',status:'pending',version:0,overdue_kind:'pending',overdue_minutes:7}]);
await app.element('ticket-overdue-toggle').onclick();
const query=new URL(app.calls.filter(call=>call.url.startsWith('/api/tickets?')).at(-1).url,'http://local');
assert.equal(query.searchParams.get('status'),'all');
assert.equal(query.searchParams.get('overdue'),'1');
assert.match(app.element('ticketList').innerHTML,/领取超时 7 分钟/);
assert.match(app.element('ticket-overdue-hint').textContent,/7 分钟/);
app.element('tools-close').onclick();
assert.equal(app.document.activeElement,app.element('ticket-overdue-toggle'));
await app.element('ticket-inbox-toggle').onclick();
assert.equal(app.element('ticket-overdue-filter').checked,false);
});
test('history is escaped, paginated and not reloaded by an unchanged poll', async () => {
const app=await workspace();
app.hooks.tickets=async()=>response([{id:10,content:'留言',status:'processing',version:1}]);
let historyCalls=0;
app.hooks.ticketEvents=async()=>{historyCalls++;return response(Array.from({length:20},(_,index)=>({id:40-index,action:'note_updated',actor_name:'
',ctime:'today',old_note:'旧备注',new_note:''})));};
await app.context.loadTickets(); await flush();
assert.match(app.element('ticket-history-list').innerHTML,/<script>/);
assert.doesNotMatch(app.element('ticket-history-list').innerHTML,/', role:'user', ctime:'now'}]);
app.element('searchBox').value = '物流';
await app.context.doSearch();
assert.match(app.element('searchBody').innerHTML, /<script>/);
assert.doesNotMatch(app.element('searchBody').innerHTML, /