- 4个数据源菜单(MySQL/PG/Redis/Mongo)合并为1个「数据源」 - InstancePage 改为统一入口,顶部Tab切换类型过滤 - 添加数据源改为弹框Modal(默认MySQL) - 去掉左下角用户区,退出登录移到右上角header - 侧边栏收窄220px,整体布局更紧凑 - 后端202 tests passing - 前端构建零错误
366 lines
13 KiB
Python
366 lines
13 KiB
Python
"""Tests for instance detail API endpoints and backup API."""
|
|
import sys
|
|
from unittest.mock import patch, AsyncMock, MagicMock
|
|
from contextlib import asynccontextmanager
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from tests.conftest import auth_header
|
|
|
|
|
|
def _make_aiomysql_mock():
|
|
"""Create a mock aiomysql module."""
|
|
aiomysql = MagicMock()
|
|
aiomysql.DictCursor = MagicMock()
|
|
return aiomysql
|
|
|
|
|
|
def _mock_mysql_connection(rows=None, description=None):
|
|
"""Create a mock MySQL connection that returns given rows."""
|
|
if rows is None:
|
|
rows = []
|
|
|
|
mock_cursor = AsyncMock()
|
|
mock_cursor.execute = AsyncMock()
|
|
mock_cursor.fetchall = AsyncMock(return_value=rows)
|
|
mock_cursor.description = description
|
|
|
|
@asynccontextmanager
|
|
async def mock_cursor_ctx(*args, **kwargs):
|
|
yield mock_cursor
|
|
|
|
mock_conn = AsyncMock()
|
|
mock_conn.cursor = mock_cursor_ctx
|
|
mock_conn.close = MagicMock()
|
|
|
|
return mock_conn, mock_cursor
|
|
|
|
|
|
class TestListDatabases:
|
|
async def test_list_databases_success(self, client: AsyncClient, test_instance, admin_token):
|
|
mock_conn, _ = _mock_mysql_connection([
|
|
{"Database": "test_db"},
|
|
{"Database": "myapp"},
|
|
{"Database": "information_schema"},
|
|
])
|
|
|
|
mock_aiomysql = _make_aiomysql_mock()
|
|
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
|
|
|
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
|
resp = await client.get(
|
|
"/api/v1/instances/" + str(test_instance.id) + "/detail/databases",
|
|
headers=auth_header(admin_token),
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "test_db" in data
|
|
assert "myapp" in data
|
|
# information_schema should be filtered out
|
|
assert "information_schema" not in data
|
|
|
|
async def test_list_databases_requires_admin(self, client: AsyncClient, test_instance, developer_token):
|
|
resp = await client.get(
|
|
"/api/v1/instances/" + str(test_instance.id) + "/detail/databases",
|
|
headers=auth_header(developer_token),
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
class TestListTables:
|
|
async def test_list_tables_success(self, client: AsyncClient, test_instance, admin_token):
|
|
mock_conn, _ = _mock_mysql_connection([
|
|
{"Name": "users", "Engine": "InnoDB", "Rows": 100, "Data_length": 16384,
|
|
"Index_length": 0, "Comment": "", "Collation": "utf8mb4_general_ci",
|
|
"Create_time": "2024-01-01", "Update_time": None},
|
|
])
|
|
|
|
mock_aiomysql = _make_aiomysql_mock()
|
|
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
|
|
|
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
|
resp = await client.get(
|
|
"/api/v1/instances/" + str(test_instance.id) + "/detail/tables",
|
|
params={"database": "test_db"},
|
|
headers=auth_header(admin_token),
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data) == 1
|
|
assert data[0]["name"] == "users"
|
|
assert data[0]["engine"] == "InnoDB"
|
|
assert data[0]["rows"] == 100
|
|
|
|
|
|
class TestTableDetail:
|
|
async def test_table_detail_success(self, client: AsyncClient, test_instance, admin_token):
|
|
# Need three separate connections for DDL, columns, indexes
|
|
ddl_conn, _ = _mock_mysql_connection([
|
|
{"Table": "users", "Create Table": "CREATE TABLE users (id INT PRIMARY KEY)"},
|
|
])
|
|
col_conn, _ = _mock_mysql_connection([
|
|
{"Field": "id", "Type": "int", "Null": "NO", "Key": "PRI", "Default": None, "Extra": "auto_increment"},
|
|
])
|
|
idx_conn, _ = _mock_mysql_connection([
|
|
{"Table": "users", "Non_unique": 0, "Key_name": "PRIMARY", "Column_name": "id"},
|
|
])
|
|
|
|
call_count = 0
|
|
|
|
async def mock_connect(*args, **kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
return ddl_conn
|
|
elif call_count == 2:
|
|
return col_conn
|
|
else:
|
|
return idx_conn
|
|
|
|
mock_aiomysql = _make_aiomysql_mock()
|
|
mock_aiomysql.connect = mock_connect
|
|
|
|
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
|
resp = await client.get(
|
|
"/api/v1/instances/" + str(test_instance.id) + "/detail/table-detail",
|
|
params={"database": "test_db", "table": "users"},
|
|
headers=auth_header(admin_token),
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "ddl" in data
|
|
assert "columns" in data
|
|
assert "indexes" in data
|
|
assert "CREATE TABLE" in data["ddl"]
|
|
|
|
|
|
class TestListUsers:
|
|
async def test_list_users_success(self, client: AsyncClient, test_instance, admin_token):
|
|
mock_conn, _ = _mock_mysql_connection([
|
|
{"user": "root", "host": "localhost"},
|
|
{"user": "admin", "host": "%"},
|
|
])
|
|
|
|
mock_aiomysql = _make_aiomysql_mock()
|
|
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
|
|
|
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
|
resp = await client.get(
|
|
"/api/v1/instances/" + str(test_instance.id) + "/detail/users",
|
|
headers=auth_header(admin_token),
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data) == 2
|
|
assert data[0]["user"] == "root"
|
|
|
|
|
|
class TestShowGrants:
|
|
async def test_show_grants_success(self, client: AsyncClient, test_instance, admin_token):
|
|
mock_conn, _ = _mock_mysql_connection([
|
|
{"Grants for root@localhost": "GRANT ALL PRIVILEGES ON *.* TO 'root'@'localhost'"},
|
|
])
|
|
|
|
mock_aiomysql = _make_aiomysql_mock()
|
|
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
|
|
|
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
|
resp = await client.get(
|
|
"/api/v1/instances/" + str(test_instance.id) + "/detail/grants",
|
|
params={"user": "root", "host": "localhost"},
|
|
headers=auth_header(admin_token),
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["user"] == "root"
|
|
assert len(data["grants"]) >= 1
|
|
|
|
|
|
class TestSlowQueries:
|
|
async def test_slow_queries_success(self, client: AsyncClient, test_instance, admin_token):
|
|
mock_conn, _ = _mock_mysql_connection([
|
|
{"start_time": "2024-01-01 00:00:00", "user_host": "root[root]@localhost",
|
|
"query_time": "00:00:05", "lock_time": "00:00:00", "rows_sent": 1,
|
|
"rows_examined": 1000, "sql_text": "SELECT * FROM big_table"},
|
|
])
|
|
|
|
mock_aiomysql = _make_aiomysql_mock()
|
|
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
|
|
|
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
|
resp = await client.get(
|
|
"/api/v1/instances/" + str(test_instance.id) + "/detail/slow-queries",
|
|
params={"database": "test_db"},
|
|
headers=auth_header(admin_token),
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert isinstance(data, list)
|
|
|
|
|
|
class TestProcesslist:
|
|
async def test_show_processlist_success(self, client: AsyncClient, test_instance, admin_token):
|
|
mock_conn, _ = _mock_mysql_connection([
|
|
{"Id": 1, "User": "root", "Host": "localhost", "db": "test_db",
|
|
"Command": "Query", "Time": 10, "State": "executing", "Info": "SELECT 1"},
|
|
])
|
|
|
|
mock_aiomysql = _make_aiomysql_mock()
|
|
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
|
|
|
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
|
resp = await client.get(
|
|
"/api/v1/instances/" + str(test_instance.id) + "/detail/processlist",
|
|
headers=auth_header(admin_token),
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data) == 1
|
|
assert data[0]["Id"] == 1
|
|
|
|
|
|
class TestKillProcess:
|
|
async def test_kill_process_success(self, client: AsyncClient, test_instance, admin_token):
|
|
mock_conn, _ = _mock_mysql_connection([])
|
|
|
|
mock_aiomysql = _make_aiomysql_mock()
|
|
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
|
|
|
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
|
resp = await client.post(
|
|
"/api/v1/instances/" + str(test_instance.id) + "/detail/processlist/123/kill",
|
|
headers=auth_header(admin_token),
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["pid"] == 123
|
|
assert "已终止" in data["message"]
|
|
|
|
|
|
class TestShowStatus:
|
|
async def test_show_status_success(self, client: AsyncClient, test_instance, admin_token):
|
|
mock_conn, _ = _mock_mysql_connection([
|
|
{"Variable_name": "Threads_connected", "Value": "5"},
|
|
{"Variable_name": "Queries", "Value": "1000"},
|
|
{"Variable_name": "Slow_queries", "Value": "10"},
|
|
{"Variable_name": "Uptime", "Value": "86400"},
|
|
])
|
|
|
|
mock_aiomysql = _make_aiomysql_mock()
|
|
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
|
|
|
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
|
resp = await client.get(
|
|
"/api/v1/instances/" + str(test_instance.id) + "/detail/status",
|
|
headers=auth_header(admin_token),
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "key_metrics" in data
|
|
assert data["key_metrics"]["Threads_connected"] == "5"
|
|
|
|
|
|
class TestShowVariables:
|
|
async def test_show_variables_success(self, client: AsyncClient, test_instance, admin_token):
|
|
mock_conn, _ = _mock_mysql_connection([
|
|
{"Variable_name": "max_connections", "Value": "151"},
|
|
{"Variable_name": "slow_query_log", "Value": "ON"},
|
|
])
|
|
|
|
mock_aiomysql = _make_aiomysql_mock()
|
|
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
|
|
|
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
|
resp = await client.get(
|
|
"/api/v1/instances/" + str(test_instance.id) + "/detail/variables",
|
|
headers=auth_header(admin_token),
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data) == 2
|
|
|
|
|
|
class TestBackupAPI:
|
|
async def test_create_backup(self, client: AsyncClient, test_instance, admin_token):
|
|
resp = await client.post(
|
|
"/api/v1/backups",
|
|
headers=auth_header(admin_token),
|
|
json={
|
|
"title": "测试备份",
|
|
"instance_id": test_instance.id,
|
|
"database": "test_db",
|
|
"remark": "自动备份",
|
|
},
|
|
)
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["title"] == "测试备份"
|
|
assert data["status"] == "pending"
|
|
assert data["database"] == "test_db"
|
|
|
|
async def test_list_backups(self, client: AsyncClient, test_instance, admin_token):
|
|
# Create a backup first
|
|
await client.post(
|
|
"/api/v1/backups",
|
|
headers=auth_header(admin_token),
|
|
json={
|
|
"title": "备份1",
|
|
"instance_id": test_instance.id,
|
|
"database": "test_db",
|
|
},
|
|
)
|
|
resp = await client.get(
|
|
"/api/v1/backups",
|
|
params={"instance_id": test_instance.id},
|
|
headers=auth_header(admin_token),
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data) >= 1
|
|
|
|
async def test_get_backup_detail(self, client: AsyncClient, test_instance, admin_token):
|
|
# Create a backup
|
|
create_resp = await client.post(
|
|
"/api/v1/backups",
|
|
headers=auth_header(admin_token),
|
|
json={
|
|
"title": "备份详情测试",
|
|
"instance_id": test_instance.id,
|
|
"database": "test_db",
|
|
},
|
|
)
|
|
backup_id = create_resp.json()["id"]
|
|
|
|
resp = await client.get(
|
|
"/api/v1/backups/" + str(backup_id),
|
|
headers=auth_header(admin_token),
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["id"] == backup_id
|
|
assert data["title"] == "备份详情测试"
|
|
|
|
async def test_get_backup_not_found(self, client: AsyncClient, admin_token):
|
|
resp = await client.get(
|
|
"/api/v1/backups/99999",
|
|
headers=auth_header(admin_token),
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_backup_requires_admin(self, client: AsyncClient, developer_token):
|
|
resp = await client.get(
|
|
"/api/v1/backups",
|
|
headers=auth_header(developer_token),
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
class TestInstanceNotFound:
|
|
async def test_databases_instance_not_found(self, client: AsyncClient, admin_token):
|
|
resp = await client.get(
|
|
"/api/v1/instances/99999/detail/databases",
|
|
headers=auth_header(admin_token),
|
|
)
|
|
assert resp.status_code == 404
|