1992 lines
65 KiB
Python
1992 lines
65 KiB
Python
from __future__ import annotations
|
|
|
|
import http.client
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import threading
|
|
from contextlib import contextmanager
|
|
from datetime import date, datetime, timezone
|
|
from http import HTTPStatus
|
|
from pathlib import Path
|
|
from typing import Any, Iterator, TextIO
|
|
|
|
import psutil
|
|
import pytest
|
|
|
|
import gyxx_flow.console as console_module
|
|
from gyxx_flow.catalog import ScheduleEntry, WorkflowCatalog, WorkflowEntry
|
|
from gyxx_flow.console import (
|
|
MAX_REQUEST_BYTES,
|
|
ConsoleConflictError,
|
|
ConsoleNotFoundError,
|
|
ConsolePreconditionError,
|
|
ConsoleRequestError,
|
|
ScheduleConfigStore,
|
|
SubprocessConsoleRunLauncher,
|
|
WorkflowConsoleService,
|
|
create_console_server,
|
|
)
|
|
from gyxx_flow.core.config import Settings
|
|
from gyxx_flow.core.context import RunContext
|
|
from gyxx_flow.core.layout import DataLayout
|
|
from gyxx_flow.core.records import RunJournal
|
|
from gyxx_flow.ops import RunIndex
|
|
from gyxx_flow.scheduler_service import PythonScheduler
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
@pytest.fixture
|
|
def console_settings(tmp_path: Path) -> Settings:
|
|
project_root = tmp_path / "project"
|
|
config_dir = project_root / "config"
|
|
config_dir.mkdir(parents=True)
|
|
for name in ("workflows.json", "schedules.json"):
|
|
shutil.copyfile(PROJECT_ROOT / "config" / name, config_dir / name)
|
|
return Settings(project_root=project_root, data_root=tmp_path / "data")
|
|
|
|
|
|
class FakeConsoleLauncher:
|
|
def __init__(self) -> None:
|
|
self.calls: list[dict[str, object]] = []
|
|
self._active: dict[str, dict[str, object]] = {}
|
|
self._cancelled: dict[str, dict[str, object]] = {}
|
|
|
|
def launch(
|
|
self,
|
|
workflow_id: str,
|
|
business_date: date,
|
|
*,
|
|
execute: bool,
|
|
shadow: bool,
|
|
) -> dict[str, object]:
|
|
if workflow_id in self._active:
|
|
raise ConsoleConflictError("already active")
|
|
operation = {
|
|
"operation_id": f"op-{len(self.calls) + 1:016x}",
|
|
"workflow_id": workflow_id,
|
|
"business_date": business_date.isoformat(),
|
|
"mode": "execute" if execute else "dry_run",
|
|
"shadow": shadow,
|
|
"started_at": "2026-08-01T00:00:00+00:00",
|
|
"status": "accepted",
|
|
}
|
|
self.calls.append(operation)
|
|
self._active[workflow_id] = operation
|
|
return dict(operation)
|
|
|
|
def active(self) -> tuple[dict[str, object], ...]:
|
|
return tuple(dict(item) for item in self._active.values())
|
|
|
|
def cancel(
|
|
self,
|
|
workflow_id: str,
|
|
operation_id: str,
|
|
) -> dict[str, object]:
|
|
operation = self._active.get(workflow_id)
|
|
if operation is None:
|
|
previous = self._cancelled.get(operation_id)
|
|
if previous is not None and previous["workflow_id"] == workflow_id:
|
|
return {**previous, "already_cancelled": True}
|
|
raise ConsoleConflictError("not active")
|
|
if operation["operation_id"] != operation_id:
|
|
raise ConsoleConflictError("not active")
|
|
del self._active[workflow_id]
|
|
operation.update(
|
|
{
|
|
"status": "cancelled",
|
|
"already_cancelled": False,
|
|
"ended_at": "2026-08-01T00:01:00+00:00",
|
|
}
|
|
)
|
|
self._cancelled[operation_id] = dict(operation)
|
|
return dict(operation)
|
|
|
|
def complete(self, workflow_id: str) -> None:
|
|
self._active.pop(workflow_id, None)
|
|
|
|
|
|
def _read_config(settings: Settings, name: str) -> dict[str, Any]:
|
|
return json.loads(
|
|
(settings.project_root / "config" / name).read_text(encoding="utf-8")
|
|
)
|
|
|
|
|
|
def _write_config(settings: Settings, name: str, payload: dict[str, Any]) -> None:
|
|
(settings.project_root / "config" / name).write_text(
|
|
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _append_non_scheduled_workflow(
|
|
settings: Settings,
|
|
*,
|
|
workflow_id: str,
|
|
module: str,
|
|
trigger: str,
|
|
) -> None:
|
|
workflows = _read_config(settings, "workflows.json")
|
|
workflows["workflows"].append(
|
|
{
|
|
"id": workflow_id,
|
|
"module": module,
|
|
"trigger": trigger,
|
|
"execution": {"entry": "console_probe.py"},
|
|
"provenance": {"source_project": "console-test"},
|
|
}
|
|
)
|
|
_write_config(settings, "workflows.json", workflows)
|
|
|
|
|
|
def _schedule_row(settings: Settings, workflow_id: str) -> dict[str, Any]:
|
|
payload = _read_config(settings, "schedules.json")
|
|
return next(
|
|
item for item in payload["schedules"] if item["workflow_id"] == workflow_id
|
|
)
|
|
|
|
|
|
def _index_failed_run(settings: Settings, workflow_id: str) -> str:
|
|
context = RunContext.create(
|
|
workflow_id,
|
|
"2026-08-01",
|
|
now=datetime(2026, 8, 1, 1, 2, 3, tzinfo=timezone.utc),
|
|
random_suffix="console1",
|
|
)
|
|
journal = RunJournal.create(
|
|
DataLayout(settings.data_root),
|
|
context,
|
|
mode="execute",
|
|
)
|
|
journal.finalize("failed", error="critical steps failed: collect")
|
|
RunIndex(settings.data_root).index_journal(journal)
|
|
return context.run_id
|
|
|
|
|
|
def _index_parallel_main_image_run(settings: Settings) -> str:
|
|
context = RunContext.create(
|
|
"product.main_image.weekly",
|
|
"2026-08-02",
|
|
now=datetime(2026, 8, 2, 1, 2, 3, tzinfo=timezone.utc),
|
|
random_suffix="parallel1",
|
|
)
|
|
journal = RunJournal.create(DataLayout(settings.data_root), context)
|
|
journal.start_step("jd.attempt-1", attempt=1)
|
|
journal.finish_step(
|
|
"jd.attempt-1",
|
|
status="failed",
|
|
exit_code=9,
|
|
error="JD browser failed " + "pass" + "word=" + "private" + "-value",
|
|
)
|
|
journal.start_step("tmall.attempt-1", attempt=1)
|
|
journal.finish_step(
|
|
"tmall.attempt-1",
|
|
status="success",
|
|
exit_code=0,
|
|
)
|
|
journal.finalize("failed", error="critical steps failed: jd")
|
|
RunIndex(settings.data_root).index_journal(journal)
|
|
return context.run_id
|
|
|
|
|
|
def _index_running_metrics_run(settings: Settings) -> RunJournal:
|
|
context = RunContext.create(
|
|
"content.metrics.daily",
|
|
"2026-08-02",
|
|
now=datetime(2026, 8, 2, 2, 3, 4, tzinfo=timezone.utc),
|
|
random_suffix="running1",
|
|
)
|
|
journal = RunJournal.create(DataLayout(settings.data_root), context)
|
|
RunIndex(settings.data_root).index_journal(journal)
|
|
journal.start_step("collect_collaborators.attempt-1", attempt=1)
|
|
return journal
|
|
|
|
|
|
def test_error_sanitizer_redacts_structured_and_url_credentials() -> None:
|
|
key_a = "pass" + "word"
|
|
key_b = "access_" + "token"
|
|
key_c = "to" + "ken"
|
|
value_a = "hunter" + "2"
|
|
value_b = "abc" + "123"
|
|
value_c = "p4" + "ss"
|
|
value_d = "query-" + "private"
|
|
cases = (
|
|
(json.dumps({key_a: value_a}), value_a),
|
|
(repr({key_b: value_b}), value_b),
|
|
(f"postgresql://alice:{value_c}@db.local/app", value_c),
|
|
(f"https://service.local/run?{key_c}={value_d}&mode=1", value_d),
|
|
)
|
|
|
|
for raw_error, sensitive_value in cases:
|
|
sanitized = console_module._sanitize_error(raw_error)
|
|
assert sanitized is not None
|
|
assert sensitive_value not in sanitized
|
|
assert "[REDACTED]" in sanitized
|
|
|
|
|
|
def test_error_sanitizer_preserves_context_and_root_cause_tail() -> None:
|
|
raw_error = (
|
|
"collector startup context\n"
|
|
+ "progress line\n" * 1_000
|
|
+ "ROOT CAUSE AT LOG TAIL"
|
|
)
|
|
|
|
sanitized = console_module._sanitize_error(raw_error)
|
|
|
|
assert sanitized is not None
|
|
assert sanitized.startswith("collector startup context")
|
|
assert "[middle output omitted]" in sanitized
|
|
assert sanitized.endswith("ROOT CAUSE AT LOG TAIL")
|
|
assert len(sanitized) <= console_module.MAX_ERROR_CHARS
|
|
|
|
|
|
def test_overview_tracks_the_current_complete_catalog_without_leaking_paths(
|
|
console_settings: Settings,
|
|
) -> None:
|
|
launcher = FakeConsoleLauncher()
|
|
run_id = _index_failed_run(console_settings, "content.metrics.daily")
|
|
service = WorkflowConsoleService(console_settings, launcher=launcher)
|
|
|
|
initial = service.overview()
|
|
initial_ids = {item["id"] for item in initial["workflows"]}
|
|
catalog = WorkflowCatalog.load(console_settings.project_root / "config")
|
|
|
|
assert initial_ids == {item.workflow_id for item in catalog.workflows}
|
|
assert initial["summary"]["total"] == len(catalog.workflows)
|
|
assert {module["id"] for module in initial["modules"]} == {
|
|
"content_marketing",
|
|
"product_commerce",
|
|
"shop_intelligence",
|
|
"supply_chain",
|
|
}
|
|
assert sum(module["count"] for module in initial["modules"]) == len(
|
|
catalog.workflows
|
|
)
|
|
failed = next(
|
|
item for item in initial["workflows"] if item["id"] == "content.metrics.daily"
|
|
)
|
|
assert failed["last_run"]["run_id"] == run_id
|
|
assert failed["last_run"]["mode"] == "execute"
|
|
assert failed["last_run"]["error"] == "critical steps failed: collect"
|
|
assert all(step["name"] and step["description"] for step in failed["steps"])
|
|
assert {step["replay_policy"] for step in failed["steps"]} == {"guarded"}
|
|
shop = next(
|
|
item for item in initial["workflows"] if item["id"] == "shop.metrics.weekly"
|
|
)
|
|
assert {step["replay_policy"] for step in shop["steps"]} == {"idempotent"}
|
|
price_appeal = next(
|
|
item
|
|
for item in initial["workflows"]
|
|
if item["id"] == "shop.douyin_price_appeal"
|
|
)
|
|
assert price_appeal["name"] == "抖音待改价申诉"
|
|
assert price_appeal["trigger"] == "scheduled"
|
|
assert price_appeal["registered"] is True
|
|
assert price_appeal["schedule"]["at"] == ["08:00", "16:00", "22:00"]
|
|
assert price_appeal["steps"][0]["replay_policy"] == "idempotent"
|
|
assert price_appeal["steps"][0]["timeout_seconds"] == 1_800
|
|
jd_self_operated = next(
|
|
item
|
|
for item in initial["workflows"]
|
|
if item["id"] == "shop.jd_self_operated.daily"
|
|
)
|
|
assert [step["id"] for step in jd_self_operated["steps"]] == [
|
|
"brand",
|
|
"product",
|
|
]
|
|
assert [
|
|
step["timeout_seconds"] for step in jd_self_operated["steps"]
|
|
] == [600, 1_800]
|
|
monthly_sales = next(
|
|
item
|
|
for item in initial["workflows"]
|
|
if item["id"] == "product.sales_sheet.daily"
|
|
)
|
|
assert monthly_sales["run_date_mode"] == "month"
|
|
assert failed["run_date_mode"] == "date"
|
|
report = next(
|
|
item
|
|
for item in initial["workflows"]
|
|
if item["id"] == "content.marketing_report.daily"
|
|
)
|
|
report_flow = report["steps"][0]["data_flow"]
|
|
assert [source["system"] for source in report_flow["sources"]] == [
|
|
"PostgreSQL",
|
|
"飞书",
|
|
]
|
|
assert report_flow["processing"] == [
|
|
"聚合业务事实与款式表现",
|
|
"调用 Hermes 生成营销分析",
|
|
"生成图表和 Markdown 日报",
|
|
]
|
|
assert report_flow["destinations"][1]["condition"] == (
|
|
"正式执行且启用 --send"
|
|
)
|
|
assert "journal_path" not in json.dumps(initial, ensure_ascii=False)
|
|
|
|
workflows = _read_config(console_settings, "workflows.json")
|
|
workflows["workflows"].append(
|
|
{
|
|
"id": "content.console_probe",
|
|
"module": "content_marketing",
|
|
"trigger": "manual",
|
|
"execution": {"entry": "console_probe.py"},
|
|
"provenance": {"source_project": "content"},
|
|
}
|
|
)
|
|
_write_config(console_settings, "workflows.json", workflows)
|
|
|
|
refreshed = service.overview()
|
|
|
|
assert refreshed["summary"]["total"] == len(catalog.workflows) + 1
|
|
assert "content.console_probe" in {
|
|
item["id"] for item in refreshed["workflows"]
|
|
}
|
|
probe = next(
|
|
item for item in refreshed["workflows"] if item["id"] == "content.console_probe"
|
|
)
|
|
assert probe["registered"] is False
|
|
assert probe["schedule"] is None
|
|
|
|
|
|
def test_workflow_display_name_is_persistent_revision_safe_and_resettable(
|
|
console_settings: Settings,
|
|
) -> None:
|
|
service = WorkflowConsoleService(
|
|
console_settings,
|
|
launcher=FakeConsoleLauncher(),
|
|
)
|
|
workflow_id = "content.marketing_report.daily"
|
|
workflows_before = (console_settings.project_root / "config" / "workflows.json").read_bytes()
|
|
initial = service.overview()
|
|
workflow = next(item for item in initial["workflows"] if item["id"] == workflow_id)
|
|
|
|
assert workflow["name"] == "营销日报生成与发送"
|
|
assert workflow["default_name"] == workflow["name"]
|
|
assert workflow["name_customized"] is False
|
|
|
|
renamed = service.update_display_name(
|
|
workflow_id,
|
|
{"display_name": " 每日内容营销简报 "},
|
|
expected_revision=initial["workflow_names_revision"],
|
|
)
|
|
assert renamed["name"] == "每日内容营销简报"
|
|
assert renamed["name_customized"] is True
|
|
|
|
restarted = WorkflowConsoleService(
|
|
console_settings,
|
|
launcher=FakeConsoleLauncher(),
|
|
).overview()
|
|
persisted = next(
|
|
item for item in restarted["workflows"] if item["id"] == workflow_id
|
|
)
|
|
assert persisted["name"] == "每日内容营销简报"
|
|
assert persisted["default_name"] == "营销日报生成与发送"
|
|
assert persisted["name_customized"] is True
|
|
assert (
|
|
console_settings.project_root / "config" / "workflows.json"
|
|
).read_bytes() == workflows_before
|
|
|
|
state_path = (
|
|
console_settings.data_root
|
|
/ "state"
|
|
/ "console"
|
|
/ "workflow-display-names.json"
|
|
)
|
|
state_before_conflict = state_path.read_bytes()
|
|
with pytest.raises(ConsoleConflictError, match="已被更新"):
|
|
service.update_display_name(
|
|
workflow_id,
|
|
{"display_name": "过期窗口中的名称"},
|
|
expected_revision=initial["workflow_names_revision"],
|
|
)
|
|
assert state_path.read_bytes() == state_before_conflict
|
|
|
|
reset = service.update_display_name(
|
|
workflow_id,
|
|
{"display_name": None},
|
|
expected_revision=renamed["workflow_names_revision"],
|
|
)
|
|
assert reset["name"] == "营销日报生成与发送"
|
|
assert reset["name_customized"] is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"display_name",
|
|
[" ", "x" * 65, "包含\u200b隐藏字符"],
|
|
)
|
|
def test_workflow_display_name_rejects_unsafe_values(
|
|
console_settings: Settings,
|
|
display_name: str,
|
|
) -> None:
|
|
service = WorkflowConsoleService(
|
|
console_settings,
|
|
launcher=FakeConsoleLauncher(),
|
|
)
|
|
revision = service.overview()["workflow_names_revision"]
|
|
|
|
with pytest.raises(ConsoleRequestError):
|
|
service.update_display_name(
|
|
"content.marketing_report.daily",
|
|
{"display_name": display_name},
|
|
expected_revision=revision,
|
|
)
|
|
|
|
|
|
def test_workflow_display_name_rejects_invalid_request_boundaries(
|
|
console_settings: Settings,
|
|
) -> None:
|
|
service = WorkflowConsoleService(
|
|
console_settings,
|
|
launcher=FakeConsoleLauncher(),
|
|
)
|
|
revision = service.overview()["workflow_names_revision"]
|
|
|
|
with pytest.raises(ConsolePreconditionError):
|
|
service.update_display_name(
|
|
"content.marketing_report.daily",
|
|
{"display_name": "新名称"},
|
|
expected_revision=None,
|
|
)
|
|
with pytest.raises(ConsoleRequestError, match="只允许"):
|
|
service.update_display_name(
|
|
"content.marketing_report.daily",
|
|
{"display_name": "新名称", "id": "other.workflow"},
|
|
expected_revision=revision,
|
|
)
|
|
with pytest.raises(ConsoleNotFoundError):
|
|
service.update_display_name(
|
|
"content.unknown",
|
|
{"display_name": "新名称"},
|
|
expected_revision=revision,
|
|
)
|
|
|
|
|
|
def test_overview_falls_back_when_workflow_display_names_are_corrupt(
|
|
console_settings: Settings,
|
|
) -> None:
|
|
state_path = (
|
|
console_settings.data_root
|
|
/ "state"
|
|
/ "console"
|
|
/ "workflow-display-names.json"
|
|
)
|
|
state_path.parent.mkdir(parents=True)
|
|
state_path.write_text("{broken", encoding="utf-8")
|
|
service = WorkflowConsoleService(
|
|
console_settings,
|
|
launcher=FakeConsoleLauncher(),
|
|
)
|
|
|
|
overview = service.overview()
|
|
|
|
workflow = next(
|
|
item
|
|
for item in overview["workflows"]
|
|
if item["id"] == "content.marketing_report.daily"
|
|
)
|
|
assert workflow["name"] == "营销日报生成与发送"
|
|
assert workflow["name_customized"] is False
|
|
assert overview["warnings"] == [
|
|
"自定义工作流名称配置无效,当前显示默认名称"
|
|
]
|
|
before = state_path.read_bytes()
|
|
with pytest.raises(ConsoleRequestError, match="配置无效"):
|
|
service.update_display_name(
|
|
workflow["id"],
|
|
{"display_name": "不会覆盖损坏配置"},
|
|
expected_revision=overview["workflow_names_revision"],
|
|
)
|
|
assert state_path.read_bytes() == before
|
|
|
|
|
|
def test_overview_includes_sanitized_parallel_step_details(
|
|
console_settings: Settings,
|
|
) -> None:
|
|
run_id = _index_parallel_main_image_run(console_settings)
|
|
service = WorkflowConsoleService(
|
|
console_settings,
|
|
launcher=FakeConsoleLauncher(),
|
|
)
|
|
|
|
overview = service.overview()
|
|
workflow = next(
|
|
item
|
|
for item in overview["workflows"]
|
|
if item["id"] == "product.main_image.weekly"
|
|
)
|
|
last_run = workflow["last_run"]
|
|
|
|
assert last_run["run_id"] == run_id
|
|
assert [step["id"] for step in last_run["steps"]] == [
|
|
"jd.attempt-1",
|
|
"tmall.attempt-1",
|
|
]
|
|
assert last_run["steps"][0]["status"] == "failed"
|
|
assert last_run["steps"][0]["exit_code"] == 9
|
|
assert "private-value" not in last_run["steps"][0]["error"]
|
|
assert "[REDACTED]" in last_run["steps"][0]["error"]
|
|
assert last_run["steps"][1]["status"] == "success"
|
|
|
|
history = service.recent_runs("product.main_image.weekly", limit=1)
|
|
assert history["runs"][0]["steps"] == last_run["steps"]
|
|
assert history["runs"][0]["mode"] == "unknown"
|
|
|
|
|
|
def test_overview_reads_live_step_progress_from_a_running_journal(
|
|
console_settings: Settings,
|
|
) -> None:
|
|
journal = _index_running_metrics_run(console_settings)
|
|
service = WorkflowConsoleService(
|
|
console_settings,
|
|
launcher=FakeConsoleLauncher(),
|
|
)
|
|
|
|
workflow = next(
|
|
item
|
|
for item in service.overview()["workflows"]
|
|
if item["id"] == "content.metrics.daily"
|
|
)
|
|
|
|
assert workflow["last_run"]["status"] == "running"
|
|
assert workflow["last_run"]["step_counts"] == {
|
|
"success": 0,
|
|
"failed": 0,
|
|
"skipped": 0,
|
|
"running": 1,
|
|
}
|
|
assert workflow["last_run"]["steps"][0]["id"] == (
|
|
"collect_collaborators.attempt-1"
|
|
)
|
|
assert workflow["last_run"]["steps"][0]["status"] == "running"
|
|
|
|
journal.finish_step(
|
|
"collect_collaborators.attempt-1",
|
|
status="success",
|
|
exit_code=0,
|
|
)
|
|
journal.start_step("refresh_self_mapping.attempt-1", attempt=1)
|
|
refreshed = next(
|
|
item
|
|
for item in service.overview()["workflows"]
|
|
if item["id"] == "content.metrics.daily"
|
|
)
|
|
|
|
assert refreshed["last_run"]["step_counts"] == {
|
|
"success": 1,
|
|
"failed": 0,
|
|
"skipped": 0,
|
|
"running": 1,
|
|
}
|
|
assert [step["status"] for step in refreshed["last_run"]["steps"]] == [
|
|
"success",
|
|
"running",
|
|
]
|
|
|
|
|
|
def test_overview_keeps_a_new_active_launch_separate_from_the_previous_run(
|
|
console_settings: Settings,
|
|
) -> None:
|
|
previous_run_id = _index_parallel_main_image_run(console_settings)
|
|
launcher = FakeConsoleLauncher()
|
|
launcher.launch(
|
|
"product.main_image.weekly",
|
|
date(2026, 8, 3),
|
|
execute=True,
|
|
shadow=False,
|
|
)
|
|
service = WorkflowConsoleService(console_settings, launcher=launcher)
|
|
|
|
workflow = next(
|
|
item
|
|
for item in service.overview()["workflows"]
|
|
if item["id"] == "product.main_image.weekly"
|
|
)
|
|
|
|
assert workflow["active_run"]["business_date"] == "2026-08-03"
|
|
assert workflow["active_run"]["mode"] == "execute"
|
|
assert workflow["last_run"]["run_id"] == previous_run_id
|
|
assert workflow["last_run"]["business_date"] == "2026-08-02"
|
|
assert workflow["last_run"]["status"] == "failed"
|
|
|
|
|
|
class RecordingLockManager:
|
|
def __init__(self) -> None:
|
|
self.calls: list[tuple[str, str]] = []
|
|
self.active = False
|
|
|
|
@contextmanager
|
|
def acquire(
|
|
self,
|
|
resource: str,
|
|
*,
|
|
owner: str,
|
|
timeout_seconds: float = 30.0,
|
|
poll_seconds: float = 0.1,
|
|
) -> Iterator[object]:
|
|
del timeout_seconds, poll_seconds
|
|
self.calls.append((resource, owner))
|
|
self.active = True
|
|
try:
|
|
yield object()
|
|
finally:
|
|
self.active = False
|
|
|
|
|
|
def test_schedule_update_uses_revision_and_named_lock_for_atomic_replacement(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
console_settings: Settings,
|
|
) -> None:
|
|
store = ScheduleConfigStore(console_settings)
|
|
revision = store.revision()
|
|
locks = RecordingLockManager()
|
|
store._locks = locks # type: ignore[assignment]
|
|
real_atomic_write = console_module.atomic_write_json
|
|
|
|
def checked_atomic_write(path: Path | str, payload: Any) -> Path:
|
|
if Path(path) == store.path:
|
|
assert locks.active is True
|
|
return real_atomic_write(path, payload)
|
|
|
|
monkeypatch.setattr(console_module, "atomic_write_json", checked_atomic_write)
|
|
|
|
schedule, updated_revision = store.update(
|
|
"content.metrics.daily",
|
|
{
|
|
"kind": "daily",
|
|
"at": "06:15",
|
|
"enabled": False,
|
|
"business_date_offset_days": -1,
|
|
},
|
|
expected_revision=f'"{revision}"',
|
|
)
|
|
|
|
assert locks.calls and locks.calls[0][0] == "config:schedules"
|
|
assert locks.calls[0][1].startswith("console-")
|
|
assert locks.active is False
|
|
assert updated_revision != revision
|
|
assert schedule.at == "06:15"
|
|
assert schedule.enabled is False
|
|
assert console_module._schedule_payload(schedule)["at"] == "06:15"
|
|
assert _schedule_row(console_settings, "content.metrics.daily") == {
|
|
"workflow_id": "content.metrics.daily",
|
|
"kind": "daily",
|
|
"at": "06:15",
|
|
"enabled": False,
|
|
"business_date_offset_days": -1,
|
|
}
|
|
|
|
|
|
def test_schedule_update_accepts_and_returns_multiple_start_times(
|
|
console_settings: Settings,
|
|
) -> None:
|
|
store = ScheduleConfigStore(console_settings)
|
|
|
|
schedule, _revision = store.update(
|
|
"content.metrics.daily",
|
|
{
|
|
"kind": "daily",
|
|
"at": ["08:00", "16:00", "22:00"],
|
|
"enabled": True,
|
|
"business_date_offset_days": 0,
|
|
},
|
|
expected_revision=store.revision(),
|
|
)
|
|
|
|
assert schedule.effective_times == ("08:00", "16:00", "22:00")
|
|
assert console_module._schedule_payload(schedule)["at"] == [
|
|
"08:00",
|
|
"16:00",
|
|
"22:00",
|
|
]
|
|
assert _schedule_row(console_settings, "content.metrics.daily")["at"] == [
|
|
"08:00",
|
|
"16:00",
|
|
"22:00",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("schedule", "now", "expected"),
|
|
[
|
|
(
|
|
ScheduleEntry(
|
|
workflow_id="test.daily",
|
|
kind="daily",
|
|
at="08:00",
|
|
at_times=("08:00", "16:00", "22:00"),
|
|
),
|
|
datetime(2026, 8, 6, 16, 30, tzinfo=timezone.utc),
|
|
"2026-08-06T22:00:00+00:00",
|
|
),
|
|
(
|
|
ScheduleEntry(
|
|
workflow_id="test.weekly",
|
|
kind="weekly",
|
|
at="08:00",
|
|
at_times=("08:00", "16:00", "22:00"),
|
|
days=("Thursday",),
|
|
),
|
|
datetime(2026, 8, 6, 22, 30, tzinfo=timezone.utc),
|
|
"2026-08-13T08:00:00+00:00",
|
|
),
|
|
(
|
|
ScheduleEntry(
|
|
workflow_id="test.monthly",
|
|
kind="monthly",
|
|
at="08:00",
|
|
at_times=("08:00", "16:00", "22:00"),
|
|
day_of_month=6,
|
|
),
|
|
datetime(2026, 8, 6, 16, 30, tzinfo=timezone.utc),
|
|
"2026-08-06T22:00:00+00:00",
|
|
),
|
|
(
|
|
ScheduleEntry(
|
|
workflow_id="test.interval",
|
|
kind="interval_days",
|
|
at="08:00",
|
|
at_times=("08:00", "16:00", "22:00"),
|
|
every_days=3,
|
|
anchor_date="2026-08-06",
|
|
),
|
|
datetime(2026, 8, 6, 22, 30, tzinfo=timezone.utc),
|
|
"2026-08-09T08:00:00+00:00",
|
|
),
|
|
],
|
|
)
|
|
def test_next_run_uses_the_nearest_slot_across_all_start_times(
|
|
schedule: ScheduleEntry,
|
|
now: datetime,
|
|
expected: str,
|
|
) -> None:
|
|
assert console_module._next_run_at(schedule, now) == expected
|
|
|
|
|
|
def test_schedule_update_rejects_stale_revision_without_changing_file(
|
|
console_settings: Settings,
|
|
) -> None:
|
|
store = ScheduleConfigStore(console_settings)
|
|
before = store.path.read_bytes()
|
|
|
|
with pytest.raises(ConsoleConflictError) as raised:
|
|
store.update(
|
|
"content.metrics.daily",
|
|
{"kind": "daily", "at": "06:15"},
|
|
expected_revision="0" * 64,
|
|
)
|
|
|
|
assert raised.value.status == HTTPStatus.CONFLICT
|
|
assert store.path.read_bytes() == before
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("workflow_id", "module", "trigger", "expected_status"),
|
|
[
|
|
(
|
|
"content.console_manual",
|
|
"content_marketing",
|
|
"manual",
|
|
HTTPStatus.BAD_REQUEST,
|
|
),
|
|
(
|
|
"product.console_unavailable",
|
|
"product_commerce",
|
|
"unavailable",
|
|
HTTPStatus.BAD_REQUEST,
|
|
),
|
|
("missing.workflow", None, None, HTTPStatus.NOT_FOUND),
|
|
],
|
|
)
|
|
def test_schedule_update_rejects_non_scheduled_or_unknown_workflows(
|
|
console_settings: Settings,
|
|
workflow_id: str,
|
|
module: str | None,
|
|
trigger: str | None,
|
|
expected_status: HTTPStatus,
|
|
) -> None:
|
|
if module is not None and trigger is not None:
|
|
_append_non_scheduled_workflow(
|
|
console_settings,
|
|
workflow_id=workflow_id,
|
|
module=module,
|
|
trigger=trigger,
|
|
)
|
|
store = ScheduleConfigStore(console_settings)
|
|
before = store.path.read_bytes()
|
|
|
|
with pytest.raises(ConsoleRequestError) as raised:
|
|
store.update(
|
|
workflow_id,
|
|
{"kind": "daily", "at": "06:15"},
|
|
expected_revision=store.revision(),
|
|
)
|
|
|
|
assert raised.value.status == expected_status
|
|
assert store.path.read_bytes() == before
|
|
|
|
|
|
def test_schedule_kind_switch_removes_fields_owned_by_the_previous_kind(
|
|
console_settings: Settings,
|
|
) -> None:
|
|
store = ScheduleConfigStore(console_settings)
|
|
|
|
updated, _revision = store.update(
|
|
"content.comments.weekly",
|
|
{
|
|
"kind": "daily",
|
|
"at": "07:45",
|
|
"enabled": True,
|
|
"business_date_offset_days": 0,
|
|
"days": ["Sunday"],
|
|
"day_of_month": 31,
|
|
"every_days": 3,
|
|
"anchor_date": "2026-08-01",
|
|
},
|
|
expected_revision=store.revision(),
|
|
)
|
|
|
|
assert updated.kind == "daily"
|
|
assert updated.days == ()
|
|
assert updated.day_of_month is None
|
|
assert updated.every_days is None
|
|
assert updated.anchor_date is None
|
|
assert _schedule_row(console_settings, "content.comments.weekly") == {
|
|
"workflow_id": "content.comments.weekly",
|
|
"kind": "daily",
|
|
"at": "07:45",
|
|
"enabled": True,
|
|
"business_date_offset_days": 0,
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
{"kind": "daily", "at": "9:00"},
|
|
{"kind": "daily", "at": "09:00:30"},
|
|
{"kind": "daily", "at": []},
|
|
{"kind": "daily", "at": ["09:00", "09:00"]},
|
|
{"kind": "daily", "at": ["09:00", "9:30"]},
|
|
{
|
|
"kind": "interval_days",
|
|
"at": "09:00",
|
|
"every_days": 3,
|
|
"anchor_date": "not-a-date",
|
|
},
|
|
{
|
|
"kind": "interval_days",
|
|
"at": "09:00",
|
|
"every_days": True,
|
|
"anchor_date": "2026-08-01",
|
|
},
|
|
{"kind": "monthly", "at": "09:00", "day_of_month": True},
|
|
{
|
|
"kind": "daily",
|
|
"at": "09:00",
|
|
"business_date_offset_days": True,
|
|
},
|
|
],
|
|
)
|
|
def test_invalid_schedule_values_leave_the_original_file_unchanged(
|
|
console_settings: Settings,
|
|
payload: dict[str, Any],
|
|
) -> None:
|
|
store = ScheduleConfigStore(console_settings)
|
|
before = store.path.read_bytes()
|
|
|
|
with pytest.raises(ConsoleRequestError):
|
|
store.update(
|
|
"content.metrics.daily",
|
|
payload,
|
|
expected_revision=store.revision(),
|
|
)
|
|
|
|
assert store.path.read_bytes() == before
|
|
|
|
|
|
def test_service_trigger_supports_dry_run_confirmed_execute_and_conflict(
|
|
console_settings: Settings,
|
|
) -> None:
|
|
launcher = FakeConsoleLauncher()
|
|
service = WorkflowConsoleService(console_settings, launcher=launcher)
|
|
workflow_id = "content.metrics.daily"
|
|
|
|
dry_run = service.trigger(workflow_id, {"business_date": "2026-08-01"})
|
|
|
|
assert dry_run["mode"] == "dry_run"
|
|
assert launcher.calls[-1]["mode"] == "dry_run"
|
|
launcher.complete(workflow_id)
|
|
|
|
with pytest.raises(ConsoleRequestError, match="确认"):
|
|
service.trigger(
|
|
workflow_id,
|
|
{"business_date": "2026-08-01", "execute": True},
|
|
)
|
|
assert len(launcher.calls) == 1
|
|
|
|
executed = service.trigger(
|
|
workflow_id,
|
|
{
|
|
"business_date": "2026-08-01",
|
|
"execute": True,
|
|
"confirmed": True,
|
|
"shadow": True,
|
|
},
|
|
)
|
|
|
|
assert executed["mode"] == "execute"
|
|
assert executed["shadow"] is True
|
|
with pytest.raises(ConsoleConflictError):
|
|
service.trigger(workflow_id, {"business_date": "2026-08-01"})
|
|
assert len(launcher.calls) == 2
|
|
|
|
|
|
def test_product_production_preflight_requires_real_database_and_lark_profile(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
|
daily = next(
|
|
item for item in catalog.workflows if item.workflow_id == "product.daily"
|
|
)
|
|
config_dir = tmp_path / ".lark-cli"
|
|
config_dir.mkdir()
|
|
(config_dir / "config.json").write_text(
|
|
json.dumps({"apps": [{"name": "hermes-analyzer"}]}),
|
|
encoding="utf-8",
|
|
)
|
|
environment = {
|
|
"PG_HOST": "database.example",
|
|
"PG_PORT": "5432",
|
|
"PG_DB": "business",
|
|
"PG_USER": "runtime",
|
|
"PG_PASSWORD": "placeholder",
|
|
"LARKSUITE_CLI_CONFIG_DIR": str(config_dir),
|
|
}
|
|
|
|
console_module._validate_product_production_runtime(daily, environment)
|
|
|
|
without_database = {
|
|
"LARKSUITE_CLI_CONFIG_DIR": str(config_dir),
|
|
}
|
|
with pytest.raises(ConsoleRequestError, match="PostgreSQL"):
|
|
console_module._validate_product_production_runtime(daily, without_database)
|
|
|
|
empty_config_dir = tmp_path / "empty-lark-cli"
|
|
empty_config_dir.mkdir()
|
|
without_lark = {
|
|
**environment,
|
|
"LARKSUITE_CLI_CONFIG_DIR": str(empty_config_dir),
|
|
}
|
|
with pytest.raises(ConsoleRequestError, match="hermes-analyzer"):
|
|
console_module._validate_product_production_runtime(daily, without_lark)
|
|
|
|
|
|
def test_monthly_sales_preflight_does_not_require_postgres_or_hermes() -> None:
|
|
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
|
monthly_sales = next(
|
|
item
|
|
for item in catalog.workflows
|
|
if item.workflow_id == "product.sales_sheet.daily"
|
|
)
|
|
|
|
console_module._validate_product_production_runtime(monthly_sales, {})
|
|
|
|
with pytest.raises(ConsoleRequestError, match="验收隔离"):
|
|
console_module._validate_product_production_runtime(
|
|
monthly_sales, {"GYXX_WORKFLOW_ACCEPTANCE": "1"}
|
|
)
|
|
|
|
|
|
def test_product_production_preflight_rejects_acceptance_and_missing_owner(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
|
alert = next(
|
|
item
|
|
for item in catalog.workflows
|
|
if item.workflow_id == "product.alert.daily"
|
|
)
|
|
config_dir = tmp_path / ".lark-cli"
|
|
config_dir.mkdir()
|
|
(config_dir / "config.json").write_text(
|
|
json.dumps({"apps": [{"name": "hermes-analyzer"}]}),
|
|
encoding="utf-8",
|
|
)
|
|
environment = {
|
|
"GYXX_POSTGRES_DSN": "postgresql://runtime:${PG_PASSWORD}@database.example/business",
|
|
"GYXX_HERMES_API_KEY": "placeholder",
|
|
"LARKSUITE_CLI_CONFIG_DIR": str(config_dir),
|
|
}
|
|
|
|
with pytest.raises(ConsoleRequestError, match="负责人"):
|
|
console_module._validate_product_production_runtime(alert, environment)
|
|
|
|
environment["GYXX_NOTIFICATION_RECIPIENT_OPEN_ID"] = "configured-owner"
|
|
environment["GYXX_WORKFLOW_ACCEPTANCE"] = "1"
|
|
with pytest.raises(ConsoleRequestError, match="验收隔离"):
|
|
console_module._validate_product_production_runtime(alert, environment)
|
|
|
|
|
|
class StubProcess:
|
|
def __init__(self, pid: int) -> None:
|
|
self.pid = pid
|
|
self.return_code: int | None = None
|
|
|
|
def poll(self) -> int | None:
|
|
return self.return_code
|
|
|
|
|
|
class StubTreeProcess:
|
|
def __init__(
|
|
self,
|
|
pid: int,
|
|
*,
|
|
started_at: float,
|
|
popen: StubProcess | None = None,
|
|
ignore_terminate: bool = False,
|
|
) -> None:
|
|
self.pid = pid
|
|
self.started_at = started_at
|
|
self.popen = popen
|
|
self.ignore_terminate = ignore_terminate
|
|
self.alive = True
|
|
self.terminated = False
|
|
self.killed = False
|
|
self.descendants: list[StubTreeProcess] = []
|
|
|
|
def create_time(self) -> float:
|
|
return self.started_at
|
|
|
|
def children(self, *, recursive: bool) -> list[StubTreeProcess]:
|
|
assert recursive is True
|
|
return list(self.descendants)
|
|
|
|
def terminate(self) -> None:
|
|
self.terminated = True
|
|
if not self.ignore_terminate:
|
|
self.alive = False
|
|
if self.popen is not None:
|
|
self.popen.return_code = -15
|
|
|
|
def kill(self) -> None:
|
|
self.killed = True
|
|
self.alive = False
|
|
if self.popen is not None:
|
|
self.popen.return_code = -9
|
|
|
|
def is_running(self) -> bool:
|
|
return self.alive
|
|
|
|
def status(self) -> str:
|
|
return psutil.STATUS_RUNNING if self.alive else psutil.STATUS_ZOMBIE
|
|
|
|
def wait(self, timeout: float | None = None) -> int:
|
|
if self.alive:
|
|
raise psutil.TimeoutExpired(timeout or 0, pid=self.pid)
|
|
return -9 if self.killed else -15
|
|
|
|
|
|
def test_subprocess_launcher_uses_fixed_argv_environment_and_unique_operations(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
settings = Settings(
|
|
project_root=tmp_path / "project",
|
|
data_root=tmp_path / "data",
|
|
)
|
|
python = tmp_path / "runtime" / "python.exe"
|
|
calls: list[tuple[list[str], dict[str, Any], StubProcess]] = []
|
|
|
|
def fake_popen(argv: list[str], **kwargs: Any) -> StubProcess:
|
|
process = StubProcess(9000 + len(calls))
|
|
calls.append((argv, kwargs, process))
|
|
return process
|
|
|
|
monkeypatch.setattr(console_module.subprocess, "Popen", fake_popen)
|
|
launcher = SubprocessConsoleRunLauncher(settings, python_executable=python)
|
|
|
|
first = launcher.launch(
|
|
"content.metrics.daily",
|
|
date(2026, 8, 1),
|
|
execute=True,
|
|
shadow=True,
|
|
)
|
|
second = launcher.launch(
|
|
"product.daily",
|
|
date(2026, 7, 31),
|
|
execute=False,
|
|
shadow=False,
|
|
)
|
|
|
|
assert first["operation_id"] != second["operation_id"]
|
|
assert re.fullmatch(r"op-[0-9a-f]{16}", str(first["operation_id"]))
|
|
assert calls[0][0] == [
|
|
str(python.resolve()),
|
|
"-m",
|
|
"gyxx_flow",
|
|
"run",
|
|
"content.metrics.daily",
|
|
"--date",
|
|
"2026-08-01",
|
|
"--execute",
|
|
"--shadow",
|
|
]
|
|
assert calls[1][0] == [
|
|
str(python.resolve()),
|
|
"-m",
|
|
"gyxx_flow",
|
|
"run",
|
|
"product.daily",
|
|
"--date",
|
|
"2026-07-31",
|
|
]
|
|
for _argv, kwargs, _process in calls:
|
|
assert kwargs["cwd"] == settings.project_root
|
|
assert kwargs["shell"] is False
|
|
assert kwargs["stdin"] is subprocess.DEVNULL
|
|
assert kwargs["stderr"] is subprocess.STDOUT
|
|
assert kwargs["env"]["GYXX_PROJECT_ROOT"] == str(settings.project_root)
|
|
assert kwargs["env"]["GYXX_DATA_ROOT"] == str(settings.data_root)
|
|
assert str(kwargs["stdout"].name).endswith(".log")
|
|
assert str(first["operation_id"]) in str(calls[0][1]["stdout"].name)
|
|
assert str(second["operation_id"]) in str(calls[1][1]["stdout"].name)
|
|
|
|
for _argv, _kwargs, process in calls:
|
|
process.return_code = 0
|
|
assert launcher.active() == ()
|
|
assert all(kwargs["stdout"].closed for _argv, kwargs, _process in calls)
|
|
|
|
|
|
def test_subprocess_launcher_cancels_exact_operation_tree_and_journal(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
console_settings: Settings,
|
|
) -> None:
|
|
calls: list[tuple[dict[str, Any], StubProcess]] = []
|
|
roots: dict[int, StubTreeProcess] = {}
|
|
children: dict[int, StubTreeProcess] = {}
|
|
grandchildren: dict[int, StubTreeProcess] = {}
|
|
real_psutil_process = console_module.psutil.Process
|
|
|
|
def fake_popen(_argv: list[str], **kwargs: Any) -> StubProcess:
|
|
pid = 9100 + len(calls)
|
|
popen = StubProcess(pid)
|
|
root = StubTreeProcess(pid, started_at=1_800_000_000.0, popen=popen)
|
|
child = StubTreeProcess(
|
|
pid + 100,
|
|
started_at=1_800_000_001.0,
|
|
ignore_terminate=True,
|
|
)
|
|
grandchild = StubTreeProcess(
|
|
pid + 200,
|
|
started_at=1_800_000_002.0,
|
|
ignore_terminate=True,
|
|
)
|
|
child.descendants.append(grandchild)
|
|
root.descendants.extend((child, grandchild))
|
|
roots[pid] = root
|
|
children[pid] = child
|
|
grandchildren[pid] = grandchild
|
|
calls.append((kwargs, popen))
|
|
return popen
|
|
|
|
def fake_wait_procs(
|
|
processes: list[StubTreeProcess],
|
|
*,
|
|
timeout: float,
|
|
) -> tuple[list[StubTreeProcess], list[StubTreeProcess]]:
|
|
assert timeout > 0
|
|
gone = [process for process in processes if not process.alive]
|
|
alive = [process for process in processes if process.alive]
|
|
return gone, alive
|
|
|
|
monkeypatch.setattr(console_module.subprocess, "Popen", fake_popen)
|
|
monkeypatch.setattr(
|
|
console_module.psutil,
|
|
"Process",
|
|
lambda pid: roots.get(pid) or real_psutil_process(pid),
|
|
)
|
|
monkeypatch.setattr(console_module.psutil, "wait_procs", fake_wait_procs)
|
|
launcher = SubprocessConsoleRunLauncher(console_settings)
|
|
workflow_id = "content.metrics.daily"
|
|
started = launcher.launch(
|
|
workflow_id,
|
|
date(2026, 8, 2),
|
|
execute=True,
|
|
shadow=False,
|
|
)
|
|
operation_id = str(started["operation_id"])
|
|
popen = calls[0][1]
|
|
|
|
context = RunContext.create(
|
|
workflow_id,
|
|
"2026-08-02",
|
|
random_suffix="cancel1",
|
|
)
|
|
journal = RunJournal.create(DataLayout(console_settings.data_root), context)
|
|
RunIndex(console_settings.data_root).index_journal(journal)
|
|
journal.start_step("collect.attempt-1", attempt=1)
|
|
lock_root = console_settings.data_root / "state" / "locks"
|
|
lock_root.mkdir(parents=True, exist_ok=True)
|
|
(lock_root / "workflow.lock").write_text(
|
|
json.dumps(
|
|
{
|
|
"resource": f"workflow:{workflow_id}",
|
|
"owner": context.run_id,
|
|
"pid": popen.pid,
|
|
"process_started_at": roots[popen.pid].started_at,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
with pytest.raises(ConsoleConflictError, match="刷新"):
|
|
launcher.cancel(workflow_id, "op-0000000000000000")
|
|
assert roots[popen.pid].terminated is False
|
|
|
|
cancelled = launcher.cancel(workflow_id, operation_id)
|
|
|
|
assert cancelled["status"] == "cancelled"
|
|
assert cancelled["journal_status"] == "cancelled"
|
|
assert cancelled["run_id"] == context.run_id
|
|
assert roots[popen.pid].terminated is True
|
|
assert children[popen.pid].terminated is True
|
|
assert children[popen.pid].killed is True
|
|
assert grandchildren[popen.pid].terminated is True
|
|
assert grandchildren[popen.pid].killed is True
|
|
assert popen.poll() is not None
|
|
assert calls[0][0]["stdout"].closed is True
|
|
assert launcher.active() == ()
|
|
indexed = RunIndex(console_settings.data_root).get(context.run_id)
|
|
assert indexed is not None
|
|
assert indexed.status == "cancelled"
|
|
assert indexed.step_counts == {
|
|
"success": 0,
|
|
"failed": 0,
|
|
"skipped": 1,
|
|
"running": 0,
|
|
}
|
|
journal_payload = json.loads(journal.path.read_text(encoding="utf-8"))
|
|
assert journal_payload["status"] == "cancelled"
|
|
assert journal_payload["steps"]["collect.attempt-1"]["status"] == "skipped"
|
|
assert journal_payload["steps"]["collect.attempt-1"]["ended_at"] is not None
|
|
|
|
repeated = launcher.cancel(workflow_id, operation_id)
|
|
assert repeated["status"] == "cancelled"
|
|
assert repeated["already_cancelled"] is True
|
|
cancellation_path = (
|
|
console_settings.data_root
|
|
/ "state"
|
|
/ "ops"
|
|
/ "console-cancellations"
|
|
/ f"{operation_id}.json"
|
|
)
|
|
assert json.loads(cancellation_path.read_text(encoding="utf-8"))[
|
|
"journal_status"
|
|
] == "cancelled"
|
|
|
|
immediate = launcher.launch(
|
|
"product.daily",
|
|
date(2026, 8, 2),
|
|
execute=True,
|
|
shadow=False,
|
|
)
|
|
immediate_result = launcher.cancel(
|
|
"product.daily",
|
|
str(immediate["operation_id"]),
|
|
)
|
|
assert immediate_result["journal_status"] == "synthetic_cancelled"
|
|
synthetic = RunIndex(console_settings.data_root).get(
|
|
str(immediate_result["run_id"])
|
|
)
|
|
assert synthetic is not None
|
|
assert synthetic.status == "cancelled"
|
|
overview = WorkflowConsoleService(
|
|
console_settings,
|
|
launcher=launcher,
|
|
).overview()
|
|
product = next(
|
|
item for item in overview["workflows"] if item["id"] == "product.daily"
|
|
)
|
|
assert product["active_run"] is None
|
|
assert product["last_run"]["status"] == "cancelled"
|
|
|
|
|
|
def test_subprocess_launcher_refuses_pid_identity_change(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
console_settings: Settings,
|
|
) -> None:
|
|
roots: dict[int, StubTreeProcess] = {}
|
|
streams: list[TextIO] = []
|
|
real_psutil_process = console_module.psutil.Process
|
|
|
|
def fake_popen(_argv: list[str], **kwargs: Any) -> StubProcess:
|
|
process = StubProcess(9200)
|
|
roots[process.pid] = StubTreeProcess(
|
|
process.pid,
|
|
started_at=1_800_000_000.0,
|
|
popen=process,
|
|
)
|
|
streams.append(kwargs["stdout"])
|
|
return process
|
|
|
|
monkeypatch.setattr(console_module.subprocess, "Popen", fake_popen)
|
|
monkeypatch.setattr(
|
|
console_module.psutil,
|
|
"Process",
|
|
lambda pid: roots.get(pid) or real_psutil_process(pid),
|
|
)
|
|
launcher = SubprocessConsoleRunLauncher(console_settings)
|
|
launched = launcher.launch(
|
|
"content.metrics.daily",
|
|
date(2026, 8, 2),
|
|
execute=False,
|
|
shadow=False,
|
|
)
|
|
roots[9200].started_at += 10
|
|
|
|
with pytest.raises(ConsoleConflictError, match="身份已变化"):
|
|
launcher.cancel(
|
|
"content.metrics.daily",
|
|
str(launched["operation_id"]),
|
|
)
|
|
|
|
assert roots[9200].terminated is False
|
|
assert streams[0].closed is False
|
|
assert len(launcher.active()) == 1
|
|
assert roots[9200].popen is not None
|
|
roots[9200].popen.return_code = 0
|
|
roots[9200].alive = False
|
|
assert launcher.active() == ()
|
|
assert streams[0].closed is True
|
|
|
|
|
|
def test_subprocess_launcher_recovers_active_operation_after_console_restart(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
console_settings: Settings,
|
|
) -> None:
|
|
calls: list[tuple[dict[str, Any], StubProcess]] = []
|
|
roots: dict[int, StubTreeProcess] = {}
|
|
real_psutil_process = console_module.psutil.Process
|
|
|
|
def fake_popen(_argv: list[str], **kwargs: Any) -> StubProcess:
|
|
process = StubProcess(9300)
|
|
roots[process.pid] = StubTreeProcess(
|
|
process.pid,
|
|
started_at=1_800_000_000.0,
|
|
popen=process,
|
|
)
|
|
calls.append((kwargs, process))
|
|
return process
|
|
|
|
def fake_wait_procs(
|
|
processes: list[StubTreeProcess],
|
|
*,
|
|
timeout: float,
|
|
) -> tuple[list[StubTreeProcess], list[StubTreeProcess]]:
|
|
assert timeout > 0
|
|
return (
|
|
[process for process in processes if not process.alive],
|
|
[process for process in processes if process.alive],
|
|
)
|
|
|
|
monkeypatch.setattr(console_module.subprocess, "Popen", fake_popen)
|
|
monkeypatch.setattr(
|
|
console_module.psutil,
|
|
"Process",
|
|
lambda pid: roots.get(pid) or real_psutil_process(pid),
|
|
)
|
|
monkeypatch.setattr(console_module.psutil, "wait_procs", fake_wait_procs)
|
|
|
|
first_launcher = SubprocessConsoleRunLauncher(console_settings)
|
|
started = first_launcher.launch(
|
|
"content.metrics.daily",
|
|
date(2026, 8, 2),
|
|
execute=True,
|
|
shadow=False,
|
|
)
|
|
operation_id = str(started["operation_id"])
|
|
active_path = (
|
|
console_settings.data_root
|
|
/ "state"
|
|
/ "ops"
|
|
/ "console-active"
|
|
/ f"{operation_id}.json"
|
|
)
|
|
assert active_path.exists()
|
|
calls[0][0]["stdout"].close()
|
|
|
|
recovered_launcher = SubprocessConsoleRunLauncher(console_settings)
|
|
|
|
assert recovered_launcher.active() == (
|
|
{
|
|
"operation_id": operation_id,
|
|
"workflow_id": "content.metrics.daily",
|
|
"business_date": "2026-08-02",
|
|
"mode": "execute",
|
|
"shadow": False,
|
|
"started_at": started["started_at"],
|
|
"status": "running",
|
|
},
|
|
)
|
|
cancelled = recovered_launcher.cancel("content.metrics.daily", operation_id)
|
|
assert cancelled["status"] == "cancelled"
|
|
assert cancelled["journal_status"] == "synthetic_cancelled"
|
|
assert active_path.exists() is False
|
|
assert roots[9300].terminated is True
|
|
|
|
|
|
def test_subprocess_launcher_discards_stale_recovery_record_without_terminating(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
console_settings: Settings,
|
|
) -> None:
|
|
streams: list[TextIO] = []
|
|
roots: dict[int, StubTreeProcess] = {}
|
|
real_psutil_process = console_module.psutil.Process
|
|
|
|
def fake_popen(_argv: list[str], **kwargs: Any) -> StubProcess:
|
|
process = StubProcess(9400)
|
|
roots[process.pid] = StubTreeProcess(
|
|
process.pid,
|
|
started_at=1_800_000_000.0,
|
|
popen=process,
|
|
)
|
|
streams.append(kwargs["stdout"])
|
|
return process
|
|
|
|
monkeypatch.setattr(console_module.subprocess, "Popen", fake_popen)
|
|
monkeypatch.setattr(
|
|
console_module.psutil,
|
|
"Process",
|
|
lambda pid: roots.get(pid) or real_psutil_process(pid),
|
|
)
|
|
first_launcher = SubprocessConsoleRunLauncher(console_settings)
|
|
started = first_launcher.launch(
|
|
"content.metrics.daily",
|
|
date(2026, 8, 2),
|
|
execute=False,
|
|
shadow=False,
|
|
)
|
|
operation_id = str(started["operation_id"])
|
|
active_path = (
|
|
console_settings.data_root
|
|
/ "state"
|
|
/ "ops"
|
|
/ "console-active"
|
|
/ f"{operation_id}.json"
|
|
)
|
|
streams[0].close()
|
|
roots[9400].started_at += 30
|
|
|
|
recovered_launcher = SubprocessConsoleRunLauncher(console_settings)
|
|
|
|
assert recovered_launcher.active() == ()
|
|
assert active_path.exists() is False
|
|
assert roots[9400].terminated is False
|
|
|
|
|
|
@contextmanager
|
|
def _running_server(
|
|
settings: Settings,
|
|
launcher: FakeConsoleLauncher,
|
|
*,
|
|
token: str | None = None,
|
|
) -> Iterator[console_module.WorkflowConsoleHTTPServer]:
|
|
server = create_console_server(
|
|
settings,
|
|
host="127.0.0.1",
|
|
port=0,
|
|
token=token,
|
|
launcher=launcher,
|
|
)
|
|
thread = threading.Thread(
|
|
target=server.serve_forever,
|
|
kwargs={"poll_interval": 0.01},
|
|
daemon=True,
|
|
)
|
|
thread.start()
|
|
try:
|
|
yield server
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=5)
|
|
|
|
|
|
def _http_request(
|
|
server: console_module.WorkflowConsoleHTTPServer,
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
body: bytes | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
) -> tuple[int, dict[str, str], bytes]:
|
|
connection = http.client.HTTPConnection(
|
|
"127.0.0.1", server.server_port, timeout=5
|
|
)
|
|
try:
|
|
connection.request(method, path, body=body, headers=headers or {})
|
|
response = connection.getresponse()
|
|
content = response.read()
|
|
return response.status, dict(response.getheaders()), content
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
def _assert_security_headers(headers: dict[str, str]) -> None:
|
|
assert "default-src 'self'" in headers["Content-Security-Policy"]
|
|
assert headers["X-Content-Type-Options"] == "nosniff"
|
|
assert headers["X-Frame-Options"] == "DENY"
|
|
assert headers["Referrer-Policy"] == "no-referrer"
|
|
assert "Access-Control-Allow-Origin" not in headers
|
|
|
|
|
|
def test_http_home_assets_and_overview_have_security_headers(
|
|
console_settings: Settings,
|
|
) -> None:
|
|
with _running_server(console_settings, FakeConsoleLauncher()) as server:
|
|
status, headers, content = _http_request(server, "GET", "/")
|
|
assert status == HTTPStatus.OK
|
|
_assert_security_headers(headers)
|
|
page = content.decode("utf-8")
|
|
assert "/assets/app.css" in page
|
|
assert "/assets/app.js" in page
|
|
assert page.count("data-custom-select") == 2
|
|
assert 'data-description="显示所有触发类型"' in page
|
|
assert 'data-description="显示全部运行结果"' in page
|
|
assert 'id="drawer-stop-button"' in page
|
|
assert 'id="stop-overlay"' in page
|
|
assert 'role="alertdialog"' in page
|
|
assert 'id="drawer-rename-button"' in page
|
|
assert 'id="drawer-name-form"' in page
|
|
assert "执行流程" in page
|
|
|
|
for path, content_type in (
|
|
("/assets/app.css", "text/css"),
|
|
("/assets/app.js", "text/javascript"),
|
|
):
|
|
status, asset_headers, asset = _http_request(server, "GET", path)
|
|
assert status == HTTPStatus.OK
|
|
assert asset
|
|
assert asset_headers["Content-Type"].startswith(content_type)
|
|
_assert_security_headers(asset_headers)
|
|
decoded_asset = asset.decode("utf-8")
|
|
if path.endswith("app.js"):
|
|
assert "并行执行" in decoded_asset
|
|
assert "节点间连线只表示真实执行依赖" in decoded_asset
|
|
assert "技术实现与运行规则" in decoded_asset
|
|
assert "数据来源" in decoded_asset
|
|
assert "节点内处理" in decoded_asset
|
|
assert "结果去向" in decoded_asset
|
|
assert "renderStepBusinessFlow" in decoded_asset
|
|
assert "persistDrawerDisplayName" in decoded_asset
|
|
assert 'method: "PUT"' in decoded_asset
|
|
assert "display-name" in decoded_asset
|
|
assert "跳过原因" in decoded_asset
|
|
assert "topology-node__diagnostic" in decoded_asset
|
|
assert "正在启动工作流" in decoded_asset
|
|
assert "手动触发" in decoded_asset
|
|
assert "path.dataset.edgeState = edgeState" in decoded_asset
|
|
assert "预演完成" in decoded_asset
|
|
assert "预演完成(全部跳过)" in decoded_asset
|
|
assert "正式执行成功" in decoded_asset
|
|
assert "正式执行完成(全部跳过)" in decoded_asset
|
|
assert "历史模式未知" in decoded_asset
|
|
assert "allStepsSkipped" in decoded_asset
|
|
assert "最近成功" not in decoded_asset
|
|
assert "scheduleOverviewPoll" in decoded_asset
|
|
assert "function scheduleTimes(schedule)" in decoded_asset
|
|
assert "多个时间用逗号分隔" in decoded_asset
|
|
assert 'times.length === 1 ? times[0] : times' in decoded_asset
|
|
assert "replaceHtmlIfChanged" in decoded_asset
|
|
assert "HISTORY_CACHE_TTL_MS" in decoded_asset
|
|
assert 'id="topology-refreshed-at"' in decoded_asset
|
|
assert 'data-workflow-id="${esc(workflow.id)}"' in decoded_asset
|
|
assert 'openDrawer(workflow.id, "steps")' in decoded_asset
|
|
assert "state.data?.scheduler?.service_running" in decoded_asset
|
|
assert "节点错误详情" in decoded_asset
|
|
assert "naturalMonthRange" in decoded_asset
|
|
assert "聚水潭查询范围" in decoded_asset
|
|
assert 'periodInput.type = monthMode ? "month" : "date"' in decoded_asset
|
|
assert 'const businessDate = monthMode ? `${selectedPeriod}-01`' in decoded_asset
|
|
assert "renderCardLiveState" in decoded_asset
|
|
assert 'data-live-progress="${completed}/${steps.length}"' in decoded_asset
|
|
assert 'data-current-step="${esc(currentNames.join("、"))}"' in decoded_asset
|
|
assert 'data-step-id="${esc(step.id)}"' in decoded_asset
|
|
assert 'data-action="steps"' in decoded_asset
|
|
assert "已处理 ${completed} / ${steps.length}" in decoded_asset
|
|
assert "topologyDisplayRun(workflow)" in decoded_asset
|
|
assert "const displayRun = busy ? topologyDisplayRun(workflow) : last;" in decoded_asset
|
|
assert 'aria-haspopup="listbox"' in decoded_asset
|
|
assert 'role="option"' in decoded_asset
|
|
assert "closeOpenCustomSelect" in decoded_asset
|
|
assert "initCustomSelects" in decoded_asset
|
|
assert 'data-action="stop"' in decoded_asset
|
|
assert 'method: "DELETE"' in decoded_asset
|
|
assert "body: {}" in decoded_asset
|
|
assert "正在停止…" in decoded_asset
|
|
if path.endswith("app.css"):
|
|
assert ".workflow-topology" in decoded_asset
|
|
assert ".topology-edge--continue" in decoded_asset
|
|
assert ".topology-edge--active" in decoded_asset
|
|
assert "@keyframes topology-node-breathe" in decoded_asset
|
|
assert '.topology-node[data-state="queued"]' in decoded_asset
|
|
assert ".run-step-detail" in decoded_asset
|
|
assert ".card-live__step[data-state=\"running\"]" in decoded_asset
|
|
assert "@keyframes card-live-flow" in decoded_asset
|
|
assert ".custom-select__menu" in decoded_asset
|
|
assert "@keyframes custom-select-in" in decoded_asset
|
|
assert ".card-action--stop" in decoded_asset
|
|
assert ".stop-modal" in decoded_asset
|
|
assert ".topology-node__business-flow" in decoded_asset
|
|
assert ".business-flow__destination" in decoded_asset
|
|
assert ".drawer-name-editor" in decoded_asset
|
|
|
|
status, api_headers, body = _http_request(server, "GET", "/api/overview")
|
|
assert status == HTTPStatus.OK
|
|
_assert_security_headers(api_headers)
|
|
payload = json.loads(body)
|
|
assert payload["summary"]["total"] == len(payload["workflows"])
|
|
assert {module["id"] for module in payload["modules"]} == {
|
|
"content_marketing",
|
|
"product_commerce",
|
|
"shop_intelligence",
|
|
"supply_chain",
|
|
}
|
|
|
|
|
|
def test_http_token_protects_api_and_never_relaxes_security_headers(
|
|
console_settings: Settings,
|
|
) -> None:
|
|
token = "console-token-that-is-long-enough"
|
|
with _running_server(
|
|
console_settings, FakeConsoleLauncher(), token=token
|
|
) as server:
|
|
status, headers, _body = _http_request(server, "GET", "/api/overview")
|
|
assert status == HTTPStatus.UNAUTHORIZED
|
|
assert headers["WWW-Authenticate"].startswith("Bearer")
|
|
_assert_security_headers(headers)
|
|
|
|
status, _headers, _body = _http_request(
|
|
server,
|
|
"GET",
|
|
"/api/overview",
|
|
headers={"Authorization": "Bearer wrong"},
|
|
)
|
|
assert status == HTTPStatus.UNAUTHORIZED
|
|
|
|
status, headers, body = _http_request(
|
|
server,
|
|
"GET",
|
|
"/api/overview",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert status == HTTPStatus.OK
|
|
_assert_security_headers(headers)
|
|
assert json.loads(body)["summary"]["total"] > 0
|
|
|
|
|
|
def test_http_mutations_require_json_marker_same_origin_and_bounded_body(
|
|
console_settings: Settings,
|
|
) -> None:
|
|
launcher = FakeConsoleLauncher()
|
|
body = json.dumps({"business_date": "2026-08-01"}).encode("utf-8")
|
|
path = "/api/workflows/content.metrics.daily/runs"
|
|
with _running_server(console_settings, launcher) as server:
|
|
origin = f"http://127.0.0.1:{server.server_port}"
|
|
|
|
status, _headers, _body = _http_request(
|
|
server,
|
|
"POST",
|
|
path,
|
|
body=body,
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
assert status == HTTPStatus.FORBIDDEN
|
|
|
|
status, _headers, _body = _http_request(
|
|
server,
|
|
"POST",
|
|
path,
|
|
body=body,
|
|
headers={"Content-Type": "text/plain", "X-GYXX-Console": "1"},
|
|
)
|
|
assert status == HTTPStatus.UNSUPPORTED_MEDIA_TYPE
|
|
|
|
status, _headers, _body = _http_request(
|
|
server,
|
|
"POST",
|
|
path,
|
|
body=body,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-GYXX-Console": "1",
|
|
"Origin": "https://evil.example",
|
|
},
|
|
)
|
|
assert status == HTTPStatus.FORBIDDEN
|
|
|
|
oversized = b"{" + b"x" * MAX_REQUEST_BYTES
|
|
status, _headers, _body = _http_request(
|
|
server,
|
|
"POST",
|
|
path,
|
|
body=oversized,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-GYXX-Console": "1",
|
|
"Origin": origin,
|
|
},
|
|
)
|
|
assert status == HTTPStatus.BAD_REQUEST
|
|
|
|
status, headers, accepted = _http_request(
|
|
server,
|
|
"POST",
|
|
path,
|
|
body=body,
|
|
headers={
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
"X-GYXX-Console": "1",
|
|
"Origin": origin,
|
|
},
|
|
)
|
|
assert status == HTTPStatus.ACCEPTED
|
|
_assert_security_headers(headers)
|
|
accepted_payload = json.loads(accepted)
|
|
assert accepted_payload["mode"] == "dry_run"
|
|
assert len(launcher.calls) == 1
|
|
|
|
cancel_path = f"{path}/{accepted_payload['operation_id']}"
|
|
status, _headers, _body = _http_request(
|
|
server,
|
|
"DELETE",
|
|
cancel_path,
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
assert status == HTTPStatus.FORBIDDEN
|
|
|
|
status, _headers, _body = _http_request(
|
|
server,
|
|
"DELETE",
|
|
cancel_path,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-GYXX-Console": "1",
|
|
"Origin": "https://evil.example",
|
|
},
|
|
)
|
|
assert status == HTTPStatus.FORBIDDEN
|
|
|
|
mutation_headers = {
|
|
"Content-Type": "application/json",
|
|
"X-GYXX-Console": "1",
|
|
"Origin": origin,
|
|
}
|
|
connection = http.client.HTTPConnection(
|
|
"127.0.0.1", server.server_port, timeout=5
|
|
)
|
|
try:
|
|
connection.request(
|
|
"DELETE",
|
|
f"{path}/op-0000000000000000",
|
|
body=b"{}",
|
|
headers=mutation_headers,
|
|
)
|
|
conflict = connection.getresponse()
|
|
assert conflict.status == HTTPStatus.CONFLICT
|
|
conflict.read()
|
|
|
|
connection.request("GET", "/api/overview")
|
|
overview = connection.getresponse()
|
|
assert overview.status == HTTPStatus.OK
|
|
assert json.loads(overview.read())["summary"]["running"] == 1
|
|
finally:
|
|
connection.close()
|
|
|
|
status, cancel_headers, cancelled = _http_request(
|
|
server,
|
|
"DELETE",
|
|
cancel_path,
|
|
body=b"{}",
|
|
headers=mutation_headers,
|
|
)
|
|
assert status == HTTPStatus.OK
|
|
_assert_security_headers(cancel_headers)
|
|
assert json.loads(cancelled)["status"] == "cancelled"
|
|
assert launcher.active() == ()
|
|
|
|
status, _headers, repeated = _http_request(
|
|
server,
|
|
"DELETE",
|
|
cancel_path,
|
|
body=b"{}",
|
|
headers=mutation_headers,
|
|
)
|
|
assert status == HTTPStatus.OK
|
|
assert json.loads(repeated)["already_cancelled"] is True
|
|
|
|
|
|
def test_http_updates_and_resets_workflow_display_name(
|
|
console_settings: Settings,
|
|
) -> None:
|
|
launcher = FakeConsoleLauncher()
|
|
workflow_id = "content.marketing_report.daily"
|
|
path = f"/api/workflows/{workflow_id}/display-name"
|
|
with _running_server(console_settings, launcher) as server:
|
|
origin = f"http://127.0.0.1:{server.server_port}"
|
|
status, _headers, overview_body = _http_request(
|
|
server,
|
|
"GET",
|
|
"/api/overview",
|
|
)
|
|
assert status == HTTPStatus.OK
|
|
revision = json.loads(overview_body)["workflow_names_revision"]
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
"X-GYXX-Console": "1",
|
|
"Origin": origin,
|
|
}
|
|
body = json.dumps(
|
|
{"display_name": "每日内容营销简报"},
|
|
ensure_ascii=False,
|
|
).encode("utf-8")
|
|
|
|
status, _headers, _body = _http_request(
|
|
server,
|
|
"PUT",
|
|
path,
|
|
body=body,
|
|
headers=headers,
|
|
)
|
|
assert status == HTTPStatus.PRECONDITION_REQUIRED
|
|
|
|
status, response_headers, renamed_body = _http_request(
|
|
server,
|
|
"PUT",
|
|
path,
|
|
body=body,
|
|
headers={**headers, "If-Match": revision},
|
|
)
|
|
assert status == HTTPStatus.OK
|
|
_assert_security_headers(response_headers)
|
|
renamed = json.loads(renamed_body)
|
|
assert renamed["name"] == "每日内容营销简报"
|
|
assert renamed["name_customized"] is True
|
|
|
|
status, _headers, _body = _http_request(
|
|
server,
|
|
"PUT",
|
|
path,
|
|
body=body,
|
|
headers={**headers, "If-Match": revision},
|
|
)
|
|
assert status == HTTPStatus.CONFLICT
|
|
|
|
status, _headers, refreshed_body = _http_request(
|
|
server,
|
|
"GET",
|
|
"/api/overview",
|
|
)
|
|
assert status == HTTPStatus.OK
|
|
refreshed = json.loads(refreshed_body)
|
|
workflow = next(
|
|
item for item in refreshed["workflows"] if item["id"] == workflow_id
|
|
)
|
|
assert workflow["name"] == "每日内容营销简报"
|
|
assert workflow["default_name"] == "营销日报生成与发送"
|
|
|
|
reset_body = json.dumps({"display_name": None}).encode("utf-8")
|
|
status, _headers, reset_response = _http_request(
|
|
server,
|
|
"PUT",
|
|
path,
|
|
body=reset_body,
|
|
headers={
|
|
**headers,
|
|
"If-Match": refreshed["workflow_names_revision"],
|
|
},
|
|
)
|
|
assert status == HTTPStatus.OK
|
|
assert json.loads(reset_response)["name"] == "营销日报生成与发送"
|
|
|
|
|
|
def test_non_loopback_binding_requires_a_long_token(
|
|
console_settings: Settings,
|
|
) -> None:
|
|
with pytest.raises(ValueError, match="requires GYXX_CONSOLE_TOKEN"):
|
|
create_console_server(
|
|
console_settings,
|
|
host="0.0.0.0",
|
|
port=0,
|
|
launcher=FakeConsoleLauncher(),
|
|
)
|
|
|
|
|
|
class NeverLaunchSchedulerWorkflows:
|
|
def launch(
|
|
self,
|
|
workflow_id: str,
|
|
slot: datetime,
|
|
business_date: date,
|
|
) -> object:
|
|
raise AssertionError(
|
|
f"dry-run scheduler unexpectedly launched {workflow_id} {slot} {business_date}"
|
|
)
|
|
|
|
def close(self, process: object) -> None:
|
|
raise AssertionError(f"unexpected close: {process}")
|
|
|
|
|
|
def _single_workflow_catalog(*, at: str, enabled: bool) -> WorkflowCatalog:
|
|
workflow = WorkflowEntry(
|
|
workflow_id="content.console_schedule",
|
|
module="content_marketing",
|
|
trigger="scheduled",
|
|
entry="run.py",
|
|
source_project="content",
|
|
source_task_name="console-schedule",
|
|
)
|
|
schedule = ScheduleEntry(
|
|
workflow_id=workflow.workflow_id,
|
|
kind="daily",
|
|
at=at,
|
|
enabled=enabled,
|
|
)
|
|
return WorkflowCatalog(
|
|
timezone="Asia/Shanghai",
|
|
workflows=(workflow,),
|
|
schedules=(schedule,),
|
|
)
|
|
|
|
|
|
def test_scheduler_reload_catalog_runs_before_each_tick(tmp_path: Path) -> None:
|
|
initial = _single_workflow_catalog(at="09:00", enabled=True)
|
|
due_catalog = _single_workflow_catalog(at="10:00", enabled=True)
|
|
disabled_catalog = _single_workflow_catalog(at="10:00", enabled=False)
|
|
loaded = iter((due_catalog, disabled_catalog))
|
|
load_calls: list[WorkflowCatalog] = []
|
|
|
|
def load_catalog() -> WorkflowCatalog:
|
|
catalog = next(loaded)
|
|
load_calls.append(catalog)
|
|
return catalog
|
|
|
|
scheduler = PythonScheduler(
|
|
initial,
|
|
tmp_path,
|
|
launcher=NeverLaunchSchedulerWorkflows(),
|
|
misfire_grace_seconds=600,
|
|
dry_run=True,
|
|
catalog_loader=load_catalog,
|
|
)
|
|
now = datetime.fromisoformat("2026-08-01T10:05:00+08:00")
|
|
|
|
assert scheduler.tick(now) == ["content.console_schedule"]
|
|
assert scheduler.catalog is due_catalog
|
|
assert scheduler.tick(now) == []
|
|
assert scheduler.catalog is disabled_catalog
|
|
assert load_calls == [due_catalog, disabled_catalog]
|