Files
baiyue-logistics-chat/tests/test_api.py
T

283 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_health_check_remains_available_when_business_requests_are_rate_limited(self):
self.app.config['RATE_LIMIT_PER_MIN'] = 1
self.assertEqual(self.client.get('/').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
response = self.client.get('/healthz')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {'status': 'ok'})
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_initialized_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'}.issubset(tables))
connection.executescript(
"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()