288 lines
15 KiB
Python
288 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.assertGreaterEqual(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), 3)
|
|
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])
|
|
image_reply = frames(self.client.post('/stream_chat', json={'msg': '[图片]/uploads/test.png'}))
|
|
self.assertIn('收到您的图片', image_reply[0]['t'])
|
|
self.assertEqual(image_reply[-1], {'d': 1})
|
|
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[1]['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)
|
|
with patch('logistics_chat.human_service.datetime') as clock:
|
|
from datetime import datetime
|
|
clock.now.return_value = datetime(2026, 10, 29, 12)
|
|
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?format=csv')
|
|
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()
|