# -*- coding: utf-8 -*- """CLIBuilder 单元测试 覆盖:7 种 Flow、4 种处理模式、时间窗口、store_id 自动注入、extra_args 等。 """ import pytest from app.schemas.tasks import TaskConfigSchema from app.services.cli_builder import CLIBuilder, VALID_FLOWS, VALID_PROCESSING_MODES @pytest.fixture def builder() -> CLIBuilder: return CLIBuilder() ETL_PATH = "/fake/etl/project" # --------------------------------------------------------------------------- # 基本命令结构 # --------------------------------------------------------------------------- class TestBasicCommand: def test_minimal_command(self, builder: CLIBuilder): """最小配置应生成 python -m cli.main --flow ... --processing-mode ...""" config = TaskConfigSchema(tasks=["ODS_MEMBER"]) cmd = builder.build_command(config, ETL_PATH) assert cmd[:3] == ["python", "-m", "cli.main"] assert "--flow" in cmd assert "--processing-mode" in cmd def test_custom_python_executable(self, builder: CLIBuilder): config = TaskConfigSchema(tasks=["ODS_MEMBER"]) cmd = builder.build_command(config, ETL_PATH, python_executable="python3") assert cmd[0] == "python3" def test_tasks_joined_by_comma(self, builder: CLIBuilder): config = TaskConfigSchema(tasks=["ODS_MEMBER", "ODS_PAYMENT", "ODS_REFUND"]) cmd = builder.build_command(config, ETL_PATH) idx = cmd.index("--tasks") assert cmd[idx + 1] == "ODS_MEMBER,ODS_PAYMENT,ODS_REFUND" def test_empty_tasks_no_tasks_arg(self, builder: CLIBuilder): """空任务列表不应生成 --tasks 参数""" config = TaskConfigSchema(tasks=[]) cmd = builder.build_command(config, ETL_PATH) assert "--tasks" not in cmd # --------------------------------------------------------------------------- # 7 种 Flow # --------------------------------------------------------------------------- class TestFlows: @pytest.mark.parametrize("flow_id", sorted(VALID_FLOWS)) def test_all_flows_accepted(self, builder: CLIBuilder, flow_id: str): config = TaskConfigSchema(tasks=["ODS_MEMBER"], flow=flow_id) cmd = builder.build_command(config, ETL_PATH) idx = cmd.index("--flow") assert cmd[idx + 1] == flow_id def test_default_flow_is_api_ods_dwd(self, builder: CLIBuilder): config = TaskConfigSchema(tasks=["ODS_MEMBER"]) cmd = builder.build_command(config, ETL_PATH) idx = cmd.index("--flow") assert cmd[idx + 1] == "api_ods_dwd" # --------------------------------------------------------------------------- # 4 种处理模式 # --------------------------------------------------------------------------- class TestProcessingModes: @pytest.mark.parametrize("mode", sorted(VALID_PROCESSING_MODES)) def test_all_modes_accepted(self, builder: CLIBuilder, mode: str): config = TaskConfigSchema(tasks=["ODS_MEMBER"], processing_mode=mode) cmd = builder.build_command(config, ETL_PATH) idx = cmd.index("--processing-mode") assert cmd[idx + 1] == mode def test_fetch_before_verify_only_in_verify_mode(self, builder: CLIBuilder): """--fetch-before-verify 仅在 verify_only 模式下生效""" config = TaskConfigSchema( tasks=["ODS_MEMBER"], processing_mode="verify_only", fetch_before_verify=True, ) cmd = builder.build_command(config, ETL_PATH) assert "--fetch-before-verify" in cmd def test_fetch_before_verify_ignored_in_increment_mode(self, builder: CLIBuilder): """increment_only 模式下 fetch_before_verify=True 不应生成参数""" config = TaskConfigSchema( tasks=["ODS_MEMBER"], processing_mode="increment_only", fetch_before_verify=True, ) cmd = builder.build_command(config, ETL_PATH) assert "--fetch-before-verify" not in cmd # --------------------------------------------------------------------------- # 时间窗口 # --------------------------------------------------------------------------- class TestTimeWindow: def test_lookback_mode_generates_lookback_args(self, builder: CLIBuilder): config = TaskConfigSchema( tasks=["ODS_MEMBER"], window_mode="lookback", lookback_hours=48, overlap_seconds=1200, ) cmd = builder.build_command(config, ETL_PATH) idx_lb = cmd.index("--lookback-hours") assert cmd[idx_lb + 1] == "48" idx_ol = cmd.index("--overlap-seconds") assert cmd[idx_ol + 1] == "1200" # lookback 模式不应生成 --window-start / --window-end assert "--window-start" not in cmd assert "--window-end" not in cmd def test_custom_mode_generates_window_args(self, builder: CLIBuilder): config = TaskConfigSchema( tasks=["ODS_MEMBER"], window_mode="custom", window_start="2026-01-01", window_end="2026-01-31", ) cmd = builder.build_command(config, ETL_PATH) idx_s = cmd.index("--window-start") assert cmd[idx_s + 1] == "2026-01-01" idx_e = cmd.index("--window-end") assert cmd[idx_e + 1] == "2026-01-31" # custom 模式不应生成 --lookback-hours / --overlap-seconds assert "--lookback-hours" not in cmd assert "--overlap-seconds" not in cmd def test_window_split_with_days(self, builder: CLIBuilder): config = TaskConfigSchema( tasks=["ODS_MEMBER"], window_split="day", window_split_days=10, ) cmd = builder.build_command(config, ETL_PATH) idx = cmd.index("--window-split") assert cmd[idx + 1] == "day" idx_d = cmd.index("--window-split-days") assert cmd[idx_d + 1] == "10" def test_window_split_none_not_generated(self, builder: CLIBuilder): """window_split='none' 不应生成 --window-split 参数""" config = TaskConfigSchema(tasks=["ODS_MEMBER"], window_split="none") cmd = builder.build_command(config, ETL_PATH) assert "--window-split" not in cmd # --------------------------------------------------------------------------- # store_id 自动注入 # --------------------------------------------------------------------------- class TestStoreId: def test_store_id_injected(self, builder: CLIBuilder): config = TaskConfigSchema(tasks=["ODS_MEMBER"], store_id=42) cmd = builder.build_command(config, ETL_PATH) idx = cmd.index("--store-id") assert cmd[idx + 1] == "42" def test_store_id_none_not_generated(self, builder: CLIBuilder): config = TaskConfigSchema(tasks=["ODS_MEMBER"], store_id=None) cmd = builder.build_command(config, ETL_PATH) assert "--store-id" not in cmd # --------------------------------------------------------------------------- # dry_run # --------------------------------------------------------------------------- class TestDryRun: def test_dry_run_flag(self, builder: CLIBuilder): config = TaskConfigSchema(tasks=["ODS_MEMBER"], dry_run=True) cmd = builder.build_command(config, ETL_PATH) assert "--dry-run" in cmd def test_no_dry_run_flag(self, builder: CLIBuilder): config = TaskConfigSchema(tasks=["ODS_MEMBER"], dry_run=False) cmd = builder.build_command(config, ETL_PATH) assert "--dry-run" not in cmd # --------------------------------------------------------------------------- # extra_args # --------------------------------------------------------------------------- class TestExtraArgs: def test_supported_value_arg(self, builder: CLIBuilder): config = TaskConfigSchema( tasks=["ODS_MEMBER"], extra_args={"pg_dsn": "postgresql://localhost/test"}, ) cmd = builder.build_command(config, ETL_PATH) idx = cmd.index("--pg-dsn") assert cmd[idx + 1] == "postgresql://localhost/test" def test_supported_bool_arg(self, builder: CLIBuilder): config = TaskConfigSchema( tasks=["ODS_MEMBER"], extra_args={"force_window_override": True}, ) cmd = builder.build_command(config, ETL_PATH) assert "--force-window-override" in cmd def test_unsupported_arg_ignored(self, builder: CLIBuilder): """不在 CLI_SUPPORTED_ARGS 中的键应被忽略""" config = TaskConfigSchema( tasks=["ODS_MEMBER"], extra_args={"unknown_param": "value"}, ) cmd = builder.build_command(config, ETL_PATH) assert "--unknown-param" not in cmd def test_none_value_ignored(self, builder: CLIBuilder): config = TaskConfigSchema( tasks=["ODS_MEMBER"], extra_args={"pg_dsn": None}, ) cmd = builder.build_command(config, ETL_PATH) assert "--pg-dsn" not in cmd def test_false_bool_arg_not_generated(self, builder: CLIBuilder): config = TaskConfigSchema( tasks=["ODS_MEMBER"], extra_args={"force_window_override": False}, ) cmd = builder.build_command(config, ETL_PATH) assert "--force-window-override" not in cmd # --------------------------------------------------------------------------- # build_command_string # --------------------------------------------------------------------------- class TestBuildCommandString: def test_returns_string(self, builder: CLIBuilder): config = TaskConfigSchema(tasks=["ODS_MEMBER"]) result = builder.build_command_string(config, ETL_PATH) assert isinstance(result, str) assert "python -m cli.main" in result def test_quotes_args_with_spaces(self, builder: CLIBuilder): config = TaskConfigSchema( tasks=["ODS_MEMBER"], extra_args={"pg_dsn": "host=localhost dbname=test"}, ) result = builder.build_command_string(config, ETL_PATH) # 包含空格的值应被引号包裹 assert '"host=localhost dbname=test"' in result