Files
baiyue-logistics-chat/tests/test_api.py
T
ouyangxq 805675f8db 重构物流客服模块并完善会话接待工作台
- 拆分应用工厂、业务服务与路由,保留原有 HTTP/SSE 和存储入口
- 分离页面资源,重排客服工作台并完善响应式导航、草稿和附件交互
- 整理维护脚本、请求示例和贡献指南,移除启动脚本中的密钥配置
- 上传文件改为仅本地保留,补充隔离的后端与前端回归验证

验证:46 项 Python 测试通过,含 14 个已有子测试;12 个 Node 前端场景通过。
2026-10-29 10:59:29 +08:00

277 lines
15 KiB
Python

"""HTTP and SSE contracts across the application factory and extracted services."""
import io
import json
import logging
from html.parser import HTMLParser
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import requests
from logistics_chat import create_app, db
class ModelResponse:
def __init__(self, text='物流测试回答,费用与时效请以报价表为准。', status=200):
self.content = text
self.status_code = status
self.closed = False
self.text = 'model error'
def iter_lines(self):
yield ('data: ' + json.dumps({'choices': [{'delta': {'content': self.content}}]})).encode()
yield b'data: [DONE]'
def json(self):
return {'choices': [{'message': {'content': self.content}}], 'error': {'message': 'model error'}}
def close(self):
self.closed = True
def frames(response):
return [json.loads(part[6:]) for part in response.get_data(as_text=True).split('\n\n') if part.startswith('data: ')]
class ApiTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.app = self.application(self.root)
self.client = self.app.test_client()
def application(self, root):
return create_app({'TESTING': True, 'DATABASE': root/'history.db',
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups',
'LOG_DIR': root/'logs', 'DEEPSEEK_API_KEY': ''})
def conversation(self):
return self.client.post('/api/new_chat').json['id']
def test_pages_static_files_and_cache_headers(self):
for path in ('/', '/admin', '/admin/dashboard'):
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
self.assertIn('no-store', response.headers['Cache-Control'])
with self.client.get('/static/fa.jpg') as image:
self.assertEqual(image.status_code, 200)
def test_page_styles_and_scripts_are_accessible(self):
class Assets(HTMLParser):
def __init__(self):
super().__init__()
self.urls = []
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == 'script' and attrs.get('src'):
self.urls.append(attrs['src'])
if tag == 'link' and attrs.get('rel') == 'stylesheet':
self.urls.append(attrs['href'])
for path in ('/', '/admin', '/admin/dashboard'):
parser = Assets()
parser.feed(self.client.get(path).get_data(as_text=True))
self.assertEqual(len(parser.urls), 3)
for url in parser.urls:
resource = self.client.get(url)
self.assertEqual(resource.status_code, 200, url)
self.assertGreater(len(resource.data), 0)
resource.close()
def test_history_card_roundtrip_and_deletion(self):
cid = self.conversation()
response = self.client.post('/stream_chat', json={'msg': '15252125', 'conv_id': cid})
events = frames(response)
self.assertEqual(events[-1], {'d': 1})
self.assertEqual(events[0]['card']['no'], '15252125')
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
self.assertEqual(len(messages), 2)
self.assertTrue(messages[1]['content'].startswith('__CARD__'))
self.assertEqual(self.client.get('/api/get_history').json[0]['title'], '15252125')
self.assertEqual(self.client.delete(f'/api/del_history?id={cid}').json, {'ok': True})
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json, [])
def test_empty_image_and_missing_key_streams(self):
self.assertIn('e', frames(self.client.post('/stream_chat', json={'msg': ''}))[0])
self.assertIn('收到您的图片', frames(self.client.post('/stream_chat', json={'msg': '[图片]/uploads/test.png'}))[0]['t'])
self.assertIn('API Key', frames(self.client.get('/stream_chat?msg=hello'))[0]['e'])
def test_attachment_filename_is_not_used_as_a_tracking_number(self):
attachment = '[文件]/uploads/20261029100145_6787.txt'
events = frames(self.client.post('/stream_chat', json={'msg': '请查看附件 ' + attachment}))
self.assertFalse(any('card' in event for event in events))
self.assertIn('API Key', events[0]['e'])
cid = self.conversation()
events = frames(self.client.post('/stream_chat', json={'msg': '查询15252125 ' + attachment, 'conv_id': cid}))
self.assertEqual(events[0]['card']['no'], '15252125')
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json[0]['content'], '查询15252125 ' + attachment)
def test_human_lifecycle_typing_and_read_receipts(self):
self.assertEqual(self.client.post('/api/human/request', json={'conv_id': 0}).status_code, 400)
sid = self.client.post('/api/human/request', json={'conv_id': self.conversation()}).json['session_id']
self.assertEqual(self.client.get('/api/human/queue').json[0]['id'], sid)
self.client.post('/api/human/accept', json={'sid': sid})
self.client.post('/api/human/send', json={'sid': sid, 'side': 'customer', 'content': 'hello'})
self.client.post('/api/human/typing', json={'sid': sid})
state = self.client.get(f'/api/human/poll?sid={sid}&after=0').json
self.assertEqual(state['status'], 'active')
self.assertTrue(state['typing'])
self.assertFalse(state['messages'][0]['read'])
self.client.post('/api/human/read', json={'sid': sid})
self.client.post('/api/human/stop_typing', json={'sid': sid})
state = self.client.get(f'/api/human/poll?sid={sid}').json
self.assertTrue(state['messages'][0]['read'])
self.assertFalse(state['typing'])
self.client.post('/api/human/close', json={'sid': sid})
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_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)
other = self.application(self.root/'other')
self.assertEqual(other.extensions['human_typing'], {})
self.assertEqual(dict(other.extensions['rate_hits']), {})
def test_factories_with_different_log_directories_do_not_cross_write(self):
applications = []
try:
for name in ('first', 'second'):
root = self.root/name
applications.append(create_app({'DATABASE': root/'history.db', 'UPLOAD_DIR': root/'uploads',
'BACKUP_DIR': root/'backups', 'LOG_DIR': root/'logs', 'DEEPSEEK_API_KEY': ''}))
applications[0].logger.warning('first application only')
for app in applications:
for handler in app.logger.handlers:
handler.flush()
self.assertIn('first application only', (self.root/'first/logs/app.log').read_text())
self.assertNotIn('first application only', (self.root/'second/logs/app.log').read_text())
finally:
for app in applications:
for handler in list(app.logger.handlers):
if isinstance(handler, logging.FileHandler):
handler.close()
app.logger.removeHandler(handler)
def test_rate_limit_and_exemptions(self):
self.app.config['RATE_LIMIT_PER_MIN'] = 2
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
self.assertEqual(self.client.get('/api/human/queue').status_code, 200)
with self.client.get('/static/fa.jpg') as image:
self.assertEqual(image.status_code, 200)
def test_attachment_roundtrip_and_rejection(self):
response = self.client.post('/api/upload', data={'file': (io.BytesIO(b'hello'), 'hello.txt')})
self.assertEqual(response.status_code, 200)
downloaded = self.client.get(response.json['url'])
self.assertEqual(downloaded.data, b'hello')
downloaded.close()
self.assertEqual(self.client.post('/api/upload', data={'file': (io.BytesIO(b'x'), 'x.exe')}).status_code, 400)
def test_feedback_stats_and_csv(self):
cid = self.conversation()
with self.app.app_context():
db.save_message(cid, 'user', 'hello, "quoted"\nworld')
self.client.post('/api/feedback', json={'vote': 1, 'question': 'hello', 'answer': 'world'})
self.assertEqual(self.client.get('/api/admin/stats').json['good'], 1)
response = self.client.get('/api/export_chat')
self.assertEqual(response.mimetype, 'text/csv')
self.assertIn('chat_export.csv', response.headers['Content-Disposition'])
self.assertIn('"hello, ""quoted""\nworld"', response.get_data(as_text=True))
self.client.delete('/api/clear_all')
self.assertEqual(self.client.get('/api/get_history').json, [])
def test_business_routes_with_fixture_tables_and_legacy_auth(self):
with self.app.app_context():
connection = db.connect()
tables = {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
self.assertTrue({'agents', 'orders', 'tickets'}.isdisjoint(tables))
connection.executescript('CREATE TABLE agents(username TEXT, password TEXT, name TEXT);'
'CREATE TABLE orders(id INTEGER PRIMARY KEY, phone TEXT, track_no TEXT, status TEXT, created TEXT);'
'CREATE TABLE tickets(id INTEGER PRIMARY KEY, content TEXT, phone TEXT, ctime TEXT, status TEXT);'
"INSERT INTO agents VALUES('demo', 'test-password', 'Agent');"
"INSERT INTO orders VALUES(1, '123', '15252125', 'active', '2026-10-28');")
connection.commit()
connection.close()
self.assertEqual(self.client.post('/admin/login', json={'username': 'demo', 'password': 'wrong'}).status_code, 401)
self.assertEqual(self.client.post('/admin/login', json={'username': 'demo', 'password': 'test-password'}).json['name'], 'Agent')
self.assertEqual(self.client.get('/api/my_orders?phone=123').json['orders'][0]['track_no'], '15252125')
self.assertEqual(self.client.post('/api/ticket', json={'content': 'help', 'phone': '123'}).status_code, 200)
self.assertEqual(self.client.get('/api/tickets').json[0]['content'], 'help')
def test_stream_compression_followups_and_deadlines(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
streamed = ModelResponse('这是用于覆盖长回答压缩的说明。'*100)
responses = [streamed, ModelResponse('基础运费2050元,时效38-45天。'), ModelResponse('["费用呢","时效呢","能带电吗"]')]
with patch('logistics_chat.ai_client.requests.post', side_effect=responses) as post:
events = frames(self.client.post('/stream_chat', json={'msg': '200kg 海运', 'conv_id': self.conversation()}))
self.assertTrue(any('t' in event for event in events))
self.assertTrue(any('replace' in event for event in events))
self.assertTrue(any('suggest' in event for event in events))
self.assertEqual(events[-1], {'d': 1})
self.assertEqual([call.kwargs['timeout'] for call in post.call_args_list], [(5, 90), (5, 30), (5, 15)])
self.assertTrue(streamed.closed)
def test_stream_network_and_model_errors(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
for error, expected in ((requests.exceptions.Timeout, '超时'),
(requests.exceptions.ConnectionError, '无法连接')):
with self.subTest(error=error):
with patch('logistics_chat.ai_client.requests.post', side_effect=error):
events = frames(self.client.post('/stream_chat', json={'msg': '物流咨询'}))
self.assertIn(expected, events[0]['e'])
denied = ModelResponse(status=401)
with patch('logistics_chat.ai_client.requests.post', return_value=denied):
events = frames(self.client.post('/stream_chat', json={'msg': '物流咨询'}))
self.assertEqual(events[0]['e'], '系统繁忙,请稍后再试')
self.assertTrue(denied.closed)
empty = ModelResponse(text='')
with patch('logistics_chat.ai_client.requests.post', return_value=empty):
events = frames(self.client.post('/stream_chat', json={'msg': '物流咨询'}))
self.assertIn('没有返回内容', events[0]['e'])
self.assertTrue(empty.closed)
def test_cancellation_saves_partial_answer_and_closes_http_response(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
cid = self.conversation()
model = ModelResponse('这是长度超过二十字的回答内容,用于验证中断保存行为。')
with patch('logistics_chat.ai_client.requests.post', return_value=model):
response = self.client.post('/stream_chat', json={'msg': '海运咨询', 'conv_id': cid}, buffered=False)
next(response.response)
response.close()
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
self.assertIn('回答中断', messages[-1]['content'])
self.assertTrue(model.closed)
def test_entry_and_package_imports_do_not_create_runtime_files(self):
root = str(Path(__file__).resolve().parents[1])
code = 'import sys; sys.path.insert(0, sys.argv[1]); import am; from logistics_chat import create_app'
with tempfile.TemporaryDirectory() as directory:
result = subprocess.run([sys.executable, '-c', code, root], cwd=directory,
capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(list(Path(directory).iterdir()), [])
def test_wsgi_compatibility_creates_one_application_on_explicit_access(self):
import am
try:
with patch.object(am, 'create_app', side_effect=lambda: self.application(self.root/'wsgi')) as factory:
application = getattr(am, 'app')
self.assertIs(getattr(am, 'app'), application)
self.assertEqual(application.test_client().get('/').status_code, 200)
self.assertEqual(factory.call_count, 1)
finally:
am.__dict__.pop('app', None)
if __name__ == '__main__':
unittest.main()