"""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