"""影响分析端点测试 — POST /api/v1/impact/analyze。""" import pytest from unittest.mock import patch, AsyncMock from app.services.impact_analyzer import ImpactAnalysis from tests.conftest import auth_header # ───────────────────────── helper ───────────────────────── def _make_analysis(**overrides) -> ImpactAnalysis: """构造一个默认的 ImpactAnalysis 对象,可按需覆盖字段。""" defaults = dict( sql_type="UPDATE", estimated_rows=50, estimated_duration_ms=200, table_size_mb=32.5, table_row_count=10000, index_usage=["PRIMARY"], has_full_scan=False, risk_level="low", warnings=[], ) defaults.update(overrides) return ImpactAnalysis(**defaults) def _payload(instance_id: int, **overrides) -> dict: """构造分析请求体。""" body = { "instance_id": instance_id, "database": "test_db", "sql_content": "UPDATE users SET name='test' WHERE id=1;", } body.update(overrides) return body # ───────────────────────── 测试用例 ───────────────────────── class TestImpactAnalyze: """影响分析端点基本测试。""" @pytest.mark.asyncio async def test_analyze_success(self, client, db, admin_token, test_instance): """正常分析 — 返回完整影响分析结果。""" analysis = _make_analysis() with patch( "app.api.v1.endpoints.impact.ImpactAnalyzer.analyze", new_callable=AsyncMock, return_value=analysis, ): resp = await client.post( "/api/v1/impact/analyze", json=_payload(test_instance.id), headers=auth_header(admin_token), ) assert resp.status_code == 200 data = resp.json() # 验证所有字段都存在且类型正确 assert data["sql_type"] == "UPDATE" assert data["estimated_rows"] == 50 assert data["estimated_duration_ms"] == 200 assert data["table_size_mb"] == 32.5 assert data["table_row_count"] == 10000 assert data["index_usage"] == ["PRIMARY"] assert data["has_full_scan"] is False assert data["risk_level"] == "low" assert data["warnings"] == [] @pytest.mark.asyncio async def test_analyze_instance_not_found(self, client, db, admin_token): """实例不存在 — 返回 404。""" resp = await client.post( "/api/v1/impact/analyze", json=_payload(instance_id=99999), headers=auth_header(admin_token), ) assert resp.status_code == 404 assert "不存在" in resp.json()["detail"] @pytest.mark.asyncio async def test_analyze_requires_auth(self, client, db, test_instance): """未携带 Token — 返回 401。""" resp = await client.post( "/api/v1/impact/analyze", json=_payload(test_instance.id), ) assert resp.status_code == 401 @pytest.mark.asyncio async def test_analyze_developer_allowed(self, client, db, developer_token, test_instance): """developer 角色也能调用(任何登录用户均可使用影响分析)。""" analysis = _make_analysis(sql_type="SELECT") with patch( "app.api.v1.endpoints.impact.ImpactAnalyzer.analyze", new_callable=AsyncMock, return_value=analysis, ): resp = await client.post( "/api/v1/impact/analyze", json=_payload(test_instance.id, sql_content="SELECT * FROM users LIMIT 10;"), headers=auth_header(developer_token), ) assert resp.status_code == 200 assert resp.json()["sql_type"] == "SELECT" @pytest.mark.asyncio async def test_analyze_missing_fields(self, client, db, admin_token, test_instance): """请求体缺少必填字段 — 返回 422 校验错误。""" # 缺少 sql_content resp = await client.post( "/api/v1/impact/analyze", json={"instance_id": test_instance.id, "database": "test_db"}, headers=auth_header(admin_token), ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_analyze_high_risk_update(self, client, db, admin_token, test_instance): """高风险 UPDATE(全表扫描 + 大量行)— 返回 high/critical 风险等级。""" analysis = _make_analysis( sql_type="UPDATE", estimated_rows=50000, estimated_duration_ms=12000, table_size_mb=200.0, table_row_count=500000, has_full_scan=True, risk_level="high", warnings=["UPDATE 缺少 WHERE 条件,将影响全表 500000 行", "可能触发全表扫描,建议添加索引条件"], ) with patch( "app.api.v1.endpoints.impact.ImpactAnalyzer.analyze", new_callable=AsyncMock, return_value=analysis, ): resp = await client.post( "/api/v1/impact/analyze", json=_payload( test_instance.id, sql_content="UPDATE users SET status=0;", ), headers=auth_header(admin_token), ) assert resp.status_code == 200 data = resp.json() assert data["risk_level"] == "high" assert data["has_full_scan"] is True assert len(data["warnings"]) > 0 @pytest.mark.asyncio async def test_analyze_critical_drop(self, client, db, admin_token, test_instance): """DROP 语句 — 风险等级应为 critical。""" analysis = _make_analysis( sql_type="DROP", risk_level="critical", warnings=["DROP 操作不可逆,请确认已备份数据"], ) with patch( "app.api.v1.endpoints.impact.ImpactAnalyzer.analyze", new_callable=AsyncMock, return_value=analysis, ): resp = await client.post( "/api/v1/impact/analyze", json=_payload( test_instance.id, sql_content="DROP TABLE users;", ), headers=auth_header(admin_token), ) assert resp.status_code == 200 data = resp.json() assert data["risk_level"] == "critical" assert any("不可逆" in w for w in data["warnings"]) @pytest.mark.asyncio async def test_analyze_alter_large_table(self, client, db, admin_token, test_instance): """ALTER 大表 — 应标记为 critical 并建议使用在线变更工具。""" analysis = _make_analysis( sql_type="ALTER", estimated_rows=2000000, estimated_duration_ms=300000, table_size_mb=2048.0, table_row_count=2000000, risk_level="critical", warnings=[ "ALTER TABLE 操作表大小为 2048.0MB,建议使用 pt-online-schema-change 或 gh-ost 进行在线变更", ], ) with patch( "app.api.v1.endpoints.impact.ImpactAnalyzer.analyze", new_callable=AsyncMock, return_value=analysis, ): resp = await client.post( "/api/v1/impact/analyze", json=_payload( test_instance.id, sql_content="ALTER TABLE big_table ADD COLUMN age INT;", ), headers=auth_header(admin_token), ) assert resp.status_code == 200 data = resp.json() assert data["sql_type"] == "ALTER" assert data["risk_level"] == "critical" assert data["table_size_mb"] > 1024 assert any("pt-online-schema-change" in w or "gh-ost" in w for w in data["warnings"]) @pytest.mark.asyncio async def test_analyze_response_contains_all_fields(self, client, db, admin_token, test_instance): """验证响应包含 ImpactAnalysisResponse 中定义的所有字段。""" analysis = _make_analysis( index_usage=["PRIMARY", "idx_name"], warnings=["建议添加 LIMIT 条件"], ) with patch( "app.api.v1.endpoints.impact.ImpactAnalyzer.analyze", new_callable=AsyncMock, return_value=analysis, ): resp = await client.post( "/api/v1/impact/analyze", json=_payload(test_instance.id), headers=auth_header(admin_token), ) assert resp.status_code == 200 data = resp.json() expected_keys = { "sql_type", "estimated_rows", "estimated_duration_ms", "table_size_mb", "table_row_count", "index_usage", "has_full_scan", "risk_level", "warnings", } assert set(data.keys()) == expected_keys assert data["index_usage"] == ["PRIMARY", "idx_name"] assert data["warnings"] == ["建议添加 LIMIT 条件"]