保存今天的修改

This commit is contained in:
songxinnuo
2026-11-02 17:51:10 +08:00
parent 04f94ad665
commit eebaba8084
44 changed files with 2831 additions and 154 deletions
+370 -11
View File
@@ -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,/&lt;img/);
assert.doesNotMatch(app.element('ticketList').innerHTML,/<img/);
});
test('a delayed ticket list cannot overwrite a newer status filter', async () => {
const app=await workspace();
let resolveOld, count=0;
app.hooks.tickets=async()=>{
if(++count===1) return new Promise(resolve=>{resolveOld=resolve;});
return response([{id:20,content:'已完成留言',status:'completed',owner_username:'alice',note:'处理结果',version:2}]);
};
const pending=app.context.loadTickets();
app.element('ticket-filter').value='completed';
app.element('ticket-filter').listeners.change();
await flush();
resolveOld(response([{id:10,content:'旧列表',status:'pending',version:0}]));
await pending;
assert.equal(app.element('ticket-state').textContent,'已完成');
assert.doesNotMatch(app.element('ticketList').innerHTML,/旧列表/);
});
test('a hung ticket save releases controls and retains notes after the request timeout', async () => {
const app=await workspace();
const ticket={id:10,content:'留言',status:'processing',owner_username:'alice',note:'',version:1};
app.hooks.tickets=async(url,body,options)=>options && options.method ? waitForAbort(options) : response([ticket]);
await app.context.loadTickets();
app.element('ticket-note').value='不能丢失的备注';
app.element('ticket-note').listeners.input();
const pending=app.element('ticket-save').onclick();
assert.equal(app.element('ticket-save').disabled,true);
await app.runTimers(10000);
await pending;
assert.equal(app.element('ticket-save').disabled,false);
assert.equal(app.element('ticket-note').value,'不能丢失的备注');
assert.match(app.element('ticket-status-message').textContent,/未确认成功.*备注已保留/);
});
test('ticket search is sent to the server and retained for pagination and status changes', async () => {
const app=await workspace();
app.hooks.tickets=async()=>response(Array.from({length:20},(_,index)=>({id:100-index,content:'查询派送',status:'pending',version:0})));
app.element('ticket-search').value='138 &小林';
await app.element('ticket-search-form').listeners.submit({preventDefault(){}});
let listCalls=app.calls.filter(call=>call.url.startsWith('/api/tickets?'));
assert.equal(new URL(listCalls.at(-1).url,'http://local').searchParams.get('q'),'138 &小林');
await app.element('ticket-more').onclick();
listCalls=app.calls.filter(call=>call.url.startsWith('/api/tickets?'));
assert.match(listCalls.at(-1).url,/before=81/);
assert.equal(new URL(listCalls.at(-1).url,'http://local').searchParams.get('q'),'138 &小林');
app.element('ticket-filter').value='pending';
app.element('ticket-filter').listeners.change();
await flush();
assert.equal(new URL(app.calls.filter(call=>call.url.startsWith('/api/tickets?')).at(-1).url,'http://local').searchParams.get('q'),'138 &小林');
});
test('new ticket alerts skip old records, deduplicate new arrivals and keep polling on older pages', async () => {
let version=1;
const app=await workspace(false,{ticketSummary:async()=>response({pending_count:24,latest_id:version,new_count:version===2?1:0})});
const notices=[];
app.context.SupportUI.notify=(...args)=>notices.push(args);
await app.context.loadTickets();
assert.equal(app.element('ticket-pending-count').textContent,'24');
assert.equal(notices.length,0);
app.hooks.tickets=async()=>response(Array.from({length:20},(_,index)=>({id:40-index,content:'旧留言',status:'pending',version:0})));
await app.context.loadTickets();
await app.element('ticket-more').onclick();
version=2;
await app.context.loadTickets();
assert.match(app.element('ticket-alert-text').textContent,/1 条新留言/);
assert.equal(app.element('ticket-alert').hidden,false);
assert.equal(notices.length,1);
await app.context.loadTickets();
assert.equal(notices.length,1);
version=3;
await app.context.loadTickets();
assert.equal(notices.length,1);
await app.element('ticket-alert-view').onclick();
assert.equal(app.element('ticket-filter').value,'pending');
assert.equal(app.element('ticket-search').value,'');
assert.equal(app.element('ticket-alert').hidden,true);
});
test('ticket header entry focuses the search field and IME confirmation does not submit', async () => {
const app=await workspace(true);
await app.element('ticket-inbox-toggle').onclick();
assert.equal(app.document.body.classList.contains('tools-open'),true);
assert.equal(app.document.activeElement,app.element('ticket-search'));
let prevented=false;
app.element('ticket-search').listeners.keydown({key:'Enter',isComposing:true,preventDefault(){prevented=true;}});
assert.equal(prevented,true);
});
test('overdue count stays global and the header opens all overdue records', async () => {
const app=await workspace(true,{ticketSummary:async()=>response({pending_count:8,latest_id:10,new_count:0,overdue_count:3})});
assert.equal(app.element('ticket-overdue-count').textContent,'3');
app.hooks.tickets=async()=>response([{id:10,content:'超时件',status:'pending',version:0,overdue_kind:'pending',overdue_minutes:7}]);
await app.element('ticket-overdue-toggle').onclick();
const query=new URL(app.calls.filter(call=>call.url.startsWith('/api/tickets?')).at(-1).url,'http://local');
assert.equal(query.searchParams.get('status'),'all');
assert.equal(query.searchParams.get('overdue'),'1');
assert.match(app.element('ticketList').innerHTML,/领取超时 7 分钟/);
assert.match(app.element('ticket-overdue-hint').textContent,/7 分钟/);
app.element('tools-close').onclick();
assert.equal(app.document.activeElement,app.element('ticket-overdue-toggle'));
await app.element('ticket-inbox-toggle').onclick();
assert.equal(app.element('ticket-overdue-filter').checked,false);
});
test('history is escaped, paginated and not reloaded by an unchanged poll', async () => {
const app=await workspace();
app.hooks.tickets=async()=>response([{id:10,content:'留言',status:'processing',version:1}]);
let historyCalls=0;
app.hooks.ticketEvents=async()=>{historyCalls++;return response(Array.from({length:20},(_,index)=>({id:40-index,action:'note_updated',actor_name:'<img>',ctime:'today',old_note:'旧备注',new_note:'<script>evil</script>'})));};
await app.context.loadTickets(); await flush();
assert.match(app.element('ticket-history-list').innerHTML,/&lt;script&gt;/);
assert.doesNotMatch(app.element('ticket-history-list').innerHTML,/<script>/);
assert.match(app.element('ticket-history-list').innerHTML,/旧备注/);
await app.context.loadTickets(); await flush();
assert.equal(historyCalls,1);
await app.element('ticket-history-more').onclick();
assert.match(app.calls.filter(call=>call.url.includes('/events?')).at(-1).url,/before=21/);
});
test('a late history response cannot replace the newly selected ticket history', async () => {
const app=await workspace();
let resolveOld;
app.hooks.tickets=async()=>response([{id:10,content:'A',status:'pending',version:0},{id:11,content:'B',status:'pending',version:0}]);
app.hooks.ticketEvents=async url=>url.includes('/10/') ? new Promise(resolve=>{resolveOld=resolve;}) : response([{id:4,action:'claimed',actor_name:'B客服',ctime:'today'}]);
await app.context.loadTickets();
app.element('ticketList').listeners.click({target:{closest(){return {getAttribute(){return '11';}};}}});
await flush();
resolveOld(response([{id:1,action:'claimed',actor_name:'A客服',ctime:'today'}])); await flush();
assert.match(app.element('ticket-history-list').innerHTML,/B客服/);
assert.doesNotMatch(app.element('ticket-history-list').innerHTML,/A客服/);
});
test('history failures offer retry without blocking a ticket save', async () => {
const app=await workspace();
const ticket={id:10,content:'留言',status:'processing',owner_username:'alice',version:1};
app.hooks.tickets=async(url,body)=>body ? response({ok:true,ticket:{...ticket,version:2,note:body.note}}) : response([ticket]);
app.hooks.ticketEvents=async()=>response({},500);
await app.context.loadTickets(); await flush();
assert.match(app.element('ticket-history-status').textContent,/失败.*重试/);
app.element('ticket-note').value='处理结果'; app.element('ticket-note').listeners.input();
await app.element('ticket-save').onclick(); await flush();
assert.match(app.element('ticket-status-message').textContent,/备注已保存/);
app.hooks.ticketEvents=async()=>response([{id:2,action:'completed',actor_name:'客服',ctime:'today',new_note:'结果'}]);
await app.element('ticket-history-reload').onclick();
assert.match(app.element('ticket-history-list').innerHTML,/处理结果:结果/);
});
test('staff writes include CSRF and logout revokes the session before redirecting', async () => {
const app = await workspace();
await app.context.logoutStaff();
const logout = app.calls.find(call=>call.url==='/admin/logout');
assert.equal(logout.headers['X-CSRF-Token'], 'fixture-csrf');
assert.equal(app.calls.filter(call=>call.redirect==='/admin').length, 1);
});
test('a rejected logout remains actionable and does not claim success', async () => {
const app = await workspace();
app.hooks.logout=async()=>response({},500);
await app.context.logoutStaff();
assert.equal(app.element('staff-logout').disabled, false);
assert.match(app.element('staff-logout-status').textContent,/退出失败/);
assert.equal(app.calls.filter(call=>call.redirect).length,0);
});
test('expired staff sessions redirect once and stop sending further polling requests', async () => {
const app = await workspace();
app.hooks.queue=async()=>response({},401);
app.hooks.active=async()=>response({},401);
await app.context.loadQueue();
await app.tick(1000);
await app.tick(3000);
const count=app.calls.length;
await app.context.loadQueue();
assert.equal(app.calls.length,count);
assert.equal(app.calls.filter(call=>call.redirect==='/admin?expired=1').length,1);
});
test('duplicate logout clicks send only one request', async () => {
const app=await workspace();
let finish;
app.hooks.logout=()=>new Promise(resolve=>{ finish=resolve; });
const first=app.context.logoutStaff();
await app.context.logoutStaff();
assert.equal(app.calls.filter(call=>call.url==='/admin/logout').length,1);
finish(response({ok:true}));
await first;
});
test('a hanging reply times out, preserves the newer draft and waits for manual retry', async () => {
const app=await workspace();
app.hooks.send=(body,options)=>waitForAbort(options);
app.element('replyText').value='先发送的回复';
const sending=app.context.sendReply();
app.element('replyText').value='继续补充的草稿';
app.element('replyText').listeners.input();
await app.runTimers(10000);
assert.equal(app.element('reply-send').disabled,false);
await sending;
assert.equal(app.context.sendingSid,'');
assert.equal(app.element('replyText').value,'继续补充的草稿');
assert.match(app.element('reply-status').textContent,/发送超时.*内容已保留.*确认聊天记录/);
await app.tick(1000);
assert.equal(app.calls.filter(call=>call.url==='/api/human/send').length,1);
app.hooks.send=()=>response({ok:true});
await app.context.sendReply();
assert.equal(app.element('replyText').value,'');
assert.equal(app.calls.filter(call=>call.url==='/api/human/send').length,2);
});
test('a hung message poll times out and the next interval resumes from the last received message', async () => {
const app=await workspace();
app.context.sessions['7'].lastId=12;
app.hooks.poll=(url,options)=>waitForAbort(options);
const polling=app.context.poll();
await flush();
await app.tick(1000);
assert.equal(app.calls.filter(call=>call.url==='/api/human/poll?sid=7&after=12').length,1);
await app.runTimers(10000);
assert.equal(app.context.polling,false);
await polling;
assert.equal(app.element('sync-status').hidden,false);
app.hooks.poll=()=>response({status:'active',messages:[{id:13,side:'customer',content:'断线恢复后的新消息',ctime:'2026-11-02 12:00'}]});
await app.tick(1000);
assert.equal(app.element('sync-status').hidden,true);
assert.equal(app.context.sessions['7'].lastId,13);
assert.equal(app.context.sessions['7'].msgs.length,1);
await app.tick(1000);
assert.equal(app.context.sessions['7'].msgs.length,1);
assert.ok(app.calls.some(call=>call.url==='/api/human/poll?sid=7&after=13'));
});
test('a hung active-session list releases the poll lock on timeout and reconnects', async () => {
const app=await workspace();
app.hooks.active=options=>waitForAbort(options);
const polling=app.context.poll();
await app.runTimers(10000);
assert.equal(app.context.polling,false);
await polling;
assert.equal(app.element('sync-status').hidden,false);
app.hooks.active=()=>response([{id:7}]);
await app.tick(1000);
assert.equal(app.element('sync-status').hidden,true);
});
test('the timeout also covers a response body that never finishes and is cleared after failures', async () => {
const app=await workspace();
app.hooks.queue=options=>({ok:true,json:()=>waitForAbort(options)});
const loading=app.context.loadQueue();
await flush();
await app.runTimers(10000);
assert.match(app.element('queue').innerHTML,/队列暂时无法加载/);
await loading;
assert.equal(app.timers.size,0);
app.hooks.queue=()=>response([],503);
await app.context.loadQueue();
assert.equal(app.timers.size,0);
app.hooks.queue=()=>response([]);
await app.context.loadQueue();
assert.match(app.element('queue').innerHTML,/当前没有排队会话/);
assert.equal(app.timers.size,0);
});
test('failed replies preserve the draft and make retry available', async () => {
const app = await workspace();
app.hooks.send = () => response({ok:false}, 503);
+43 -1
View File
@@ -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);
});
+18
View File
@@ -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']
+17
View File
@@ -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)
+154
View File
@@ -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())
+4 -2
View File
@@ -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')})
+4 -1
View File
@@ -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__':
+74
View File
@@ -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)
+249
View File
@@ -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)
+92
View File
@@ -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})
+266
View File
@@ -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))
+5 -2
View File
@@ -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):