feat: scheduler + CLI + docker-compose + README
M6 — the scheduling/deployment layer. Makes the system runnable as a
scheduled service + deployable via Docker.
Part A — SyncScheduler (app/sync/scheduler.py):
- APScheduler (AsyncIOScheduler) wraps the orchestrator's sync_* functions.
- Daily full sync at FULL_SYNC_HOUR (default 02:00 local) + incremental
message sync every MESSAGE_INTERVAL_HOURS (default 4).
- ENTITY_FUNCS registry dispatches run_once("<entity>") to the right runner
(departments/accounts/friends/groups/voice_calls/phone_calls/sms/messages/
all_wechat/all).
- Conversations for the message run are discovered from the local customer
mirror (no extra upstream call).
- Failure policy: every run is wrapped — a blown entity is logged + swallowed
so the scheduler keeps its next appointment.
- Per-run JSON-line log (level=info/warning/error, totals, failed entities).
Part B — CLI entry point (app/sync/cli.py):
- `python -m app.sync.cli sync [--entity <name>] [--quiet]` — one-shot sync.
- `python -m app.sync.cli scheduler [--full-sync-hour H] [--message-interval-hours N]`
— long-running scheduler (blocks until SIGINT/SIGTERM).
- Uses _push_settings_into_environ + build_client_from_env (session-cookie
mode preferred; pcLogin deferred).
- Base.metadata.create_all on every command (fresh-checkout friendly).
- Exit codes: 0 success, 1 generic failure, 2 bad CLI / auth misconfig.
Part C — Docker stack:
- yunke-server/Dockerfile: python:3.12-slim, installs deps + app, single
image serves both API (uvicorn) + scheduler (CLI).
- yunkes/Dockerfile: multi-stage node:20 build → nginx serves dist/ +
reverse-proxies /api → backend:8000.
- yunkes/nginx.conf: /api proxy with proxy_buffering off (streaming media),
SPA fallback, asset caching.
- docker-compose.yml: mysql (8.0, healthcheck, volume) + redis (7-alpine) +
backend + scheduler + frontend (port 5173 → 80). Env-driven creds + cadence.
Part D — README.md at repo root: dev mode + docker mode quick-start, config
table, architecture overview, current limitations (pcLogin deferred, WA voice/
media deferred, conservative pacing, conversation discovery is local-only).
Verification:
- pytest: 323 passed (was 294; +29 new for scheduler + CLI).
- npm run build: clean.
- python -m app.sync.cli --help: prints usage.
- docker compose config: VALID.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
"""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(<entity>), 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()
|
||||
@@ -0,0 +1,389 @@
|
||||
"""Tests for app.sync.scheduler — periodic-sync wrapper (M6 Part A).
|
||||
|
||||
These tests do NOT hit the network and do NOT start a real APScheduler loop
|
||||
(that path is exercised manually via ``python -m app.sync.cli scheduler``).
|
||||
They verify the testable surface of :class:`SyncScheduler`:
|
||||
|
||||
* ``run_once`` dispatches by entity name to the right orchestrator function.
|
||||
* ``run_once("<unknown>")`` raises ``ValueError`` with the known-entity list.
|
||||
* ``_discover_conversations`` reads distinct WeChat pairs from the DB.
|
||||
* ``_log_run`` emits one JSON line per run with sensible totals + level.
|
||||
* The daily vs. message-every-N-hours schedule is registered with the right
|
||||
triggers when :meth:`start` is called (using a fake scheduler fixture).
|
||||
* Failures inside a runner are swallowed and logged, not re-raised.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.models import Customer
|
||||
from app.sync import scheduler as scheduler_module
|
||||
from app.sync.scheduler import (
|
||||
ENTITY_FUNCS,
|
||||
SyncScheduler,
|
||||
_discover_conversations,
|
||||
_log_run,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _make_engine():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
return engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def engine():
|
||||
eng = await _make_engine()
|
||||
try:
|
||||
yield eng
|
||||
finally:
|
||||
await eng.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session_maker(engine):
|
||||
return async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ENTITY_FUNCS registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_entity_funcs_has_expected_names():
|
||||
"""The registry exposes the documented manual-trigger entity names."""
|
||||
expected = {
|
||||
"departments",
|
||||
"accounts",
|
||||
"friends",
|
||||
"groups",
|
||||
"voice_calls",
|
||||
"phone_calls",
|
||||
"sms",
|
||||
"messages",
|
||||
"all_wechat",
|
||||
"all",
|
||||
}
|
||||
assert expected <= set(ENTITY_FUNCS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _discover_conversations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_discover_conversations_returns_distinct_wechat_pairs(session_maker):
|
||||
"""Distinct (owner, friend_id) for channel='wechat' only; non-wechat + nulls dropped."""
|
||||
async with session_maker() as s:
|
||||
s.add_all(
|
||||
[
|
||||
Customer(
|
||||
friend_id="f1",
|
||||
channel="wechat",
|
||||
owner_account_id="wx_a",
|
||||
),
|
||||
Customer(
|
||||
friend_id="f2",
|
||||
channel="wechat",
|
||||
owner_account_id="wx_a",
|
||||
),
|
||||
Customer(
|
||||
friend_id="f3",
|
||||
channel="wechat",
|
||||
owner_account_id="wx_b",
|
||||
),
|
||||
# Different channel — should be excluded.
|
||||
Customer(
|
||||
friend_id="p1@s.whatsapp.net",
|
||||
channel="whatsapp",
|
||||
owner_account_id="wx_a",
|
||||
),
|
||||
# Null owner — should be excluded.
|
||||
Customer(
|
||||
friend_id="orphan",
|
||||
channel="wechat",
|
||||
owner_account_id=None,
|
||||
),
|
||||
]
|
||||
)
|
||||
await s.commit()
|
||||
|
||||
pairs = await _discover_conversations(s)
|
||||
assert sorted(pairs) == sorted([("wx_a", "f1"), ("wx_a", "f2"), ("wx_b", "f3")])
|
||||
|
||||
|
||||
async def test_discover_conversations_respects_limit(session_maker):
|
||||
"""``limit`` caps the work per message-sync run."""
|
||||
async with session_maker() as s:
|
||||
for i in range(10):
|
||||
s.add(
|
||||
Customer(
|
||||
friend_id=f"f{i}",
|
||||
channel="wechat",
|
||||
owner_account_id="wx_a",
|
||||
)
|
||||
)
|
||||
await s.commit()
|
||||
|
||||
pairs = await _discover_conversations(s, limit=3)
|
||||
assert len(pairs) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _log_run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_log_run_emits_one_json_line(capsys, caplog):
|
||||
"""One JSON line, level=info when all sub-runs succeed."""
|
||||
results = [
|
||||
_mk_result("department", "success", 5, 5),
|
||||
_mk_result("wechat_friend", "success", 3, 3),
|
||||
]
|
||||
with caplog.at_level(logging.INFO, logger="app.sync.scheduler"):
|
||||
_log_run("all", results, started_at=dt.datetime.now(dt.UTC))
|
||||
out = capsys.readouterr().out.strip().splitlines()
|
||||
assert len(out) == 1
|
||||
payload = json.loads(out[0])
|
||||
assert payload["event"] == "sync_run"
|
||||
assert payload["entity"] == "all"
|
||||
assert payload["level"] == "info"
|
||||
assert payload["totals"] == {"fetched": 8, "upserted": 8, "count": 2}
|
||||
assert payload["failed_entities"] == []
|
||||
assert len(payload["results"]) == 2
|
||||
|
||||
|
||||
def test_log_run_level_warning_when_any_failed(capsys):
|
||||
"""A failed sub-result bumps the level to warning + lists the failed entity."""
|
||||
results = [
|
||||
_mk_result("department", "success", 5, 5),
|
||||
_mk_result("wechat_friend", "failed", 0, 0, error="boom"),
|
||||
]
|
||||
_log_run("all", results, started_at=dt.datetime.now(dt.UTC))
|
||||
payload = json.loads(capsys.readouterr().out.strip())
|
||||
assert payload["level"] == "warning"
|
||||
assert payload["failed_entities"] == ["wechat_friend"]
|
||||
assert payload["totals"]["fetched"] == 5
|
||||
|
||||
|
||||
def test_log_run_level_error_on_top_level_failure(capsys):
|
||||
"""A top-level error (no results at all) -> level=error + error field."""
|
||||
_log_run("all", [], started_at=dt.datetime.now(dt.UTC), error="AuthFailed")
|
||||
payload = json.loads(capsys.readouterr().out.strip())
|
||||
assert payload["level"] == "error"
|
||||
assert payload["error"] == "AuthFailed"
|
||||
assert payload["totals"]["count"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SyncScheduler.run_once — dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_run_once_unknown_entity_raises(engine, session_maker):
|
||||
"""An unknown entity name raises ValueError listing the known entities."""
|
||||
sched = SyncScheduler(engine=engine, session_maker=session_maker)
|
||||
with pytest.raises(ValueError, match="unknown entity"):
|
||||
await sched.run_once("does_not_exist")
|
||||
|
||||
|
||||
async def test_run_once_dispatches_to_entity_runner(engine, session_maker, monkeypatch):
|
||||
"""run_once('friends') calls _run_friends (which calls sync_wechat_friends)."""
|
||||
# Stub _build_client_from_env so no network / login happens.
|
||||
fake_client = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
"_build_client_from_env",
|
||||
AsyncMock(return_value=fake_client),
|
||||
)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_runner(session, client, store):
|
||||
captured["session"] = session
|
||||
captured["client"] = client
|
||||
captured["store"] = store
|
||||
return [_mk_result("wechat_friend", "success", 7, 7)]
|
||||
|
||||
# Patch the registry entry, not the orchestrator function.
|
||||
monkeypatch.setitem(ENTITY_FUNCS, "friends", fake_runner)
|
||||
|
||||
sched = SyncScheduler(engine=engine, session_maker=session_maker)
|
||||
results = await sched.run_once("friends")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].entity == "wechat_friend"
|
||||
assert results[0].fetched == 7
|
||||
assert captured["client"] is fake_client
|
||||
assert captured["store"] is not None
|
||||
|
||||
|
||||
async def test_run_once_swallows_runner_exception(engine, session_maker, monkeypatch, capsys):
|
||||
"""A blown-up runner is logged + swallowed (returns []; scheduler keeps going)."""
|
||||
fake_client = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
"_build_client_from_env",
|
||||
AsyncMock(return_value=fake_client),
|
||||
)
|
||||
|
||||
async def boom(session, client, store):
|
||||
raise RuntimeError("upstream 500")
|
||||
|
||||
monkeypatch.setitem(ENTITY_FUNCS, "groups", boom)
|
||||
|
||||
sched = SyncScheduler(engine=engine, session_maker=session_maker)
|
||||
# Must NOT raise.
|
||||
results = await sched.run_once("groups")
|
||||
assert results == []
|
||||
out = capsys.readouterr().out
|
||||
assert "sync_run" in out
|
||||
payload = json.loads(out.strip().splitlines()[-1])
|
||||
assert payload["level"] == "error"
|
||||
assert "upstream 500" in payload["error"]
|
||||
|
||||
|
||||
async def test_run_once_swallows_client_build_failure(engine, session_maker, monkeypatch):
|
||||
"""If auth env is missing -> _build_client_from_env raises -> logged + empty list."""
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
"_build_client_from_env",
|
||||
AsyncMock(side_effect=RuntimeError("no auth configured")),
|
||||
)
|
||||
sched = SyncScheduler(engine=engine, session_maker=session_maker)
|
||||
results = await sched.run_once("all")
|
||||
assert results == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SyncScheduler.start — job registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_start_registers_two_jobs_with_expected_triggers(engine, session_maker):
|
||||
"""start() wires a daily CronTrigger (full) + an IntervalTrigger (messages)."""
|
||||
sched = SyncScheduler(
|
||||
full_sync_hour=3,
|
||||
message_interval_hours=6,
|
||||
engine=engine,
|
||||
session_maker=session_maker,
|
||||
)
|
||||
# Replace the real AsyncIOScheduler with a spy before start() creates it,
|
||||
# so we can assert on add_job without running the loop.
|
||||
spy = MagicMock()
|
||||
spy.add_job = MagicMock()
|
||||
with patch.object(scheduler_module, "AsyncIOScheduler", return_value=spy):
|
||||
await sched.start()
|
||||
|
||||
# Two jobs registered.
|
||||
assert spy.add_job.call_count == 2
|
||||
job_ids = {c.kwargs["id"] for c in spy.add_job.call_args_list}
|
||||
assert job_ids == {"yunke_full_sync", "yunke_message_sync"}
|
||||
|
||||
# Full sync -> CronTrigger(hour=3); messages -> IntervalTrigger(hours=6).
|
||||
# add_job was called as (func, trigger, kwargs...) so trigger is args[1].
|
||||
full_call = next(
|
||||
c for c in spy.add_job.call_args_list if c.kwargs["id"] == "yunke_full_sync"
|
||||
)
|
||||
full_trigger = full_call.args[1]
|
||||
# CronTrigger exposes the parsed schedule via str().
|
||||
assert str(full_trigger) == "cron[hour='3']"
|
||||
|
||||
msg_call = next(
|
||||
c for c in spy.add_job.call_args_list if c.kwargs["id"] == "yunke_message_sync"
|
||||
)
|
||||
interval = msg_call.args[1]
|
||||
# IntervalTrigger exposes its interval as a timedelta on .interval`.
|
||||
assert interval.interval.total_seconds() == 6 * 3600
|
||||
|
||||
# The full-sync job passes args=["all"] (so it hits _run_all).
|
||||
assert full_call.kwargs["args"] == ["all"]
|
||||
assert msg_call.kwargs["args"] == ["messages"]
|
||||
|
||||
# coalesce=True + max_instances=1 protects against overlap.
|
||||
for c in spy.add_job.call_args_list:
|
||||
assert c.kwargs["coalesce"] is True
|
||||
assert c.kwargs["max_instances"] == 1
|
||||
|
||||
|
||||
async def test_start_is_idempotent(engine, session_maker):
|
||||
"""A second start() is a no-op (does not register a second scheduler)."""
|
||||
sched = SyncScheduler(engine=engine, session_maker=session_maker)
|
||||
with patch.object(scheduler_module, "AsyncIOScheduler", return_value=MagicMock()):
|
||||
await sched.start()
|
||||
first = sched._scheduler
|
||||
await sched.start() # second call — ignored.
|
||||
assert sched._scheduler is first
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stop / teardown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_stop_disposes_owned_engine():
|
||||
"""When the scheduler owns its engine, stop() disposes it."""
|
||||
# Patch create_async_engine so we don't try to connect to a real DB.
|
||||
fake_engine = MagicMock()
|
||||
fake_engine.dispose = AsyncMock()
|
||||
fake_engine.begin = MagicMock()
|
||||
# start() will call engine.begin() to create tables; fake it.
|
||||
class _Ctx:
|
||||
async def __aenter__(self):
|
||||
conn = MagicMock()
|
||||
conn.run_sync = AsyncMock()
|
||||
return conn
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return None
|
||||
|
||||
fake_engine.begin.return_value = _Ctx()
|
||||
|
||||
with patch.object(scheduler_module, "create_async_engine", return_value=fake_engine):
|
||||
sched = SyncScheduler()
|
||||
assert sched._owns_engine is True
|
||||
await sched.stop()
|
||||
fake_engine.dispose.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_stop_does_not_dispose_injected_engine(engine, session_maker):
|
||||
"""An engine injected by the caller is NOT disposed on stop()."""
|
||||
sched = SyncScheduler(engine=engine, session_maker=session_maker)
|
||||
assert sched._owns_engine is False
|
||||
await sched.stop() # must not dispose — caller (test fixture) owns it.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mk_result(
|
||||
entity: str, status: str, fetched: int, upserted: int, *, error: str | None = None):
|
||||
from app.sync.orchestrator import SyncResult
|
||||
|
||||
return SyncResult(
|
||||
entity=entity,
|
||||
status=status,
|
||||
fetched=fetched,
|
||||
upserted=upserted,
|
||||
error=error,
|
||||
)
|
||||
Reference in New Issue
Block a user