Files
baiyue-logistics-chat/tests/frontend/admin.test.cjs
T

691 lines
36 KiB
JavaScript

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:'<img src=x onerror=alert(1)>',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,/&lt;img/);
assert.doesNotMatch(app.element('ticketList').innerHTML,/<img/);
});
test('a delayed ticket list cannot overwrite a newer status filter', async () => {
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:'<img>',ctime:'today',old_note:'旧备注',new_note:'<script>evil</script>'})));};
await app.context.loadTickets(); await flush();
assert.match(app.element('ticket-history-list').innerHTML,/&lt;script&gt;/);
assert.doesNotMatch(app.element('ticket-history-list').innerHTML,/<script>/);
assert.match(app.element('ticket-history-list').innerHTML,/旧备注/);
await app.context.loadTickets(); await flush();
assert.equal(historyCalls,1);
await app.element('ticket-history-more').onclick();
assert.match(app.calls.filter(call=>call.url.includes('/events?')).at(-1).url,/before=21/);
});
test('a late history response cannot replace the newly selected ticket history', async () => {
const app=await workspace();
let resolveOld;
app.hooks.tickets=async()=>response([{id:10,content:'A',status:'pending',version:0},{id:11,content:'B',status:'pending',version:0}]);
app.hooks.ticketEvents=async url=>url.includes('/10/') ? new Promise(resolve=>{resolveOld=resolve;}) : response([{id:4,action:'claimed',actor_name:'B客服',ctime:'today'}]);
await app.context.loadTickets();
app.element('ticketList').listeners.click({target:{closest(){return {getAttribute(){return '11';}};}}});
await flush();
resolveOld(response([{id:1,action:'claimed',actor_name:'A客服',ctime:'today'}])); await flush();
assert.match(app.element('ticket-history-list').innerHTML,/B客服/);
assert.doesNotMatch(app.element('ticket-history-list').innerHTML,/A客服/);
});
test('history failures offer retry without blocking a ticket save', async () => {
const app=await workspace();
const ticket={id:10,content:'留言',status:'processing',owner_username:'alice',version:1};
app.hooks.tickets=async(url,body)=>body ? response({ok:true,ticket:{...ticket,version:2,note:body.note}}) : response([ticket]);
app.hooks.ticketEvents=async()=>response({},500);
await app.context.loadTickets(); await flush();
assert.match(app.element('ticket-history-status').textContent,/失败.*重试/);
app.element('ticket-note').value='处理结果'; app.element('ticket-note').listeners.input();
await app.element('ticket-save').onclick(); await flush();
assert.match(app.element('ticket-status-message').textContent,/备注已保存/);
app.hooks.ticketEvents=async()=>response([{id:2,action:'completed',actor_name:'客服',ctime:'today',new_note:'结果'}]);
await app.element('ticket-history-reload').onclick();
assert.match(app.element('ticket-history-list').innerHTML,/处理结果:结果/);
});
test('staff writes include CSRF and logout revokes the session before redirecting', async () => {
const app = await workspace();
await app.context.logoutStaff();
const logout = app.calls.find(call=>call.url==='/admin/logout');
assert.equal(logout.headers['X-CSRF-Token'], 'fixture-csrf');
assert.equal(app.calls.filter(call=>call.redirect==='/admin').length, 1);
});
test('a rejected logout remains actionable and does not claim success', async () => {
const app = await workspace();
app.hooks.logout=async()=>response({},500);
await app.context.logoutStaff();
assert.equal(app.element('staff-logout').disabled, false);
assert.match(app.element('staff-logout-status').textContent,/退出失败/);
assert.equal(app.calls.filter(call=>call.redirect).length,0);
});
test('expired staff sessions redirect once and stop sending further polling requests', async () => {
const app = await workspace();
app.hooks.queue=async()=>response({},401);
app.hooks.active=async()=>response({},401);
await app.context.loadQueue();
await app.tick(1000);
await app.tick(3000);
const count=app.calls.length;
await app.context.loadQueue();
assert.equal(app.calls.length,count);
assert.equal(app.calls.filter(call=>call.redirect==='/admin?expired=1').length,1);
});
test('duplicate logout clicks send only one request', async () => {
const app=await workspace();
let finish;
app.hooks.logout=()=>new Promise(resolve=>{ finish=resolve; });
const first=app.context.logoutStaff();
await app.context.logoutStaff();
assert.equal(app.calls.filter(call=>call.url==='/admin/logout').length,1);
finish(response({ok:true}));
await first;
});
test('a hanging reply times out, preserves the newer draft and waits for manual retry', async () => {
const app=await workspace();
app.hooks.send=(body,options)=>waitForAbort(options);
app.element('replyText').value='先发送的回复';
const sending=app.context.sendReply();
app.element('replyText').value='继续补充的草稿';
app.element('replyText').listeners.input();
await app.runTimers(10000);
assert.equal(app.element('reply-send').disabled,false);
await sending;
assert.equal(app.context.sendingSid,'');
assert.equal(app.element('replyText').value,'继续补充的草稿');
assert.match(app.element('reply-status').textContent,/发送超时.*内容已保留.*确认聊天记录/);
await app.tick(1000);
assert.equal(app.calls.filter(call=>call.url==='/api/human/send').length,1);
app.hooks.send=()=>response({ok:true});
await app.context.sendReply();
assert.equal(app.element('replyText').value,'');
assert.equal(app.calls.filter(call=>call.url==='/api/human/send').length,2);
});
test('a hung message poll times out and the next interval resumes from the last received message', async () => {
const app=await workspace();
app.context.sessions['7'].lastId=12;
app.hooks.poll=(url,options)=>waitForAbort(options);
const polling=app.context.poll();
await flush();
await app.tick(1000);
assert.equal(app.calls.filter(call=>call.url==='/api/human/poll?sid=7&after=12').length,1);
await app.runTimers(10000);
assert.equal(app.context.polling,false);
await polling;
assert.equal(app.element('sync-status').hidden,false);
app.hooks.poll=()=>response({status:'active',messages:[{id:13,side:'customer',content:'断线恢复后的新消息',ctime:'2026-11-02 12:00'}]});
await app.tick(1000);
assert.equal(app.element('sync-status').hidden,true);
assert.equal(app.context.sessions['7'].lastId,13);
assert.equal(app.context.sessions['7'].msgs.length,1);
await app.tick(1000);
assert.equal(app.context.sessions['7'].msgs.length,1);
assert.ok(app.calls.some(call=>call.url==='/api/human/poll?sid=7&after=13'));
});
test('a hung active-session list releases the poll lock on timeout and reconnects', async () => {
const app=await workspace();
app.hooks.active=options=>waitForAbort(options);
const polling=app.context.poll();
await app.runTimers(10000);
assert.equal(app.context.polling,false);
await polling;
assert.equal(app.element('sync-status').hidden,false);
app.hooks.active=()=>response([{id:7}]);
await app.tick(1000);
assert.equal(app.element('sync-status').hidden,true);
});
test('the timeout also covers a response body that never finishes and is cleared after failures', async () => {
const app=await workspace();
app.hooks.queue=options=>({ok:true,json:()=>waitForAbort(options)});
const loading=app.context.loadQueue();
await flush();
await app.runTimers(10000);
assert.match(app.element('queue').innerHTML,/队列暂时无法加载/);
await loading;
assert.equal(app.timers.size,0);
app.hooks.queue=()=>response([],503);
await app.context.loadQueue();
assert.equal(app.timers.size,0);
app.hooks.queue=()=>response([]);
await app.context.loadQueue();
assert.match(app.element('queue').innerHTML,/当前没有排队会话/);
assert.equal(app.timers.size,0);
});
test('failed replies preserve the draft and make retry available', async () => {
const app = await workspace();
app.hooks.send = () => response({ok:false}, 503);
app.element('replyText').value = '请确认这批货物的外箱尺寸';
await app.context.sendReply();
assert.equal(app.element('replyText').value, '请确认这批货物的外箱尺寸');
assert.equal(app.element('reply-send').disabled, false);
assert.match(app.element('reply-status').textContent, /内容已保留/);
app.hooks.send = () => response({ok:true});
await app.context.sendReply();
assert.equal(app.element('replyText').value, '');
});
test('a second send click cannot duplicate an in-flight reply', async () => {
const app = await workspace();
let resolve;
app.hooks.send = () => new Promise(done => { resolve = done; });
app.element('replyText').value = '正在确认线路';
const first = app.context.sendReply();
await app.context.sendReply();
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1);
assert.equal(app.element('reply-send').disabled, true);
resolve(response({ok:true}));
await first;
});
test('new text typed while a reply is sending is retained', async () => {
const app = await workspace();
let resolve;
app.hooks.send = () => new Promise(done => { resolve = done; });
const input = app.element('replyText');
input.value = '第一条回复';
const sending = app.context.sendReply();
input.value = '继续补充的第二条回复';
input.listeners.input();
resolve(response({ok:true}));
await sending;
assert.equal(input.value, '继续补充的第二条回复');
});
test('switching sessions during a send does not erase another customer draft', async () => {
const app = await workspace();
await app.context.accept(8);
app.element('replyText').value = '客户 8 的草稿';
app.context.switchSession('7');
app.element('replyText').value = '发给客户 7';
let resolve;
app.hooks.send = () => new Promise(done => { resolve = done; });
const sending = app.context.sendReply();
app.context.switchSession('8');
resolve(response({ok:true}));
await sending;
assert.equal(app.context.curSid, '8');
assert.equal(app.element('replyText').value, '客户 8 的草稿');
app.context.switchSession('7');
assert.equal(app.element('replyText').value, '');
});
test('quick replies become editable drafts before sending', async () => {
const app = await workspace();
app.context.quickReply('请提供运单号');
assert.equal(app.element('replyText').value, '请提供运单号');
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
assert.match(app.element('reply-status').textContent, /可编辑后发送/);
});
test('a failed close keeps the selected session and reply visible', async () => {
const app = await workspace();
app.hooks.close = () => response({ok:false}, 503);
await app.context.closeSession();
assert.equal(app.context.curSid, '7');
assert.equal(app.element('chatContent').style.display, 'flex');
assert.match(app.element('reply-status').textContent, /会话仍保留/);
});
test('IME confirmation and Shift+Enter do not submit a reply', async () => {
const app = await workspace();
app.element('replyText').value = '正在输入中文';
const onKey = app.element('replyText').listeners.keydown;
const preventDefault = () => {};
onKey({key:'Enter', isComposing:true, shiftKey:false, preventDefault});
onKey({key:'Enter', isComposing:false, shiftKey:true, preventDefault});
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
onKey({key:'Enter', isComposing:false, shiftKey:false, preventDefault});
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1);
});
test('the compact service drawer is keyboard accessible and closes on Escape', async () => {
const app = await workspace(true);
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'true');
app.context.setToolsOpen(true);
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'false');
assert.equal(app.document.activeElement, app.element('tools-close'));
app.documentListeners.keydown({key:'Escape'});
assert.equal(app.element('tools-toggle').attributes['aria-expanded'], 'false');
assert.equal(app.document.activeElement, app.element('tools-toggle'));
});
test('desktop service information starts closed and can be toggled on demand', async () => {
const app = await workspace();
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'true');
app.context.setToolsOpen(true);
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'false');
assert.equal(app.element('tools-toggle').attributes['aria-expanded'], 'true');
app.context.setToolsOpen(false);
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'true');
});
test('returning to the mobile session list keeps the selected session and draft', async () => {
const app = await workspace(true);
app.element('replyText').value = '返回列表前的草稿';
app.context.showSessionList();
assert.equal(app.context.curSid, '7');
assert.equal(app.document.body.classList.contains('chat-view'), false);
assert.equal(app.document.activeElement, app.element('tab-active'));
assert.equal(app.element('replyText').value, '返回列表前的草稿');
app.context.switchSession('7');
assert.equal(app.document.body.classList.contains('chat-view'), true);
assert.equal(app.element('replyText').value, '返回列表前的草稿');
assert.equal(app.calls.filter(call => call.url === '/api/human/close').length, 0);
});
test('mobile operators can open service information without selecting a conversation', async () => {
const app = await workspace(true);
app.context.showEmptySession();
app.element('tools-from-list').click();
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'false');
app.element('tools-close').click();
assert.equal(app.document.activeElement, app.element('tools-from-list'));
});
test('mobile drafts are measured after the chat view becomes visible', async () => {
const app = await workspace(true);
const input = app.element('replyText');
Object.defineProperty(input, 'scrollHeight', {
get: () => app.document.body.classList.contains('chat-view') ? 128 : 0,
});
input.value = '需要恢复的多行草稿';
app.context.showSessionList();
app.context.switchSession('7');
assert.equal(input.style.height, '128px');
assert.equal(input.value, '需要恢复的多行草稿');
});
test('transfer returns a conversation to the queue without auto-accepting it again', async () => {
const app = await workspace();
app.hooks.queue = () => response([{id:7, ctime:'now'}]);
await app.context.transferSession();
await flush();
assert.equal(app.context.curSid, 0);
assert.equal(app.context.sessions['7'], undefined);
assert.equal(app.calls.filter(call => call.url === '/api/human/accept').length, 1);
});
test('failed transfers retain the conversation and draft', async () => {
const app = await workspace();
app.hooks.transfer = () => response({ok:false}, 503);
app.element('replyText').value = '等待确认的草稿';
await app.context.transferSession();
assert.equal(app.context.curSid, '7');
assert.equal(app.element('replyText').value, '等待确认的草稿');
assert.match(app.element('reply-status').textContent, /转接未成功/);
});
test('reassignment removes the old owners composer but retains unsent text for recovery', async () => {
const app = await workspace();
app.element('replyText').value = '尚未发送的处理意见';
app.hooks.poll = () => response({status:'active',owner_username:'bob',messages:[]});
await app.context.poll();
assert.equal(app.context.curSid,0);
assert.equal(app.context.sessions['7'],undefined);
assert.equal(app.context.drafts['7'],'尚未发送的处理意见');
assert.equal(app.element('routing-draft-recovery').hidden,false);
assert.match(app.element('routing-retired-drafts').innerHTML,/尚未发送的处理意见/);
});
test('moving a session to an offline group preserves the previous owners draft', async () => {
const app = await workspace();
app.element('replyText').value = '需要转交的草稿';
app.hooks.poll = () => response({status:'waiting',owner_username:null,messages:[]});
await app.context.poll();
assert.equal(app.context.curSid,0);
assert.equal(app.context.drafts['7'],'需要转交的草稿');
assert.match(app.element('routing-retired-drafts').innerHTML,/需要转交的草稿/);
});
test('cancelling the in-page transfer confirmation leaves the session untouched', async () => {
const app = await workspace(false, {confirmResult:false});
await app.context.transferSession();
assert.equal(app.dialogs.length, 1);
assert.match(app.dialogs[0].message, /确定转接/);
assert.equal(app.calls.filter(call => call.url === '/api/human/transfer').length, 0);
assert.equal(app.context.curSid, '7');
});
test('a late note response cannot overwrite a different session note', async () => {
const app = await workspace();
app.context.sessions['7'].noteLoaded = false;
let resolve;
app.hooks.poll = url => url.includes('sid=7&') ? new Promise(done => { resolve = done; }) : response({status:'active', note:'客户 8 的备注', messages:[]});
const first = app.context.loadSessionNote('7');
app.context.sessions['8'] = {lastId:0, msgs:[], unread:0};
app.context.switchSession('8');
await flush();
resolve(response({status:'active', note:'客户 7 的备注', messages:[]}));
await first;
assert.equal(app.element('noteInput').value, '客户 8 的备注');
});
test('failed note saves keep the edited note available', async () => {
const app = await workspace();
app.hooks.note = () => response({ok:false}, 503);
app.element('noteInput').value = '下个班次继续跟进';
await app.context.saveNote();
assert.equal(app.element('noteInput').value, '下个班次继续跟进');
assert.match(app.element('reply-status').textContent, /备注保存失败/);
});
test('recalling an older reply leaves the newest reply intact', async () => {
const app = await workspace();
app.context.sessions['7'].msgs = [
{id:1, side:'agent', content:'较早回复'}, {id:2, side:'agent', content:'最新回复'},
];
app.context.sessions['7'].msgs.forEach(app.context.appendMsg);
await app.context.recallMsg(1);
assert.equal(app.context.sessions['7'].msgs[0].content, '该消息已撤回');
assert.equal(app.context.sessions['7'].msgs[1].content, '最新回复');
assert.equal(app.element('chatBox').children[0].textContent, '该消息已撤回');
});
test('historical search renders customer text without interpreting HTML', async () => {
const app = await workspace();
app.hooks.search = () => response([{title:'<img onerror=bad()>', content:'<script>bad()</script>', role:'user', ctime:'now'}]);
app.element('searchBox').value = '物流';
await app.context.doSearch();
assert.match(app.element('searchBody').innerHTML, /&lt;script&gt;/);
assert.doesNotMatch(app.element('searchBody').innerHTML, /<script>/);
});