修复评价统计、留言校验和聊天操作反馈

This commit is contained in:
2026-10-30 14:38:25 +08:00
parent b8f6ca586d
commit c2eecf3aed
9 changed files with 249 additions and 42 deletions
+21 -4
View File
@@ -8,8 +8,17 @@ from .tracking import card_to_text
def feedback(data):
"""收集 👍/👎 反馈,存到 feedback 表"""
if not isinstance(data, dict):
return {'ok': False, 'error': '评价格式无效。'}, 400
raw_vote = data.get('vote')
votes = {'up': 1, 'down': -1, '1': 1, '-1': -1}
vote = votes.get(raw_vote) if isinstance(raw_vote, str) else raw_vote
question, answer = data.get('question', ''), data.get('answer', '')
if type(vote) is not int or vote not in (-1, 1) or not isinstance(question, str) or not isinstance(answer, str):
return {'ok': False, 'error': '评价内容无效,请重试。'}, 400
conn = connect()
conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)', (str(data.get('question', ''))[:500], str(data.get('answer', ''))[:2000], str(data.get('vote', '')), now_str()))
conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)',
(question[:500], answer[:2000], vote, now_str()))
conn.commit()
conn.close()
return {'ok': True}
@@ -19,8 +28,8 @@ def admin_stats():
conn = connect()
total_conv = conn.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0]
total_msg = conn.execute('SELECT COUNT(*) FROM messages').fetchone()[0]
good = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=1').fetchone()[0]
bad = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=-1').fetchone()[0]
good = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('1', 'up')").fetchone()[0]
bad = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('-1', 'down')").fetchone()[0]
today = datetime.now().strftime('%Y-%m-%d')
today_conv = conn.execute('SELECT COUNT(*) FROM chat_history WHERE date(ctime)=date(?)', (today,)).fetchone()[0]
rating_avg, rating_count = conn.execute('SELECT COALESCE(AVG(stars), 0), COUNT(*) FROM ratings').fetchone()
@@ -39,8 +48,16 @@ def my_orders(phone):
def add_ticket(data):
if not isinstance(data, dict):
return {'ok': False, 'error': '留言格式无效。'}, 400
content, phone = data.get('content'), data.get('phone', '')
if not isinstance(content, str) or not 1 <= len(content.strip()) <= 2000:
return {'ok': False, 'error': '请输入 1–2000 字的留言。'}, 400
if not isinstance(phone, str) or len(phone.strip()) > 32:
return {'ok': False, 'error': '联系电话格式无效。'}, 400
conn = connect()
conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)', (data.get('content', ''), data.get('phone', ''), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending'))
conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)',
(content.strip(), phone.strip(), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending'))
conn.commit()
conn.close()
return {'ok': True}
+1 -1
View File
@@ -13,7 +13,7 @@ def my_orders():
@bp.route('/api/ticket', methods=['POST'])
def add_ticket():
return business_service.add_ticket(request.get_json() or {})
return business_service.add_ticket(request.get_json(silent=True))
@bp.route('/api/tickets')
+1 -1
View File
@@ -45,4 +45,4 @@ def stream_chat():
@bp.route('/api/feedback', methods=['POST'])
def feedback():
return business_service.feedback(request.get_json(silent=True) or {})
return business_service.feedback(request.get_json(silent=True))
+2
View File
@@ -133,6 +133,8 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.msg-actions { display: flex; gap: 5px; flex-wrap: wrap; padding-top: 12px; margin-top: 10px; border-top: 1px solid var(--border); }
.msg-action-btn { min-height: 30px; border: 0; padding: 3px 8px; color: var(--secondary); background: transparent; border-radius: 5px; font-size: 12px; }
.msg-action-btn:hover, .msg-action-btn.voted { background: var(--surface-hover); color: var(--brand-text); }
.vote-status { align-self: center; color: var(--muted); font-size: 11px; }
.vote-status.is-error { color: var(--danger); }
.error-bubble { border-color: #e9b4bb; background: var(--danger-soft); color: var(--danger); }
.err-line { margin-bottom: 9px; }
.retry-btn { border: 1px solid currentColor; border-radius: 6px; padding: 4px 9px; color: inherit; background: transparent; font-size: 11px; }
+67 -21
View File
@@ -28,7 +28,7 @@ let humanPollTimer = null;
let humanSendingToken = null;
let humanClosing = false;
window.humanSid = 0;
SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
const emojiPicker = SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
function setComposerStatus(message, isError){
@@ -245,6 +245,9 @@ const I18N = {
stop: "停止",
copy: "复制",
copied: "已复制",
copyFailed: "复制失败",
voteUp: "这条回复有帮助",
voteDown: "这条回复没有帮助",
retry: "重答",
retryAIOnly: "请先返回 AI 问答,再重答这条消息。",
clickRetry: "点击重试",
@@ -355,6 +358,9 @@ const I18N = {
stop: "Stop",
copy: "Copy",
copied: "Copied",
copyFailed: "Copy failed",
voteUp: "This reply was helpful",
voteDown: "This reply was not helpful",
retry: "Regenerate",
retryAIOnly: "Return to AI chat before regenerating this answer.",
clickRetry: "Retry",
@@ -483,6 +489,7 @@ function applyLanguage(lang){
const n = b.dataset.action;
if(n === "copy") b.textContent = dict.copy;
if(n === "retry") b.textContent = dict.retry;
if(n === 'voteUp' || n === 'voteDown') b.setAttribute('aria-label', dict[n]);
});
document.querySelectorAll(".retry-btn").forEach(function(b){
if(!b.dataset.keep) b.textContent = dict.clickRetry;
@@ -824,6 +831,7 @@ function ensureConv(){
}
function resetConversation(id){
emojiPicker.close();
if(voiceInput) voiceInput.cancel();
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
@@ -846,6 +854,7 @@ function openConversation(id){
if(openingConv === 'clearing') return;
if(openingConv === id) return; // 防连点
const version = resetConversation(id);
if(isNarrow()){ sideOpen = false; renderSidebar(); }
openingConv = id;
setComposerStatus(t('historyLoading'));
renderSideHistory();
@@ -1185,7 +1194,7 @@ async function sendHumanMessage(content){
async function leaveOfflineMessage(){
const version = conversationVersion;
addMsg(t('humanHours'), false);
const message = window.prompt(t('offlinePrompt'));
const message = (window.prompt(t('offlinePrompt')) || '').trim();
if(!message) return;
try{
const data = await humanRequest('/api/ticket', {content:message, phone:''});
@@ -1193,8 +1202,8 @@ async function leaveOfflineMessage(){
if(version === conversationVersion) addMsg(t('offlineSaved'), false);
}catch(error){
if(version !== conversationVersion) return;
if(!inputDom.value) inputDom.value = message;
setComposerStatus(t('networkFail'), true);
if(!inputDom.value){ inputDom.value = message; autoGrow(); }
setComposerStatus(error.status === 400 ? error.message : t('networkFail'), true);
}
}
@@ -1391,26 +1400,34 @@ function attachActions(bubble, rawText, question, showRetry){
copyBtn.dataset.action = "copy";
copyBtn.textContent = t("copy");
copyBtn.onclick = function(){
if(copyBtn.disabled) return;
copyBtn.disabled = true;
const plain = mdToPlain(rawText);
const done = ()=> {
copyBtn.textContent = t("copied");
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, 1200);
const done = function(copied){
copyBtn.disabled = false;
copyBtn.textContent = t(copied ? 'copied' : 'copyFailed');
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, copied ? 1200 : 3000);
};
if(navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(plain).then(done).catch(fallback);
return navigator.clipboard.writeText(plain).then(()=>done(true)).catch(fallback);
}else{
fallback();
return fallback();
}
function fallback(){
const ta = document.createElement("textarea");
ta.value = plain;
ta.readOnly = true;
ta.style.position = "fixed";
ta.style.opacity = "0";
const previousFocus = document.activeElement;
document.body.appendChild(ta);
ta.select();
try{ document.execCommand("copy"); }catch(e){}
document.body.removeChild(ta);
done();
let copied = false;
try{ ta.focus({preventScroll:true}); ta.select(); copied = Boolean(document.execCommand("copy")); }catch(e){}
finally{
ta.remove();
if(previousFocus && previousFocus.isConnected) previousFocus.focus({preventScroll:true});
}
done(copied);
}
};
bar.appendChild(copyBtn);
@@ -1425,23 +1442,52 @@ function attachActions(bubble, rawText, question, showRetry){
}
// 👍/👎 反馈
let votePending = false;
const voteButtons = [];
const voteStatus = document.createElement('span');
voteStatus.className = 'vote-status';
voteStatus.setAttribute('role', 'status');
function addVote(vote, label){
const b = document.createElement("button");
b.className = "msg-action-btn";
b.textContent = label;
b.onclick = function(){
if(b.classList.contains("voted")) return;
bar.querySelectorAll(".msg-action-btn").forEach(function(x){ x.classList.remove("voted"); });
b.classList.add("voted");
fetch("/api/feedback", {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({question: question, answer: rawText, vote: vote})
}).catch(function(){});
b.dataset.action = vote === 'up' ? 'voteUp' : 'voteDown';
b.setAttribute('aria-label', t(vote === 'up' ? 'voteUp' : 'voteDown'));
b.setAttribute('aria-pressed', 'false');
b.onclick = async function(){
if(votePending || b.classList.contains('voted')) return;
votePending = true;
voteButtons.forEach(button => { button.disabled = true; });
voteStatus.textContent = '';
voteStatus.removeAttribute('data-i18n');
voteStatus.classList.remove('is-error');
try{
const data = await requestJson('/api/feedback', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({question:question, answer:rawText, vote:vote})
});
if(!data.ok) throw new Error('Feedback failed');
voteButtons.forEach(function(button){
button.classList.toggle('voted', button === b);
button.setAttribute('aria-pressed', String(button === b));
});
voteStatus.textContent = t('ratingThanks');
voteStatus.setAttribute('data-i18n', 'ratingThanks');
}catch(error){
voteStatus.textContent = t('ratingFailed');
voteStatus.setAttribute('data-i18n', 'ratingFailed');
voteStatus.classList.add('is-error');
}finally{
votePending = false;
voteButtons.forEach(button => { button.disabled = false; });
}
};
voteButtons.push(b);
bar.appendChild(b);
}
addVote("up", "👍");
addVote("down", "👎");
bar.appendChild(voteStatus);
bubble.appendChild(bar);
}
+16 -8
View File
@@ -58,6 +58,10 @@ var SupportUI = {
},
initEmoji(button, panel, input, onInsert){
const emojis = ['😊', '😂', '👍', '❤️', '🙏', '😅', '😢', '🎉', '📦', '🚚'];
function close(){
panel.hidden = true;
button.setAttribute('aria-expanded', 'false');
}
button.onclick = function(){
panel.hidden = !panel.hidden;
button.setAttribute('aria-expanded', String(!panel.hidden));
@@ -70,20 +74,24 @@ var SupportUI = {
const start = input.selectionStart == null ? input.value.length : input.selectionStart;
const end = input.selectionEnd == null ? start : input.selectionEnd;
input.value = input.value.slice(0, start) + emoji + input.value.slice(end);
panel.hidden = true;
button.setAttribute('aria-expanded', 'false');
input.focus();
close();
input.focus({preventScroll:true});
input.setSelectionRange(start + emoji.length, start + emoji.length);
if(onInsert) onInsert();
};
panel.appendChild(choice);
});
panel.addEventListener('keydown', function(event){
if(event.key === 'Escape'){
panel.hidden = true;
button.setAttribute('aria-expanded', 'false');
button.focus();
['pointerdown','mousedown'].forEach(function(name){
document.addEventListener(name, function(event){
if(!panel.hidden && !panel.contains(event.target) && !button.contains(event.target)) close();
});
});
document.addEventListener('keydown', function(event){
if(event.key === 'Escape' && !panel.hidden){
close();
button.focus({preventScroll:true});
}
});
return {close:close};
}
};
+3 -2
View File
@@ -32,6 +32,7 @@ async function workspace(compact = false){
setAttribute(name, value){ this.attributes[name] = value; }
toggleAttribute(name, enabled){ if(enabled) this.attributes[name] = ''; else delete this.attributes[name]; }
appendChild(element){ this.children.push(element); }
contains(target){ return this === target || this.children.some(child=>child.contains(target)); }
addEventListener(name, callback){ this.listeners[name] = callback; }
querySelectorAll(){ return []; }
querySelector(selector){
@@ -47,7 +48,7 @@ async function workspace(compact = false){
const quickButtons = [new Element(), new Element()];
document = {
body: new Element(), activeElement: null,
getElementById(id){ if(!elements.has(id)) elements.set(id, new Element()); return elements.get(id); },
getElementById(id){ if(!elements.has(id)){ const value=new Element(); if(id==='agent-emoji-panel') value.hidden=true; elements.set(id,value); } return elements.get(id); },
createElement(){ return new Element(); },
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
querySelector(selector){
@@ -55,7 +56,7 @@ async function workspace(compact = false){
if(!match) return null;
return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null;
},
addEventListener(name, callback){ documentListeners[name] = callback; },
addEventListener(name, callback){ const previous=documentListeners[name]; documentListeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
};
const calls = [];
const hooks = {};
+102 -5
View File
@@ -13,7 +13,7 @@ async function customerPage(pageOptions = {}){
constructor(){
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300; this.scrollTop = 0;
this.listeners = {}; this.focusCalls = [];
this.listeners = {}; this.focusCalls = []; this.isConnected = true;
this.classList = {
contains: name => this.className.split(' ').includes(name),
add: name => { this.className += ' '+name; },
@@ -32,8 +32,10 @@ async function customerPage(pageOptions = {}){
click(){ if(this.disabled) return; if(this.onclick) return this.onclick(); if(this.listeners.click) return this.listeners.click({target:this}); }
appendChild(child){ child.parent = this; this.children.push(child); }
append(...children){ children.forEach(child => this.appendChild(child)); }
contains(target){ return this === target || this.children.some(child=>child.contains(target)); }
focus(options){ this.focusCalls.push(options); document.activeElement=this; if(this.listeners.focus) this.listeners.focus(); }
blur(){ if(document.activeElement === this){ document.activeElement=null; if(this.listeners.blur) this.listeners.blur(); } }
select(){ document.activeElement=this; }
querySelector(selector){
return this.querySelectorAll(selector)[0] || null;
}
@@ -50,21 +52,23 @@ async function customerPage(pageOptions = {}){
if(selector === 'button[data-human-support]' && this.attributes['data-human-support'] != null) return this;
return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector);
}
remove(){ if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
remove(){ this.isConnected=false; if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
}
const element = key => { if(!elements.has(key)) elements.set(key, new Element()); return elements.get(key); };
element('emoji-panel').hidden=true;
const quickButtons = [0, 1, 2].map(index => { const button = new Element(); button.dataset.questionIndex = String(index); return button; });
const document = {
body: new Element(), documentElement: {},
getElementById: id => ['human-typing', 'imgPreview', 'recentChatWrap'].includes(id) ? null : element(id),
querySelector: selector => element(selector), querySelectorAll: selector => selector === '[data-question-index]' ? quickButtons : [],
createElement: () => new Element(),
addEventListener: (name, callback) => { listeners[name] = callback; },
execCommand: () => pageOptions.copyResult !== false,
addEventListener: (name, callback) => { const previous=listeners[name]; listeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
};
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
let state = {status:'active', messages:[]};
const context = vm.createContext({
document, console, Date:TestDate, navigator:{language:'zh-CN'}, AbortController, TextDecoder, confirm:() => true,
document, console, Date:TestDate, navigator:{language:'zh-CN',clipboard:pageOptions.clipboard}, AbortController, TextDecoder, confirm:() => true,
FormData:class { append(){} },
requestAnimationFrame:callback => callback(),
localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}},
@@ -95,7 +99,7 @@ async function customerPage(pageOptions = {}){
: url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)}
: url === '/api/visitor/human/request' ? {ok:true, session_id:7}
: url.startsWith('/api/visitor/human/poll') ? state
: ['/api/rate', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
: ['/api/rate', '/api/feedback', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
: url === '/api/get_history' ? (pageOptions.history || []) : [];
return {ok:true, json:async () => data};
},
@@ -112,6 +116,7 @@ async function customerPage(pageOptions = {}){
if(pageOptions.human !== false) await context.window.showHuman();
await flush();
return {context, element, calls, hooks, quickButtons, voiceOptions,
documentEvent:(name,event)=>{ if(listeners[name]) listeners[name](event); },
resize:async height => {
context.window.innerHeight=height;
if(context.window.visualViewport) context.window.visualViewport.height=height;
@@ -782,3 +787,95 @@ test('an AI regeneration is not forwarded to a human session', async () => {
assert.match(app.element('chatDom').textContent,/测试 AI 回复/);
assert.match(app.element('composer-status').textContent,/先返回 AI/);
});
test('feedback waits for server confirmation, ignores rapid duplicates and allows retry after failure', async () => {
const app=await customerPage({human:false});
await app.context.window.fillInput('反馈测试');await flush();
const log=app.element('chatDom'), buttons=log.querySelectorAll('.msg-action-btn');
const up=buttons.find(button=>button.textContent==='👍'), down=buttons.find(button=>button.textContent==='👎');
let finish;
app.hooks['/api/feedback']=()=>new Promise(resolve=>{finish=resolve;});
const sending=up.onclick();
await flush();
await down.onclick();
assert.equal(app.calls.filter(call=>call.url==='/api/feedback').length,1);
assert.equal(up.classList.contains('voted'),false);
assert.ok(up.disabled && down.disabled);
finish(response({ok:false},503));await sending;
assert.equal(up.classList.contains('voted'),false);
assert.match(log.textContent,/评价未提交/);
assert.equal(up.disabled,false);
app.hooks['/api/feedback']=()=>response({ok:true});
await up.onclick();
assert.ok(up.classList.contains('voted'));
assert.equal(up.getAttribute('aria-pressed'),'true');
assert.match(log.textContent,/感谢您的评价/);
await up.onclick();
assert.equal(app.calls.filter(call=>call.url==='/api/feedback').length,2);
});
test('clipboard rejection and a failed fallback never report a successful copy', async () => {
const app=await customerPage({human:false,copyResult:false,clipboard:{writeText:()=>Promise.reject(new Error('denied'))}});
await app.context.window.fillInput('复制测试');await flush();
const copy=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='copy');
app.element('input-text').focus();
await copy.onclick();await flush();
assert.equal(copy.textContent,'复制失败');
assert.equal(copy.disabled,false);
assert.equal(app.context.document.activeElement,app.element('input-text'));
assert.equal(app.element('input-text').focusCalls.at(-1).preventScroll,true);
assert.equal(app.context.document.body.children.length,0);
await app.runTimers(3000);
assert.equal(copy.textContent,'复制');
});
test('successful clipboard fallback reports copied and keeps text focus stable', async () => {
const app=await customerPage({human:false});
await app.context.window.fillInput('复制测试');await flush();
const copy=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='copy');
await copy.onclick();
assert.equal(copy.textContent,'已复制');
assert.equal(copy.disabled,false);
await app.runTimers(1200);
assert.equal(copy.textContent,'复制');
});
test('blank offline messages are ignored and invalid ticket text stays editable with a readable error', async () => {
const empty=await customerPage({human:false,hour:23,offlineMessage:' '});
await empty.context.window.showHuman();await flush();
assert.equal(empty.calls.filter(call=>call.url==='/api/ticket').length,0);
const app=await customerPage({human:false,hour:23,offlineMessage:'需要保留的留言'});
app.hooks['/api/ticket']=()=>response({ok:false,error:'请输入 1–2000 字的留言。'},400);
await app.context.window.showHuman();await flush();
assert.equal(app.element('input-text').value,'需要保留的留言');
assert.notEqual(app.element('input-text').style.height,'');
assert.match(app.element('composer-status').textContent,/1–2000/);
});
test('mobile history selection closes the drawer while desktop navigation stays visible', async () => {
for(const width of [390,1200]){
const app=await customerPage({human:false,width,history:[{id:9,title:'历史会话',ctime:'now'}]});
if(width===390) await app.click(app.element('.toggle-divider'));
assert.equal(app.element('.toggle-divider').getAttribute('aria-expanded'),'true');
await app.click(app.element('historyWrap').children[0]);
assert.equal(app.element('.toggle-divider').getAttribute('aria-expanded'),String(width>900));
}
});
test('emoji panel dismisses outside or on Escape, stays open for its own controls, and closes on conversation change', async () => {
const app=await customerPage({human:false,width:390});
const toggle=app.element('emoji-toggle'), panel=app.element('emoji-panel');
await app.click(toggle);
assert.equal(panel.hidden,false);
app.documentEvent('pointerdown',{target:panel.children[0]});
assert.equal(panel.hidden,false);
app.documentEvent('pointerdown',{target:app.element('input-text')});
assert.equal(panel.hidden,true);
assert.equal(toggle.getAttribute('aria-expanded'),'false');
await app.click(toggle);
app.documentEvent('keydown',{key:'Escape'});
assert.equal(panel.hidden,true);
await app.click(toggle);
await app.click(app.element('.new-chat-btn'));
assert.equal(panel.hidden,true);
});
+36
View File
@@ -378,6 +378,42 @@ class ApiTests(unittest.TestCase):
self.client.delete('/api/clear_all')
self.assertEqual(self.client.get('/api/get_history').json, [])
def test_browser_votes_and_legacy_rows_are_both_counted_in_admin_stats(self):
for vote in ('up', 'down', 1, -1):
self.assertEqual(self.client.post('/api/feedback', json={'vote':vote,'question':'q','answer':'a'}).status_code,200)
with self.app.app_context():
connection=db.connect()
connection.executemany('INSERT INTO feedback(vote) VALUES(?)', [('up',),('down',)])
connection.commit(); connection.close()
stats=self.client.get('/api/admin/stats').json
self.assertEqual((stats['good'],stats['bad']),(3,3))
def test_invalid_customer_feedback_and_tickets_return_json_errors_without_writes(self):
for data in (None, [], [1], {}, {'vote':True}, {'vote':0}, {'vote':'unknown'},
{'vote':'up','answer':{}}, {'vote':'down','question':[]}):
with self.subTest(feedback=data):
response=self.client.post('/api/feedback',json=data)
self.assertEqual(response.status_code,400)
self.assertFalse(response.json['ok'])
for data in (None, [], [1], {}, {'content':' '}, {'content':{}}, {'content':'x'*2001},
{'content':'help','phone':[]}, {'content':'help','phone':'1'*33}):
with self.subTest(ticket=data):
response=self.client.post('/api/ticket',json=data)
self.assertEqual(response.status_code,400)
self.assertFalse(response.json['ok'])
for path in ('/api/feedback','/api/ticket'):
response=self.client.post(path,data='{bad',content_type='application/json')
self.assertEqual(response.status_code,400)
self.assertFalse(response.json['ok'])
with self.app.app_context():
connection=db.connect()
for table in ('feedback','tickets'):
self.assertEqual(connection.execute('SELECT COUNT(*) FROM '+table).fetchone()[0],0)
connection.close()
self.assertEqual(self.client.post('/api/ticket',json={'content':' 请跟进报价 ','phone':' +86 123 '}).status_code,200)
ticket=self.client.get('/api/tickets').json[0]
self.assertEqual((ticket['content'],ticket['phone']),('请跟进报价','+86 123'))
def test_business_routes_with_initialized_tables_and_legacy_auth(self):
with self.app.app_context():
connection = db.connect()