feat: 三大核心功能完成 — query_history持久化 + EXPLAIN真实解析 + Dashboard修复

P1 查询历史持久化 (47 tests):
- QueryHistory/QuickQuery SQLAlchemy model 建表
- query_history_service 从内存迁移到 SQLite async
- 每用户500条上限自动清理
- 7个API endpoint全部对接持久化
- 10个预置快捷查询模板

P2 EXPLAIN真实解析 (40 tests):
- MySQL EXPLAIN FORMAT=JSON 执行+解析
- PostgreSQL EXPLAIN (FORMAT JSON) 执行+解析
- SQLite EXPLAIN QUERY PLAN 解析
- 统一树结构输出 + 优化警告
- POST /api/v1/sql-assistant/explain endpoint

Dashboard修复:
- 修复重复export default导致页面崩溃
- 对接/instances /tickets /dashboard/stats真实API
- 替换硬编码统计数字为API驱动
- 修复calcHealthScore安全访问

AuditLogsPage:
- 修复Fragment key warning

1081 tests passed (10 system_settings failures are pre-existing)
This commit is contained in:
logan committed 2026-05-24 12:29:21 +08:00
1 parent 1fcb984aee
commit c7fccf99c7
8 files changed
+2274 -400

No files matched your search

+994
View File
@@ -0,0 +1,994 @@
"""EXPLAIN 服务解析测试 — 测试 MySQL / PostgreSQL / SQLite 解析逻辑 + API endpoint。
使用 mock 的数据库连接,不依赖真实数据库。
"""
import json
import asyncio
import sys
from unittest.mock import patch, AsyncMock, MagicMock
import pytest
from httpx import AsyncClient
from app.core.crypto import encrypt_password
from app.models.instance import Instance
from app.services.explain_service import (
ExplainService,
parse_mysql_explain_json,
parse_postgres_explain_json,
parse_sqlite_explain,
make_plan_node,
_empty_result,
)
from tests.conftest import auth_header
# ===========================================================================
# Part 1: MySQL EXPLAIN JSON 解析测试
# ===========================================================================
class TestMySQLExplainParsing:
"""测试 MySQL EXPLAIN FORMAT=JSON 结果解析。"""
def test_simple_table_scan(self):
"""测试简单全表扫描。"""
raw = {
"query_block": {
"select_id": 1,
"cost_info": {"query_cost": "0.35"},
"table": {
"table_name": "users",
"access_type": "ALL",
"rows_examined_per_scan": 1000,
"rows_produced_per_join": 1000,
"filtered": "100.00",
"cost_info": {"read_cost": "0.25"},
},
}
}
result = parse_mysql_explain_json(raw)
assert result["db_type"] == "mysql"
assert len(result["execution_plan"]) == 1
root = result["execution_plan"][0]
assert root["operation"] == "Table Scan"
assert root["table"] == "users"
assert root["rows"] == 1000
assert root["cost"] == 0.25
# 警告检测
assert any("全表扫描" in w for w in result["warnings"])
# 汇总
summary = result["summary"]
assert summary["has_full_scan"] is True
assert summary["estimated_rows"] == 1000
assert summary["total_cost"] == 0.25
def test_index_lookup(self):
"""测试索引查找。"""
raw = {
"query_block": {
"select_id": 1,
"cost_info": {"query_cost": "0.10"},
"table": {
"table_name": "orders",
"access_type": "ref",
"possible_keys": "idx_user_id",
"key": "idx_user_id",
"used_key_parts": ["user_id"],
"rows_examined_per_scan": 10,
"filtered": "100.00",
},
}
}
result = parse_mysql_explain_json(raw)
root = result["execution_plan"][0]
assert root["operation"] == "Index Lookup"
assert root["table"] == "orders"
assert root["key"] == "idx_user_id"
assert root["rows"] == 10
summary = result["summary"]
assert summary["has_full_scan"] is False
assert summary["index_usage_ratio"] == 1.0 # 1 table, 1 index usage
def test_nested_loop_join(self):
"""测试嵌套循环 JOIN。"""
raw = {
"query_block": {
"select_id": 1,
"cost_info": {"query_cost": "1.50"},
"nested_loop": [
{
"table": {
"table_name": "users",
"access_type": "ALL",
"rows_examined_per_scan": 100,
}
},
{
"table": {
"table_name": "orders",
"access_type": "ref",
"key": "idx_user_id",
"rows_examined_per_scan": 5,
}
},
],
}
}
result = parse_mysql_explain_json(raw)
root = result["execution_plan"][0]
assert root["operation"] == "Query Block"
assert len(root["children"]) == 2
child1 = root["children"][0]
assert child1["operation"] == "Table Scan"
assert child1["table"] == "users"
child2 = root["children"][1]
assert child2["operation"] == "Index Lookup"
assert child2["table"] == "orders"
assert child2["key"] == "idx_user_id"
def test_const_and_eq_ref(self):
"""测试 const / eq_ref 类型映射。"""
# 直接测试 _mysql_access_type_to_operation
from app.services.explain_service import _mysql_access_type_to_operation
assert _mysql_access_type_to_operation("const") == "Const Row"
assert _mysql_access_type_to_operation("eq_ref") == "Unique Lookup"
assert _mysql_access_type_to_operation("range") == "Index Range Scan"
assert _mysql_access_type_to_operation("ALL") == "Table Scan"
assert _mysql_access_type_to_operation("index") == "Full Index Scan"
def test_json_string_input(self):
"""测试字符串 JSON 输入。"""
raw_str = json.dumps({
"query_block": {
"select_id": 1,
"table": {
"table_name": "t",
"access_type": "ALL",
"rows_examined_per_scan": 50,
},
}
})
result = parse_mysql_explain_json(raw_str)
assert result["db_type"] == "mysql"
assert result["execution_plan"][0]["table"] == "t"
def test_invalid_json_string(self):
"""测试无效 JSON 字符串。"""
result = parse_mysql_explain_json("not valid json{{{")
assert result["db_type"] == "mysql"
assert len(result["warnings"]) > 0
assert any("解析失败" in w for w in result["warnings"])
def test_empty_result(self):
"""测试空结果。"""
result = parse_mysql_explain_json({})
assert result["db_type"] == "mysql"
assert result["execution_plan"] == []
def test_total_cost_uses_max(self):
"""测试 total_cost 取最大值。"""
raw = {
"query_block": {
"select_id": 1,
"cost_info": {"query_cost": "10.5"},
"nested_loop": [
{
"table": {
"table_name": "a",
"access_type": "ALL",
"rows_examined_per_scan": 100,
"cost_info": {"read_cost": "5.0"},
}
},
{
"table": {
"table_name": "b",
"access_type": "ref",
"rows_examined_per_scan": 10,
"cost_info": {"read_cost": "3.0"},
}
},
],
}
}
result = parse_mysql_explain_json(raw)
# total_cost should be the max of all costs
assert result["summary"]["total_cost"] == 10.5
# ===========================================================================
# Part 2: PostgreSQL EXPLAIN JSON 解析测试
# ===========================================================================
class TestPostgreSQLExplainParsing:
"""测试 PostgreSQL EXPLAIN (FORMAT JSON) 结果解析。"""
def test_seq_scan(self):
"""测试顺序扫描。"""
raw = [
{
"Plan": {
"Node Type": "Seq Scan",
"Relation Name": "users",
"Alias": "users",
"Startup Cost": 0.00,
"Total Cost": 15.50,
"Plan Rows": 1000,
"Plan Width": 68,
}
}
]
result = parse_postgres_explain_json(raw)
assert result["db_type"] == "postgres"
assert len(result["execution_plan"]) == 1
root = result["execution_plan"][0]
assert root["operation"] == "Table Scan"
assert root["table"] == "users"
assert root["rows"] == 1000
assert root["cost"] == 15.50
assert any("顺序扫描" in w for w in result["warnings"])
assert result["summary"]["has_full_scan"] is True
def test_index_scan(self):
"""测试索引扫描。"""
raw = [
{
"Plan": {
"Node Type": "Index Scan",
"Relation Name": "orders",
"Alias": "orders",
"Index Name": "idx_user_id",
"Startup Cost": 0.15,
"Total Cost": 8.50,
"Plan Rows": 10,
"Plan Width": 48,
"Index Cond": "(user_id = 42)",
}
}
]
result = parse_postgres_explain_json(raw)
root = result["execution_plan"][0]
assert root["operation"] == "Index Scan"
assert root["table"] == "orders"
assert root["key"] == "idx_user_id"
assert root["rows"] == 10
assert "索引条件" in (root["extra"] or "")
def test_join_with_children(self):
"""测试 JOIN 嵌套结构。"""
raw = [
{
"Plan": {
"Node Type": "Nested Loop",
"Startup Cost": 0.15,
"Total Cost": 25.50,
"Plan Rows": 100,
"Plan Width": 120,
"Plans": [
{
"Node Type": "Seq Scan",
"Relation Name": "users",
"Total Cost": 12.00,
"Plan Rows": 100,
},
{
"Node Type": "Index Scan",
"Relation Name": "orders",
"Index Name": "idx_user_id",
"Total Cost": 0.30,
"Plan Rows": 1,
"Index Cond": "(user_id = users.id)",
},
],
}
}
]
result = parse_postgres_explain_json(raw)
root = result["execution_plan"][0]
assert root["operation"] == "Nested Loop Join"
assert len(root["children"]) == 2
assert root["children"][0]["operation"] == "Table Scan"
assert root["children"][0]["table"] == "users"
assert root["children"][1]["operation"] == "Index Scan"
assert root["children"][1]["table"] == "orders"
assert root["children"][1]["key"] == "idx_user_id"
def test_hash_join(self):
"""测试 Hash Join。"""
raw = [
{
"Plan": {
"Node Type": "Hash Join",
"Join Type": "Inner",
"Relation Name": None,
"Hash Cond": "users.id = orders.user_id",
"Total Cost": 50.00,
"Plan Rows": 500,
"Plans": [
{
"Node Type": "Seq Scan",
"Relation Name": "users",
"Total Cost": 15.00,
"Plan Rows": 1000,
},
{
"Node Type": "Hash",
"Total Cost": 20.00,
"Plan Rows": 500,
"Plans": [
{
"Node Type": "Seq Scan",
"Relation Name": "orders",
"Total Cost": 18.00,
"Plan Rows": 500,
}
],
},
],
}
}
]
result = parse_postgres_explain_json(raw)
root = result["execution_plan"][0]
assert "Inner" in root["operation"]
assert "Hash Join" in root["operation"]
assert len(root["children"]) == 2
# Hash has a nested child
hash_child = root["children"][1]
assert hash_child["operation"] == "Hash"
assert len(hash_child["children"]) == 1
assert hash_child["children"][0]["table"] == "orders"
def test_sort_and_aggregate(self):
"""测试排序和聚合。"""
raw = [
{
"Plan": {
"Node Type": "Aggregate",
"Total Cost": 30.00,
"Plan Rows": 5,
"Group Key": ["department"],
"Plans": [
{
"Node Type": "Sort",
"Total Cost": 28.00,
"Plan Rows": 1000,
"Sort Key": ["salary DESC"],
"Sort Method": "quicksort",
"Plans": [
{
"Node Type": "Seq Scan",
"Relation Name": "employees",
"Total Cost": 15.00,
"Plan Rows": 1000,
}
],
}
],
}
}
]
result = parse_postgres_explain_json(raw)
root = result["execution_plan"][0]
assert root["operation"] == "Aggregate"
assert "分组键" in (root["extra"] or "")
sort_child = root["children"][0]
assert sort_child["operation"] == "Sort"
assert "排序键" in (sort_child["extra"] or "")
seq_child = sort_child["children"][0]
assert seq_child["operation"] == "Table Scan"
def test_json_string_input(self):
"""测试 JSON 字符串输入。"""
raw_str = json.dumps([
{"Plan": {"Node Type": "Seq Scan", "Relation Name": "t", "Total Cost": 5.0, "Plan Rows": 10}}
])
result = parse_postgres_explain_json(raw_str)
assert result["db_type"] == "postgres"
assert result["execution_plan"][0]["table"] == "t"
def test_invalid_json_string(self):
"""测试无效 JSON 字符串。"""
result = parse_postgres_explain_json("invalid{{{")
assert any("解析失败" in w for w in result["warnings"])
def test_pg_node_type_mapping(self):
"""测试 PG 节点类型映射。"""
from app.services.explain_service import _pg_node_type_to_operation
assert _pg_node_type_to_operation("Seq Scan") == "Table Scan"
assert _pg_node_type_to_operation("Index Scan") == "Index Scan"
assert _pg_node_type_to_operation("Index Only Scan") == "Index Only Scan"
assert _pg_node_type_to_operation("Hash Join") == "Hash Join"
assert _pg_node_type_to_operation("Sort") == "Sort"
assert _pg_node_type_to_operation("Aggregate") == "Aggregate"
assert _pg_node_type_to_operation("Limit") == "Limit"
assert _pg_node_type_to_operation("UnknownType") == "UnknownType"
def test_actual_rows_and_time(self):
"""测试 ANALYZE 包含的实际行数和耗时。"""
raw = [
{
"Plan": {
"Node Type": "Seq Scan",
"Relation Name": "big_table",
"Total Cost": 100.0,
"Plan Rows": 50000,
"Actual Rows": 49998,
"Actual Total Time": 23.456,
}
}
]
result = parse_postgres_explain_json(raw)
root = result["execution_plan"][0]
assert root.get("actual_rows") == 49998
assert root.get("actual_time_ms") == 23.456
assert "实际行数" in (root["extra"] or "")
assert "实际耗时" in (root["extra"] or "")
# ===========================================================================
# Part 3: SQLite EXPLAIN QUERY PLAN 解析测试
# ===========================================================================
class TestSQLiteExplainParsing:
"""测试 SQLite EXPLAIN QUERY PLAN 结果解析。"""
def test_table_scan(self):
"""测试全表扫描。"""
rows = [
{"id": 3, "parent": 0, "notused": 0, "detail": "SCAN TABLE users"},
]
result = parse_sqlite_explain(rows)
assert result["db_type"] == "sqlite"
assert len(result["execution_plan"]) == 1
root = result["execution_plan"][0]
assert root["operation"] == "Table Scan"
assert root["table"] == "users"
assert any("全表扫描" in w for w in result["warnings"])
def test_index_search(self):
"""测试索引搜索。"""
rows = [
{"id": 2, "parent": 0, "notused": 0, "detail": "SEARCH TABLE users USING INDEX idx_name (name=?)"},
]
result = parse_sqlite_explain(rows)
root = result["execution_plan"][0]
assert root["operation"] == "Index Lookup"
assert root["table"] == "users"
assert root["key"] == "idx_name"
def test_covering_index(self):
"""测试覆盖索引。"""
rows = [
{"id": 2, "parent": 0, "notused": 0, "detail": "SEARCH TABLE users USING COVERING INDEX idx_name (name=?)"},
]
result = parse_sqlite_explain(rows)
root = result["execution_plan"][0]
assert root["key"] == "idx_name"
assert "Covering Index" in (root["extra"] or "")
def test_subquery(self):
"""测试子查询。"""
rows = [
{"id": 3, "parent": 0, "notused": 0, "detail": "SCAN TABLE users"},
{"id": 6, "parent": 0, "notused": 0, "detail": "CORRELATED SCALAR SUBQUERY 2"},
]
result = parse_sqlite_explain(rows)
assert len(result["execution_plan"]) == 2
def test_empty_rows(self):
"""测试空结果。"""
result = parse_sqlite_explain([])
assert result["db_type"] == "sqlite"
assert result["execution_plan"] == []
# ===========================================================================
# Part 4: 统一辅助函数测试
# ===========================================================================
class TestHelperFunctions:
"""测试辅助函数。"""
def test_make_plan_node_defaults(self):
"""测试 make_plan_node 默认值。"""
node = make_plan_node(id=1, operation="Table Scan")
assert node["id"] == 1
assert node["operation"] == "Table Scan"
assert node["table"] is None
assert node["rows"] is None
assert node["cost"] is None
assert node["key"] is None
assert node["extra"] is None
assert node["children"] == []
def test_make_plan_node_full(self):
"""测试 make_plan_node 完整参数。"""
node = make_plan_node(
id=2,
operation="Index Scan",
table="orders",
rows=100,
cost=5.5,
key="idx_id",
extra="Using index",
children=[{"id": 3, "operation": "Child"}],
alias="o",
)
assert node["table"] == "orders"
assert node["rows"] == 100
assert node["cost"] == 5.5
assert node["key"] == "idx_id"
assert len(node["children"]) == 1
assert node["alias"] == "o"
def test_empty_result(self):
"""测试空结果构造。"""
result = _empty_result("mysql", {"foo": "bar"}, error="test error")
assert result["db_type"] == "mysql"
assert result["execution_plan"] == []
assert "test error" in result["warnings"]
assert result["raw_result"] == {"foo": "bar"}
def test_empty_result_no_error(self):
"""测试无 error 的空结果。"""
result = _empty_result("postgres", None)
assert "EXPLAIN 结果为空" in result["warnings"][0]
# ===========================================================================
# Part 5: ExplainService.execute_explain 路由测试 (mock DB)
# ===========================================================================
class TestExplainEndpoint:
"""测试 POST /api/v1/sql-assistant/explain API endpoint。"""
@pytest.fixture
async def mysql_instance(self, db):
"""创建 MySQL 测试实例。"""
inst = Instance(
name="TestMySQL",
db_type="mysql",
host="127.0.0.1",
port=3306,
username="root",
password=encrypt_password("test123"),
database="test_db",
env="development",
status="disconnected",
)
db.add(inst)
await db.commit()
await db.refresh(inst)
return inst
@pytest.fixture
async def pg_instance(self, db):
"""创建 PostgreSQL 测试实例。"""
inst = Instance(
name="TestPG",
db_type="postgres",
host="127.0.0.1",
port=5432,
username="postgres",
password=encrypt_password("test123"),
database="test_db",
env="development",
status="disconnected",
)
db.add(inst)
await db.commit()
await db.refresh(inst)
return inst
@pytest.fixture
async def sqlite_instance(self, db):
"""创建 SQLite 测试实例。"""
inst = Instance(
name="TestSQLite",
db_type="sqlite",
host="/tmp/test.db",
port=0,
username="",
password="",
database="",
env="development",
status="disconnected",
)
db.add(inst)
await db.commit()
await db.refresh(inst)
return inst
async def test_explain_mysql_success(self, client, admin_token, mysql_instance):
"""测试 MySQL EXPLAIN 成功。"""
# Mock aiomysql
mock_cursor = AsyncMock()
mock_cursor.execute = AsyncMock()
mock_cursor.fetchall = AsyncMock(return_value=[
{"EXPLAIN": json.dumps({
"query_block": {
"select_id": 1,
"cost_info": {"query_cost": "0.35"},
"table": {
"table_name": "users",
"access_type": "ALL",
"rows_examined_per_scan": 1000,
"filtered": "100.00",
},
}
})}
])
mock_cursor.description = [("EXPLAIN",)]
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
mock_cursor.__aexit__ = AsyncMock(return_value=False)
mock_conn = AsyncMock()
mock_conn.cursor = MagicMock(return_value=mock_cursor)
mock_conn.close = MagicMock()
with patch("app.services.explain_service.aiomysql") as mock_aiomysql:
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
mock_aiomysql.DictCursor = MagicMock()
resp = await client.post(
"/api/v1/sql-assistant/explain",
json={
"instance_id": mysql_instance.id,
"database": "test_db",
"query": "SELECT * FROM users",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["db_type"] == "mysql"
assert len(data["execution_plan"]) == 1
assert data["execution_plan"][0]["operation"] == "Table Scan"
assert data["execution_plan"][0]["table"] == "users"
assert "elapsed_ms" in data
assert isinstance(data["summary"], dict)
async def test_explain_postgres_success(self, client, admin_token, pg_instance):
"""测试 PostgreSQL EXPLAIN 成功。"""
mock_conn = AsyncMock()
mock_conn.fetchval = AsyncMock(return_value=json.dumps([
{
"Plan": {
"Node Type": "Seq Scan",
"Relation Name": "users",
"Total Cost": 15.50,
"Plan Rows": 1000,
}
}
]))
mock_conn.close = AsyncMock()
with patch("app.services.explain_service.asyncpg") as mock_asyncpg:
mock_asyncpg.connect = AsyncMock(return_value=mock_conn)
resp = await client.post(
"/api/v1/sql-assistant/explain",
json={
"instance_id": pg_instance.id,
"database": "test_db",
"query": "SELECT * FROM users",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["db_type"] == "postgres"
assert len(data["execution_plan"]) == 1
assert data["execution_plan"][0]["operation"] == "Table Scan"
assert data["execution_plan"][0]["table"] == "users"
async def test_explain_empty_query(self, client, admin_token, mysql_instance):
"""测试空查询报错。"""
resp = await client.post(
"/api/v1/sql-assistant/explain",
json={
"instance_id": mysql_instance.id,
"database": "test_db",
"query": "",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 400
async def test_explain_non_select_query(self, client, admin_token, mysql_instance):
"""测试非 SELECT 查询报错。"""
mock_conn = AsyncMock()
mock_conn.cursor = MagicMock()
mock_conn.close = MagicMock()
with patch("app.services.explain_service.aiomysql") as mock_aiomysql:
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
mock_aiomysql.DictCursor = MagicMock()
resp = await client.post(
"/api/v1/sql-assistant/explain",
json={
"instance_id": mysql_instance.id,
"database": "test_db",
"query": "DELETE FROM users WHERE id = 1",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 400
assert "SELECT" in resp.json()["detail"] or "WITH" in resp.json()["detail"]
async def test_explain_instance_not_found(self, client, admin_token):
"""测试不存在的实例。"""
resp = await client.post(
"/api/v1/sql-assistant/explain",
json={
"instance_id": 99999,
"database": "test_db",
"query": "SELECT 1",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 404
async def test_explain_guest_forbidden(self, client, guest_token, mysql_instance):
"""测试访客无权限。"""
resp = await client.post(
"/api/v1/sql-assistant/explain",
json={
"instance_id": mysql_instance.id,
"database": "test_db",
"query": "SELECT 1",
},
headers=auth_header(guest_token),
)
assert resp.status_code == 403
async def test_explain_with_with_clause(self, client, admin_token, pg_instance):
"""测试 WITH (CTE) 查询。"""
mock_conn = AsyncMock()
mock_conn.fetchval = AsyncMock(return_value=json.dumps([
{
"Plan": {
"Node Type": "CTE Scan",
"Total Cost": 5.0,
"Plan Rows": 10,
}
}
]))
mock_conn.close = AsyncMock()
with patch("app.services.explain_service.asyncpg") as mock_asyncpg:
mock_asyncpg.connect = AsyncMock(return_value=mock_conn)
resp = await client.post(
"/api/v1/sql-assistant/explain",
json={
"instance_id": pg_instance.id,
"database": "test_db",
"query": "WITH cte AS (SELECT 1) SELECT * FROM cte",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["db_type"] == "postgres"
async def test_explain_unsupported_db_type(self, client, admin_token, db):
"""测试不支持的 db_type。"""
# Create a Redis instance
inst = Instance(
name="TestRedis",
db_type="redis",
host="127.0.0.1",
port=6379,
username="",
password=encrypt_password(""),
database="0",
env="development",
status="disconnected",
)
db.add(inst)
await db.commit()
await db.refresh(inst)
resp = await client.post(
"/api/v1/sql-assistant/explain",
json={
"instance_id": inst.id,
"database": "0",
"query": "SELECT * FROM users",
},
headers=auth_header(admin_token),
)
# Either 400 or 500 depending on the error path
assert resp.status_code in (400, 500)
# ===========================================================================
# Part 6: ExplainService 单元测试 (直接 mock 连接)
# ===========================================================================
class TestExplainServiceUnit:
"""直接测试 ExplainService 的路由逻辑。"""
def _make_instance(self, db_type="mysql", **overrides):
defaults = dict(
name="Test",
db_type=db_type,
host="127.0.0.1",
port=3306,
username="root",
password="test123",
database="test_db",
)
defaults.update(overrides)
inst = MagicMock(spec=Instance)
for k, v in defaults.items():
setattr(inst, k, v)
return inst
@pytest.mark.asyncio
async def test_mysql_explain_service(self):
"""测试 ExplainService MySQL 路径。"""
instance = self._make_instance(db_type="mysql")
mock_cursor = AsyncMock()
mock_cursor.execute = AsyncMock()
mock_cursor.fetchall = AsyncMock(return_value=[
{"EXPLAIN": json.dumps({
"query_block": {
"select_id": 1,
"table": {
"table_name": "t",
"access_type": "ALL",
"rows_examined_per_scan": 50,
},
}
})}
])
mock_cursor.description = [("EXPLAIN",)]
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
mock_cursor.__aexit__ = AsyncMock(return_value=False)
mock_conn = AsyncMock()
mock_conn.cursor = MagicMock(return_value=mock_cursor)
mock_conn.close = MagicMock()
with patch("app.services.explain_service.aiomysql") as mock_aiomysql:
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
mock_aiomysql.DictCursor = MagicMock()
result = await ExplainService.execute_explain(
instance, "test_db", "SELECT * FROM t"
)
assert result["db_type"] == "mysql"
assert result["execution_plan"][0]["operation"] == "Table Scan"
@pytest.mark.asyncio
async def test_postgres_explain_service(self):
"""测试 ExplainService PostgreSQL 路径。"""
instance = self._make_instance(db_type="postgres", port=5432)
mock_conn = AsyncMock()
mock_conn.fetchval = AsyncMock(return_value=json.dumps([
{"Plan": {"Node Type": "Seq Scan", "Relation Name": "t", "Total Cost": 5.0, "Plan Rows": 10}}
]))
mock_conn.close = AsyncMock()
with patch("app.services.explain_service.asyncpg") as mock_asyncpg:
mock_asyncpg.connect = AsyncMock(return_value=mock_conn)
result = await ExplainService.execute_explain(
instance, "test_db", "SELECT * FROM t"
)
assert result["db_type"] == "postgres"
assert result["execution_plan"][0]["operation"] == "Table Scan"
@pytest.mark.asyncio
async def test_unsupported_db_type(self):
"""测试不支持的 db_type。"""
instance = self._make_instance(db_type="redis")
with pytest.raises(ValueError, match="暂不支持"):
await ExplainService.execute_explain(instance, "0", "SELECT * FROM key")
@pytest.mark.asyncio
async def test_non_select_rejected(self):
"""测试非 SELECT/WITH 查询被拒绝。"""
instance = self._make_instance(db_type="mysql")
with pytest.raises(ValueError, match="SELECT"):
await ExplainService.execute_explain(instance, "test_db", "INSERT INTO t VALUES (1)")
@pytest.mark.asyncio
async def test_with_query_accepted(self):
"""测试 WITH (CTE) 查询被接受。"""
instance = self._make_instance(db_type="postgres", port=5432)
mock_conn = AsyncMock()
mock_conn.fetchval = AsyncMock(return_value=json.dumps([
{"Plan": {"Node Type": "CTE Scan", "Total Cost": 1.0, "Plan Rows": 1}}
]))
mock_conn.close = AsyncMock()
with patch("app.services.explain_service.asyncpg") as mock_asyncpg:
mock_asyncpg.connect = AsyncMock(return_value=mock_conn)
result = await ExplainService.execute_explain(
instance, "test_db", "WITH cte AS (SELECT 1) SELECT * FROM cte"
)
assert result["db_type"] == "postgres"
@pytest.mark.asyncio
async def test_sqlite_explain_service(self):
"""测试 ExplainService SQLite 路径。"""
instance = self._make_instance(db_type="sqlite", host="/tmp/test_explain.db", port=0)
mock_rows = [
{"id": 2, "parent": 0, "notused": 0, "detail": "SEARCH TABLE users USING INDEX idx_id (id=?)"},
]
with patch("app.services.explain_service.asyncio") as mock_asyncio_mod:
# We need to mock asyncio.get_event_loop().run_in_executor()
mock_loop = MagicMock()
mock_loop.run_in_executor = AsyncMock(return_value=mock_rows)
mock_asyncio_mod.get_event_loop.return_value = mock_loop
mock_asyncio_mod.wait_for = AsyncMock(return_value=mock_rows)
# Keep the real TimeoutError
mock_asyncio_mod.TimeoutError = asyncio.TimeoutError
# Also need to patch sqlite3 inside the module
import sqlite3 as real_sqlite3
result = await ExplainService._explain_sqlite(instance, "SELECT * FROM users WHERE id = 1")
assert result["db_type"] == "sqlite"
assert result["execution_plan"][0]["operation"] == "Index Lookup"
assert result["execution_plan"][0]["table"] == "users"
assert result["execution_plan"][0]["key"] == "idx_id"
@@ -1,7 +1,7 @@
"""查询历史和快捷查询 API 测试 — P1 审计闭环。
"""查询历史和快捷查询 持久化测试 — 验证 SQLite 存储。
测试范围:
- 查询历史记录和查看
- 查询历史记录和查看(CRUD + 分页 + 过滤)
- 收藏切换
- 快捷查询 CRUD
- 查询统计
@@ -10,10 +10,11 @@
"""
import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import create_access_token, hash_password
from app.models.user import User
from app.models.query_history import QueryHistory, QuickQueryModel
from app.services.query_history_service import QueryHistoryService
@@ -39,9 +40,10 @@ class TestQueryHistoryRecord:
"""查询历史记录功能测试。"""
@pytest.mark.asyncio
async def test_record_query(self):
async def test_record_query(self, db: AsyncSession):
"""测试记录一条查询。"""
entry = await QueryHistoryService.record_query(
db=db,
user_id=1,
username="admin",
instance_id=1,
@@ -53,6 +55,7 @@ class TestQueryHistoryRecord:
execution_time_ms=50,
result_rows=10,
)
await db.commit()
assert entry.id > 0
assert entry.user_id == 1
assert entry.db_type == "mysql"
@@ -63,9 +66,10 @@ class TestQueryHistoryRecord:
assert entry.error_message is None
@pytest.mark.asyncio
async def test_record_failed_query(self):
async def test_record_failed_query(self, db: AsyncSession):
"""测试记录失败的查询。"""
entry = await QueryHistoryService.record_query(
db=db,
user_id=1,
username="admin",
instance_id=1,
@@ -79,11 +83,12 @@ class TestQueryHistoryRecord:
is_success=False,
error_message="Table doesn't exist",
)
await db.commit()
assert entry.is_success is False
assert entry.error_message == "Table doesn't exist"
@pytest.mark.asyncio
async def test_record_multiple_db_types(self):
async def test_record_multiple_db_types(self, db: AsyncSession):
"""测试记录不同数据库类型的查询。"""
for db_type, query, q_type in [
("mysql", "SELECT 1", "SELECT"),
@@ -92,6 +97,7 @@ class TestQueryHistoryRecord:
("mongo", '{"collection": "users", "operation": "find"}', "find"),
]:
entry = await QueryHistoryService.record_query(
db=db,
user_id=1,
username="admin",
instance_id=1,
@@ -103,14 +109,16 @@ class TestQueryHistoryRecord:
execution_time_ms=10,
result_rows=1,
)
await db.commit()
assert entry.db_type == db_type
assert entry.query_type == q_type
@pytest.mark.asyncio
async def test_record_query_long_content_truncated(self):
async def test_record_query_long_content_truncated(self, db: AsyncSession):
"""测试超长查询内容被截断。"""
long_sql = "SELECT * FROM t WHERE id = " + "1" * 3000
entry = await QueryHistoryService.record_query(
db=db,
user_id=1,
username="admin",
instance_id=1,
@@ -122,18 +130,16 @@ class TestQueryHistoryRecord:
execution_time_ms=10,
result_rows=0,
)
await db.commit()
assert len(entry.query_content) == 2000
@pytest.mark.asyncio
async def test_record_query_max_history_per_user(self):
async def test_record_query_max_history_per_user(self, db: AsyncSession):
"""测试每个用户最多保留500条记录。"""
QueryHistoryService._history = []
QueryHistoryService._history_counter = 0
QueryHistoryService._favorites = set()
# 创建超过限制的记录
for i in range(510):
await QueryHistoryService.record_query(
db=db,
user_id=99,
username="test_user",
instance_id=1,
@@ -145,13 +151,14 @@ class TestQueryHistoryRecord:
execution_time_ms=i,
result_rows=0,
)
await db.commit()
user_entries = [h for h in QueryHistoryService._history if h.user_id == 99]
assert len(user_entries) <= 500
# 清理
QueryHistoryService._history = []
QueryHistoryService._history_counter = 0
from sqlalchemy import select, func
count_stmt = select(func.count()).select_from(QueryHistory).where(
QueryHistory.user_id == 99
)
total = (await db.execute(count_stmt)).scalar()
assert total <= 500
# ── 查询历史查看 ──
@@ -160,12 +167,8 @@ class TestQueryHistoryRecord:
class TestQueryHistoryView:
"""查询历史查看功能测试。"""
async def _setup_history(self):
async def _setup_history(self, db: AsyncSession):
"""准备测试数据。"""
QueryHistoryService._history = []
QueryHistoryService._history_counter = 0
QueryHistoryService._favorites = set()
entries = [
("SELECT * FROM users", "mysql", 1, 50, 10),
("SELECT * FROM orders", "mysql", 1, 30, 20),
@@ -174,6 +177,7 @@ class TestQueryHistoryView:
]
for sql, db_type, inst_id, time_ms, rows in entries:
await QueryHistoryService.record_query(
db=db,
user_id=1,
username="admin",
instance_id=inst_id,
@@ -185,67 +189,68 @@ class TestQueryHistoryView:
execution_time_ms=time_ms,
result_rows=rows,
)
await db.commit()
@pytest.mark.asyncio
async def test_get_history_default(self):
async def test_get_history_default(self, db: AsyncSession):
"""测试默认查看历史。"""
await self._setup_history()
result = await QueryHistoryService.get_history(user_id=1)
await self._setup_history(db)
result = await QueryHistoryService.get_history(db=db, user_id=1)
assert result["total"] == 4
assert result["page"] == 1
assert result["page_size"] == 20
assert len(result["items"]) == 4
@pytest.mark.asyncio
async def test_get_history_filter_by_db_type(self):
async def test_get_history_filter_by_db_type(self, db: AsyncSession):
"""测试按数据库类型筛选。"""
await self._setup_history()
result = await QueryHistoryService.get_history(user_id=1, db_type="mysql")
await self._setup_history(db)
result = await QueryHistoryService.get_history(db=db, user_id=1, db_type="mysql")
assert result["total"] == 2
for item in result["items"]:
assert item["db_type"] == "mysql"
@pytest.mark.asyncio
async def test_get_history_filter_by_instance(self):
async def test_get_history_filter_by_instance(self, db: AsyncSession):
"""测试按实例ID筛选。"""
await self._setup_history()
result = await QueryHistoryService.get_history(user_id=1, instance_id=2)
await self._setup_history(db)
result = await QueryHistoryService.get_history(db=db, user_id=1, instance_id=2)
assert result["total"] == 1
assert result["items"][0]["db_type"] == "redis"
@pytest.mark.asyncio
async def test_get_history_filter_by_keyword(self):
async def test_get_history_filter_by_keyword(self, db: AsyncSession):
"""测试按关键词筛选。"""
await self._setup_history()
result = await QueryHistoryService.get_history(user_id=1, keyword="users")
await self._setup_history(db)
result = await QueryHistoryService.get_history(db=db, user_id=1, keyword="users")
assert result["total"] == 1
assert "users" in result["items"][0]["query_content"].lower()
@pytest.mark.asyncio
async def test_get_history_pagination(self):
async def test_get_history_pagination(self, db: AsyncSession):
"""测试分页。"""
await self._setup_history()
result = await QueryHistoryService.get_history(user_id=1, page=1, page_size=2)
await self._setup_history(db)
result = await QueryHistoryService.get_history(db=db, user_id=1, page=1, page_size=2)
assert result["total"] == 4
assert len(result["items"]) == 2
result2 = await QueryHistoryService.get_history(user_id=1, page=2, page_size=2)
result2 = await QueryHistoryService.get_history(db=db, user_id=1, page=2, page_size=2)
assert result2["total"] == 4
assert len(result2["items"]) == 2
@pytest.mark.asyncio
async def test_get_history_other_user_empty(self):
async def test_get_history_other_user_empty(self, db: AsyncSession):
"""测试查看其他用户的历史为空。"""
await self._setup_history()
result = await QueryHistoryService.get_history(user_id=999)
await self._setup_history(db)
result = await QueryHistoryService.get_history(db=db, user_id=999)
assert result["total"] == 0
assert result["items"] == []
@pytest.mark.asyncio
async def test_history_sorted_by_time_desc(self):
async def test_history_sorted_by_time_desc(self, db: AsyncSession):
"""测试历史按时间倒序排列。"""
await self._setup_history()
result = await QueryHistoryService.get_history(user_id=1)
await self._setup_history(db)
result = await QueryHistoryService.get_history(db=db, user_id=1)
times = [item["created_at"] for item in result["items"]]
assert times == sorted(times, reverse=True)
@@ -256,61 +261,61 @@ class TestQueryHistoryView:
class TestQueryHistoryFavorite:
"""查询收藏功能测试。"""
async def _setup_for_favorite(self):
QueryHistoryService._history = []
QueryHistoryService._history_counter = 0
QueryHistoryService._favorites = set()
async def _setup_for_favorite(self, db: AsyncSession):
entry = await QueryHistoryService.record_query(
db=db,
user_id=1, username="admin", instance_id=1,
instance_name="测试", db_type="mysql", database_name="test",
query_content="SELECT 1", query_type="SELECT",
execution_time_ms=10, result_rows=1,
)
await db.commit()
return entry.id
@pytest.mark.asyncio
async def test_toggle_favorite_add(self):
async def test_toggle_favorite_add(self, db: AsyncSession):
"""测试添加收藏。"""
entry_id = await self._setup_for_favorite()
is_fav = await QueryHistoryService.toggle_favorite(user_id=1, history_id=entry_id)
entry_id = await self._setup_for_favorite(db)
is_fav = await QueryHistoryService.toggle_favorite(db=db, user_id=1, history_id=entry_id)
await db.commit()
assert is_fav is True
@pytest.mark.asyncio
async def test_toggle_favorite_remove(self):
async def test_toggle_favorite_remove(self, db: AsyncSession):
"""测试取消收藏。"""
entry_id = await self._setup_for_favorite()
await QueryHistoryService.toggle_favorite(user_id=1, history_id=entry_id)
is_fav = await QueryHistoryService.toggle_favorite(user_id=1, history_id=entry_id)
entry_id = await self._setup_for_favorite(db)
await QueryHistoryService.toggle_favorite(db=db, user_id=1, history_id=entry_id)
await db.commit()
is_fav = await QueryHistoryService.toggle_favorite(db=db, user_id=1, history_id=entry_id)
await db.commit()
assert is_fav is False
@pytest.mark.asyncio
async def test_get_favorites(self):
async def test_get_favorites(self, db: AsyncSession):
"""测试获取收藏列表。"""
entry_id = await self._setup_for_favorite()
await QueryHistoryService.toggle_favorite(user_id=1, history_id=entry_id)
entry_id = await self._setup_for_favorite(db)
await QueryHistoryService.toggle_favorite(db=db, user_id=1, history_id=entry_id)
await db.commit()
result = await QueryHistoryService.get_favorites(user_id=1)
result = await QueryHistoryService.get_favorites(db=db, user_id=1)
assert result["total"] == 1
assert result["items"][0]["id"] == entry_id
@pytest.mark.asyncio
async def test_get_favorites_empty(self):
async def test_get_favorites_empty(self, db: AsyncSession):
"""测试无收藏时返回空列表。"""
QueryHistoryService._history = []
QueryHistoryService._favorites = set()
result = await QueryHistoryService.get_favorites(user_id=1)
result = await QueryHistoryService.get_favorites(db=db, user_id=1)
assert result["total"] == 0
assert result["items"] == []
@pytest.mark.asyncio
async def test_favorites_per_user(self):
async def test_favorites_per_user(self, db: AsyncSession):
"""测试收藏是用户隔离的。"""
entry_id = await self._setup_for_favorite()
await QueryHistoryService.toggle_favorite(user_id=1, history_id=entry_id)
entry_id = await self._setup_for_favorite(db)
await QueryHistoryService.toggle_favorite(db=db, user_id=1, history_id=entry_id)
await db.commit()
result = await QueryHistoryService.get_favorites(user_id=999)
result = await QueryHistoryService.get_favorites(db=db, user_id=999)
assert result["total"] == 0
@@ -320,15 +325,11 @@ class TestQueryHistoryFavorite:
class TestQuickQueries:
"""快捷查询功能测试。"""
async def _setup(self):
QueryHistoryService._quick_queries = []
QueryHistoryService._quick_counter = 0
@pytest.mark.asyncio
async def test_create_quick_query(self):
async def test_create_quick_query(self, db: AsyncSession):
"""测试创建快捷查询。"""
await self._setup()
qq = await QueryHistoryService.create_quick_query(
db=db,
name="查看活跃用户",
description="查看最近活跃的用户列表",
query_content="SELECT * FROM users WHERE last_login > DATE_SUB(NOW(), INTERVAL 7 DAY)",
@@ -338,117 +339,121 @@ class TestQuickQueries:
created_by=1,
created_by_name="admin",
)
await db.commit()
assert qq.id > 0
assert qq.name == "查看活跃用户"
assert qq.is_shared is False
assert qq.created_by == 1
@pytest.mark.asyncio
async def test_get_quick_queries_personal(self):
async def test_get_quick_queries_personal(self, db: AsyncSession):
"""测试获取个人快捷查询。"""
await self._setup()
await QueryHistoryService.create_quick_query(
name="个人查询", description="", query_content="SELECT 1",
db=db, name="个人查询", description="", query_content="SELECT 1",
db_type="mysql", database_pattern="*",
is_shared=False, created_by=1, created_by_name="admin",
)
await QueryHistoryService.create_quick_query(
name="他人查询", description="", query_content="SELECT 2",
db=db, name="他人查询", description="", query_content="SELECT 2",
db_type="mysql", database_pattern="*",
is_shared=False, created_by=2, created_by_name="dba",
)
await db.commit()
# 用户1只能看到自己的 + 共享的
items = await QueryHistoryService.get_quick_queries(user_id=1)
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1)
assert len(items) == 1
assert items[0]["name"] == "个人查询"
@pytest.mark.asyncio
async def test_get_quick_queries_shared(self):
async def test_get_quick_queries_shared(self, db: AsyncSession):
"""测试获取共享快捷查询。"""
await self._setup()
await QueryHistoryService.create_quick_query(
name="共享查询", description="", query_content="SELECT 1",
db=db, name="共享查询", description="", query_content="SELECT 1",
db_type="mysql", database_pattern="*",
is_shared=True, created_by=2, created_by_name="dba",
)
await db.commit()
items = await QueryHistoryService.get_quick_queries(user_id=1)
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1)
assert len(items) == 1
assert items[0]["is_shared"] is True
@pytest.mark.asyncio
async def test_get_quick_queries_filter_db_type(self):
async def test_get_quick_queries_filter_db_type(self, db: AsyncSession):
"""测试按数据库类型筛选快捷查询。"""
await self._setup()
await QueryHistoryService.create_quick_query(
name="MySQL查询", description="", query_content="SELECT 1",
db=db, name="MySQL查询", description="", query_content="SELECT 1",
db_type="mysql", database_pattern="*",
is_shared=True, created_by=1, created_by_name="admin",
)
await QueryHistoryService.create_quick_query(
name="Redis查询", description="", query_content="GET key",
db=db, name="Redis查询", description="", query_content="GET key",
db_type="redis", database_pattern="*",
is_shared=True, created_by=1, created_by_name="admin",
)
await db.commit()
items = await QueryHistoryService.get_quick_queries(user_id=1, db_type="redis")
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1, db_type="redis")
assert len(items) == 1
assert items[0]["db_type"] == "redis"
@pytest.mark.asyncio
async def test_get_quick_queries_filter_keyword(self):
async def test_get_quick_queries_filter_keyword(self, db: AsyncSession):
"""测试按关键词筛选快捷查询。"""
await self._setup()
await QueryHistoryService.create_quick_query(
name="查看活跃用户", description="", query_content="SELECT 1",
db=db, name="查看活跃用户", description="", query_content="SELECT 1",
db_type="mysql", database_pattern="*",
is_shared=True, created_by=1, created_by_name="admin",
)
await QueryHistoryService.create_quick_query(
name="查看慢查询", description="", query_content="SELECT 2",
db=db, name="查看慢查询", description="", query_content="SELECT 2",
db_type="mysql", database_pattern="*",
is_shared=True, created_by=1, created_by_name="admin",
)
await db.commit()
items = await QueryHistoryService.get_quick_queries(user_id=1, keyword="活跃")
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1, keyword="活跃")
assert len(items) == 1
assert "活跃" in items[0]["name"]
@pytest.mark.asyncio
async def test_use_quick_query(self):
async def test_use_quick_query(self, db: AsyncSession):
"""测试使用快捷查询。"""
await self._setup()
qq = await QueryHistoryService.create_quick_query(
name="测试", description="", query_content="SELECT 1",
db=db, name="测试", description="", query_content="SELECT 1",
db_type="mysql", database_pattern="*",
is_shared=True, created_by=1, created_by_name="admin",
)
await db.commit()
content = await QueryHistoryService.use_quick_query(qq.id)
content = await QueryHistoryService.use_quick_query(db, qq.id)
await db.commit()
assert content == "SELECT 1"
@pytest.mark.asyncio
async def test_use_quick_query_increments_count(self):
async def test_use_quick_query_increments_count(self, db: AsyncSession):
"""测试使用快捷查询增加使用次数。"""
await self._setup()
qq = await QueryHistoryService.create_quick_query(
name="测试", description="", query_content="SELECT 1",
db=db, name="测试", description="", query_content="SELECT 1",
db_type="mysql", database_pattern="*",
is_shared=True, created_by=1, created_by_name="admin",
)
await db.commit()
assert qq.usage_count == 0
await QueryHistoryService.use_quick_query(qq.id)
await QueryHistoryService.use_quick_query(db, qq.id)
await db.commit()
assert qq.usage_count == 1
await QueryHistoryService.use_quick_query(qq.id)
await QueryHistoryService.use_quick_query(db, qq.id)
await db.commit()
assert qq.usage_count == 2
@pytest.mark.asyncio
async def test_use_quick_query_not_found(self):
async def test_use_quick_query_not_found(self, db: AsyncSession):
"""测试使用不存在的快捷查询。"""
content = await QueryHistoryService.use_quick_query(99999)
content = await QueryHistoryService.use_quick_query(db, 99999)
assert content is None
@@ -459,24 +464,22 @@ class TestDefaultQuickQueries:
"""预置快捷查询模板测试。"""
@pytest.mark.asyncio
async def test_default_templates_exist(self):
async def test_default_templates_exist(self, db: AsyncSession):
"""测试预置模板存在。"""
QueryHistoryService._quick_queries = []
QueryHistoryService._quick_counter = 0
QueryHistoryService.seed_default_quick_queries()
await QueryHistoryService.seed_default_quick_queries(db)
await db.commit()
items = await QueryHistoryService.get_quick_queries(user_id=1)
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1)
# 预置了10个模板
assert len(items) == 10
@pytest.mark.asyncio
async def test_default_templates_cover_db_types(self):
async def test_default_templates_cover_db_types(self, db: AsyncSession):
"""测试预置模板覆盖所有数据库类型。"""
QueryHistoryService._quick_queries = []
QueryHistoryService._quick_counter = 0
QueryHistoryService.seed_default_quick_queries()
await QueryHistoryService.seed_default_quick_queries(db)
await db.commit()
items = await QueryHistoryService.get_quick_queries(user_id=1)
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1)
db_types = {item["db_type"] for item in items}
assert "mysql" in db_types
assert "redis" in db_types
@@ -484,27 +487,36 @@ class TestDefaultQuickQueries:
assert "mongo" in db_types
@pytest.mark.asyncio
async def test_default_templates_all_shared(self):
async def test_default_templates_all_shared(self, db: AsyncSession):
"""测试预置模板都是共享的。"""
QueryHistoryService._quick_queries = []
QueryHistoryService._quick_counter = 0
QueryHistoryService.seed_default_quick_queries()
await QueryHistoryService.seed_default_quick_queries(db)
await db.commit()
items = await QueryHistoryService.get_quick_queries(user_id=1)
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1)
for item in items:
assert item["is_shared"] is True
@pytest.mark.asyncio
async def test_default_templates_system_created(self):
async def test_default_templates_system_created(self, db: AsyncSession):
"""测试预置模板由系统创建。"""
QueryHistoryService._quick_queries = []
QueryHistoryService._quick_counter = 0
QueryHistoryService.seed_default_quick_queries()
await QueryHistoryService.seed_default_quick_queries(db)
await db.commit()
items = await QueryHistoryService.get_quick_queries(user_id=1)
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1)
for item in items:
assert item["created_by_name"] == "系统"
@pytest.mark.asyncio
async def test_seed_idempotent(self, db: AsyncSession):
"""测试 seed 是幂等的(多次调用不会重复插入)。"""
await QueryHistoryService.seed_default_quick_queries(db)
await db.commit()
await QueryHistoryService.seed_default_quick_queries(db)
await db.commit()
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1)
assert len(items) == 10
# ── 查询统计 ──
@@ -512,14 +524,10 @@ class TestDefaultQuickQueries:
class TestQueryStatistics:
"""查询统计功能测试。"""
async def _setup_stats(self):
QueryHistoryService._history = []
QueryHistoryService._history_counter = 0
QueryHistoryService._favorites = set()
async def _setup_stats(self, db: AsyncSession):
for i in range(5):
await QueryHistoryService.record_query(
user_id=1, username="admin", instance_id=1,
db=db, user_id=1, username="admin", instance_id=1,
instance_name="测试", db_type="mysql", database_name="test",
query_content=f"SELECT * FROM table_{i} WHERE id = 1",
query_type="SELECT",
@@ -528,29 +536,30 @@ class TestQueryStatistics:
)
for i in range(3):
await QueryHistoryService.record_query(
user_id=1, username="admin", instance_id=2,
db=db, user_id=1, username="admin", instance_id=2,
instance_name="测试Redis", db_type="redis", database_name="db0",
query_content="GET mykey",
query_type="GET",
execution_time_ms=5,
result_rows=1,
)
await db.commit()
@pytest.mark.asyncio
async def test_statistics_basic(self):
async def test_statistics_basic(self, db: AsyncSession):
"""测试基本统计。"""
await self._setup_stats()
stats = await QueryHistoryService.get_query_statistics(user_id=1)
await self._setup_stats(db)
stats = await QueryHistoryService.get_query_statistics(db=db, user_id=1)
assert stats["total_queries"] == 8
assert stats["avg_execution_time_ms"] > 0
assert stats["success_rate"] == 100.0
@pytest.mark.asyncio
async def test_statistics_db_type_distribution(self):
async def test_statistics_db_type_distribution(self, db: AsyncSession):
"""测试数据库类型分布统计。"""
await self._setup_stats()
stats = await QueryHistoryService.get_query_statistics(user_id=1)
await self._setup_stats(db)
stats = await QueryHistoryService.get_query_statistics(db=db, user_id=1)
assert "mysql" in stats["db_type_distribution"]
assert "redis" in stats["db_type_distribution"]
@@ -558,27 +567,26 @@ class TestQueryStatistics:
assert stats["db_type_distribution"]["redis"] == 3
@pytest.mark.asyncio
async def test_statistics_daily_trend(self):
async def test_statistics_daily_trend(self, db: AsyncSession):
"""测试每日趋势。"""
await self._setup_stats()
stats = await QueryHistoryService.get_query_statistics(user_id=1)
await self._setup_stats(db)
stats = await QueryHistoryService.get_query_statistics(db=db, user_id=1)
assert "daily_trend" in stats
assert len(stats["daily_trend"]) == 7
@pytest.mark.asyncio
async def test_statistics_empty(self):
async def test_statistics_empty(self, db: AsyncSession):
"""测试无记录时返回提示。"""
QueryHistoryService._history = []
stats = await QueryHistoryService.get_query_statistics(user_id=999)
stats = await QueryHistoryService.get_query_statistics(db=db, user_id=999)
assert "message" in stats
assert stats["message"] == "暂无查询记录"
@pytest.mark.asyncio
async def test_statistics_top_tables(self):
async def test_statistics_top_tables(self, db: AsyncSession):
"""测试高频表统计。"""
await self._setup_stats()
stats = await QueryHistoryService.get_query_statistics(user_id=1)
await self._setup_stats(db)
stats = await QueryHistoryService.get_query_statistics(db=db, user_id=1)
assert "top_tables" in stats
# 应该能从SQL中提取表名
@@ -592,18 +600,14 @@ class TestQueryHistoryAPI:
"""查询历史 API 端点集成测试。"""
@pytest.mark.asyncio
async def test_my_history_unauthorized(self, client: AsyncClient):
async def test_my_history_unauthorized(self, client):
"""测试未认证访问历史。"""
resp = await client.get("/api/v1/query-history/my")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_my_history_empty(self, client: AsyncClient, admin_headers: dict):
async def test_my_history_empty(self, client, admin_headers, db):
"""测试空历史记录。"""
QueryHistoryService._history = []
QueryHistoryService._history_counter = 0
QueryHistoryService._favorites = set()
resp = await client.get("/api/v1/query-history/my", headers=admin_headers)
assert resp.status_code == 200
data = resp.json()
@@ -611,18 +615,15 @@ class TestQueryHistoryAPI:
assert data["items"] == []
@pytest.mark.asyncio
async def test_my_history_with_data(self, client: AsyncClient, admin_headers: dict):
async def test_my_history_with_data(self, client, admin_headers, db, admin_user):
"""测试有数据的历史记录。"""
QueryHistoryService._history = []
QueryHistoryService._history_counter = 0
QueryHistoryService._favorites = set()
await QueryHistoryService.record_query(
user_id=1, username="admin", instance_id=1,
db=db, user_id=admin_user.id, username="admin", instance_id=1,
instance_name="测试", db_type="mysql", database_name="test",
query_content="SELECT 1", query_type="SELECT",
execution_time_ms=10, result_rows=1,
)
await db.commit()
resp = await client.get("/api/v1/query-history/my", headers=admin_headers)
assert resp.status_code == 200
@@ -630,24 +631,21 @@ class TestQueryHistoryAPI:
assert data["total"] >= 1
@pytest.mark.asyncio
async def test_my_history_filter_db_type(self, client: AsyncClient, admin_headers: dict):
async def test_my_history_filter_db_type(self, client, admin_headers, db, admin_user):
"""测试按类型筛选。"""
QueryHistoryService._history = []
QueryHistoryService._history_counter = 0
QueryHistoryService._favorites = set()
await QueryHistoryService.record_query(
user_id=1, username="admin", instance_id=1,
db=db, user_id=admin_user.id, username="admin", instance_id=1,
instance_name="测试", db_type="mysql", database_name="test",
query_content="SELECT 1", query_type="SELECT",
execution_time_ms=10, result_rows=1,
)
await QueryHistoryService.record_query(
user_id=1, username="admin", instance_id=2,
db=db, user_id=admin_user.id, username="admin", instance_id=2,
instance_name="测试Redis", db_type="redis", database_name="db0",
query_content="GET key", query_type="GET",
execution_time_ms=5, result_rows=1,
)
await db.commit()
resp = await client.get(
"/api/v1/query-history/my?db_type=mysql",
@@ -659,18 +657,15 @@ class TestQueryHistoryAPI:
assert data["items"][0]["db_type"] == "mysql"
@pytest.mark.asyncio
async def test_toggle_favorite_api(self, client: AsyncClient, admin_headers: dict):
async def test_toggle_favorite_api(self, client, admin_headers, db, admin_user):
"""测试收藏API。"""
QueryHistoryService._history = []
QueryHistoryService._history_counter = 0
QueryHistoryService._favorites = set()
entry = await QueryHistoryService.record_query(
user_id=1, username="admin", instance_id=1,
db=db, user_id=admin_user.id, username="admin", instance_id=1,
instance_name="测试", db_type="mysql", database_name="test",
query_content="SELECT 1", query_type="SELECT",
execution_time_ms=10, result_rows=1,
)
await db.commit()
resp = await client.post(
f"/api/v1/query-history/{entry.id}/favorite",
@@ -681,12 +676,8 @@ class TestQueryHistoryAPI:
assert data["is_favorite"] is True
@pytest.mark.asyncio
async def test_favorites_api(self, client: AsyncClient, admin_headers: dict):
async def test_favorites_api(self, client, admin_headers, db):
"""测试收藏列表API。"""
QueryHistoryService._history = []
QueryHistoryService._history_counter = 0
QueryHistoryService._favorites = set()
resp = await client.get("/api/v1/query-history/favorites", headers=admin_headers)
assert resp.status_code == 200
data = resp.json()
@@ -694,23 +685,18 @@ class TestQueryHistoryAPI:
assert "items" in data
@pytest.mark.asyncio
async def test_statistics_api(self, client: AsyncClient, admin_headers: dict):
async def test_statistics_api(self, client, admin_headers, db):
"""测试统计API。"""
QueryHistoryService._history = []
QueryHistoryService._history_counter = 0
QueryHistoryService._favorites = set()
resp = await client.get("/api/v1/query-history/statistics", headers=admin_headers)
assert resp.status_code == 200
data = resp.json()
assert "message" in data or "total_queries" in data
@pytest.mark.asyncio
async def test_quick_queries_list_api(self, client: AsyncClient, admin_headers: dict):
async def test_quick_queries_list_api(self, client, admin_headers, db):
"""测试快捷查询列表API。"""
QueryHistoryService._quick_queries = []
QueryHistoryService._quick_counter = 0
QueryHistoryService.seed_default_quick_queries()
await QueryHistoryService.seed_default_quick_queries(db)
await db.commit()
resp = await client.get("/api/v1/query-history/quick-queries", headers=admin_headers)
assert resp.status_code == 200
@@ -719,11 +705,8 @@ class TestQueryHistoryAPI:
assert len(data["items"]) == 10
@pytest.mark.asyncio
async def test_quick_queries_create_api(self, client: AsyncClient, admin_headers: dict):
async def test_quick_queries_create_api(self, client, admin_headers, db):
"""测试创建快捷查询API。"""
QueryHistoryService._quick_queries = []
QueryHistoryService._quick_counter = 0
resp = await client.post(
"/api/v1/query-history/quick-queries",
params={
@@ -741,11 +724,10 @@ class TestQueryHistoryAPI:
assert "id" in data
@pytest.mark.asyncio
async def test_quick_queries_filter_api(self, client: AsyncClient, admin_headers: dict):
async def test_quick_queries_filter_api(self, client, admin_headers, db):
"""测试快捷查询筛选API。"""
QueryHistoryService._quick_queries = []
QueryHistoryService._quick_counter = 0
QueryHistoryService.seed_default_quick_queries()
await QueryHistoryService.seed_default_quick_queries(db)
await db.commit()
resp = await client.get(
"/api/v1/query-history/quick-queries?db_type=redis",
@@ -757,29 +739,22 @@ class TestQueryHistoryAPI:
assert item["db_type"] == "redis"
@pytest.mark.asyncio
async def test_guest_can_view_own_history(self, client: AsyncClient, guest_headers: dict):
async def test_guest_can_view_own_history(self, client, guest_headers, db):
"""测试访客可以查看自己的历史(但无权执行查询)。"""
QueryHistoryService._history = []
QueryHistoryService._history_counter = 0
QueryHistoryService._favorites = set()
resp = await client.get("/api/v1/query-history/my", headers=guest_headers)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_pagination_parameters(self, client: AsyncClient, admin_headers: dict):
async def test_pagination_parameters(self, client, admin_headers, db, admin_user):
"""测试分页参数。"""
QueryHistoryService._history = []
QueryHistoryService._history_counter = 0
QueryHistoryService._favorites = set()
for i in range(15):
await QueryHistoryService.record_query(
user_id=1, username="admin", instance_id=1,
db=db, user_id=admin_user.id, username="admin", instance_id=1,
instance_name="测试", db_type="mysql", database_name="test",
query_content=f"SELECT {i}", query_type="SELECT",
execution_time_ms=10, result_rows=1,
)
await db.commit()
resp = await client.get(
"/api/v1/query-history/my?page=1&page_size=5",