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:
@@ -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
|
||||||
Reference in New Issue
Block a user