"""Regression tests for chat API, permissions, concurrency and cleanup.""" import asyncio import json from pathlib import Path import sys import tempfile import unittest from unittest.mock import AsyncMock, patch BASE = Path(__file__).resolve().parents[1] sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)] from chat import engine class FakeResponse: def __init__(self, status, payload=None): self.status = status self.payload = payload or {} async def json(self): return self.payload async def text(self): return json.dumps(self.payload) async def __aenter__(self): return self async def __aexit__(self, *args): return False class FakeSession: def __init__(self, responses): self.responses = responses self.payloads = [] async def __aenter__(self): return self async def __aexit__(self, *args): return False def post(self, url, **kwargs): self.payloads.append(kwargs["json"]) response = self.responses.pop(0) if isinstance(response, Exception): raise response return response class ChatRegressionTests(unittest.IsolatedAsyncioTestCase): async def test_official_retry_preserves_multimodal_and_reasoning(self): responses = [ FakeResponse(500, {"error": "temporary"}), FakeResponse(200, {"choices": [{"message": {"content": "ok"}}]}), ] sessions = [] def factory(): session = FakeSession(responses) sessions.append(session) return session content = [{"type": "text", "text": "task"}] with patch.object(engine.aiohttp, "ClientSession", side_effect=factory), \ patch.object(engine.asyncio, "sleep", new=AsyncMock()): result = await engine._call_deepseek_official( "system", content, 0, presence_penalty=0.3, reasoning=True) self.assertEqual(result, "ok") self.assertEqual(len(sessions), 2) for session in sessions: self.assertEqual(session.payloads[0]["messages"][1]["content"], content) self.assertEqual(session.payloads[0]["thinking"], {"type": "enabled"}) self.assertEqual(session.payloads[0]["presence_penalty"], 0.3) async def test_json_structure_is_validated(self): valid = '{"message":[{"content":"ok"}]}' self.assertIsNotNone(engine.parse_json_response(valid)) for raw in ('{"message":["x"]}', '{"message":[null]}', '{"message":[{}]}', '{"message":[{"content":1}]}'): self.assertIsNone(engine.parse_json_response(raw)) async def test_mixed_group_batch_disables_actor_tools(self): engine._group_pending_messages[1] = ["普通用户请求", "管理员插话"] engine._group_pending_users[1] = [2, 1] engine._group_pending_images[1] = [] reply = AsyncMock() with patch.object(engine, "_call_group_model_and_reply", new=reply), \ patch.object(engine.random, "uniform", return_value=0): await engine._process_group_messages(1) self.assertEqual(reply.await_args.args[3], 0) async def test_private_turns_are_serialized(self): active = 0 maximum = 0 async def impl(*args, **kwargs): nonlocal active, maximum active += 1 maximum = max(maximum, active) await asyncio.sleep(0.02) active -= 1 center = type("Center", (), { "begin_conversation": lambda self, uid: None, "end_conversation": lambda self, uid: None, "deliver_due": AsyncMock(), })() import notifications with patch.object(engine, "_resolve_delivery", return_value=None), \ patch.object(engine, "_call_model_and_reply_impl", new=impl), \ patch.object(notifications, "center", center): await asyncio.gather(*[ engine._call_model_and_reply(None, 1, f"message-{i}", False) for i in range(3) ]) self.assertEqual(maximum, 1) async def test_reasoning_json_gets_one_repair_retry(self): valid = '{"message":[{"content":"ok"}],"tools":[{"name":"web_search","args":{"query":"x"}}]}' model = AsyncMock(side_effect=["not json", valid]) with patch.object(engine, "call_deepseek", new=model): parsed = await engine._reasoning_json_response("system", "user") self.assertEqual(parsed["tools"][0]["name"], "web_search") self.assertEqual(model.await_count, 2) self.assertTrue(all(call.kwargs["reasoning"] for call in model.await_args_list)) async def test_reasoning_json_stops_after_two_invalid_responses(self): model = AsyncMock(side_effect=["bad", "still bad"]) with patch.object(engine, "call_deepseek", new=model): parsed = await engine._reasoning_json_response("system", "user") self.assertIsNone(parsed) self.assertEqual(model.await_count, 2) async def test_send_segments_reports_delivery_failure(self): with patch.object(engine, "send_private_message", new=AsyncMock(side_effect=[True, False])): ok = await engine.send_message_segments(None, 1, [{"content": "a"}, {"content": "b"}]) self.assertFalse(ok) async def test_pending_images_are_cleaned_after_consumption(self): with tempfile.NamedTemporaryFile(delete=False) as handle: path = Path(handle.name) engine._user_pending_messages[1] = ["读图"] engine._user_pending_images[1] = [{"url": str(path)}] with patch.object(engine, "_call_model_and_reply", new=AsyncMock()): await engine._process_pending_messages(1, 0) self.assertFalse(path.exists()) if __name__ == "__main__": unittest.main()