mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* feat: Web/API 指数入口与共享 canonical 去重基础 为 Issue #2303 Phase 2 PR1 落地 Web/API 指数入口适配: - API 使用 parse_analysis_target 构造结构化 AnalysisTarget 并贯通到 pipeline - API 与 TaskQueue 去重按 asset_type 分支(指数用 canonical_id,个股用 legacy code) - BatchTaskAcceptedResponse 追加可选 rejected 字段,未登记 CSI 单股 400、批量仅该目标失败 - TaskInfo 固化 dedupe_key,避免指数与同码个股折叠及 _analyzing_stocks 残留 - Web 移除 assetType=index 全局过滤,Chat 名称识别保护指数 canonical - 补齐 Pipeline 指数 DecisionSignal market_override=cn 真实分支测试 * fix: 收敛指数 canonical 身份与批量响应契约 PR #2312 review 修复:报告 meta 补充 asset_type 隐藏指数自选;/analyze 在解析前限制非空原始 token;is_single 统一驱动 metadata/409/单任务 202;HomePage 三元计数继续后续 chunk。 * fix: avoid double space in index news search query * fix: preserve canonical index identity * fix: validate legacy task asset type * fix: preserve canonical index identity in chat * fix: preserve index identity across chat backends
164 lines
6.0 KiB
Python
164 lines
6.0 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
===================================
|
||
Analysis Integration Tests
|
||
===================================
|
||
|
||
Covers:
|
||
- API endpoint /analyze
|
||
- Name resolution to code
|
||
- Task queue submission
|
||
- Metadata persistence (original_query, selection_source)
|
||
"""
|
||
|
||
import pytest
|
||
from unittest.mock import patch, MagicMock
|
||
from fastapi.testclient import TestClient
|
||
from api.app import create_app
|
||
from src.services.task_queue import AnalysisTaskQueue, TaskStatus
|
||
from src.config import Config
|
||
import src.auth as auth
|
||
|
||
@pytest.fixture
|
||
def client():
|
||
app = create_app()
|
||
return TestClient(app)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def disable_auth():
|
||
"""Keep analysis integration tests independent from local auth env state."""
|
||
auth._auth_enabled = None
|
||
with patch("api.middlewares.auth.is_auth_enabled", return_value=False), \
|
||
patch("src.auth.is_auth_enabled", return_value=False):
|
||
yield
|
||
auth._auth_enabled = None
|
||
|
||
@pytest.fixture
|
||
def mock_task_queue():
|
||
with patch("api.v1.endpoints.analysis.get_task_queue") as mock_get:
|
||
queue = MagicMock(spec=AnalysisTaskQueue)
|
||
mock_get.return_value = queue
|
||
yield queue
|
||
|
||
class TestAnalysisIntegration:
|
||
"""End-to-end integration tests for the analysis flow."""
|
||
|
||
def test_trigger_analysis_flow_manual_name(self, client, mock_task_queue):
|
||
"""Test flow: User enters stock name -> resolved to code -> task submitted."""
|
||
# Setup mock behavior
|
||
mock_task_queue.submit_tasks_batch.return_value = (
|
||
[MagicMock(task_id="test_task_123", stock_code="600519", analysis_phase="auto")],
|
||
[]
|
||
)
|
||
|
||
# Trigger analysis with a stock name
|
||
response = client.post(
|
||
"/api/v1/analysis/analyze",
|
||
json={
|
||
"stock_code": "贵州茅台",
|
||
"async_mode": True,
|
||
"original_query": "贵州茅台",
|
||
"selection_source": "manual"
|
||
}
|
||
)
|
||
|
||
assert response.status_code == 202
|
||
data = response.json()
|
||
assert data["task_id"] == "test_task_123"
|
||
assert data["status"] == "pending"
|
||
# 单股 202(TaskAccepted schema)不包含可选的 asset_type 字段;legacy
|
||
# MagicMock 任务即使 asset_type 是 MagicMock 子对象也必须保持该契约。
|
||
assert "asset_type" not in data
|
||
|
||
# Verify task queue received the correct resolved code and metadata.
|
||
# Use call_args so this integration test stays focused on analysis flow
|
||
# semantics even if the queue API gains orthogonal optional flags.
|
||
mock_task_queue.submit_tasks_batch.assert_called_once()
|
||
_, kwargs = mock_task_queue.submit_tasks_batch.call_args
|
||
assert kwargs["stock_codes"] == ["600519"]
|
||
assert kwargs["stock_name"] is None
|
||
assert kwargs["original_query"] == "贵州茅台"
|
||
assert kwargs["selection_source"] == "manual"
|
||
assert kwargs["report_type"] == "detailed"
|
||
assert kwargs["analysis_phase"] == "auto"
|
||
assert kwargs["force_refresh"] is False
|
||
assert kwargs["notify"] is True
|
||
|
||
def test_trigger_analysis_batch_deduplication(self, client, mock_task_queue):
|
||
"""Test de-duplication across different formats (600519 and 600519.SH)."""
|
||
mock_task_queue.submit_tasks_batch.return_value = ([], [])
|
||
|
||
client.post(
|
||
"/api/v1/analysis/analyze",
|
||
json={
|
||
"stock_codes": ["600519", "600519.SH"],
|
||
"async_mode": True
|
||
}
|
||
)
|
||
|
||
# Should only submit once after de-duplication
|
||
mock_task_queue.submit_tasks_batch.assert_called_once()
|
||
args, kwargs = mock_task_queue.submit_tasks_batch.call_args
|
||
assert len(kwargs["stock_codes"]) == 1
|
||
assert kwargs["stock_codes"] == ["600519"]
|
||
assert kwargs["analysis_phase"] == "auto"
|
||
|
||
def test_trigger_analysis_dos_protection(self, client):
|
||
"""Test that excessive stock codes are rejected."""
|
||
too_many_codes = [f"{i:06d}" for i in range(101)]
|
||
response = client.post(
|
||
"/api/v1/analysis/analyze",
|
||
json={
|
||
"stock_codes": too_many_codes,
|
||
"async_mode": True
|
||
}
|
||
)
|
||
|
||
assert response.status_code == 400
|
||
assert "最多支持" in response.json()["message"]
|
||
|
||
def test_trigger_analysis_metadata_isolation_in_batch(self, client, mock_task_queue):
|
||
"""Test that single-stock metadata isn't applied to batch tasks."""
|
||
mock_task_queue.submit_tasks_batch.return_value = ([], [])
|
||
|
||
client.post(
|
||
"/api/v1/analysis/analyze",
|
||
json={
|
||
"stock_codes": ["600519", "000001"],
|
||
"stock_name": "贵州茅台",
|
||
"original_query": "茅台",
|
||
"async_mode": True
|
||
}
|
||
)
|
||
|
||
# Batch request: metadata should be None
|
||
mock_task_queue.submit_tasks_batch.assert_called_once()
|
||
args, kwargs = mock_task_queue.submit_tasks_batch.call_args
|
||
assert kwargs["stock_name"] is None
|
||
assert kwargs["original_query"] is None
|
||
assert kwargs["selection_source"] is None
|
||
assert kwargs["analysis_phase"] == "auto"
|
||
|
||
def test_trigger_analysis_explicit_analysis_phase(self, client, mock_task_queue):
|
||
"""Explicit analysis_phase is passed through to the task queue."""
|
||
mock_task_queue.submit_tasks_batch.return_value = (
|
||
[MagicMock(task_id="test_task_phase", stock_code="600519", analysis_phase="intraday")],
|
||
[]
|
||
)
|
||
|
||
response = client.post(
|
||
"/api/v1/analysis/analyze",
|
||
json={
|
||
"stock_code": "600519",
|
||
"async_mode": True,
|
||
"analysis_phase": "intraday",
|
||
},
|
||
)
|
||
|
||
assert response.status_code == 202
|
||
assert response.json()["analysis_phase"] == "intraday"
|
||
mock_task_queue.submit_tasks_batch.assert_called_once()
|
||
_, kwargs = mock_task_queue.submit_tasks_batch.call_args
|
||
assert kwargs["analysis_phase"] == "intraday"
|