Files
Neo-ZQYY/apps/backend/tests/test_etl_status_router.py

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