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, /