test(schedule): add layer-boundary smoke tests (stage 7)

Pin the new five-layer contract so a later refactor can't silently break
an import surface or lifecycle:

- domain: ExecutionResult defaults, terminal/failed state-set invariants,
  naive_utc normalization (import-side-effect-free)
- infrastructure.storage: SchedulerStorageClient base64 upload via
  httpx.MockTransport (no live server; base_url required for relative URL)
- scheduling: CronScheduler start/close lifecycle, global trigger cleared
- application: SchedulerService wires cron + orchestrator + worker, handler
  dispatch table points at the wired NodeExecutor
- execution: schedule.notebook_runner shim re-exports the real main

25 schedule tests green; schedule/pyproject.toml untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-09-02 10:10:41 +08:00
committed by tao.chen
co-authored by Claude
parent b6799a8a4d
commit ff03a79938
+177
View File
@@ -0,0 +1,177 @@
"""Layer-boundary smoke tests for the schedule service refactor.
The 11 flat files under ``schedule/src/schedule/`` were reorganized into five
subpackages (domain / scheduling / application / execution / infrastructure)
with **zero behavior change**. These tests pin the new *boundaries* so a later
refactor can't silently break an import surface or a lifecycle contract:
- ``domain`` — pure constants / dataclass, import-side-effect-free
- ``infrastructure.storage`` — SchedulerStorageClient uploads via the backend
storage API (httpx MockTransport, no live server)
- ``scheduling`` — CronScheduler start/close lifecycle
- ``application`` — SchedulerService wires cron + orchestrator + worker
- ``execution`` — the ``schedule.notebook_runner`` shim still points at the
real runner (the worker's ``-m`` subprocess string depends on it)
All mocks / tmp only — no MySQL, no real HTTP.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import MagicMock
import httpx
import pytest
from schedule.application.service import SchedulerService
from schedule.domain.context import (
FAILED_NODE_STATES,
TERMINAL_NODE_STATES,
TERMINAL_RUN_STATES,
naive_utc,
)
from schedule.domain.execution import ExecutionResult
from schedule.execution import NodeExecutor
from schedule.infrastructure.storage import SchedulerStorageClient
from schedule.scheduling.orchestrator import DispatchOrchestrator
from schedule.scheduling.scheduler import CronScheduler
# ── domain: pure types / constants ────────────────────────────────────────
def test_execution_result_has_sane_defaults() -> None:
result = ExecutionResult(
status="succeeded",
exit_code=0,
logs=b"ok\n",
result=b"{}",
result_file_name="out.json",
result_content_type="application/json",
)
assert result.status == "succeeded"
assert result.exit_code == 0
# Optional error fields default to None, not to a sentinel.
assert result.error_code is None
assert result.error_message is None
def test_terminal_and_failed_node_state_sets() -> None:
assert isinstance(TERMINAL_NODE_STATES, frozenset)
assert isinstance(FAILED_NODE_STATES, frozenset)
assert "succeeded" in TERMINAL_NODE_STATES
assert "failed" in TERMINAL_NODE_STATES
# Every failed state must also be terminal — otherwise the DAG would
# treat a dead node as still running.
assert FAILED_NODE_STATES.issubset(TERMINAL_NODE_STATES)
assert TERMINAL_RUN_STATES.issubset(TERMINAL_NODE_STATES)
def test_naive_utc_normalizes_to_utc_naive() -> None:
# 20:00 +08:00 == 12:00 UTC; the tz must be stripped afterwards.
aware = datetime(2026, 8, 21, 20, 0, tzinfo=timezone(timedelta(hours=8)))
assert naive_utc(aware) == datetime(2026, 8, 21, 12, 0)
assert naive_utc(aware).tzinfo is None
assert naive_utc(None) is None
# ── infrastructure: storage client (no live server) ───────────────────────
def test_storage_client_create_object_uploads_base64() -> None:
import base64
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/internal/v1/objects"
payload = json.loads(request.content)
assert payload["usage_type"] == "run_log"
assert payload["file_name"] == "run.log"
assert payload["is_immutable"] is True
# The raw bytes must round-trip through base64 in the JSON body.
assert base64.b64decode(payload["content_base64"]) == b"hello\n"
return httpx.Response(
200,
json={"data": {"storage_object_id": "obj-1", "storage_uri": "s3://x"}},
)
transport = httpx.MockTransport(handler)
async def upload() -> dict[str, object]:
# base_url is required: a relative path + MockTransport fails to
# normalize the response URL in this httpx version.
async with httpx.AsyncClient(
transport=transport, base_url="http://testserver"
) as http:
storage = SchedulerStorageClient(http)
return await storage.create_object(
workspace_id="01WS0000000000000000000001",
user_id="01USR0000000000000000000A",
usage_type="run_log",
file_name="run.log",
content_type="text/plain",
content=b"hello\n",
idempotency_key="run:node:1",
)
data = __import__("asyncio").run(upload())
assert data["storage_object_id"] == "obj-1"
# ── scheduling: cron lifecycle (no MySQL touched) ──────────────────────────
@pytest.mark.asyncio
async def test_cron_scheduler_start_close_lifecycle() -> None:
"""start()/close() must not raise and must release the global trigger."""
scheduler = CronScheduler(
session_factory=MagicMock(),
database_url="sqlite://",
on_trigger=MagicMock(),
)
scheduler.start()
await scheduler.close()
# close() clears the module-level callback so a stopped service can't
# dispatch persisted cron ticks.
from schedule.scheduling import scheduler as scheduler_module
assert scheduler_module._ACTIVE_TRIGGER is None
# ── application: service assembly wires all three components ───────────────
@pytest.mark.asyncio
@pytest.mark.filterwarnings(
"ignore:coroutine .* was never awaited:RuntimeWarning"
)
async def test_scheduler_service_wires_components_and_lifecycle() -> None:
service = SchedulerService(
session_factory=MagicMock(),
storage_http_client=MagicMock(),
object_store=MagicMock(),
storage_client=MagicMock(),
database_url="sqlite://",
)
assert isinstance(service.cron, CronScheduler)
assert isinstance(service.worker, NodeExecutor)
assert isinstance(service.orchestrator, DispatchOrchestrator)
# orchestrator's dispatch table must reference the wired worker handler.
# (Bound methods create a fresh object per access, so compare __self__.)
assert service.orchestrator._node_execute_handler.__self__ is service.worker
await service.start()
await service.close()
assert service.orchestrator._loop_task is None
# ── execution: the notebook_runner shim is the real runner ─────────────────
def test_notebook_runner_shim_reexports_real_main() -> None:
from schedule.execution.runners import notebook as real
from schedule.notebook_runner import main as shim_main
assert shim_main is real.main