"""Tests for ``python -m app.sync.cli`` (M6 Part B). The CLI's network / DB / scheduler-loop paths are exercised manually (they need real env + a running DB). These tests verify the testable surface: * ``--help`` and the subcommand parsers build without error. * The ``sync`` command dispatches into :class:`SyncScheduler.run_once` with the right entity and prints a summary line. * An unknown subcommand / bad ``--entity`` exits non-zero. * Env-var fallbacks (``FULL_SYNC_HOUR`` / ``MESSAGE_INTERVAL_HOURS``) work. """ from __future__ import annotations import asyncio from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest from app.sync import cli as cli_module from app.sync.orchestrator import SyncResult # --------------------------------------------------------------------------- # Argument parser # --------------------------------------------------------------------------- def test_parser_help_lists_both_subcommands(capsys): """``--help`` mentions both ``sync`` and ``scheduler``.""" with pytest.raises(SystemExit) as exc: cli_module._build_parser().parse_args(["--help"]) assert exc.value.code == 0 out = capsys.readouterr().out assert "sync" in out assert "scheduler" in out def test_parser_requires_subcommand(): """No subcommand -> argparse errors (exit code 2).""" with pytest.raises(SystemExit) as exc: cli_module._build_parser().parse_args([]) assert exc.value.code == 2 def test_parser_sync_entity_choices_match_registry(): """``--entity`` choices mirror the scheduler registry.""" parser = cli_module._build_parser() args = parser.parse_args(["sync", "--entity", "friends"]) assert args.command == "sync" assert args.entity == "friends" def test_parser_rejects_unknown_entity(): """An unknown entity name is rejected by argparse (exit code 2).""" with pytest.raises(SystemExit) as exc: cli_module._build_parser().parse_args(["sync", "--entity", "nope"]) assert exc.value.code == 2 def test_parser_scheduler_flags_default_none(): """``--full-sync-hour`` and ``--message-interval-hours`` default to None (env-fallback path).""" args = cli_module._build_parser().parse_args(["scheduler"]) assert args.full_sync_hour is None assert args.message_interval_hours is None # --------------------------------------------------------------------------- # main() — exit codes # --------------------------------------------------------------------------- def test_main_returns_2_on_unknown_subcommand(capsys): """An unknown subcommand surfaces as exit code 2 (argparse).""" with pytest.raises(SystemExit) as exc: cli_module.main(["bogus"]) assert exc.value.code == 2 def test_main_value_error_becomes_exit_2(monkeypatch, capsys): """A ValueError surfaced from a command (e.g. unknown entity) is exit 2.""" async def _boom(_args): raise ValueError("unknown entity: does_not_exist") monkeypatch.setattr(cli_module, "_cmd_sync", _boom) rc = cli_module.main(["sync", "--entity", "friends"]) assert rc == 2 err = capsys.readouterr().err assert "unknown entity" in err def test_main_generic_exception_becomes_exit_1(monkeypatch): """Any non-ValueError exception is exit 1 (don't print misleading usage).""" async def _boom(_args): raise RuntimeError("kaboom") monkeypatch.setattr(cli_module, "_cmd_sync", _boom) rc = cli_module.main(["sync"]) assert rc == 1 # --------------------------------------------------------------------------- # _cmd_sync — dispatch + summary printing # --------------------------------------------------------------------------- def test_cmd_sync_dispatches_run_once_and_prints_summary(monkeypatch, capsys): """``sync`` builds a SyncScheduler, calls run_once(), prints a summary.""" # ``_cmd_sync`` is async — wrap a coroutine-returning stub via patch. fake_results = [ SyncResult(entity="wechat_friend", status="success", fetched=3, upserted=3), SyncResult(entity="message", status="success", fetched=10, upserted=10, channel="wechat"), ] async def _fake_init_db(_db_url): engine = MagicMock() engine.dispose = AsyncMock() sm = MagicMock() return engine, sm scheduler_mock = MagicMock() scheduler_mock.run_once = AsyncMock(return_value=list(fake_results)) def _ctor(*, engine, session_maker): # Returned to _cmd_sync which then calls .run_once() on it. return scheduler_mock monkeypatch.setattr(cli_module, "_init_db", _fake_init_db) monkeypatch.setattr(cli_module, "SyncScheduler", _ctor) args = cli_module._build_parser().parse_args(["sync", "--entity", "friends"]) rc = asyncio.run(cli_module._cmd_sync(args)) assert rc == 0 scheduler_mock.run_once.assert_awaited_once_with("friends") out = capsys.readouterr().out assert "[sync] friends" in out assert "fetched=13" in out assert "upserted=13" in out def test_cmd_sync_partial_failure_is_exit_0(monkeypatch): """If at least one sub-result succeeded, exit 0 (partial delivery).""" fake_results = [ SyncResult(entity="department", status="success", fetched=2, upserted=2), SyncResult(entity="wechat_friend", status="failed", fetched=0, upserted=0, error="x"), ] async def _fake_init_db(_db_url): engine = MagicMock() engine.dispose = AsyncMock() return engine, MagicMock() scheduler_mock = MagicMock() scheduler_mock.run_once = AsyncMock(return_value=list(fake_results)) monkeypatch.setattr(cli_module, "_init_db", _fake_init_db) monkeypatch.setattr(cli_module, "SyncScheduler", lambda **kw: scheduler_mock) args = cli_module._build_parser().parse_args(["sync"]) rc = asyncio.run(cli_module._cmd_sync(args)) assert rc == 0 def test_cmd_sync_all_failed_is_exit_1(monkeypatch): """Every sub-run failed -> exit 1 (nothing useful delivered).""" fake_results = [ SyncResult(entity="department", status="failed", fetched=0, upserted=0, error="x"), SyncResult(entity="wechat_friend", status="failed", fetched=0, upserted=0, error="y"), ] async def _fake_init_db(_db_url): engine = MagicMock() engine.dispose = AsyncMock() return engine, MagicMock() scheduler_mock = MagicMock() scheduler_mock.run_once = AsyncMock(return_value=list(fake_results)) monkeypatch.setattr(cli_module, "_init_db", _fake_init_db) monkeypatch.setattr(cli_module, "SyncScheduler", lambda **kw: scheduler_mock) args = cli_module._build_parser().parse_args(["sync"]) rc = asyncio.run(cli_module._cmd_sync(args)) assert rc == 1 # --------------------------------------------------------------------------- # Env-var fallbacks for the scheduler command # --------------------------------------------------------------------------- def test_env_int_returns_default_when_missing(monkeypatch): """Missing env var -> default.""" monkeypatch.delenv("FULL_SYNC_HOUR", raising=False) assert cli_module._env_int("FULL_SYNC_HOUR", default=42) == 42 def test_env_int_parses_value(monkeypatch): """A clean int env var is parsed.""" monkeypatch.setenv("FULL_SYNC_HOUR", "13") assert cli_module._env_int("FULL_SYNC_HOUR", default=2) == 13 def test_env_int_falls_back_on_garbage(monkeypatch, capsys): """Non-int env var falls back to default + prints a warning.""" monkeypatch.setenv("FULL_SYNC_HOUR", "two") assert cli_module._env_int("FULL_SYNC_HOUR", default=2) == 2 err = capsys.readouterr().err assert "FULL_SYNC_HOUR" in err # --------------------------------------------------------------------------- # _init_db — create_all is invoked # --------------------------------------------------------------------------- async def test_init_db_creates_tables(): """_init_db returns an engine + session maker and runs create_all. Uses an in-memory SQLite engine via the real SQLAlchemy path so the create_all call is exercised end-to-end (not mocked). """ from sqlalchemy import text engine, sm = await cli_module._init_db("sqlite+aiosqlite:///:memory:") try: # Tables created: customer is one of the registered tables. from app.db.base import Base assert "customer" in Base.metadata.tables # engine + session_maker both usable (no exception = pass). async with sm() as s: await s.execute(text("SELECT 1")) finally: await engine.dispose()