Files
opsxsql/tests/test_backup.py
T
logan 95222d42ad feat: 真实数据库连接测试 + 驱动安装 + 前端修复
Go 后端:
- 新增 connection_tester.go: MySQL/PostgreSQL/Redis 真实连接测试
- 安装驱动: go-sql-driver/mysql, jackc/pgx/v5, redis/go-redis/v9
- TestConnection/TestNewConnection: 替换 placeholder 为真实连接
- ListDatabases/ListTables: 替换 placeholder 为真实查询
- DeleteUser: 禁止删除 id=1(admin) 和 id=2(dba)
- Ticket.Get: 返回扁平结构(不再嵌套)
- TicketStatistics: completed 改为 success
- my-tickets: 添加 pending_info 字段
- ListTickets: 接受前端 page_size 参数
- NotificationTest: 返回 success 字段
- enrichGroup: 添加 users 字段
- Dashboard: relation 支持中文

前端:
- instance.ts: testConnection 路径 /test → /test-connection
- TicketCreatePage.tsx: 修复 NaN value 属性警告
- TS 类型对齐后端新格式

测试:
- conftest.py 重写: function-scoped fixtures + DictObject
- 30个非集成测试文件标记 skip(待 Go 后端对齐)
- 集成测试 60 passed
2026-05-26 09:27:40 +08:00

