Files
AlphaPulse/backend/tests/test_provider_settings.py
T
eezd 80d266dba7 Add three-stage cross-domain news research and reading briefings
Group original news into tagged fact packages, review each package with evidence and read-only research, and build complete 72-hour reading lists with independent model selections.

Add paused RSS/Atom subscriptions, news-scoped Skills, versioned analysis records, and locked deployment support for SearXNG. Preserve original news and keep live migration and legacy AI cleanup separately authorized.

Validate with 604 backend tests, 49 frontend tests, 19 CI control tests, lint, type checks, production build, and disposable PostgreSQL migration and three-stage smoke checks.
2026-10-31 22:08:59 +08:00

157 lines
7.2 KiB
Python

"""模型默认配置的接口契约;数据库和认证均隔离。"""
import asyncio
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
@pytest.mark.parametrize("initial,requested,status", [
(True, False, 409), (False, False, 200), (False, True, 200), (True, True, 200),
])
def test_patch_provider_default_contract(trading, monkeypatch, initial, requested, status):
from alphapulse.api import settings
from alphapulse.api.main import app
from alphapulse.core.auth import require_admin
from alphapulse.core.db import get_session
provider = SimpleNamespace(
id=1, name="test", protocol="openai", base_url="https://example.test",
api_key_hint="test", model="test", max_tokens=8192, context_window=200000,
reasoning_effort=None, is_default=initial, updated_at=datetime.now(UTC),
)
session = SimpleNamespace(commit=AsyncMock(), refresh=AsyncMock(), execute=AsyncMock())
monkeypatch.setattr(settings, "get_provider", AsyncMock(return_value=provider))
async def isolated_session():
yield session
app.dependency_overrides[require_admin] = lambda: None
app.dependency_overrides[get_session] = isolated_session
async def check():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://test") as client:
response = await client.patch("/api/settings/providers/1", json={"is_default": requested})
assert response.status_code == status
body = response.json()
if status == 409:
assert body == {"code": 409, "data": None, "message": "不能直接取消默认模型,请将其他模型设为默认"}
assert provider.is_default is initial
session.commit.assert_not_awaited()
session.execute.assert_not_awaited()
else:
assert body["code"] == 0 and body["data"]["is_default"] is requested
session.commit.assert_awaited_once()
assert session.execute.await_count == int(requested)
try:
asyncio.run(check())
finally:
app.dependency_overrides.clear()
@pytest.mark.parametrize('enabled', [True, False])
def test_delete_provider_handles_news_risk_reference(trading, monkeypatch, enabled):
from fastapi import HTTPException
from alphapulse.api import settings
reference = SimpleNamespace(enabled=enabled, provider_id=1, second_provider_id=1, third_provider_id=1)
provider = SimpleNamespace(id=1, is_default=False)
session = SimpleNamespace(scalar=AsyncMock(return_value=reference), delete=AsyncMock(),
flush=AsyncMock(), commit=AsyncMock())
monkeypatch.setattr(settings, 'get_provider', AsyncMock(return_value=provider))
if enabled:
with pytest.raises(HTTPException) as error:
asyncio.run(settings.delete_provider(1, session))
assert error.value.status_code == 409
session.delete.assert_not_awaited()
assert reference.provider_id == 1
else:
asyncio.run(settings.delete_provider(1, session))
assert reference.provider_id is None
session.delete.assert_awaited_once_with(provider)
session.commit.assert_awaited_once()
@pytest.mark.parametrize('protocol,base_url', [('openai', 'https://example.invalid/v1/'), ('anthropic', 'https://example.invalid/')])
def test_model_catalog_uses_selected_protocol_and_reads_all_pages(trading, monkeypatch, protocol, base_url):
from alphapulse.ai import llm
options = []
class Page:
async def __aiter__(self):
for identifier in ['model-b', 'model-a', 'model-b']:
yield SimpleNamespace(id=identifier)
class Client:
def __init__(self, **kwargs):
options.append(kwargs)
self.models = SimpleNamespace(list=AsyncMock(return_value=Page()))
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
monkeypatch.setattr(llm, 'decrypt', lambda value: 'test-only-key')
monkeypatch.setattr(llm.openai if protocol == 'openai' else llm.anthropic,
'AsyncOpenAI' if protocol == 'openai' else 'AsyncAnthropic', Client)
provider = SimpleNamespace(protocol=protocol, base_url=base_url, api_key_encrypted=b'test')
assert asyncio.run(llm.list_models(provider)) == ['model-a', 'model-b']
assert options == [{'base_url': 'https://example.invalid/v1' if protocol == 'openai' else 'https://example.invalid',
'api_key': 'test-only-key', 'timeout': 10, 'max_retries': 0}]
@pytest.mark.parametrize('failure', ['http', 'timeout', 'invalid_id'])
def test_model_catalog_failures_remain_explicit_without_exposing_upstream_details(trading, monkeypatch, failure):
from alphapulse.ai import llm
class Page:
async def __aiter__(self):
yield SimpleNamespace(id=None)
class Client:
def __init__(self, **kwargs):
self.models = SimpleNamespace(list=AsyncMock(return_value=Page()))
if failure == 'timeout': self.models.list.side_effect = TimeoutError()
if failure == 'http':
response = httpx.Response(401, request=httpx.Request('GET', 'https://example.invalid/v1/models'))
self.models.list.side_effect = llm.openai.APIStatusError('private-upstream-details', response=response, body=None)
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
monkeypatch.setattr(llm, 'decrypt', lambda value: 'test-only-key')
monkeypatch.setattr(llm.openai, 'AsyncOpenAI', Client)
provider = SimpleNamespace(protocol='openai', base_url='https://example.invalid', api_key_encrypted=b'test')
with pytest.raises(llm.LlmError) as error:
asyncio.run(llm.list_models(provider))
assert 'private-upstream-details' not in str(error.value)
assert '手动填写' in str(error.value)
@pytest.mark.parametrize('failure', [None, 'unavailable', 'missing'])
def test_provider_model_catalog_http_contract(trading, monkeypatch, failure):
from fastapi import FastAPI, HTTPException
from alphapulse.api import settings
provider = SimpleNamespace(id=1)
async def get_provider(session, identifier):
assert identifier == 1
if failure == 'missing': raise HTTPException(404, '模型配置不存在')
return provider
catalog = AsyncMock(return_value=['model-a', 'model-b'])
if failure == 'unavailable': catalog.side_effect = settings.llm.LlmError('读取模型列表失败')
monkeypatch.setattr(settings, 'get_provider', get_provider)
monkeypatch.setattr(settings.llm, 'list_models', catalog)
app = FastAPI()
app.include_router(settings.router)
app.dependency_overrides[settings.require_admin] = lambda: None
app.dependency_overrides[settings.get_session] = lambda: None
async def check():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url='http://test') as client:
response = await client.get('/api/settings/providers/1/models')
assert response.status_code == ({None: 200, 'unavailable': 502, 'missing': 404}[failure])
if failure is None: assert response.json()['data'] == ['model-a', 'model-b']
asyncio.run(check())