247 lines
8.0 KiB
Python
247 lines
8.0 KiB
Python
# -*- 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
|