plan(SP22): self-review fixes — phase4_at, browser check via shared client, dead code

This commit is contained in:
Tyler
2026-06-21 12:56:25 -06:00
parent 022b229d81
commit 134eb4f404
@@ -2392,13 +2392,32 @@ async def test_phase1_preflight_passes_when_backend_healthy(tmp_path: Path):
@pytest.mark.asyncio @pytest.mark.asyncio
@respx.mock
async def test_phase1_preflight_fails_fast_if_browser_down(tmp_path: Path): async def test_phase1_preflight_fails_fast_if_browser_down(tmp_path: Path):
"""If the Vite dev server is not reachable, phase 1 fails-fast """If the Vite dev server is not reachable, phase 1 fails-fast
with BrowserNotReachableError before phase 2 even tries.""" with BrowserNotReachableError before phase 2 even tries."""
from cyclone_pipeline.exceptions import BrowserNotReachableError from cyclone_pipeline.exceptions import BrowserNotReachableError
respx.get("http://127.0.0.1:8000/api/health").mock(
return_value=Response(200, json={
"status": "ok", "version": "0.1.0",
"db": {"ok": True},
"scheduler": {"running": True, "interval_s": 60},
"pubsub": {}, "batch": {},
})
)
respx.get("http://127.0.0.1:8000/api/admin/scheduler/status").mock(
return_value=Response(200, json={
"running": True, "interval_s": 60,
"sftp_block": "co_medicaid",
})
)
# Make the head request fail with a connection error
respx.head("http://127.0.0.1:9999/").mock(
side_effect=httpx.ConnectError("Connection refused")
)
cfg = PipelineConfig( cfg = PipelineConfig(
api_base="http://127.0.0.1:8000", api_base="http://127.0.0.1:8000",
browser_base="http://127.0.0.1:9999", # nothing listens here browser_base="http://127.0.0.1:9999",
run_dir=tmp_path / "runs", run_dir=tmp_path / "runs",
headless=True, headless=True,
) )
@@ -2526,11 +2545,15 @@ class CyclonePipeline:
f"Backend unhealthy: {snap.model_dump()}" f"Backend unhealthy: {snap.model_dump()}"
) )
sched = await self._client.scheduler_status() sched = await self._client.scheduler_status()
# Verify the browser-side URL is reachable # Verify the browser-side URL is reachable. We use the same
# httpx.AsyncClient as the API calls so tests can mock the
# head request via `respx`.
try: try:
async with httpx.AsyncClient(timeout=5.0) as http: assert self._client._http is not None # type: ignore[attr-defined]
r = await http.head(self.cfg.browser_base + "/") r = await self._client._http.head( # type: ignore[attr-defined]
r.raise_for_status() self.cfg.browser_base + "/"
)
r.raise_for_status()
except httpx.HTTPError as e: except httpx.HTTPError as e:
raise BrowserNotReachableError(self.cfg.browser_base) from e raise BrowserNotReachableError(self.cfg.browser_base) from e
result = PhaseResult( result = PhaseResult(
@@ -2950,6 +2973,10 @@ Append to `cyclone_pipeline/pipeline.py`:
self.run_state.pending_data["submission_filename"] = ( self.run_state.pending_data["submission_filename"] = (
result_obj.filename result_obj.filename
) )
# Record the moment phase 4 completed; phase 7 uses this as
# the `since` for `processed-files` so we only see inbound
# files from the MFT scheduler *after* our submit.
self.run_state.pending_data["phase4_at"] = _now_iso()
log.info( log.info(
"phase4_submit_ok", "phase4_submit_ok",
submission_id=result_obj.submission_id, submission_id=result_obj.submission_id,
@@ -3418,7 +3445,7 @@ Append to `cyclone_pipeline/pipeline.py`:
t0 = time.monotonic() t0 = time.monotonic()
assert self._client is not None assert self._client is not None
since = self.run_state.pending_data.get( since = self.run_state.pending_data.get(
"submission_filename_set_at", self.run_state.started_at "phase4_at", self.run_state.started_at
) )
files = await self._client.processed_files(since=since) files = await self._client.processed_files(since=since)
html_files = [f for f in files if f.kind == "html"] html_files = [f for f in files if f.kind == "html"]
@@ -3442,10 +3469,8 @@ Append to `cyclone_pipeline/pipeline.py`:
result_detail=self._result_detail(), result_detail=self._result_detail(),
started_at=self.run_state.started_at, started_at=self.run_state.started_at,
finished_at=_now_iso(), finished_at=_now_iso(),
duration_s=( duration_s=sum(
_iso_to_monotonic(self.run_state.started_at) - p.duration_s for p in self.run_state.completed_phases
_iso_to_monotonic(self.run_state.started_at) +
sum(p.duration_s for p in self.run_state.completed_phases)
), ),
phases=list(self.run_state.completed_phases), phases=list(self.run_state.completed_phases),
expected_835_by=expected_835, expected_835_by=expected_835,
@@ -3548,18 +3573,6 @@ def _next_monday_iso() -> str:
return now_mt.date().isoformat() return now_mt.date().isoformat()
days_ahead = (7 - weekday) % 7 or 7 days_ahead = (7 - weekday) % 7 or 7
return (now_mt + timedelta(days=days_ahead)).date().isoformat() return (now_mt + timedelta(days=days_ahead)).date().isoformat()
def _iso_to_monotonic(iso: str) -> float:
"""Best-effort ISO→monotonic. Returns 0.0 on parse error so the
report's `duration_s` is at least non-negative."""
try:
from datetime import datetime
return datetime.fromisoformat(
iso.replace("Z", "+00:00")
).timestamp()
except (ValueError, AttributeError):
return 0.0
``` ```
- [ ] **Step 4: Run test to verify it passes** - [ ] **Step 4: Run test to verify it passes**