在准备环境前提交次全部更改。

This commit is contained in:
Neo
2026-02-19 08:35:13 +08:00
parent ded6dfb9d8
commit 4eac07da47
1387 changed files with 6107191 additions and 33002 deletions

View File

@@ -0,0 +1,246 @@
# -*- coding: utf-8 -*-
"""ETL 状态路由单元测试
覆盖 2 个端点:
- GET /api/etl-status/cursors
- GET /api/etl-status/recent-runs
通过 mock 绕过数据库连接,专注路由逻辑验证。
"""
import os
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-unit-tests")
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
from app.auth.dependencies import CurrentUser, get_current_user
from app.main import app
_TEST_USER = CurrentUser(user_id=1, site_id=100)
def _override_auth():
return _TEST_USER
app.dependency_overrides[get_current_user] = _override_auth
client = TestClient(app)
_MOCK_ETL_CONN = "app.routers.etl_status.get_etl_readonly_connection"
_MOCK_APP_CONN = "app.routers.etl_status.get_connection"
def _make_mock_conn(rows):
"""构造 mock 数据库连接cursor 返回指定行。"""
mock_conn = MagicMock()
mock_cur = MagicMock()
mock_cur.fetchall.return_value = rows
mock_cur.fetchone.return_value = None
mock_conn.cursor.return_value.__enter__ = lambda s: mock_cur
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
return mock_conn, mock_cur
# ---------------------------------------------------------------------------
# GET /api/etl-status/cursors
# ---------------------------------------------------------------------------
class TestListCursors:
@patch(_MOCK_ETL_CONN)
def test_returns_cursor_list(self, mock_get_conn):
conn, cur = _make_mock_conn([
("ODS_FETCH_ORDERS", "2024-06-15 10:30:00+08", 1500),
("ODS_FETCH_MEMBERS", "2024-06-15 09:00:00+08", 800),
])
# fetchone 用于 EXISTS 检查
cur.fetchone.return_value = (True,)
mock_get_conn.return_value = conn
resp = client.get("/api/etl-status/cursors")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["task_code"] == "ODS_FETCH_ORDERS"
assert data[0]["last_fetch_time"] == "2024-06-15 10:30:00+08"
assert data[0]["record_count"] == 1500
assert data[1]["task_code"] == "ODS_FETCH_MEMBERS"
# 验证 site_id 传递
mock_get_conn.assert_called_once_with(_TEST_USER.site_id)
conn.close.assert_called_once()
@patch(_MOCK_ETL_CONN)
def test_table_not_exists_returns_empty(self, mock_get_conn):
"""etl_admin.etl_cursor 表不存在时返回空列表。"""
conn, cur = _make_mock_conn([])
cur.fetchone.return_value = (False,)
mock_get_conn.return_value = conn
resp = client.get("/api/etl-status/cursors")
assert resp.status_code == 200
assert resp.json() == []
@patch(_MOCK_ETL_CONN)
def test_null_fields(self, mock_get_conn):
"""游标字段可能为 None任务从未执行过"""
conn, cur = _make_mock_conn([
("ODS_FETCH_INVENTORY", None, None),
])
cur.fetchone.return_value = (True,)
mock_get_conn.return_value = conn
resp = client.get("/api/etl-status/cursors")
assert resp.status_code == 200
data = resp.json()
assert data[0]["task_code"] == "ODS_FETCH_INVENTORY"
assert data[0]["last_fetch_time"] is None
assert data[0]["record_count"] is None
@patch(_MOCK_ETL_CONN)
def test_empty_cursors(self, mock_get_conn):
"""表存在但无数据。"""
conn, cur = _make_mock_conn([])
cur.fetchone.return_value = (True,)
mock_get_conn.return_value = conn
resp = client.get("/api/etl-status/cursors")
assert resp.status_code == 200
assert resp.json() == []
# ---------------------------------------------------------------------------
# GET /api/etl-status/recent-runs
# ---------------------------------------------------------------------------
class TestListRecentRuns:
@patch(_MOCK_APP_CONN)
def test_returns_recent_runs(self, mock_get_conn):
conn, cur = _make_mock_conn([
(
"a1b2c3d4-0000-0000-0000-000000000001",
["ODS_FETCH_ORDERS", "DWD_LOAD_FROM_ODS"],
"success",
"2024-06-15 10:30:00+08",
"2024-06-15 10:35:00+08",
300000,
0,
),
(
"a1b2c3d4-0000-0000-0000-000000000002",
["DWS_AGGREGATE"],
"failed",
"2024-06-15 09:00:00+08",
"2024-06-15 09:01:00+08",
60000,
1,
),
])
mock_get_conn.return_value = conn
resp = client.get("/api/etl-status/recent-runs")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
run0 = data[0]
assert run0["id"] == "a1b2c3d4-0000-0000-0000-000000000001"
assert run0["task_codes"] == ["ODS_FETCH_ORDERS", "DWD_LOAD_FROM_ODS"]
assert run0["status"] == "success"
assert run0["duration_ms"] == 300000
assert run0["exit_code"] == 0
run1 = data[1]
assert run1["status"] == "failed"
assert run1["exit_code"] == 1
conn.close.assert_called_once()
@patch(_MOCK_APP_CONN)
def test_empty_runs(self, mock_get_conn):
conn, cur = _make_mock_conn([])
mock_get_conn.return_value = conn
resp = client.get("/api/etl-status/recent-runs")
assert resp.status_code == 200
assert resp.json() == []
@patch(_MOCK_APP_CONN)
def test_null_optional_fields(self, mock_get_conn):
"""正在执行的任务 finished_at / duration_ms / exit_code 为 None。"""
conn, cur = _make_mock_conn([
(
"a1b2c3d4-0000-0000-0000-000000000003",
["ODS_FETCH_MEMBERS"],
"running",
"2024-06-15 11:00:00+08",
None,
None,
None,
),
])
mock_get_conn.return_value = conn
resp = client.get("/api/etl-status/recent-runs")
assert resp.status_code == 200
data = resp.json()
assert data[0]["status"] == "running"
assert data[0]["finished_at"] is None
assert data[0]["duration_ms"] is None
assert data[0]["exit_code"] is None
@patch(_MOCK_APP_CONN)
def test_site_id_filter(self, mock_get_conn):
"""验证查询时传入了正确的 site_id 参数。"""
conn, cur = _make_mock_conn([])
mock_get_conn.return_value = conn
client.get("/api/etl-status/recent-runs")
# 验证 SQL 中传入了 site_id 和 limit
call_args = cur.execute.call_args
params = call_args[0][1]
assert params[0] == _TEST_USER.site_id
assert params[1] == 50
@patch(_MOCK_APP_CONN)
def test_empty_task_codes(self, mock_get_conn):
"""task_codes 为 None 时应返回空列表。"""
conn, cur = _make_mock_conn([
(
"a1b2c3d4-0000-0000-0000-000000000004",
None,
"pending",
"2024-06-15 12:00:00+08",
None,
None,
None,
),
])
mock_get_conn.return_value = conn
resp = client.get("/api/etl-status/recent-runs")
assert resp.status_code == 200
assert resp.json()[0]["task_codes"] == []
# ---------------------------------------------------------------------------
# 认证测试
# ---------------------------------------------------------------------------
class TestEtlStatusAuth:
def test_requires_auth(self):
"""移除 auth override 后,所有端点应返回 401/403。"""
original = app.dependency_overrides.pop(get_current_user, None)
try:
for url in ["/api/etl-status/cursors", "/api/etl-status/recent-runs"]:
resp = client.get(url)
assert resp.status_code in (401, 403), f"GET {url} 应需要认证"
finally:
if original:
app.dependency_overrides[get_current_user] = original