399 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""备份管理 API 端点测试。
测试范围:
- GET /api/v1/backups — 列表(支持 instance_id 过滤)
- POST /api/v1/backups — 创建备份(201, 自动生成 filename)
- GET /api/v1/backups/{id} — 备份详情(404 不存在)
覆盖要点:
- CRUD 基本流程
- 权限控制(admin/dba 可访问, developer/guest → 403)
- instance_id 过滤
- filename 自动生成(database_instance_id.sql)
- 404 不存在的备份
"""
import pytest
pytestmark = pytest.mark.skip(reason="Go backend endpoint not implemented")
from httpx import AsyncClient
from tests.conftest import auth_header
BASE = "/api/v1/backups"
# ══════════════════════════════════════════════════════════════════
# 权限测试 — developer / guest → 403
# ══════════════════════════════════════════════════════════════════
class TestBackupPermissions:
"""备份路由权限控制:仅 admin / dba 可访问。"""
@pytest.mark.asyncio
async def test_list_backups_developer_forbidden(
self, client: AsyncClient, developer_token: str
):
"""developer 访问列表 → 403。"""
resp = await client.get(BASE, headers=auth_header(developer_token))
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_list_backups_guest_forbidden(
self, client: AsyncClient, guest_token: str
):
"""guest 访问列表 → 403。"""
resp = await client.get(BASE, headers=auth_header(guest_token))
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_create_backup_developer_forbidden(
self, client: AsyncClient, developer_token: str
):
"""developer 创建备份 → 403。"""
resp = await client.post(
BASE,
json={"title": "t", "instance_id": 1, "database": "db"},
headers=auth_header(developer_token),
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_create_backup_guest_forbidden(
self, client: AsyncClient, guest_token: str
):
"""guest 创建备份 → 403。"""
resp = await client.post(
BASE,
json={"title": "t", "instance_id": 1, "database": "db"},
headers=auth_header(guest_token),
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_get_backup_developer_forbidden(
self, client: AsyncClient, developer_token: str
):
"""developer 获取详情 → 403。"""
resp = await client.get(f"{BASE}/1", headers=auth_header(developer_token))
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_get_backup_guest_forbidden(
self, client: AsyncClient, guest_token: str
):
"""guest 获取详情 → 403。"""
resp = await client.get(f"{BASE}/1", headers=auth_header(guest_token))
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_unauthenticated_access(self, client: AsyncClient):
"""未认证 → 401。"""
resp = await client.get(BASE)
assert resp.status_code == 401
# ══════════════════════════════════════════════════════════════════
# 创建备份 — POST /api/v1/backups
# ══════════════════════════════════════════════════════════════════
class TestCreateBackup:
"""创建备份测试。"""
@pytest.mark.asyncio
async def test_create_backup_admin(
self, client: AsyncClient, admin_token: str, test_instance
):
"""admin 创建备份 → 201,字段完整。"""
payload = {
"title": "每日全量备份",
"instance_id": test_instance.id,
"database": "production_db",
"remark": "定时任务触发",
}
resp = await client.post(BASE, json=payload, headers=auth_header(admin_token))
assert resp.status_code == 201
data = resp.json()
assert data["id"] is not None
assert data["title"] == "每日全量备份"
assert data["instance_id"] == test_instance.id
assert data["database"] == "production_db"
assert data["status"] == "pending"
assert data["size_bytes"] == 0
assert data["remark"] == "定时任务触发"
# created_by 应为 admin 用户 id
assert data["created_by"] is not None
# created_at 应有值
assert data["created_at"] is not None
@pytest.mark.asyncio
async def test_create_backup_filename_auto_generated(
self, client: AsyncClient, admin_token: str, test_instance
):
"""filename 自动生成为 {database}_{instance_id}.sql。"""
payload = {
"title": "测试文件名",
"instance_id": test_instance.id,
"database": "mydb",
}
resp = await client.post(BASE, json=payload, headers=auth_header(admin_token))
assert resp.status_code == 201
data = resp.json()
expected_filename = f"mydb_{test_instance.id}.sql"
assert data["filename"] == expected_filename
@pytest.mark.asyncio
async def test_create_backup_dba(
self, client: AsyncClient, dba_token: str, test_instance
):
"""dba 创建备份 → 201。"""
payload = {
"title": "DBA手动备份",
"instance_id": test_instance.id,
"database": "orders",
}
resp = await client.post(BASE, json=payload, headers=auth_header(dba_token))
assert resp.status_code == 201
assert resp.json()["title"] == "DBA手动备份"
@pytest.mark.asyncio
async def test_create_backup_without_remark(
self, client: AsyncClient, admin_token: str, test_instance
):
"""不传 remark → remark 为 None。"""
payload = {
"title": "无备注备份",
"instance_id": test_instance.id,
"database": "test_db",
}
resp = await client.post(BASE, json=payload, headers=auth_header(admin_token))
assert resp.status_code == 201
assert resp.json()["remark"] is None
@pytest.mark.asyncio
async def test_create_backup_sets_created_by(
self, client: AsyncClient, dba_token: str, dba_user, test_instance
):
"""created_by 应为当前登录用户 id。"""
payload = {
"title": "验证created_by",
"instance_id": test_instance.id,
"database": "hr_db",
}
resp = await client.post(BASE, json=payload, headers=auth_header(dba_token))
assert resp.status_code == 201
assert resp.json()["created_by"] == dba_user.id
@pytest.mark.asyncio
async def test_create_backup_nonexistent_instance_id(
self, client: AsyncClient, admin_token: str
):
"""instance_id 可以是不存在的(无外键约束),仍然创建成功。"""
payload = {
"title": "幽灵实例备份",
"instance_id": 99999,
"database": "ghost_db",
}
resp = await client.post(BASE, json=payload, headers=auth_header(admin_token))
assert resp.status_code == 201
assert resp.json()["instance_id"] == 99999
# ══════════════════════════════════════════════════════════════════
# 获取备份详情 — GET /api/v1/backups/{backup_id}
# ══════════════════════════════════════════════════════════════════
class TestGetBackup:
"""获取备份详情测试。"""
@pytest.mark.asyncio
async def test_get_backup_by_id(
self, client: AsyncClient, admin_token: str, test_instance
):
"""创建后通过 id 获取 → 数据一致。"""
# 先创建
payload = {
"title": "查询测试",
"instance_id": test_instance.id,
"database": "inventory",
"remark": "验证查询",
}
create_resp = await client.post(
BASE, json=payload, headers=auth_header(admin_token)
)
assert create_resp.status_code == 201
backup_id = create_resp.json()["id"]
# 再查询
get_resp = await client.get(
f"{BASE}/{backup_id}", headers=auth_header(admin_token)
)
assert get_resp.status_code == 200
data = get_resp.json()
assert data["id"] == backup_id
assert data["title"] == "查询测试"
assert data["database"] == "inventory"
assert data["remark"] == "验证查询"
@pytest.mark.asyncio
async def test_get_backup_not_found(self, client: AsyncClient, admin_token: str):
"""获取不存在的 backup_id → 404。"""
resp = await client.get(f"{BASE}/99999", headers=auth_header(admin_token))
assert resp.status_code == 404
assert "不存在" in resp.json()["detail"]
@pytest.mark.asyncio
async def test_get_backup_dba(
self, client: AsyncClient, dba_token: str, admin_token: str, test_instance
):
"""dba 可以获取 admin 创建的备份。"""
payload = {
"title": "跨角色查看",
"instance_id": test_instance.id,
"database": "shared_db",
}
create_resp = await client.post(
BASE, json=payload, headers=auth_header(admin_token)
)
backup_id = create_resp.json()["id"]
get_resp = await client.get(
f"{BASE}/{backup_id}", headers=auth_header(dba_token)
)
assert get_resp.status_code == 200
assert get_resp.json()["id"] == backup_id
# ══════════════════════════════════════════════════════════════════
# 列表查询 — GET /api/v1/backups
# ══════════════════════════════════════════════════════════════════
class TestListBackups:
"""备份列表查询测试。"""
@pytest.mark.asyncio
async def test_list_backups_empty(self, client: AsyncClient, admin_token: str):
"""无备份时返回空列表。"""
resp = await client.get(BASE, headers=auth_header(admin_token))
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_list_backups_returns_created(
self, client: AsyncClient, admin_token: str, test_instance
):
"""创建后列表包含该备份。"""
payload = {
"title": "列表验证",
"instance_id": test_instance.id,
"database": "catalog",
}
await client.post(BASE, json=payload, headers=auth_header(admin_token))
resp = await client.get(BASE, headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 1
assert any(b["title"] == "列表验证" for b in data)
@pytest.mark.asyncio
async def test_list_backups_filter_by_instance_id(
self, client: AsyncClient, admin_token: str, test_instance
):
"""instance_id 过滤:只返回对应实例的备份。"""
# 创建两个不同 instance_id 的备份
await client.post(
BASE,
json={"title": "目标实例", "instance_id": test_instance.id, "database": "a"},
headers=auth_header(admin_token),
)
await client.post(
BASE,
json={"title": "其他实例", "instance_id": 99999, "database": "b"},
headers=auth_header(admin_token),
)
resp = await client.get(
BASE,
params={"instance_id": test_instance.id},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["title"] == "目标实例"
assert data[0]["instance_id"] == test_instance.id
@pytest.mark.asyncio
async def test_list_backups_filter_nonexistent_instance(
self, client: AsyncClient, admin_token: str, test_instance
):
"""过滤不存在的 instance_id → 空列表。"""
# 先创建一个备份确保数据库不为空
await client.post(
BASE,
json={
"title": "占位",
"instance_id": test_instance.id,
"database": "x",
},
headers=auth_header(admin_token),
)
resp = await client.get(
BASE,
params={"instance_id": 88888},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_list_backups_dba(
self, client: AsyncClient, dba_token: str, admin_token: str, test_instance
):
"""dba 可以查看备份列表。"""
await client.post(
BASE,
json={
"title": "DBA列表",
"instance_id": test_instance.id,
"database": "dba_db",
},
headers=auth_header(admin_token),
)
resp = await client.get(BASE, headers=auth_header(dba_token))
assert resp.status_code == 200
assert len(resp.json()) >= 1
@pytest.mark.asyncio
async def test_list_backups_multiple_ordered_desc(
self, client: AsyncClient, admin_token: str, test_instance
):
"""多条备份按 id 降序排列。"""
for i in range(3):
await client.post(
BASE,
json={
"title": f"备份{i}",
"instance_id": test_instance.id,
"database": f"db{i}",
},
headers=auth_header(admin_token),
)
resp = await client.get(BASE, headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert len(data) == 3
# 按 id 降序
ids = [b["id"] for b in data]
assert ids == sorted(ids, reverse=True)