CI / lint (push) Successful in 5s
CI / extension-version (push) Successful in 6s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 2m3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 6s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
The front door is now a feed, and a blank feed implies things should be here in a way a blank masonry does not. On a fresh install this is the first screen anyone sees — including someone who is not the operator, which is what milestone 328 is making possible. Tells the two empties apart, which is the point. "No sources yet" gets the on-ramp; "sources configured, nothing landed yet" gets told that the first check takes a while and pointed at Downloads. Telling someone to add a source when they already have three and are mid-backfill reads as the app not knowing its own state. Needs total_sources on schedule-status to distinguish them — deliberately not auto_sources, which counts only what is on a schedule, so a source with auto_check off would have read as "nothing configured". Both exact-shape assertions updated in THIS change rather than after CI caught them, which is the lesson from B3's red push. An absent status falls back to the on-ramp on purpose: it is merely redundant to an established operator, whereas "see what's running" shown to someone with nothing configured is a dead end. A filtered miss is deliberately NOT the onboarding case — the operator has posts, they just narrowed past them. Showing a fresh-install on-ramp there would tell someone with a full library to go set it up. This is where logo.svg lands, as the operator asked. It earns its place on a first-run screen and not on a populated feed, and gives the on-ramp something to compose around instead of prose plus two buttons. Large: the mark stops reading below ~48px, which is why the 22px nav slot has a different one. Pinned by test so a later tidy-up cannot quietly shrink it to a glyph. Also extracts mountWithStore into the shared test support module. Writing the second spec created exactly the copy-paste that open issue 3109 tracks for the backend row factories, so it is consolidated now rather than at copy three, and recorded as snippet 3829 with the two traps it does NOT solve — named slots rendering nothing, and components that fetch on mount. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
298 lines
10 KiB
Python
298 lines
10 KiB
Python
"""FC-3i: /api/system/activity/* endpoint tests.
|
|
|
|
Mocks Redis LLEN + celery inspect via monkeypatch on the module-level
|
|
_read_queues_sync / _read_workers_sync helpers, so tests don't need
|
|
a live broker or running workers.
|
|
"""
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from backend.app.api import system_activity as activity_module
|
|
from backend.app.models import TaskRun
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_caches(monkeypatch):
|
|
"""Clear the module-level caches between tests so cache state from
|
|
one test doesn't leak into the next."""
|
|
monkeypatch.setitem(activity_module._QUEUE_CACHE, "data", None)
|
|
monkeypatch.setitem(activity_module._QUEUE_CACHE, "ts", 0.0)
|
|
monkeypatch.setitem(activity_module._WORKER_CACHE, "data", None)
|
|
monkeypatch.setitem(activity_module._WORKER_CACHE, "ts", 0.0)
|
|
|
|
|
|
# --- /queues -------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queues_returns_all_known_queues(client, monkeypatch):
|
|
def _fake():
|
|
return {
|
|
"queues": dict.fromkeys(activity_module._QUEUE_NAMES, 0),
|
|
"fetched_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
monkeypatch.setattr(activity_module, "_read_queues_sync", _fake)
|
|
|
|
resp = await client.get("/api/system/activity/queues")
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert set(body["queues"].keys()) == set(activity_module._QUEUE_NAMES)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_summary_returns_rollup_shape(client, monkeypatch):
|
|
def _fake():
|
|
return {
|
|
"queues": {**dict.fromkeys(activity_module._QUEUE_NAMES, 0), "ml": 3},
|
|
"fetched_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
monkeypatch.setattr(activity_module, "_read_queues_sync", _fake)
|
|
|
|
resp = await client.get("/api/system/activity/summary")
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert set(body.keys()) == {"scheduler", "queues", "queued_total", "running", "failing"}
|
|
assert body["queued_total"] == 3 # only ml has a non-zero depth
|
|
assert isinstance(body["running"], int)
|
|
assert isinstance(body["failing"], int)
|
|
assert set(body["scheduler"]) == {
|
|
"last_tick_at", "next_due_at", "due_now", "auto_sources",
|
|
"failing_sources", "no_access_sources", "total_sources",
|
|
"platform_cooldowns",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queues_cached_within_ttl(client, monkeypatch):
|
|
call_count = {"n": 0}
|
|
|
|
def _counting():
|
|
call_count["n"] += 1
|
|
return {"queues": {}, "fetched_at": "x"}
|
|
monkeypatch.setattr(activity_module, "_read_queues_sync", _counting)
|
|
|
|
await client.get("/api/system/activity/queues")
|
|
await client.get("/api/system/activity/queues")
|
|
await client.get("/api/system/activity/queues")
|
|
assert call_count["n"] == 1 # cached after the first call
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queues_redis_failure_returns_null_per_failing_queue(client, monkeypatch):
|
|
"""_read_queues_sync's per-queue try/except returns None on failure."""
|
|
def _partial():
|
|
out = dict.fromkeys(activity_module._QUEUE_NAMES, 0)
|
|
out["ml"] = None # simulate broker hiccup on the ml queue
|
|
return {"queues": out, "fetched_at": "x"}
|
|
monkeypatch.setattr(activity_module, "_read_queues_sync", _partial)
|
|
|
|
resp = await client.get("/api/system/activity/queues")
|
|
body = await resp.get_json()
|
|
assert body["queues"]["ml"] is None
|
|
assert body["queues"]["import"] == 0
|
|
|
|
|
|
# --- /workers ------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workers_returns_per_worker_queue_membership(client, monkeypatch):
|
|
def _fake():
|
|
return {
|
|
"workers": {
|
|
"worker@host1": {"queues": ["import", "thumbnail"], "active_count": 2},
|
|
"worker@host2": {"queues": ["ml"], "active_count": 0},
|
|
},
|
|
"fetched_at": "x",
|
|
}
|
|
monkeypatch.setattr(activity_module, "_read_workers_sync", _fake)
|
|
|
|
resp = await client.get("/api/system/activity/workers")
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert "worker@host1" in body["workers"]
|
|
assert "import" in body["workers"]["worker@host1"]["queues"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workers_cached_within_ttl(client, monkeypatch):
|
|
call_count = {"n": 0}
|
|
|
|
def _counting():
|
|
call_count["n"] += 1
|
|
return {"workers": {}, "fetched_at": "x"}
|
|
monkeypatch.setattr(activity_module, "_read_workers_sync", _counting)
|
|
|
|
await client.get("/api/system/activity/workers")
|
|
await client.get("/api/system/activity/workers")
|
|
assert call_count["n"] == 1
|
|
|
|
|
|
# --- /runs ---------------------------------------------------------
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def _seed_runs(db):
|
|
"""Insert 5 task_run rows for paging tests."""
|
|
now = datetime.now(UTC)
|
|
for i in range(5):
|
|
db.add(TaskRun(
|
|
celery_task_id=f"tid-{i}",
|
|
queue="import" if i % 2 == 0 else "ml",
|
|
task_name=f"backend.app.tasks.fake.task_{i}",
|
|
target_id=i,
|
|
started_at=now - timedelta(seconds=10 - i),
|
|
finished_at=now - timedelta(seconds=9 - i),
|
|
duration_ms=1000,
|
|
status="ok" if i < 3 else "error",
|
|
error_type="ValueError" if i >= 3 else None,
|
|
error_message="boom" if i >= 3 else None,
|
|
))
|
|
await db.commit()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runs_paginated_descending_by_id(client, _seed_runs):
|
|
resp = await client.get("/api/system/activity/runs?limit=3")
|
|
body = await resp.get_json()
|
|
assert len(body["runs"]) == 3
|
|
# Descending: latest id first.
|
|
ids = [r["id"] for r in body["runs"]]
|
|
assert ids == sorted(ids, reverse=True)
|
|
assert body["next_cursor"] is not None # 5 total, asked for 3 → more
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runs_filter_by_queue(client, _seed_runs):
|
|
resp = await client.get("/api/system/activity/runs?queue=ml")
|
|
body = await resp.get_json()
|
|
assert all(r["queue"] == "ml" for r in body["runs"])
|
|
assert len(body["runs"]) == 2 # i=1, i=3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runs_filter_by_status(client, _seed_runs):
|
|
resp = await client.get("/api/system/activity/runs?status=error")
|
|
body = await resp.get_json()
|
|
assert all(r["status"] == "error" for r in body["runs"])
|
|
assert len(body["runs"]) == 2 # i=3, i=4
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runs_filter_by_task_substring(client, _seed_runs):
|
|
# Case-insensitive substring across the full task_name.
|
|
resp = await client.get("/api/system/activity/runs?task=FAKE")
|
|
body = await resp.get_json()
|
|
assert len(body["runs"]) == 5
|
|
assert all("fake" in r["task_name"] for r in body["runs"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runs_filter_by_task_escapes_underscore(client, _seed_runs):
|
|
# The literal "_" in "task_3" must match one row, not act as a
|
|
# single-char wildcard matching every task_N.
|
|
resp = await client.get("/api/system/activity/runs?task=task_3")
|
|
body = await resp.get_json()
|
|
assert len(body["runs"]) == 1
|
|
assert body["runs"][0]["task_name"].endswith("task_3")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runs_keyset_cursor(client, _seed_runs):
|
|
page1 = await (await client.get("/api/system/activity/runs?limit=2")).get_json()
|
|
assert len(page1["runs"]) == 2
|
|
assert page1["next_cursor"] is not None
|
|
|
|
page2 = await (await client.get(
|
|
f"/api/system/activity/runs?limit=2&before_id={page1['next_cursor']}"
|
|
)).get_json()
|
|
assert len(page2["runs"]) == 2
|
|
page1_ids = {r["id"] for r in page1["runs"]}
|
|
page2_ids = {r["id"] for r in page2["runs"]}
|
|
assert page1_ids.isdisjoint(page2_ids)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runs_invalid_limit_400(client):
|
|
resp = await client.get("/api/system/activity/runs?limit=not-a-number")
|
|
assert resp.status_code == 400
|
|
|
|
|
|
# --- /failures -----------------------------------------------------
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def _seed_failures(db):
|
|
"""Mix of ok / error / timeout rows; some old, some recent."""
|
|
now = datetime.now(UTC)
|
|
recent = [
|
|
("error", "OperationalError"),
|
|
("error", "OperationalError"),
|
|
("error", "OperationalError"),
|
|
("timeout", "TimeoutError"),
|
|
("timeout", "TimeoutError"),
|
|
("error", "OSError"),
|
|
("ok", None), # success — should NOT appear in failures
|
|
]
|
|
for i, (status, etype) in enumerate(recent):
|
|
db.add(TaskRun(
|
|
celery_task_id=f"f-{i}",
|
|
queue="ml",
|
|
task_name="backend.app.tasks.fake.x",
|
|
target_id=i,
|
|
started_at=now - timedelta(seconds=120 - i),
|
|
finished_at=now - timedelta(seconds=60 - i),
|
|
duration_ms=1000,
|
|
status=status,
|
|
error_type=etype,
|
|
error_message="boom" if status != "ok" else None,
|
|
))
|
|
# One ancient failure (>24h) that should NOT appear.
|
|
db.add(TaskRun(
|
|
celery_task_id="f-old",
|
|
queue="ml",
|
|
task_name="backend.app.tasks.fake.x",
|
|
target_id=999,
|
|
started_at=now - timedelta(hours=30),
|
|
finished_at=now - timedelta(hours=29),
|
|
duration_ms=1000,
|
|
status="error",
|
|
error_type="OldError",
|
|
error_message="ancient",
|
|
))
|
|
await db.commit()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failures_returns_recent_errors_and_timeouts(client, _seed_failures):
|
|
resp = await client.get("/api/system/activity/failures")
|
|
body = await resp.get_json()
|
|
statuses = {r["status"] for r in body["recent"]}
|
|
assert statuses == {"error", "timeout"}
|
|
# No ok rows.
|
|
assert "ok" not in statuses
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failures_count_by_type_groups_correctly(client, _seed_failures):
|
|
resp = await client.get("/api/system/activity/failures")
|
|
body = await resp.get_json()
|
|
counts = body["count_by_type"]
|
|
assert counts.get("OperationalError") == 3
|
|
assert counts.get("TimeoutError") == 2
|
|
assert counts.get("OSError") == 1
|
|
assert "OldError" not in counts # outside 24h window
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failures_only_within_24h_window(client, _seed_failures):
|
|
resp = await client.get("/api/system/activity/failures")
|
|
body = await resp.get_json()
|
|
ids = {r["error_type"] for r in body["recent"]}
|
|
assert "OldError" not in ids
|