Files
AlphaPulse/backend/tests/test_failure_paths.py
eezd f67a8ca402 Align backend authentication and API response contracts
Replace cookie authentication with revocable Bearer sessions, optional one-use PNG challenges, bounded login limits, and short-lived market WebSocket tickets. Keep captcha disabled by default.

Standardize ordinary JSON responses while preserving HTTP statuses, raw OpenAPI, redirects, SSE, and WebSocket semantics. Revoke long connections with their owning session.

Expose official calendar data and collector status to the frontend and research tools, support bilingual news topics, and keep summaries and forward-only labels explicit. Verify the staged snapshot with all 305 backend tests.
2026-10-26 12:04:21 +08:00

287 lines
13 KiB
Python

import asyncio
import json
import uuid
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import httpx
import httpx2
import pytest
def install_model_transport(llm, monkeypatch, protocol, handler):
monkeypatch.setattr(llm, "decrypt", lambda value: "test-only")
module = llm.openai if protocol == "openai" else llm.anthropic
name = "AsyncOpenAI" if protocol == "openai" else "AsyncAnthropic"
constructor = getattr(module, name)
http = httpx if protocol == "openai" else httpx2
monkeypatch.setattr(module, name, lambda **kwargs: constructor(**kwargs, http_client=http.AsyncClient(transport=http.MockTransport(handler))))
return SimpleNamespace(protocol=protocol, base_url="https://model.invalid", api_key_encrypted=b"test", model="test", max_tokens=100)
@pytest.mark.parametrize("protocol", ["openai", "anthropic"])
@pytest.mark.parametrize("complete", [False, True])
def test_model_requires_protocol_end_marker(trading, monkeypatch, protocol, complete):
from alphapulse.ai import llm
if protocol == "openai":
chunks = [{"id": "1", "object": "chat.completion.chunk", "created": 0, "model": "test", "choices": [{"index": 0, "delta": {"content": "部分回答"}, "finish_reason": None}]}]
if complete:
chunks.append(chunks[0] | {"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]})
body = "".join(f"data: {json.dumps(c)}\n\n" for c in chunks) + "data: [DONE]\n\n"
else:
events = [
{"type": "message_start", "message": {"id": "1", "type": "message", "role": "assistant", "model": "test", "content": [], "stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": 1, "output_tokens": 0}}},
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "部分回答"}},
{"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 1}},
]
if complete:
events.append({"type": "message_stop"})
body = "".join(f"event: {e['type']}\ndata: {json.dumps(e)}\n\n" for e in events)
def handle(request):
http = httpx if protocol == "openai" else httpx2
return http.Response(200, headers={"content-type": "text/event-stream"}, content=body.encode())
provider = install_model_transport(llm, monkeypatch, protocol, handle)
async def check():
output = []
try:
async for item in llm.stream(provider, "system", [], []):
output.append(item)
except llm.LlmError:
assert not complete
else:
assert complete
assert [x.text for x in output if isinstance(x, llm.TextDelta)] == ["部分回答"]
assert any(isinstance(x, llm.Completion) for x in output) == complete
asyncio.run(check())
def test_connection_failure_uses_only_sdk_retry_budget(trading, monkeypatch):
from alphapulse.ai import llm
attempts = 0
def handle(request):
nonlocal attempts
attempts += 1
raise httpx.ConnectError("测试连接失败", request=request)
provider = install_model_transport(llm, monkeypatch, "openai", handle)
async def check():
with pytest.raises(llm.LlmError, match="无法连接"):
async for _ in llm.stream(provider, "system", [], []):
pass
asyncio.run(check())
assert attempts == llm.MAX_RETRIES + 1
def test_pipeline_error_keeps_history_checkpoint_and_marks_run_failed(trading, monkeypatch):
from scrapy import signals
from scrapy.crawler import Crawler
from twisted.python.failure import Failure
from alphapulse.crawler.spiders import base
class Spider(base.NewsSpider):
name = "test"
now = datetime.now(UTC)
target, checkpoint = now - timedelta(days=10), now - timedelta(days=1)
source = SimpleNamespace(history_target=target, history_position=checkpoint, consecutive_failures=0)
writes = []
class Session:
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
async def execute(self, statement):
writes.append(statement.compile().params)
async def get(self, *args, **kwargs):
return source
async def commit(self):
pass
monkeypatch.setattr(base, "SessionLocal", Session)
async def check():
crawler = Crawler(Spider)
spider = Spider.from_crawler(crawler)
run = base.RunState(uuid.uuid4(), "history", now, target, history_target=target, history_position=target)
spider.run = run
crawler.signals.send_catch_log(signals.item_error, item={}, response=None, spider=spider, failure=Failure(RuntimeError("写库失败")))
spider._close_run()
await asyncio.gather(*spider._tasks)
assert spider.history_checkpoint is None and source.history_position == checkpoint
assert source.consecutive_failures == 1
assert writes[0]["status"] == "failed" and "写库失败" in writes[0]["error"]
asyncio.run(check())
@pytest.mark.parametrize("failure_at", ["usage", "second_tool"])
def test_chat_completes_tool_results_on_disconnect_or_exception(trading, monkeypatch, failure_at):
from alphapulse.api import chat
provider = SimpleNamespace(id=1, context_window=10000, max_tokens=100, name="test", reasoning_effort=None)
class Session:
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
async def scalar(self, *args):
return provider
def expunge(self, *args):
pass
async def current(*args):
return SimpleNamespace(id=1)
async def system(*args):
return "system"
async def history(*args):
return []
saved = []
async def save(*messages):
saved.extend(messages)
calls = [{"id": "a", "name": "first", "arguments": {}}, {"id": "b", "name": "second", "arguments": {}}]
async def stream(*args):
yield chat.llm.Completion(tool_calls=calls)
async def tool(name, args):
if name == "second":
raise RuntimeError("工具失败")
return chat.ToolOutcome("实际结果")
monkeypatch.setattr(chat, "SessionLocal", Session)
monkeypatch.setattr(chat, "active_session", current)
monkeypatch.setattr(chat, "build_system_prompt", system)
monkeypatch.setattr(chat, "session_messages", history)
monkeypatch.setattr(chat, "save_messages", save)
monkeypatch.setattr(chat.llm, "stream", stream)
monkeypatch.setattr(chat, "run_tool", tool)
async def check():
generator = chat._message_events(chat.SendIn(text="test"))
events = []
async for event in generator:
events.append(event.event)
if failure_at == "usage" and event.event == "usage":
await generator.aclose()
break
results = [m.content for m in saved if m.role == "tool"]
assert [r["tool_call_id"] for r in results] == ["a", "b"]
if failure_at == "second_tool":
assert results[0]["result"] == "实际结果"
assert "error" in json.loads(results[1]["result"])
assert events[-1] == "error"
asyncio.run(check())
def test_skill_rename_rejects_whitespace_without_writing(trading):
from fastapi import HTTPException
from alphapulse.api import skills
node = SimpleNamespace(name="原名称")
class Session:
async def get(self, *args):
return node
async def check():
with pytest.raises(HTTPException) as error:
await skills.patch(1, skills.NodePatch(name=" \t "), Session())
assert error.value.status_code == 400
assert node.name == "原名称"
asyncio.run(check())
def test_chat_reset_blocks_new_generation_until_commit(trading, monkeypatch):
from alphapulse.api import chat
async def check():
monkeypatch.setattr(chat, "generating", asyncio.Lock())
entered, finish = asyncio.Event(), asyncio.Event()
monkeypatch.setattr(chat, "SessionLocal", lambda: pytest.fail("重置期间不能进入生成的数据库路径"))
saved = []
class Session:
async def execute(self, statement):
entered.set()
await finish.wait()
def add(self, row):
saved.append(row)
async def commit(self):
assert chat.generating.locked()
async def reset_and_send():
task = asyncio.create_task(chat.reset(chat.ResetIn(clear_context=True), Session()))
try:
await entered.wait()
events = [event async for event in chat._message_events(chat.SendIn(text="重置期间的新消息"))]
assert len(events) == 1 and events[0].event == "error"
assert not task.done()
finally:
finish.set()
await task
await asyncio.wait_for(reset_and_send(), timeout=2)
assert len(saved) == 1 and saved[0].content == "" and saved[0].source == "reset"
assert not chat.generating.locked()
asyncio.run(check())
def test_chat_reset_rejects_busy_generation_and_releases_lock_on_failure(trading, monkeypatch):
from fastapi import HTTPException
from alphapulse.api import chat
async def check():
monkeypatch.setattr(chat, "generating", asyncio.Lock())
class Session:
async def execute(self, statement):
raise RuntimeError("重置写库失败")
async with chat.generating:
with pytest.raises(HTTPException) as error:
await chat.reset(chat.ResetIn(), Session())
assert error.value.status_code == 409
with pytest.raises(RuntimeError, match="重置写库失败"):
await chat.reset(chat.ResetIn(), Session())
assert not chat.generating.locked()
asyncio.run(check())
@pytest.mark.parametrize("change", ["metadata", "content", "unchanged"])
def test_news_updates_metadata_without_creating_content_revision(trading, monkeypatch, change):
from alphapulse.crawler import pipelines
from alphapulse.models import NewsItem
original = {
"url": "https://example.invalid/1", "title": "原标题", "summary": "摘要", "content_html": "<p>正文</p>",
"content_text": "正文", "author": "原作者", "tags": ["旧标签"], "is_paywalled": False,
"published_at": datetime(2026, 10, 20, tzinfo=UTC), "source_updated_at": None, "extra": {},
}
news = NewsItem(id=1, source_code="cls_telegraph", external_id="1", revision_no=1,
content_hash=pipelines.content_hash(original), **original)
incoming = dict(original)
if change != "unchanged":
incoming.update(author="更正作者", tags=["新标签"], is_paywalled=True, extra={"level": 1},
published_at=original["published_at"] + timedelta(minutes=1),
source_updated_at=original["published_at"] + timedelta(minutes=2))
if change == "content":
incoming.update(title="修订标题", content_html="<p>修订正文</p>", content_text="修订正文")
revisions, events = [], []
class Session:
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
async def scalar(self, statement):
return news
def add(self, revision):
revisions.append(revision)
async def commit(self):
pass
async def publish(channel, data):
events.append(json.loads(data))
monkeypatch.setattr(pipelines, "SessionLocal", Session)
monkeypatch.setattr(pipelines.redis, "publish", publish)
run = SimpleNamespace(items_seen=0, items_new=0, items_revised=0)
pipeline = pipelines.NewsPipeline(SimpleNamespace(spider=SimpleNamespace(run=run)))
async def check():
for _ in range(2):
await pipeline.process_item({**incoming, "source_code": "cls_telegraph", "external_id": "1", "raw_response_id": 10})
assert {key: getattr(news, key) for key in pipelines.UPDATABLE_FIELDS} == incoming
assert news.content_hash == pipelines.content_hash(incoming)
assert news.revision_no == (2 if change == "content" else 1)
assert run.items_seen == 2 and run.items_new == 0
if change == "content":
assert len(revisions) == run.items_revised == 1
assert revisions[0].revision_no == 2 and revisions[0].content_html == incoming["content_html"]
assert events == [{"id": 1, "source_code": "cls_telegraph", "kind": "revised"}]
else:
assert not revisions and not events and run.items_revised == 0
asyncio.run(check())