保存今天的修改
This commit is contained in:
+370
-11
@@ -6,13 +6,14 @@ 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){
|
||||
async function workspace(compact = false, initialHooks = {}){
|
||||
const elements = new Map();
|
||||
const documentListeners = {};
|
||||
let document;
|
||||
@@ -43,6 +44,7 @@ async function workspace(compact = false){
|
||||
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()];
|
||||
@@ -52,6 +54,8 @@ async function workspace(compact = false){
|
||||
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;
|
||||
@@ -59,35 +63,390 @@ async function workspace(compact = false){
|
||||
addEventListener(name, callback){ const previous=documentListeners[name]; documentListeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
|
||||
};
|
||||
const calls = [];
|
||||
const hooks = {};
|
||||
const hooks = initialHooks;
|
||||
const timers = new Map(), intervals = new Map();
|
||||
let timerId = 0;
|
||||
const context = vm.createContext({
|
||||
document, console,
|
||||
window: {matchMedia: () => ({matches: compact}), addEventListener(){}, confirm(){ return true; }},
|
||||
setInterval(){}, setTimeout(){}, clearTimeout(){},
|
||||
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});
|
||||
if(url === '/api/human/send' && hooks.send) return hooks.send(body);
|
||||
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();
|
||||
if(url.includes('/api/human/poll') && hooks.poll) return hooks.poll(url);
|
||||
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 ? {ok:true} : []);
|
||||
return response(options && options.method ? {ok:true} : []);
|
||||
},
|
||||
});
|
||||
vm.runInContext(sharedSource, context, {filename:'support.js'});
|
||||
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, document, documentListeners};
|
||||
return {context, element: id => document.getElementById(id), calls, hooks, 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,/<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,/<script>/);
|
||||
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);
|
||||
|
||||
@@ -1045,6 +1045,48 @@ test('invalid customer credentials leave the login form open without sending ano
|
||||
assert.equal(app.element('customer-login').hidden,true);assert.equal(app.element('customer-password').value,'');
|
||||
});
|
||||
|
||||
test('mobile account entry shares login state and prevents duplicate actions while signing in', async () => {
|
||||
const app=await customerPage({human:false,width:390});
|
||||
let finishLogin;
|
||||
app.hooks['/api/customer/login']=()=>new Promise(resolve=>{finishLogin=resolve;});
|
||||
app.hooks['/api/customer/logout']=()=>response({ok:true,authenticated:false,user:null});
|
||||
const mobile=app.element('customer-account-mobile');
|
||||
mobile.focus();
|
||||
await app.click(mobile);
|
||||
assert.equal(app.element('customer-login').hidden,false);
|
||||
app.element('customer-username').value='customer';app.element('customer-password').value='test-password';
|
||||
const signing=app.element('customer-login-form').listeners.submit({preventDefault(){}});
|
||||
assert.equal(mobile.disabled,true);
|
||||
assert.equal(app.element('customer-account').disabled,true);
|
||||
await app.click(mobile);
|
||||
finishLogin(response({ok:true,authenticated:true,user:{id:3,name:'测试客户'}}));
|
||||
await signing;
|
||||
assert.equal(app.element('customer-login').hidden,true);
|
||||
assert.equal(mobile.textContent,app.element('customer-account').textContent);
|
||||
assert.match(mobile.textContent,/测试客户/);
|
||||
assert.equal(app.context.document.activeElement,mobile);
|
||||
await app.click(mobile);
|
||||
assert.equal(mobile.textContent,'客户登录');
|
||||
assert.equal(app.element('customer-account').textContent,'客户登录');
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/customer/login').length,1);
|
||||
});
|
||||
|
||||
test('language switching updates input controls and sidebar accessible labels', async () => {
|
||||
const app=await customerPage({human:false,width:390});
|
||||
await app.click(app.element('lang-toggle'));
|
||||
assert.equal(app.element('upload-btn').getAttribute('aria-label'),'Attach a file');
|
||||
assert.equal(app.element('emoji-toggle').getAttribute('aria-label'),'Choose an emoji');
|
||||
assert.equal(app.element('history-search').getAttribute('aria-label'),'Search conversation history');
|
||||
assert.equal(app.element('.sidebar-backdrop').getAttribute('aria-label'),'Close history sidebar');
|
||||
assert.equal(app.element('.toggle-divider').getAttribute('aria-label'),'Expand sidebar');
|
||||
assert.equal(app.element('customer-account-mobile').textContent,'Customer sign in');
|
||||
await app.click(app.element('.toggle-divider'));
|
||||
assert.equal(app.element('.toggle-divider').getAttribute('aria-label'),'Collapse sidebar');
|
||||
await app.click(app.element('lang-toggle'));
|
||||
assert.equal(app.element('upload-btn').getAttribute('aria-label'),'发送附件');
|
||||
assert.equal(app.element('.toggle-divider').getAttribute('aria-label'),'收起侧栏');
|
||||
});
|
||||
|
||||
test('a late login cannot replay an order into a newer conversation', async () => {
|
||||
const app=await customerPage({human:false});let release;
|
||||
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated:false,user:null});
|
||||
@@ -1071,7 +1113,7 @@ test('signing out clears visible order conversation and returns to guest mode',
|
||||
await app.context.window.fillInput('ORDER20260001');await flush();
|
||||
assert.ok(app.element('chatDom').querySelector('.user-message'));
|
||||
await app.click(app.element('customer-account'));
|
||||
assert.equal(app.element('customer-account').textContent,'登录');
|
||||
assert.equal(app.element('customer-account').textContent,'客户登录');
|
||||
assert.equal(app.element('chatDom').querySelector('.user-message'),null);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/customer/logout').length,1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Authenticate test clients through the production login and CSRF contracts."""
|
||||
|
||||
from logistics_chat import db
|
||||
|
||||
|
||||
def login_staff(app, client, username='staff-test-fixture', password='fixture-password', seed=True):
|
||||
if seed:
|
||||
with app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute('INSERT INTO agents(username,password,name) VALUES(?,?,?)',
|
||||
(username, password, 'Fixture Staff'))
|
||||
connection.commit()
|
||||
response = client.post('/admin/login', json={'username':username, 'password':password})
|
||||
assert response.status_code == 200, response.status_code
|
||||
session = client.get('/api/admin/session')
|
||||
assert session.status_code == 200, session.status_code
|
||||
client.environ_base['HTTP_X_CSRF_TOKEN'] = session.json['csrf_token']
|
||||
|
||||
@@ -14,6 +14,7 @@ from unittest.mock import patch
|
||||
import requests
|
||||
|
||||
from logistics_chat import create_app, db
|
||||
from staff_fixture import login_staff
|
||||
|
||||
|
||||
class ModelResponse:
|
||||
@@ -45,6 +46,7 @@ class ApiTests(unittest.TestCase):
|
||||
self.root = Path(self.directory.name)
|
||||
self.app = self.application(self.root)
|
||||
self.client = self.app.test_client()
|
||||
login_staff(self.app, self.client)
|
||||
self.client.post('/api/visitor/session', json={'key':'1'*64})
|
||||
self.app.extensions['rate_hits'].clear()
|
||||
from logistics_chat.order_data import import_records
|
||||
@@ -313,6 +315,21 @@ class ApiTests(unittest.TestCase):
|
||||
self.assertEqual(self.client.get('/api/human/active').json, [])
|
||||
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'], 'closed')
|
||||
|
||||
def test_human_queue_marks_items_over_five_minutes_using_server_clock(self):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
with patch('logistics_chat.human_service.datetime') as clock:
|
||||
clock.now.return_value = datetime(2026, 10, 29, 12, 0, tzinfo=timezone(timedelta(hours=8)))
|
||||
sid = self.client.post('/api/human/request',json={'conv_id':self.conversation()}).json['session_id']
|
||||
before = self.client.get('/api/human/queue').json[0]
|
||||
self.assertEqual((before['waiting_minutes'],before['overdue']), (0,False))
|
||||
clock.now.return_value = datetime(2026, 10, 29, 12, 5, tzinfo=timezone(timedelta(hours=8)))
|
||||
boundary = self.client.get('/api/human/queue').json[0]
|
||||
self.assertFalse(boundary['overdue'])
|
||||
clock.now.return_value = datetime(2026, 10, 29, 12, 5, 1, tzinfo=timezone(timedelta(hours=8)))
|
||||
after = self.client.get('/api/human/queue').json[0]
|
||||
self.assertEqual(after['id'], sid)
|
||||
self.assertEqual((after['waiting_minutes'],after['overdue'],after['overdue_minutes']), (5,True,1))
|
||||
|
||||
def test_application_state_is_not_shared_between_factories(self):
|
||||
self.app.extensions['human_typing']['1'] = 123
|
||||
self.app.extensions['rate_hits']['127.0.0.1'].append(123)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""CLI database backup, integrity and restore operations."""
|
||||
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from logistics_chat import database_backup
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class DatabaseMaintenanceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
self.database = self.root/'history.db'
|
||||
self.backups = self.root/'backups'
|
||||
self.backups.mkdir()
|
||||
with closing(sqlite3.connect(self.database)) as connection:
|
||||
connection.execute('CREATE TABLE records(value TEXT NOT NULL)')
|
||||
connection.execute("INSERT INTO records VALUES('original')")
|
||||
connection.commit()
|
||||
|
||||
def run_cli(self, *args):
|
||||
return subprocess.run([sys.executable, 'scripts/db_maintenance.py', '--db', str(self.database),
|
||||
'--backup-dir', str(self.backups), *args], capture_output=True, text=True,
|
||||
encoding='utf-8', timeout=15)
|
||||
|
||||
def test_backup_and_check_create_verified_snapshot(self):
|
||||
result = self.run_cli('backup')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
backup = Path(result.stdout.strip().split(': ', 1)[-1])
|
||||
self.assertTrue(backup.is_file())
|
||||
checked = self.run_cli('check', '--input', str(backup))
|
||||
self.assertEqual(checked.returncode, 0, checked.stderr)
|
||||
self.assertIn('ok', checked.stdout.lower())
|
||||
|
||||
def test_restore_keeps_current_safety_backup_and_replaces_database(self):
|
||||
source = self.backups/'source.db'
|
||||
with closing(sqlite3.connect(source)) as connection:
|
||||
connection.execute('CREATE TABLE records(value TEXT NOT NULL)')
|
||||
connection.execute("INSERT INTO records VALUES('restored')")
|
||||
connection.commit()
|
||||
result = self.run_cli('restore', '--input', str(source), '--service-stopped')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
with closing(sqlite3.connect(self.database)) as connection:
|
||||
self.assertEqual(connection.execute('SELECT value FROM records').fetchone()[0], 'restored')
|
||||
self.assertTrue(list(self.backups.glob('pre-restore-*.db')))
|
||||
|
||||
def test_restore_rejects_a_backup_outside_configured_directory(self):
|
||||
outside = self.root/'outside.db'
|
||||
outside.write_bytes(self.database.read_bytes())
|
||||
result = self.run_cli('restore', '--input', str(outside), '--service-stopped')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
with closing(sqlite3.connect(self.database)) as connection:
|
||||
self.assertEqual(connection.execute('SELECT value FROM records').fetchone()[0], 'original')
|
||||
|
||||
def test_restore_requires_explicit_stopped_service_declaration(self):
|
||||
result = self.run_cli('restore', '--input', str(self.backups/'source.db'))
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
|
||||
def test_corrupt_backup_cannot_overwrite_current_data(self):
|
||||
corrupt = self.backups/'corrupt.db'
|
||||
corrupt.write_bytes(b'not sqlite')
|
||||
result = self.run_cli('restore', '--input', str(corrupt), '--service-stopped')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
with closing(sqlite3.connect(self.database)) as connection:
|
||||
self.assertEqual(connection.execute('SELECT value FROM records').fetchone()[0], 'original')
|
||||
|
||||
def test_wal_records_are_present_and_snapshot_does_not_overwrite_existing_file(self):
|
||||
destination = self.backups/'wal.db'
|
||||
with closing(sqlite3.connect(self.database)) as connection:
|
||||
connection.execute('PRAGMA journal_mode=WAL')
|
||||
connection.execute("INSERT INTO records VALUES('committed-wal')")
|
||||
connection.commit()
|
||||
database_backup.snapshot_database(self.database, destination)
|
||||
with closing(sqlite3.connect(destination)) as copied:
|
||||
self.assertEqual(copied.execute('SELECT COUNT(*) FROM records').fetchone()[0], 2)
|
||||
with self.assertRaises(FileExistsError):
|
||||
database_backup.snapshot_database(self.database, destination)
|
||||
self.assertEqual(list(self.backups.glob('*.tmp*')), [])
|
||||
|
||||
def test_restore_of_clean_wal_database_does_not_leave_or_reuse_sidecars(self):
|
||||
with closing(sqlite3.connect(self.database)) as connection:
|
||||
connection.execute('PRAGMA journal_mode=WAL')
|
||||
source = self.backups/'source.db'
|
||||
database_backup.snapshot_database(self.database, source)
|
||||
# A read-only WAL source can leave empty coordination sidecars.
|
||||
with closing(sqlite3.connect(self.database)) as connection:
|
||||
connection.execute('PRAGMA wal_checkpoint(TRUNCATE)')
|
||||
database_backup.restore_database(self.database, source, self.backups)
|
||||
for suffix in ('-wal','-shm'):
|
||||
self.assertFalse(Path(str(self.database)+suffix).exists())
|
||||
database_backup.restore_database(self.database, source, self.backups)
|
||||
|
||||
def test_daily_worker_works_without_requests_retries_failures_and_deduplicates(self):
|
||||
from flask import Flask
|
||||
app = Flask(__name__)
|
||||
app.config.update(DATABASE=self.database, BACKUP_DIR=self.backups)
|
||||
worker = database_backup.DailyBackupWorker(app)
|
||||
tomorrow = datetime.now(database_backup.CHINA_TIME) + timedelta(days=1)
|
||||
with patch('logistics_chat.database_backup.daily_backup', side_effect=OSError('fixture disk full')):
|
||||
with self.assertRaises(OSError):
|
||||
worker.tick(tomorrow)
|
||||
self.assertTrue(worker.tick(tomorrow))
|
||||
self.assertFalse(worker.tick(tomorrow))
|
||||
self.assertEqual(len(list(self.backups.glob('history_*.db'))), 1)
|
||||
|
||||
def test_missing_database_and_invalid_snapshot_are_never_created_or_published(self):
|
||||
missing = self.root/'missing.db'
|
||||
with self.assertRaises(sqlite3.Error):
|
||||
database_backup.snapshot_database(missing, self.backups/'bad.db')
|
||||
self.assertFalse(missing.exists())
|
||||
self.assertFalse((self.backups/'bad.db').exists())
|
||||
self.assertEqual(list(self.backups.glob('*.tmp')), [])
|
||||
|
||||
def test_daily_retention_leaves_upgrade_and_manual_backups_untouched(self):
|
||||
for name in ('history_20000101.db','pre-migration-old.db','manual-old.db','history_invalid.db'):
|
||||
(self.backups/name).write_bytes(b'fixture')
|
||||
database_backup.daily_backup(self.database, self.backups)
|
||||
self.assertFalse((self.backups/'history_20000101.db').exists())
|
||||
for name in ('pre-migration-old.db','manual-old.db','history_invalid.db'):
|
||||
self.assertTrue((self.backups/name).exists())
|
||||
|
||||
def test_daily_backup_keeps_exactly_seven_calendar_dates(self):
|
||||
today = datetime.now(database_backup.CHINA_TIME)
|
||||
for offset in range(1, 9):
|
||||
(self.backups/('history_' + (today-timedelta(days=offset)).strftime('%Y%m%d') + '.db')).write_bytes(b'old')
|
||||
database_backup.daily_backup(self.database, self.backups, today)
|
||||
self.assertEqual(len(list(self.backups.glob('history_*.db'))), 7)
|
||||
|
||||
def test_migration_retention_removes_only_oldest_recognized_snapshots(self):
|
||||
names = []
|
||||
for index in range(7):
|
||||
name = f'pre-migration-20261102-12000{index}-000000-{index:08x}.db'
|
||||
names.append(name)
|
||||
(self.backups/name).write_bytes(b'fixture')
|
||||
(self.backups/'pre-migration-old.db').write_bytes(b'keep')
|
||||
(self.backups/'manual-old.db').write_bytes(b'keep')
|
||||
(self.backups/'pre-migration-20261102-120000-000000-badname.db').write_bytes(b'keep')
|
||||
(self.backups/'pre-migration-20261102-120000-000000-ffffffff.db').mkdir()
|
||||
|
||||
database_backup.prune_migration_snapshots(self.backups, keep=5)
|
||||
|
||||
self.assertEqual([name for name in names if (self.backups/name).exists()], names[2:])
|
||||
self.assertTrue((self.backups/'pre-migration-old.db').exists())
|
||||
self.assertTrue((self.backups/'manual-old.db').exists())
|
||||
self.assertTrue((self.backups/'pre-migration-20261102-120000-000000-badname.db').exists())
|
||||
self.assertTrue((self.backups/'pre-migration-20261102-120000-000000-ffffffff.db').is_dir())
|
||||
@@ -9,6 +9,7 @@ import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from deploy import wsgi
|
||||
from staff_fixture import login_staff
|
||||
|
||||
|
||||
class DeploymentTests(unittest.TestCase):
|
||||
@@ -30,9 +31,10 @@ class DeploymentTests(unittest.TestCase):
|
||||
self.addCleanup(close_handlers)
|
||||
client = app.test_client()
|
||||
client.post('/api/visitor/session', json={'key':'1'*64})
|
||||
for path in ('/', '/admin/dashboard', '/api/tickets', '/api/my_orders?phone=123'):
|
||||
self.assertEqual(client.get(path).status_code, 200, path)
|
||||
for path, status in (('/',200), ('/admin/dashboard',302), ('/api/tickets',401), ('/api/my_orders?phone=123',401)):
|
||||
self.assertEqual(client.get(path).status_code, status, path)
|
||||
self.assertEqual(client.post('/admin/login', json={'username': 'demo', 'password': 'test-password'}).status_code, 401)
|
||||
login_staff(app, client)
|
||||
self.assertEqual(client.post('/api/ticket', json={'content': 'Acceptance ticket', 'phone': '123'}).status_code, 200)
|
||||
self.assertEqual(client.get('/api/tickets').json[0]['content'], 'Acceptance ticket')
|
||||
uploaded = client.post('/api/upload', data={'file': (io.BytesIO(b'acceptance'), 'note.txt')})
|
||||
|
||||
@@ -10,6 +10,7 @@ from unittest.mock import patch
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from logistics_chat import create_app, db
|
||||
from staff_fixture import login_staff
|
||||
|
||||
|
||||
class DevIntegrationTests(unittest.TestCase):
|
||||
@@ -22,6 +23,7 @@ class DevIntegrationTests(unittest.TestCase):
|
||||
'DEEPSEEK_API_KEY': '', 'RATE_LIMIT_PER_MIN': 1000})
|
||||
self.client = self.app.test_client()
|
||||
self.client.post('/api/visitor/session', json={'key': '1' * 64})
|
||||
login_staff(self.app, self.client)
|
||||
|
||||
def session(self):
|
||||
cid = self.client.post('/api/new_chat').json['id']
|
||||
@@ -130,6 +132,7 @@ class DevIntegrationTests(unittest.TestCase):
|
||||
connection.commit()
|
||||
connection.close()
|
||||
db.init_db()
|
||||
login_staff(self.app, self.client, 'demo', seed=False)
|
||||
results = self.client.get('/api/search_chat?q=专线').json
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertIn('SUM', results[0]['content'])
|
||||
@@ -160,7 +163,7 @@ class DevIntegrationTests(unittest.TestCase):
|
||||
state = self.client.get('/api/human/poll?sid=1').json
|
||||
self.assertEqual(state['messages'][0]['content'], '保留的回复')
|
||||
self.assertEqual(state['note'], '')
|
||||
self.assertEqual(self.client.get('/api/agents/list').json[0]['username'], 'fixture-agent')
|
||||
self.assertIn('fixture-agent', [row['username'] for row in self.client.get('/api/agents/list').json])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Existing staff credentials remain usable across password storage formats."""
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from logistics_chat import create_app, db
|
||||
|
||||
|
||||
class StaffLoginTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
root = Path(directory.name)
|
||||
self.app = create_app({'TESTING': True, 'DATABASE': root / 'history.db',
|
||||
'UPLOAD_DIR': root / 'uploads', 'BACKUP_DIR': root / 'backups',
|
||||
'LOG_DIR': root / 'logs', 'DEEPSEEK_API_KEY': '',
|
||||
'CUSTOMER_DEMO_ENABLED': False})
|
||||
self.client = self.app.test_client()
|
||||
|
||||
def add_staff(self, password):
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute('INSERT INTO agents(username,password,name) VALUES(?,?,?)',
|
||||
('staff-fixture', password, '测试客服'))
|
||||
connection.commit()
|
||||
|
||||
def login(self, password):
|
||||
return self.client.post('/admin/login', json={'username': 'staff-fixture', 'password': password})
|
||||
|
||||
def test_existing_hashed_password_accepts_original_password(self):
|
||||
password = 'Fixture-密码-42!'
|
||||
for method in ('pbkdf2:sha256:1000', 'scrypt:1024:8:1'):
|
||||
with self.subTest(method=method):
|
||||
encoded = generate_password_hash(password, method=method)
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute('DELETE FROM agents')
|
||||
connection.commit()
|
||||
self.add_staff(encoded)
|
||||
response = self.login(password)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json, {'ok': True, 'name': '测试客服'})
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
self.assertEqual(connection.execute('SELECT password FROM agents').fetchone()[0], encoded)
|
||||
|
||||
def test_wrong_password_and_stored_hash_cannot_log_in(self):
|
||||
encoded = generate_password_hash('Fixture-password', method='pbkdf2:sha256:1000')
|
||||
self.add_staff(encoded)
|
||||
self.assertEqual(self.login('wrong-password').status_code, 401)
|
||||
self.assertEqual(self.login(encoded).status_code, 401)
|
||||
|
||||
def test_legacy_plain_password_still_works(self):
|
||||
self.add_staff('旧密码-fixture')
|
||||
self.assertEqual(self.login('旧密码-fixture').status_code, 200)
|
||||
self.assertEqual(self.login('wrong').status_code, 401)
|
||||
|
||||
def test_invalid_credentials_are_rejected_without_server_errors(self):
|
||||
self.add_staff('legacy-fixture')
|
||||
for payload in ({}, [], 'invalid', {'username': [], 'password': {}},
|
||||
{'username': 'staff-fixture', 'password': ''},
|
||||
{'username': 'missing', 'password': 'legacy-fixture'}):
|
||||
with self.subTest(payload=payload):
|
||||
self.app.extensions['staff_login_attempts'].clear()
|
||||
self.assertEqual(self.client.post('/admin/login', json=payload).status_code, 401)
|
||||
|
||||
def test_corrupt_hash_cannot_be_used_as_a_plain_password(self):
|
||||
encoded = 'pbkdf2:invalid$fixture$invalid'
|
||||
self.add_staff(encoded)
|
||||
self.assertEqual(self.login(encoded).status_code, 401)
|
||||
self.assertEqual(self.login('test-password').status_code, 401)
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Staff authorization uses real logins against disposable storage."""
|
||||
|
||||
import hashlib
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import test_staff_login as credentials
|
||||
from logistics_chat import db
|
||||
|
||||
|
||||
class StaffSessionTests(unittest.TestCase):
|
||||
add_staff = credentials.StaffLoginTests.add_staff
|
||||
login = credentials.StaffLoginTests.login
|
||||
|
||||
def setUp(self):
|
||||
credentials.StaffLoginTests.setUp(self)
|
||||
self.app.config['RATE_LIMIT_PER_MIN'] = 1000
|
||||
|
||||
def signed_in(self):
|
||||
self.add_staff('fixture-password')
|
||||
response = self.login('fixture-password')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return self.client.get('/api/admin/session').json['csrf_token']
|
||||
|
||||
def test_anonymous_staff_pages_and_all_staff_endpoints_are_blocked(self):
|
||||
self.assertEqual(self.client.get('/admin/dashboard').status_code, 302)
|
||||
public = {'static', 'pages.index', 'pages.health', 'admin.admin', 'admin.admin_login',
|
||||
'chat.stream_chat', 'chat.feedback', 'history.new_chat', 'history.get_history',
|
||||
'history.get_messages', 'history.del_history', 'history.clear_all',
|
||||
'files.upload_file', 'files.uploaded_file', 'business.add_ticket', 'business.rate_chat',
|
||||
'visitor.visitor_session', 'visitor.visitor_human', 'customer.session',
|
||||
'customer.login', 'customer.logout', 'speech.speech_config', 'speech.speech_transcribe'}
|
||||
for rule in self.app.url_map.iter_rules():
|
||||
if rule.endpoint in public or rule.rule == '/admin/dashboard':
|
||||
continue
|
||||
method = 'POST' if 'POST' in rule.methods else 'GET'
|
||||
path = rule.rule.replace('<path:name>', 'fixture.txt')
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.client.open(path, method=method, json={}).status_code, 401)
|
||||
self.assertEqual(self.client.get('/api/future-private-route').status_code, 401)
|
||||
self.assertEqual(self.client.get('/').status_code, 200)
|
||||
self.assertEqual(self.client.get('/healthz').status_code, 200)
|
||||
|
||||
def test_customer_ticket_and_rating_keep_their_existing_guest_contract(self):
|
||||
self.client.post('/api/visitor/session', json={'key':'a'*64})
|
||||
self.assertEqual(self.client.post('/api/ticket', json={'content':'fixture ticket'}).status_code, 200)
|
||||
self.assertEqual(self.client.post('/api/rate', json={'sid':999, 'stars':5}).status_code, 404)
|
||||
|
||||
def test_cookie_and_database_store_24_hour_opaque_sessions(self):
|
||||
self.add_staff('fixture-password')
|
||||
response = self.client.post('/admin/login', json={'username':'staff-fixture', 'password':'fixture-password'},
|
||||
base_url='https://localhost')
|
||||
cookie = response.headers['Set-Cookie']
|
||||
for flag in ('HttpOnly', 'Secure', 'SameSite=Lax', 'Max-Age=86400'):
|
||||
self.assertIn(flag, cookie)
|
||||
token = self.client.get_cookie('baiyue_staff').value
|
||||
with self.app.app_context():
|
||||
row = db.connect().execute('SELECT token_hash FROM staff_sessions').fetchone()
|
||||
self.assertEqual(row[0], hashlib.sha256(token.encode()).hexdigest())
|
||||
self.assertNotIn(token, self.client.get('/admin/dashboard').text)
|
||||
self.assertEqual(self.client.get('/admin/dashboard').status_code, 200)
|
||||
|
||||
def test_staff_writes_require_csrf_and_same_origin(self):
|
||||
csrf = self.signed_in()
|
||||
self.assertEqual(self.client.post('/api/human/note', json={}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/admin/logout', headers={'X-CSRF-Token':'bad'}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/admin/logout', headers={'X-CSRF-Token':csrf, 'Origin':'https://evil.test'}).status_code, 403)
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 200)
|
||||
|
||||
def test_logout_revokes_replayed_cookie_and_preserves_data(self):
|
||||
csrf = self.signed_in()
|
||||
token = self.client.get_cookie('baiyue_staff').value
|
||||
self.assertEqual(self.client.post('/admin/logout', headers={'X-CSRF-Token':csrf}).status_code, 200)
|
||||
self.assertIsNone(self.client.get_cookie('baiyue_staff'))
|
||||
self.client.set_cookie('baiyue_staff', token)
|
||||
self.assertEqual(self.client.get('/api/admin/stats').status_code, 401)
|
||||
with self.app.app_context():
|
||||
self.assertEqual(db.connect().execute('SELECT COUNT(*) FROM agents').fetchone()[0], 1)
|
||||
|
||||
def test_login_rotates_current_token(self):
|
||||
self.signed_in()
|
||||
old = self.client.get_cookie('baiyue_staff').value
|
||||
self.login('fixture-password')
|
||||
self.assertNotEqual(old, self.client.get_cookie('baiyue_staff').value)
|
||||
self.client.set_cookie('baiyue_staff', old)
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
|
||||
|
||||
def test_sessions_expire_at_24_hours_without_renewing_on_requests_or_restart(self):
|
||||
from logistics_chat import create_app
|
||||
with patch('logistics_chat.staff_service.time.time', return_value=1000):
|
||||
self.signed_in()
|
||||
token = self.client.get_cookie('baiyue_staff').value
|
||||
restarted = self.client
|
||||
for elapsed in (8*3600, 23*3600, 24*3600-1):
|
||||
with self.subTest(elapsed=elapsed), patch('logistics_chat.staff_service.time.time', return_value=1000+elapsed):
|
||||
response = self.client.get('/api/admin/session')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn('Set-Cookie', response.headers)
|
||||
self.assertEqual(self.client.get_cookie('baiyue_staff').value, token)
|
||||
restarted = create_app(dict(self.app.config)).test_client()
|
||||
restarted.set_cookie('baiyue_staff', token)
|
||||
self.assertEqual(restarted.get('/api/admin/session').status_code, 200)
|
||||
with patch('logistics_chat.staff_service.time.time', return_value=1000+24*3600):
|
||||
for client in (self.client, restarted):
|
||||
self.assertEqual(client.get('/api/admin/session').status_code, 401)
|
||||
self.assertEqual(client.get('/admin/dashboard').status_code, 302)
|
||||
|
||||
def test_sessions_survive_app_restart_until_explicit_logout(self):
|
||||
from logistics_chat import create_app
|
||||
self.signed_in()
|
||||
token = self.client.get_cookie('baiyue_staff').value
|
||||
restarted = create_app(dict(self.app.config)).test_client()
|
||||
restarted.set_cookie('baiyue_staff', token)
|
||||
response = restarted.get('/api/admin/session')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(restarted.post('/admin/logout', headers={'X-CSRF-Token':response.json['csrf_token']}).status_code, 200)
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
|
||||
|
||||
def test_another_browser_login_does_not_remove_existing_session(self):
|
||||
csrf = self.signed_in()
|
||||
another = self.app.test_client()
|
||||
self.assertEqual(another.post('/admin/login', json={'username':'staff-fixture','password':'fixture-password'}).status_code, 200)
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 200)
|
||||
self.assertEqual(self.client.post('/admin/logout', headers={'X-CSRF-Token':csrf}).status_code, 200)
|
||||
self.assertEqual(another.get('/api/admin/session').status_code, 200)
|
||||
|
||||
def test_upgrade_preserves_deadlines_and_revokes_unbounded_and_expired_sessions(self):
|
||||
self.signed_in()
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute('UPDATE staff_sessions SET expires_at=2000')
|
||||
connection.execute('INSERT INTO staff_sessions SELECT ?,username,auth_stamp,csrf_token,500 FROM staff_sessions',
|
||||
(hashlib.sha256(('b'*64).encode()).hexdigest(),))
|
||||
connection.execute('INSERT INTO staff_sessions SELECT ?,username,auth_stamp,csrf_token,0 FROM staff_sessions LIMIT 1',
|
||||
(hashlib.sha256(('c'*64).encode()).hexdigest(),))
|
||||
connection.commit()
|
||||
with patch('logistics_chat.staff_service.time.time', return_value=1000):
|
||||
db.init_db()
|
||||
with patch('logistics_chat.staff_service.time.time', return_value=1500):
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 200)
|
||||
for token in ('b'*64, 'c'*64):
|
||||
other = self.app.test_client()
|
||||
other.set_cookie('baiyue_staff', token)
|
||||
self.assertEqual(other.get('/api/admin/session').status_code, 401)
|
||||
with patch('logistics_chat.staff_service.time.time', return_value=2000):
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
|
||||
|
||||
def test_password_change_and_account_deletion_invalidate_sessions(self):
|
||||
self.signed_in()
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute("UPDATE agents SET password='new-fixture-password'")
|
||||
connection.commit()
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
|
||||
self.login('new-fixture-password')
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute('DELETE FROM agents')
|
||||
connection.commit()
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
|
||||
|
||||
def test_legacy_agent_id_session_schema_is_upgraded_without_changing_credentials(self):
|
||||
from werkzeug.security import generate_password_hash
|
||||
password_hash = generate_password_hash('fixture-password', method='scrypt:1024:8:1')
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute('DROP TABLE staff_sessions')
|
||||
connection.execute('DROP TABLE agents')
|
||||
connection.execute('CREATE TABLE agents(id INTEGER PRIMARY KEY, username TEXT UNIQUE, '
|
||||
"password TEXT, name TEXT, role TEXT NOT NULL DEFAULT 'agent')")
|
||||
connection.execute('INSERT INTO agents VALUES(?,?,?,?,?)', (7,'staff-fixture',password_hash,'测试客服','admin'))
|
||||
connection.execute('CREATE TABLE staff_sessions(id TEXT PRIMARY KEY, agent_id INTEGER NOT NULL, '
|
||||
'created_at REAL NOT NULL, expires_at REAL NOT NULL, password_version TEXT NOT NULL)')
|
||||
connection.execute('INSERT INTO staff_sessions VALUES(?,?,?,?,?)', ('legacy-cookie',7,1000,9999999999,'legacy-version'))
|
||||
connection.execute("INSERT INTO tickets(content,phone) VALUES('fixture-ticket','fixture-phone')")
|
||||
connection.commit()
|
||||
db.init_db()
|
||||
db.init_db()
|
||||
self.assertEqual(connection.execute('SELECT id,username,password,name,role FROM agents').fetchone(),
|
||||
(7,'staff-fixture',password_hash,'测试客服','admin'))
|
||||
self.assertEqual(connection.execute('SELECT content FROM tickets').fetchone()[0], 'fixture-ticket')
|
||||
self.assertEqual(self.login('fixture-password').status_code, 200)
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 200)
|
||||
self.assertEqual(self.client.get('/admin/dashboard').status_code, 200)
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute("UPDATE agents SET password='new-fixture-password'")
|
||||
connection.commit()
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
|
||||
self.login('new-fixture-password')
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute('DELETE FROM agents')
|
||||
connection.commit()
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
|
||||
|
||||
def test_cross_site_login_and_form_login_are_rejected(self):
|
||||
self.add_staff('fixture-password')
|
||||
self.assertEqual(self.client.post('/admin/login', json={'username':'staff-fixture','password':'fixture-password'},
|
||||
headers={'Origin':'https://evil.test'}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/admin/login', data={'username':'staff-fixture','password':'fixture-password'}).status_code, 415)
|
||||
|
||||
def test_restoring_password_or_recreating_account_cannot_restore_old_session(self):
|
||||
for change in ('password', 'account', 'replace'):
|
||||
with self.subTest(change=change):
|
||||
if change == 'password':
|
||||
self.signed_in()
|
||||
else:
|
||||
self.login('fixture-password')
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
if change == 'password':
|
||||
connection.execute("UPDATE agents SET password='temporary-password'")
|
||||
connection.execute("UPDATE agents SET password='fixture-password'")
|
||||
elif change == 'account':
|
||||
connection.execute('DELETE FROM agents')
|
||||
connection.execute('INSERT INTO agents VALUES(?,?,?)', ('staff-fixture','fixture-password','测试客服'))
|
||||
else:
|
||||
for password in ('temporary-password', 'fixture-password'):
|
||||
connection.execute('INSERT OR REPLACE INTO agents VALUES(?,?,?)', ('staff-fixture',password,'测试客服'))
|
||||
connection.commit()
|
||||
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
|
||||
|
||||
def test_failed_logins_are_throttled_separately_from_polling(self):
|
||||
self.add_staff('fixture-password')
|
||||
for _ in range(5):
|
||||
self.assertEqual(self.login('wrong').status_code, 401)
|
||||
self.assertEqual(self.login('wrong').status_code, 429)
|
||||
self.assertEqual(self.client.get('/healthz').status_code, 200)
|
||||
|
||||
def test_random_cookie_and_customer_session_do_not_authorize_staff(self):
|
||||
self.client.set_cookie('baiyue_staff', 'a'*64)
|
||||
self.client.set_cookie('baiyue_customer', 'b'*64)
|
||||
self.client.post('/api/visitor/session', json={'key':'c'*64})
|
||||
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
|
||||
self.assertEqual(self.client.get('/api/tickets').status_code, 401)
|
||||
|
||||
def test_proxy_peer_failures_do_not_lock_other_accounts_or_trust_spoofed_headers(self):
|
||||
self.add_staff('fixture-password')
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute('INSERT INTO agents VALUES(?,?,?)', ('other-fixture','other-password','Other Staff'))
|
||||
connection.commit()
|
||||
for _ in range(5):
|
||||
self.assertEqual(self.login('wrong').status_code, 401)
|
||||
fresh = self.app.test_client()
|
||||
self.assertEqual(fresh.post('/admin/login', json={'username':'staff-fixture','password':'wrong'},
|
||||
headers={'X-Forwarded-For':'203.0.113.42'}).status_code, 429)
|
||||
self.assertEqual(fresh.post('/admin/login', json={'username':'other-fixture','password':'other-password'}).status_code, 200)
|
||||
@@ -8,6 +8,9 @@ import time
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
from unittest.mock import patch
|
||||
from contextlib import closing
|
||||
import sqlite3
|
||||
|
||||
from flask import Flask
|
||||
from werkzeug.datastructures import FileStorage
|
||||
@@ -115,6 +118,95 @@ class StorageTests(unittest.TestCase):
|
||||
self.assertFalse(old.exists())
|
||||
self.assertEqual((uploads/'new.txt').read_text(), 'new')
|
||||
|
||||
def test_schema_version_and_pre_migration_snapshot_are_recorded_before_upgrade(self):
|
||||
connection = db.connect()
|
||||
connection.execute('DROP TABLE IF EXISTS schema_meta')
|
||||
connection.commit()
|
||||
connection.close()
|
||||
db.init_db()
|
||||
connection = db.connect()
|
||||
self.assertEqual(connection.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0], str(db.SCHEMA_VERSION))
|
||||
connection.close()
|
||||
snapshots = list((self.root/'backups').glob('pre-migration-*.db'))
|
||||
self.assertEqual(len(snapshots), 1)
|
||||
self.assertEqual(db.check_database(snapshots[0]), 'ok')
|
||||
with closing(sqlite3.connect(snapshots[0])) as snapshot:
|
||||
self.assertIsNone(snapshot.execute("SELECT 1 FROM sqlite_master WHERE name='schema_meta'").fetchone())
|
||||
|
||||
def test_startup_migration_snapshots_keep_only_the_latest_five(self):
|
||||
for _ in range(7):
|
||||
db.init_db()
|
||||
snapshots = sorted((self.root/'backups').glob('pre-migration-*.db'))
|
||||
self.assertEqual(len(snapshots), 5)
|
||||
self.assertTrue(all(path.is_file() for path in snapshots))
|
||||
self.assertTrue(all(db.check_database(path) == 'ok' for path in snapshots))
|
||||
|
||||
def test_backup_failure_prevents_any_schema_changes(self):
|
||||
with closing(db.connect()) as connection:
|
||||
connection.execute('DROP TABLE schema_meta')
|
||||
connection.commit()
|
||||
with patch('logistics_chat.db.snapshot_database', side_effect=OSError('fixture disk full')):
|
||||
with self.assertRaisesRegex(OSError, 'disk full'):
|
||||
db.init_db()
|
||||
with closing(db.connect()) as connection:
|
||||
self.assertIsNone(connection.execute("SELECT 1 FROM sqlite_master WHERE name='schema_meta'").fetchone())
|
||||
|
||||
def test_failed_startup_snapshot_does_not_prune_previous_backups(self):
|
||||
backups = self.root/'backups'
|
||||
backups.mkdir()
|
||||
previous = []
|
||||
for index in range(6):
|
||||
path = backups/f'pre-migration-20260101-00000{index}-000000-{index:08x}.db'
|
||||
db.snapshot_database(self.root/'history.db', path)
|
||||
previous.append(path)
|
||||
with patch('logistics_chat.db.snapshot_database', side_effect=OSError('fixture disk full')):
|
||||
with self.assertRaises(OSError):
|
||||
db.init_db()
|
||||
self.assertTrue(all(path.exists() for path in previous))
|
||||
|
||||
def test_failed_migration_rolls_back_schema_and_data_and_keeps_preimage(self):
|
||||
with closing(db.connect()) as connection:
|
||||
connection.execute('DROP TABLE schema_meta')
|
||||
connection.commit()
|
||||
def broken(connection):
|
||||
connection.execute('CREATE TABLE partial_upgrade(value TEXT)')
|
||||
connection.execute("INSERT INTO tickets(content) VALUES('must roll back')")
|
||||
raise RuntimeError('fixture migration failed')
|
||||
with patch('logistics_chat.db.MIGRATIONS', ((1, broken), *db.MIGRATIONS[1:])):
|
||||
with self.assertRaisesRegex(RuntimeError, 'migration failed'):
|
||||
db.init_db()
|
||||
with closing(db.connect()) as connection:
|
||||
self.assertIsNone(connection.execute("SELECT 1 FROM sqlite_master WHERE name='partial_upgrade'").fetchone())
|
||||
self.assertEqual(connection.execute('SELECT COUNT(*) FROM tickets').fetchone()[0], 0)
|
||||
self.assertEqual(len(list((self.root/'backups').glob('pre-migration-*.db'))), 1)
|
||||
|
||||
def test_newer_database_version_is_never_downgraded(self):
|
||||
with closing(db.connect()) as connection:
|
||||
connection.execute("UPDATE schema_meta SET value='999' WHERE key='schema_version'")
|
||||
connection.commit()
|
||||
with self.assertRaisesRegex(RuntimeError, 'newer'):
|
||||
db.init_db()
|
||||
with closing(db.connect()) as connection:
|
||||
self.assertEqual(connection.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0], '999')
|
||||
|
||||
def test_missing_migration_step_cannot_mark_database_as_upgraded(self):
|
||||
with patch('logistics_chat.db.SCHEMA_VERSION', db.SCHEMA_VERSION + 1):
|
||||
with self.assertRaisesRegex(RuntimeError, 'migration'):
|
||||
db.init_db()
|
||||
with closing(db.connect()) as connection:
|
||||
self.assertEqual(connection.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0], str(db.SCHEMA_VERSION))
|
||||
|
||||
def test_snapshot_backup_is_verified_and_restore_source_is_not_modified(self):
|
||||
destination = self.root/'backups'/'manual.db'
|
||||
db.snapshot_database(self.root/'history.db', destination)
|
||||
self.assertEqual(db.check_database(destination), 'ok')
|
||||
original = destination.read_bytes()
|
||||
connection = db.connect()
|
||||
connection.execute("INSERT INTO chat_history(title,ctime) VALUES('after-backup','now')")
|
||||
connection.commit()
|
||||
connection.close()
|
||||
self.assertEqual(destination.read_bytes(), original)
|
||||
|
||||
def test_upload_uses_generated_name_and_configured_directory(self):
|
||||
upload = FileStorage(stream=io.BytesIO(b'hello'), filename='../report.txt')
|
||||
result = file_service.upload_file({'file': upload})
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Offline ticket workflow uses authenticated staff and isolated SQLite storage."""
|
||||
|
||||
from contextlib import closing
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from threading import Barrier
|
||||
from unittest.mock import patch
|
||||
from datetime import datetime
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from logistics_chat import create_app, db
|
||||
from staff_fixture import login_staff
|
||||
|
||||
|
||||
class TicketTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.root = Path(directory.name)
|
||||
self.app = create_app({'TESTING': True, 'DATABASE': self.root/'history.db',
|
||||
'UPLOAD_DIR': self.root/'uploads', 'BACKUP_DIR': self.root/'backups',
|
||||
'LOG_DIR': self.root/'logs', 'DEEPSEEK_API_KEY': '', 'RATE_LIMIT_PER_MIN': 1000})
|
||||
self.client = self.app.test_client()
|
||||
self.other = self.app.test_client()
|
||||
login_staff(self.app, self.client, username='alice')
|
||||
login_staff(self.app, self.other, username='bob')
|
||||
self.client.post('/api/visitor/session', json={'key':'a'*64})
|
||||
self.assertEqual(self.client.post('/api/ticket', json={'content':'请查询派送', 'phone':'123'}).status_code, 200)
|
||||
self.ticket = self.client.get('/api/tickets').json[0]['id']
|
||||
|
||||
def claim(self, client=None):
|
||||
return (client or self.client).post(f'/api/tickets/{self.ticket}/claim', json={})
|
||||
|
||||
def update(self, payload, client=None):
|
||||
return (client or self.client).patch(f'/api/tickets/{self.ticket}', json=payload)
|
||||
|
||||
def test_claim_notes_and_completion_remain_queryable(self):
|
||||
self.assertEqual(self.update({'version':0,'note':'不能跳过领取','status':'completed'}).status_code, 409)
|
||||
claimed = self.claim()
|
||||
self.assertEqual(claimed.status_code, 200)
|
||||
ticket = claimed.json['ticket']
|
||||
self.assertEqual((ticket['status'], ticket['owner_username']), ('processing', 'alice'))
|
||||
saved = self.update({'version':ticket['version'],'note':'已联系派送站'})
|
||||
self.assertEqual(saved.status_code, 200)
|
||||
done = self.update({'version':saved.json['ticket']['version'],'note':'已联系并处理完成','status':'completed'})
|
||||
self.assertEqual(done.status_code, 200)
|
||||
self.assertTrue(done.json['ticket']['completed_at'])
|
||||
self.assertEqual(self.client.get('/api/tickets').json, [])
|
||||
record = self.client.get('/api/tickets?status=completed').json[0]
|
||||
self.assertEqual(record['note'], '已联系并处理完成')
|
||||
self.assertEqual(record['owner_username'], 'alice')
|
||||
self.assertEqual(self.update({'version':record['version'],'note':'重新修改'}).status_code, 409)
|
||||
|
||||
def test_other_staff_cannot_take_over_or_change_owner(self):
|
||||
ticket = self.claim().json['ticket']
|
||||
self.assertEqual(self.claim(self.other).status_code, 409)
|
||||
self.assertEqual(self.update({'version':ticket['version'],'note':'覆盖'}, self.other).status_code, 403)
|
||||
self.assertEqual(self.update({'version':ticket['version'],'note':'伪造','owner_username':'bob'}).status_code, 400)
|
||||
|
||||
def test_concurrent_claim_has_one_winner(self):
|
||||
gate = Barrier(2)
|
||||
def claim(client):
|
||||
gate.wait(timeout=5)
|
||||
return client.post(f'/api/tickets/{self.ticket}/claim', json={}).status_code
|
||||
with ThreadPoolExecutor(max_workers=2) as workers:
|
||||
results = list(workers.map(claim, (self.client, self.other)))
|
||||
self.assertEqual(sorted(results), [200, 409])
|
||||
events = self.client.get(f'/api/tickets/{self.ticket}/events').json
|
||||
self.assertEqual(sum(event['action'] == 'claimed' for event in events), 1)
|
||||
|
||||
def test_stale_update_and_invalid_input_cannot_overwrite_notes(self):
|
||||
ticket = self.claim().json['ticket']
|
||||
version = ticket['version']
|
||||
self.assertEqual(self.update({'version':version,'note':'第一份备注'}).status_code, 200)
|
||||
self.assertEqual(self.update({'version':version,'note':'旧页面覆盖'}).status_code, 409)
|
||||
for payload in ([], {'note':'缺少版本'}, {'version':True,'note':'坏版本'},
|
||||
{'version':version+1,'note':'x'*2001}, {'version':version+1,'status':'pending'},
|
||||
{'version':version+1,'note':' ','status':'completed'}):
|
||||
with self.subTest(payload=str(payload)[:60]):
|
||||
self.assertEqual(self.update(payload).status_code, 400)
|
||||
self.assertEqual(self.client.get('/api/tickets?status=processing').json[0]['note'], '第一份备注')
|
||||
|
||||
def test_login_csrf_and_missing_ticket_are_enforced(self):
|
||||
guest = self.app.test_client()
|
||||
self.assertEqual(guest.post(f'/api/tickets/{self.ticket}/claim', json={}).status_code, 401)
|
||||
self.assertEqual(guest.patch(f'/api/tickets/{self.ticket}', json={}).status_code, 401)
|
||||
self.assertEqual(self.client.post(f'/api/tickets/{self.ticket}/claim', json={}, headers={'X-CSRF-Token':''}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/api/tickets/9999/claim', json={}).status_code, 404)
|
||||
|
||||
def test_oversized_ids_and_cursors_return_client_errors(self):
|
||||
too_large = 2**63
|
||||
self.assertEqual(self.client.get(f'/api/tickets?before={too_large}').status_code, 400)
|
||||
self.assertEqual(self.client.get(f'/api/tickets/{too_large}').status_code, 404)
|
||||
self.assertEqual(self.client.post(f'/api/tickets/{too_large}/claim', json={}).status_code, 404)
|
||||
self.assertEqual(self.client.patch(f'/api/tickets/{too_large}', json={'version':0,'note':'x'}).status_code, 404)
|
||||
|
||||
def test_filters_and_pagination_do_not_hide_old_tickets(self):
|
||||
for index in range(4):
|
||||
self.client.post('/api/ticket', json={'content':str(index)})
|
||||
first = self.client.get('/api/tickets?status=all&limit=2').json
|
||||
second = self.client.get(f'/api/tickets?status=all&limit=2&before={first[-1]["id"]}').json
|
||||
self.assertEqual(len({row['id'] for row in first + second}), 4)
|
||||
self.assertEqual(len(first), 2)
|
||||
for query in ('status=bad', 'limit=0', 'limit=101', 'before=bad'):
|
||||
self.assertEqual(self.client.get('/api/tickets?' + query).status_code, 400)
|
||||
|
||||
def test_version_one_schema_is_upgraded_without_changing_ticket_or_account(self):
|
||||
with self.app.app_context(), closing(db.connect()) as connection:
|
||||
connection.executescript('DROP TABLE tickets; CREATE TABLE tickets('
|
||||
'id INTEGER PRIMARY KEY,content TEXT,phone TEXT,ctime TEXT,status TEXT);'
|
||||
"INSERT INTO tickets VALUES(42,'旧留言','123','yesterday','pending');"
|
||||
"UPDATE schema_meta SET value='1' WHERE key='schema_version';")
|
||||
db.init_db()
|
||||
row = connection.execute('SELECT content,status,owner_username,note,version FROM tickets WHERE id=42').fetchone()
|
||||
self.assertEqual(row, ('旧留言','pending',None,'',0))
|
||||
self.assertEqual(connection.execute('SELECT COUNT(*) FROM agents').fetchone()[0], 2)
|
||||
self.assertEqual(connection.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0], str(db.SCHEMA_VERSION))
|
||||
|
||||
def test_search_supports_content_phone_owner_and_notes_with_status(self):
|
||||
ticket = self.claim().json['ticket']
|
||||
self.update({'version':ticket['version'],'note':'已联系新加坡派送站'})
|
||||
for keyword in ('派送', '23', 'alice', 'Fixture Staff', '新加坡'):
|
||||
with self.subTest(keyword=keyword):
|
||||
result = self.client.get('/api/tickets', query_string={'status':'processing','q':keyword})
|
||||
self.assertEqual([row['id'] for row in result.json], [self.ticket])
|
||||
self.assertEqual(self.client.get('/api/tickets?q=派送').json, [])
|
||||
self.assertEqual(self.client.get('/api/tickets?status=all&q=找不到').json, [])
|
||||
self.assertEqual(self.client.get('/api/tickets', query_string={'q':'x'*101}).status_code, 400)
|
||||
|
||||
def test_search_treats_sql_wildcards_as_literal_and_keeps_pagination(self):
|
||||
for content in ('含有 100%_\\ 的留言', '普通记录', '相同查询甲', '相同查询乙', '相同查询丙'):
|
||||
self.client.post('/api/ticket', json={'content':content})
|
||||
for keyword in ('%', '_', '\\', '100%_\\'):
|
||||
result = self.client.get('/api/tickets', query_string={'q':keyword}).json
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertIn('100%', result[0]['content'])
|
||||
first = self.client.get('/api/tickets?q=相同&limit=2').json
|
||||
second = self.client.get('/api/tickets', query_string={'q':'相同','limit':2,'before':first[-1]['id']}).json
|
||||
self.assertEqual(len(first), 2)
|
||||
self.assertEqual(len(second), 1)
|
||||
self.assertEqual(len({row['id'] for row in first+second}), 3)
|
||||
|
||||
def test_summary_counts_all_pending_and_new_arrivals_independently_of_pages(self):
|
||||
response = self.client.get('/api/tickets/summary')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
baseline = response.json
|
||||
self.assertEqual({key:baseline[key] for key in ('pending_count','latest_id','new_count')},
|
||||
{'pending_count':1,'latest_id':self.ticket,'new_count':0})
|
||||
self.claim()
|
||||
self.client.post('/api/ticket', json={'content':'新留言'})
|
||||
changed = self.client.get('/api/tickets/summary', query_string={'after':baseline['latest_id']}).json
|
||||
self.assertEqual(changed['pending_count'], 1)
|
||||
self.assertEqual(changed['new_count'], 1)
|
||||
self.assertGreater(changed['latest_id'], baseline['latest_id'])
|
||||
self.assertEqual(self.client.get('/api/tickets/summary', query_string={'after':changed['latest_id']}).json['new_count'], 0)
|
||||
for cursor in ('bad', '-1', str(2**63)):
|
||||
self.assertEqual(self.client.get('/api/tickets/summary',query_string={'after':cursor}).status_code, 400)
|
||||
self.assertEqual(self.app.test_client().get('/api/tickets/summary').status_code, 401)
|
||||
|
||||
def test_history_records_actor_and_old_new_notes_only_for_successful_changes(self):
|
||||
ticket = self.claim().json['ticket']
|
||||
saved = self.update({'version':ticket['version'],'note':'原备注'}).json['ticket']
|
||||
self.assertEqual(self.update({'version':ticket['version'],'note':'旧版本'}).status_code, 409)
|
||||
self.assertEqual(self.update({'version':saved['version'],'note':'越权'},self.other).status_code, 403)
|
||||
done = self.update({'version':saved['version'],'note':'完成结果','status':'completed'})
|
||||
self.assertEqual(done.status_code, 200)
|
||||
result = self.client.get(f'/api/tickets/{self.ticket}/events')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
events = list(reversed(result.json))
|
||||
self.assertEqual([event['action'] for event in events], ['submitted','claimed','note_updated','note_updated','completed'])
|
||||
self.assertEqual(events[1]['actor_username'], 'alice')
|
||||
self.assertEqual((events[3]['old_note'],events[3]['new_note']), ('原备注','完成结果'))
|
||||
self.assertTrue(all(event['ctime'] for event in events))
|
||||
self.assertEqual(self.app.test_client().get(f'/api/tickets/{self.ticket}/events').status_code, 401)
|
||||
self.assertEqual(self.client.get('/api/tickets/9999/events').status_code, 404)
|
||||
self.assertEqual(self.client.get(f'/api/tickets/{self.ticket}/events?limit=0').status_code, 400)
|
||||
page = self.client.get(f'/api/tickets/{self.ticket}/events?limit=2').json
|
||||
older = self.client.get(f'/api/tickets/{self.ticket}/events?limit=2&before={page[-1]["id"]}').json
|
||||
self.assertEqual(len({event['id'] for event in page+older}),4)
|
||||
|
||||
def test_audit_failure_rolls_back_ticket_mutation(self):
|
||||
with patch('logistics_chat.ticket_service.record_event', side_effect=RuntimeError('fixture audit failure')):
|
||||
with self.assertRaisesRegex(RuntimeError,'audit failure'):
|
||||
self.claim()
|
||||
self.assertEqual(self.client.get('/api/tickets').json[0]['status'],'pending')
|
||||
|
||||
def test_audit_failure_rolls_back_submission_and_note_completion(self):
|
||||
with patch('logistics_chat.ticket_service.record_event', side_effect=RuntimeError('fixture audit failure')):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.client.post('/api/ticket',json={'content':'不应留下未审计的记录'})
|
||||
self.assertEqual(len(self.client.get('/api/tickets').json),1)
|
||||
ticket = self.claim().json['ticket']
|
||||
with patch('logistics_chat.ticket_service.record_event', side_effect=RuntimeError('fixture audit failure')):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.update({'version':ticket['version'],'note':'结果','status':'completed'})
|
||||
current = self.client.get(f'/api/tickets/{self.ticket}').json['ticket']
|
||||
self.assertEqual((current['status'],current['note'],current['version']),('processing','',ticket['version']))
|
||||
|
||||
def test_overdue_filters_and_unusable_legacy_times_are_safe(self):
|
||||
for bad_time in ('not a date','2026-02-30 00:00:00'):
|
||||
with self.app.app_context(), closing(db.connect()) as connection:
|
||||
connection.execute('UPDATE tickets SET ctime=? WHERE id=?',(bad_time,self.ticket))
|
||||
connection.commit()
|
||||
self.assertFalse(self.client.get('/api/tickets').json[0]['overdue_kind'])
|
||||
self.assertEqual(self.client.get('/api/tickets?overdue=1').json,[])
|
||||
self.assertEqual(self.client.get('/api/tickets/summary').json['overdue_count'],0)
|
||||
with self.app.app_context(), closing(db.connect()) as connection:
|
||||
connection.execute("UPDATE tickets SET ctime='not a date' WHERE id=?",(self.ticket,))
|
||||
connection.commit()
|
||||
self.assertFalse(self.client.get('/api/tickets').json[0]['overdue_kind'])
|
||||
self.assertEqual(self.client.get('/api/tickets?overdue=1').json,[])
|
||||
self.assertEqual(self.client.get('/api/tickets?overdue=bad').status_code,400)
|
||||
self.assertEqual(self.client.get(f'/api/tickets/{2**63}/events').status_code,404)
|
||||
for query in ('limit=101','before=bad',f'before={2**63}'):
|
||||
self.assertEqual(self.client.get(f'/api/tickets/{self.ticket}/events?'+query).status_code,400)
|
||||
with self.app.app_context(), closing(db.connect()) as connection:
|
||||
connection.execute("UPDATE tickets SET ctime='2026-01-01 00:00:00'")
|
||||
connection.commit()
|
||||
self.assertEqual(len(self.client.get('/api/tickets?overdue=1&q=派送').json),1)
|
||||
self.assertEqual(self.client.get('/api/tickets?overdue=1&q=找不到').json,[])
|
||||
self.assertEqual(self.client.get('/api/tickets?status=completed&overdue=1').json,[])
|
||||
|
||||
def test_overdue_boundaries_and_note_edits_do_not_reset_claim_clock(self):
|
||||
with self.app.app_context(), closing(db.connect()) as connection:
|
||||
connection.execute("UPDATE tickets SET ctime='2026-11-02 09:00:00' WHERE id=?",(self.ticket,))
|
||||
connection.commit()
|
||||
with patch('logistics_chat.ticket_service.datetime') as clock:
|
||||
clock.now.return_value=datetime(2026,11,2,9,30)
|
||||
self.assertFalse(self.client.get('/api/tickets').json[0]['overdue_kind'])
|
||||
clock.now.return_value=datetime(2026,11,2,9,31)
|
||||
row=self.client.get('/api/tickets').json[0]
|
||||
self.assertEqual((row['overdue_kind'],row['overdue_minutes']),('pending',1))
|
||||
claimed=self.claim().json['ticket']
|
||||
self.assertEqual(claimed['claimed_at'],'2026-11-02 09:31:00')
|
||||
clock.now.return_value=datetime(2026,11,2,9,51)
|
||||
self.assertFalse(self.client.get('/api/tickets?status=processing').json[0]['overdue_kind'])
|
||||
clock.now.return_value=datetime(2026,11,2,9,52)
|
||||
saved=self.update({'version':claimed['version'],'note':'仍在跟进'}).json['ticket']
|
||||
self.assertEqual(saved['overdue_kind'],'processing')
|
||||
summary=self.client.get('/api/tickets/summary').json
|
||||
self.assertEqual((summary['overdue_count'],summary['processing_overdue_count']), (1,1))
|
||||
self.assertEqual(len(self.client.get('/api/tickets?status=all&overdue=1').json),1)
|
||||
done=self.update({'version':saved['version'],'status':'completed'}).json['ticket']
|
||||
self.assertFalse(done['overdue_kind'])
|
||||
self.assertEqual(self.client.get('/api/tickets/summary').json['overdue_count'],0)
|
||||
|
||||
def test_v2_upgrade_marks_legacy_baseline_without_inventing_history(self):
|
||||
ticket=self.claim().json['ticket']
|
||||
with self.app.app_context(), closing(db.connect()) as connection:
|
||||
connection.execute('DROP TABLE ticket_events')
|
||||
connection.execute('DROP INDEX IF EXISTS idx_ticket_claimed_time')
|
||||
connection.execute('DROP INDEX IF EXISTS idx_ticket_pending_time')
|
||||
connection.execute('ALTER TABLE tickets DROP COLUMN claimed_at')
|
||||
connection.execute('ALTER TABLE tickets DROP COLUMN claimed_at_estimated')
|
||||
connection.execute("UPDATE tickets SET updated_at='2026-11-02 08:00:00',note='旧备注'")
|
||||
connection.execute("UPDATE schema_meta SET value='2' WHERE key='schema_version'")
|
||||
connection.commit()
|
||||
db.init_db()
|
||||
db.init_db()
|
||||
events=self.client.get(f'/api/tickets/{self.ticket}/events').json
|
||||
self.assertEqual([event['action'] for event in events],['legacy_snapshot'])
|
||||
self.assertIsNone(events[0]['actor_username'])
|
||||
row=self.client.get(f'/api/tickets/{self.ticket}').json['ticket']
|
||||
self.assertEqual((row['owner_username'],row['note']),('alice','旧备注'))
|
||||
self.assertEqual((row['claimed_at'],row['claimed_at_estimated']),('2026-11-02 08:00:00',1))
|
||||
@@ -8,6 +8,7 @@ from unittest.mock import patch
|
||||
from datetime import datetime
|
||||
|
||||
from logistics_chat import create_app, db
|
||||
from staff_fixture import login_staff
|
||||
|
||||
|
||||
class VisitorTests(unittest.TestCase):
|
||||
@@ -18,6 +19,8 @@ class VisitorTests(unittest.TestCase):
|
||||
self.app = create_app({'TESTING':True, 'DATABASE':root/'history.db', 'UPLOAD_DIR':root/'uploads',
|
||||
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'','RATE_LIMIT_PER_MIN':1000})
|
||||
self.a, self.b = self.app.test_client(), self.app.test_client()
|
||||
self.staff = self.app.test_client()
|
||||
login_staff(self.app, self.staff)
|
||||
for client, key in ((self.a,'1'*64),(self.b,'2'*64)):
|
||||
self.assertEqual(client.post('/api/visitor/session',json={'key':key}).status_code,200)
|
||||
|
||||
@@ -96,7 +99,7 @@ class VisitorTests(unittest.TestCase):
|
||||
for action in ('send','close'):
|
||||
self.assertEqual(self.b.post('/api/visitor/human/'+action,json={'sid':sid,'content':'attack'}).status_code,404)
|
||||
self.assertEqual(self.a.post('/api/visitor/human/send',json={'sid':sid,'side':'agent','content':'fake agent'}).status_code,403)
|
||||
self.a.post('/api/human/note',json={'sid':sid,'note':'STAFF_PRIVATE'})
|
||||
self.assertEqual(self.staff.post('/api/human/note',json={'sid':sid,'note':'STAFF_PRIVATE'}).status_code,200)
|
||||
response=self.a.get(f'/api/visitor/human/poll?sid={sid}')
|
||||
self.assertNotIn('note',response.json)
|
||||
self.assertNotIn('STAFF_PRIVATE',response.get_data(as_text=True))
|
||||
@@ -115,7 +118,7 @@ class VisitorTests(unittest.TestCase):
|
||||
root=Path(self.app.config['UPLOAD_DIR'])
|
||||
(root/'legacy.txt').write_text('LEGACY_PRIVATE')
|
||||
self.assertEqual(self.a.get('/uploads/legacy.txt').status_code,404)
|
||||
with self.a.get('/api/staff'+url) as staff:
|
||||
with self.staff.get('/api/staff'+url) as staff:
|
||||
self.assertEqual(staff.data,b'A_PRIVATE')
|
||||
|
||||
def test_guest_keys_and_client_claims_cannot_unlock_orders(self):
|
||||
|
||||
Reference in New Issue
Block a user