Files
kefu/wechat_rpa/test_gateway_failure_audit.py
T
2026-09-21 10:34:06 +08:00

135 lines
7.0 KiB
Python

"""Offline Dify attachment regression checks, including independent outlet failure."""
import asyncio
import base64
import json
from unittest import TestCase
import httpx
import model_gateway as gw
def outlet(kind='dify', **changes):
config = {'id': kind, 'name': kind, 'kind': kind, 'base_url': f'https://{kind}.invalid/v1',
'api_key': 'synthetic-key', 'model': 'audit', 'max_tokens': 300,
'temperature': .2, 'timeout_ms': 2000, 'max_inflight': 2}
config.update(changes)
return gw.Outlet(config=config, gate=asyncio.Semaphore(2))
def image(raw=b'audit-png', mime='image/png'):
return {'type': 'image_url', 'image_url': {'url': 'data:' + mime + ';base64,' + base64.b64encode(raw).decode()}}
def messages(*images):
return [{'role': 'user', 'content': [{'type': 'text', 'text': '请描述这些图片。'}, *images]}]
class GatewayFailureAudit(TestCase):
def run_request(self, handler, content, **kwargs):
async def run():
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
return await gw.call_outlet(client, kwargs.pop('outlet', outlet()), content, **kwargs)
return asyncio.run(run())
def test_plain_text_does_not_upload(self):
paths = []
def handler(request):
paths.append(request.url.path)
self.assertNotIn('files', json.loads(request.content))
return httpx.Response(200, json={'answer': '纯文字回复。'})
result = self.run_request(handler, [{'role': 'user', 'content': '在吗?'}])
self.assertEqual(result['text'], '纯文字回复。')
self.assertEqual(paths, ['/v1/chat-messages'])
def test_exact_endpoint_is_used_for_upload_root_and_chat(self):
paths = []
def handler(request):
paths.append(request.url.path)
return httpx.Response(200, json={'id': 'audit-upload'} if request.url.path.endswith('/files/upload') else {'answer': '成功。'})
result = self.run_request(handler, messages(image()), outlet=outlet(base_url='https://dify.invalid/custom/invoke', endpoint_mode='exact'))
self.assertEqual(result['text'], '成功。')
self.assertEqual(paths, ['/custom/invoke/files/upload', '/custom/invoke'])
def test_multiple_images_preserve_order_mime_bytes_and_same_user(self):
uploads, bodies = [], []
def handler(request):
if request.url.path.endswith('/files/upload'):
uploads.append(request.content)
self.assertIn(b'name="user"\r\n\r\nwechat-rpa\r\n', request.content)
return httpx.Response(200, json={'id': f'upload-{len(uploads)}'})
bodies.append(json.loads(request.content))
return httpx.Response(200, json={'answer': '两张图片已读取。'})
result = self.run_request(handler, messages(image(b'first-jpeg', 'image/jpeg'), image(b'second-webp', 'image/webp')))
self.assertEqual(result['error'], '')
self.assertEqual(len(uploads), 2)
self.assertIn(b'Content-Type: image/jpeg', uploads[0])
self.assertIn(b'first-jpeg', uploads[0])
self.assertIn(b'Content-Type: image/webp', uploads[1])
self.assertIn(b'second-webp', uploads[1])
self.assertEqual(bodies[0]['user'], 'wechat-rpa')
self.assertEqual([item['upload_file_id'] for item in bodies[0]['files']], ['upload-1', 'upload-2'])
self.assertNotIn('base64', bodies[0]['query'])
def test_old_turn_attachment_is_not_uploaded_for_a_text_followup(self):
seen = []
def handler(request):
seen.append(request.url.path)
return httpx.Response(200, json={'answer': '已说明。'})
content = [*messages(image()), {'role': 'assistant', 'content': '一张蓝色图片。'}, {'role': 'user', 'content': '谢谢'}]
result = self.run_request(handler, content)
self.assertEqual(result['error'], '')
self.assertEqual(seen, ['/v1/chat-messages'])
def test_same_inline_and_legacy_image_is_only_uploaded_once(self):
seen = []
def handler(request):
seen.append(request.url.path)
return httpx.Response(200, json={'id': 'one'} if request.url.path.endswith('/files/upload') else {'answer': '一张。'})
self.run_request(handler, messages(image()), image_b64=base64.b64encode(b'audit-png').decode())
self.assertEqual(seen.count('/v1/files/upload'), 1)
def _assert_upload_failure_is_local(self, upload_response):
async def run():
def handler(request):
if request.url.host == 'openai.invalid':
return httpx.Response(200, json={'choices': [{'message': {'content': '另一出口仍然成功。'}}]})
self.assertTrue(request.url.path.endswith('/files/upload'))
if isinstance(upload_response, type) and issubclass(upload_response, httpx.HTTPError):
raise upload_response('synthetic upload failure', request=request)
return upload_response
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
return await asyncio.gather(gw.call_outlet(client, outlet('dify'), messages(image())), gw.call_outlet(client, outlet('openai'), messages(image())))
results = asyncio.run(run())
self.assertEqual(results[0]['text'], '')
self.assertTrue(results[0]['error'])
self.assertEqual(results[1]['text'], '另一出口仍然成功。')
self.assertEqual(results[1]['error'], '')
def test_upload_invalid_json_is_local_to_dify_outlet(self):
self._assert_upload_failure_is_local(httpx.Response(200, text='invalid-json'))
def test_upload_non_object_json_is_local_to_dify_outlet(self):
for payload in ([{'id': 'wrong'}], 'wrong-shape', 7, None):
with self.subTest(payload=payload):
self._assert_upload_failure_is_local(httpx.Response(200, json=payload))
def test_upload_transport_failure_is_local_to_dify_outlet(self):
for error in (httpx.ReadTimeout, httpx.ConnectError):
with self.subTest(error=error.__name__):
self._assert_upload_failure_is_local(error)
def test_upload_http_failure_is_local_to_dify_outlet(self):
for status in (401, 413, 503):
with self.subTest(status=status):
self._assert_upload_failure_is_local(httpx.Response(status, text='synthetic failure'))
def test_remote_image_uses_file_reference_without_local_fetch(self):
requests = []
def handler(request):
requests.append(request)
body = json.loads(request.content)
self.assertEqual(body['files'], [{'type': 'image', 'transfer_method': 'remote_url', 'url': 'https://images.invalid/audit.png'}])
return httpx.Response(200, json={'answer': '已引用。'})
self.run_request(handler, messages({'type': 'image_url', 'image_url': {'url': 'https://images.invalid/audit.png'}}))
self.assertEqual(len(requests), 1)