# -*- 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