Files
local-ci-cd-system/tests/python/test_operations_executions.py
T
Simone 7d1f4e2784 feat(web): row-count selector on the Executions card
Adds a "Show" selector (10 / 25 / 50 / 100) to the card header and drops the
default row count from 15 to 10. The chosen value rides on the card's own
polling URL (`/executions?limit=N`), so it survives the 5s htmx refresh with no
client-side state; a config default outside those four is rendered as an extra
option instead of disappearing. The query is parsed permissively — a stale or
hand-edited polling URL clamps or falls back to the config default rather than
422-ing a card that would otherwise render.

Ordering is now explicit rather than inherited from whatever Gitea returns:
newest first, a not-yet-started (queued) run above everything, ties broken on
Gitea's run id so matrix runs sharing a timestamp keep a stable order.
2026-07-25 15:36:21 +02:00

731 lines
24 KiB
Python

"""Tests for the pure ``operations.executions`` layer (Executions card).
The module under test talks to the Gitea Actions API over
``urllib.request.urlopen``, which is monkeypatched throughout: no test ever
touches the network. Zero ``click``/``print`` in the module itself — every
call returns an :class:`ExecutionsView`, and *no* failure mode raises.
"""
from __future__ import annotations
import json
import urllib.error
from datetime import UTC, datetime
from io import BytesIO
from pathlib import Path
from typing import Any
import pytest
import ci_orchestrator.operations.executions as ex
from ci_orchestrator.config import (
BackendConfig,
CargoCacheConfig,
Config,
Paths,
WebConfig,
WebOidcConfig,
)
from ci_orchestrator.operations.executions import Execution, ExecutionsView, list_executions
from ci_orchestrator.operations.vms import LiveVm
_NOW = datetime(2026, 7, 25, 12, 0, 0, tzinfo=UTC)
def _make_config(
tmp_path: Path,
*,
gitea_url: str | None = "https://gitea.example",
limit: int = 10,
) -> Config:
return Config(
paths=Paths(
root=tmp_path,
templates=tmp_path / "templates",
build_vms=tmp_path / "build-vms",
artifacts=tmp_path / "artifacts",
keys=tmp_path / "keys",
),
backend=BackendConfig(),
cargo_cache=CargoCacheConfig(
enabled=True, root=tmp_path / "cache", retention_days=30
),
web=WebConfig(gitea_url=gitea_url, executions_limit=limit),
)
def _run(
*,
run_id: int = 412,
number: int = 12,
attempt: int = 1,
status: str = "success",
conclusion: str = "success",
started: str | None = "2026-07-25T11:50:00Z",
completed: str | None = "2026-07-25T11:57:20Z",
branch: str = "main",
repo: str = "simone/local-ci-cd-system",
path: str = ".gitea/workflows/build-ns7zip.yml",
) -> dict[str, Any]:
"""One ``ActionWorkflowRun`` as Gitea 1.26 serialises it."""
return {
"id": run_id,
"run_number": number,
"run_attempt": attempt,
"status": status,
"conclusion": conclusion,
"started_at": started,
"completed_at": completed,
"head_branch": branch,
"head_sha": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
"event": "push",
"path": path,
"display_title": "feat: something",
"html_url": f"https://gitea.example/{repo}/actions/runs/{number}",
"repository": {"full_name": repo},
"trigger_actor": {"login": "Simone"},
}
class _FakeResponse:
"""Minimal ``urlopen`` context manager returning a JSON body."""
def __init__(self, payload: object) -> None:
self._body = BytesIO(json.dumps(payload).encode("utf-8"))
def __enter__(self) -> BytesIO:
return self._body
def __exit__(self, *_exc: object) -> None:
return None
def _http_error(code: int, body: str = "") -> urllib.error.HTTPError:
return urllib.error.HTTPError(
url="https://gitea.example",
code=code,
msg="nope",
hdrs=None, # type: ignore[arg-type]
fp=BytesIO(body.encode("utf-8")) if body else None,
)
def _install_urlopen(
monkeypatch: pytest.MonkeyPatch, handler: Any
) -> list[tuple[str, str | None]]:
"""Patch ``urlopen``; return the (url, authorization) log it appends to."""
calls: list[tuple[str, str | None]] = []
def _fake_urlopen(request: Any, timeout: float | None = None) -> Any:
calls.append((request.full_url, request.get_header("Authorization")))
result = handler(request.full_url)
if isinstance(result, Exception):
raise result
return _FakeResponse(result)
monkeypatch.setattr(ex.urllib.request, "urlopen", _fake_urlopen)
return calls
def _admin_ok(runs: list[dict[str, Any]]) -> Any:
def handler(url: str) -> Any:
if "/admin/actions/runs" in url:
return {"total_count": len(runs), "workflow_runs": runs}
raise AssertionError(f"unexpected url: {url}")
return handler
def _vm(name: str, *, ip: str = "192.168.79.201", uptime: int = 120) -> LiveVm:
return LiveVm(
name=name,
vmx=f"/var/lib/ci/build-vms/{name}/{name}.vmx",
ip=ip,
guest_os="ubuntu-64",
uptime_sec=uptime,
is_orphan=False,
)
@pytest.fixture(autouse=True)
def _clear_cache() -> Any:
"""Every test starts with an empty TTL cache (it is module-level state)."""
ex.clear_cache()
yield
ex.clear_cache()
# ── happy path ──────────────────────────────────────────────────────────────
def test_admin_payload_parsed_into_executions(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
calls = _install_urlopen(monkeypatch, _admin_ok([_run()]))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.error is None
assert view.source == "admin"
assert view.auth_failed is False
assert view.runs == [
Execution(
run_id=412,
number=12,
attempt=1,
workflow="build-ns7zip.yml",
repo="simone/local-ci-cd-system",
branch="main",
commit="a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
event="push",
actor="Simone",
status="success",
started=datetime(2026, 7, 25, 11, 50, tzinfo=UTC),
completed=datetime(2026, 7, 25, 11, 57, 20, tzinfo=UTC),
duration_sec=440,
html_url="https://gitea.example/simone/local-ci-cd-system/actions/runs/12",
vms=[],
)
]
# Bearer auth against the admin endpoint, with the configured limit.
assert calls == [
(
"https://gitea.example/api/v1/admin/actions/runs?limit=10",
"Bearer tok",
)
]
def test_request_sends_an_explicit_user_agent(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Cloudflare in front of Gitea 403s urllib's default agent (error 1010)."""
agents: list[str | None] = []
def _fake_urlopen(request: Any, timeout: float | None = None) -> Any:
agents.append(request.get_header("User-agent"))
return _FakeResponse({"workflow_runs": []})
monkeypatch.setattr(ex.urllib.request, "urlopen", _fake_urlopen)
list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert agents and agents[0]
assert "python-urllib" not in str(agents[0]).lower()
@pytest.mark.parametrize(
("path", "expected"),
[
(".gitea/workflows/build-ns7zip.yml", "build-ns7zip.yml"),
# Gitea's <file>@<ref> form: splitting on "/" alone yields "main".
("lint.yml@refs/heads/main", "lint.yml"),
(".gitea/workflows/ci.yml@refs/heads/feat/x", "ci.yml"),
("", ""),
],
)
def test_workflow_name_drops_the_ref(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, path: str, expected: str
) -> None:
_install_urlopen(monkeypatch, _admin_ok([_run(path=path)]))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.runs[0].workflow == expected
def test_zero_attempt_reads_as_one(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Gitea sends ``run_attempt: 0`` for a first run; job ids start at 1."""
_install_urlopen(monkeypatch, _admin_ok([_run(run_id=384, attempt=0)]))
vm = _vm("Clone_384-1_20260725_115000_linux")
view = list_executions(_make_config(tmp_path), [vm], token="tok", now=_NOW)
assert view.runs[0].attempt == 1
assert view.runs[0].vms == [vm]
def test_http_error_carries_the_server_message(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Gitea names the missing scope in its body -- surface it, don't guess."""
body = json.dumps(
{"message": "token does not have at least one of required scope(s)"}
)
_install_urlopen(monkeypatch, lambda _url: _http_error(500, body))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.error is not None
assert "HTTP 500" in view.error
assert "required scope(s)" in view.error
def test_non_json_error_body_is_included_verbatim(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A Cloudflare interception is HTML, not Gitea JSON -- still show it."""
_install_urlopen(monkeypatch, lambda _url: _http_error(500, "<html>error code: 1010</html>"))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.error is not None
assert "1010" in view.error
def test_empty_error_body_leaves_a_bare_status(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_install_urlopen(monkeypatch, lambda _url: _http_error(500))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.error == "Gitea API returned HTTP 500."
def test_limit_argument_overrides_config(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
calls = _install_urlopen(monkeypatch, _admin_ok([]))
list_executions(_make_config(tmp_path), [], token="tok", limit=3, now=_NOW)
assert calls[0][0].endswith("limit=3")
def test_running_run_duration_counts_from_now(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
running = _run(status="in_progress", conclusion="", completed=None)
_install_urlopen(monkeypatch, _admin_ok([running]))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.runs[0].status == "running"
assert view.runs[0].completed is None
# 11:50:00 -> 12:00:00
assert view.runs[0].duration_sec == 600
def test_run_without_start_has_no_duration(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
queued = _run(status="waiting", conclusion="", started=None, completed=None)
_install_urlopen(monkeypatch, _admin_ok([queued]))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.runs[0].status == "queued"
assert view.runs[0].started is None
assert view.runs[0].duration_sec is None
@pytest.mark.parametrize(
("status", "conclusion", "expected"),
[
("in_progress", "", "running"),
("running", "", "running"),
("waiting", "", "queued"),
("queued", "", "queued"),
("blocked", "", "queued"),
("completed", "success", "success"),
("completed", "failure", "failure"),
("completed", "cancelled", "cancelled"),
("completed", "skipped", "skipped"),
("success", "success", "success"),
("failure", "failure", "failure"),
("completed", "neutral", "unknown"),
("", "", "unknown"),
],
)
def test_status_mapping(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
status: str,
conclusion: str,
expected: str,
) -> None:
_install_urlopen(
monkeypatch, _admin_ok([_run(status=status, conclusion=conclusion)])
)
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.runs[0].status == expected
def test_missing_fields_degrade_per_field(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A sparse run object must still produce a row, never an exception."""
_install_urlopen(monkeypatch, _admin_ok([{"id": 9}]))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
row = view.runs[0]
assert (row.run_id, row.number, row.attempt) == (9, 0, 1)
assert (row.workflow, row.repo, row.branch, row.commit) == ("", "", "", "")
assert (row.event, row.actor, row.html_url) == ("", "", "")
assert row.status == "unknown"
assert row.duration_sec is None
assert view.error is None
def test_actor_falls_back_to_actor_field(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
payload = _run()
del payload["trigger_actor"]
payload["actor"] = {"login": "someone-else"}
_install_urlopen(monkeypatch, _admin_ok([payload]))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.runs[0].actor == "someone-else"
def test_non_list_runs_payload_is_an_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_install_urlopen(monkeypatch, lambda _url: {"workflow_runs": "nope"})
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.runs == []
assert view.error is not None
assert view.source == "none"
# ── ordering ────────────────────────────────────────────────────────────────
def test_runs_are_sorted_newest_first(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Gitea's order is not contracted -- the card sorts by recency itself."""
payload = [
_run(run_id=1, number=1, started="2026-07-25T09:00:00Z"),
_run(run_id=3, number=3, started="2026-07-25T11:00:00Z"),
_run(run_id=2, number=2, started="2026-07-25T10:00:00Z"),
]
_install_urlopen(monkeypatch, _admin_ok(payload))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert [r.number for r in view.runs] == [3, 2, 1]
def test_queued_run_sorts_above_started_runs(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A run with no start time was just created: it is the newest of all."""
payload = [
_run(run_id=1, number=1, started="2026-07-25T11:00:00Z"),
_run(run_id=2, number=2, status="waiting", conclusion="", started=None, completed=None),
]
_install_urlopen(monkeypatch, _admin_ok(payload))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert [r.status for r in view.runs] == ["queued", "success"]
def test_same_timestamp_falls_back_to_run_id(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Matrix runs share a timestamp; ids keep the order stable, not arbitrary."""
stamp = "2026-07-25T11:00:00Z"
payload = [
_run(run_id=10, number=10, started=stamp),
_run(run_id=12, number=12, started=stamp),
_run(run_id=11, number=11, started=stamp),
]
_install_urlopen(monkeypatch, _admin_ok(payload))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert [r.number for r in view.runs] == [12, 11, 10]
# ── endpoint fallback ───────────────────────────────────────────────────────
@pytest.mark.parametrize("code", [403, 404])
def test_admin_denied_falls_back_to_user_endpoint(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, code: int
) -> None:
def handler(url: str) -> Any:
if "/admin/" in url:
return _http_error(code)
return {"workflow_runs": [_run()]}
calls = _install_urlopen(monkeypatch, handler)
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.source == "user"
assert view.error is None
assert len(view.runs) == 1
assert [url for url, _auth in calls] == [
"https://gitea.example/api/v1/admin/actions/runs?limit=10",
"https://gitea.example/api/v1/user/actions/runs?limit=10",
]
def test_both_endpoints_denied_names_the_scope_problem(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""403 on both listings means "valid token, no API scope" -- say that."""
_install_urlopen(monkeypatch, lambda _url: _http_error(403))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.runs == []
assert view.source == "none"
assert view.error is not None
assert "403" in view.error
assert "read:repository" in view.error
assert "gitea_token_cred" in view.error
assert view.auth_failed is False # a refresh would not fix a scope problem
def test_user_endpoint_404_keeps_the_plain_http_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Only a 403 on the *user* listing implies a scope problem, not a 404."""
def handler(url: str) -> Any:
return _http_error(403 if "/admin/" in url else 404)
_install_urlopen(monkeypatch, handler)
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.error == "Gitea API returned HTTP 404."
# ── degraded paths ──────────────────────────────────────────────────────────
def test_missing_token_lists_vms_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
calls = _install_urlopen(monkeypatch, _admin_ok([_run()]))
vm = _vm("Clone_412-1_20260725_115000_linux")
view = list_executions(_make_config(tmp_path), [vm], token=None, now=_NOW)
assert view == ExecutionsView(
runs=[],
unmatched_vms=[vm],
source="none",
error="Gitea token not configured.",
auth_failed=False,
)
assert calls == [] # no HTTP attempted without a token
def test_missing_gitea_url_lists_vms_only(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
calls = _install_urlopen(monkeypatch, _admin_ok([_run()]))
config = _make_config(tmp_path, gitea_url=None)
view = list_executions(config, [_vm("Clone_1-1_20260725_115000_x")], token="tok", now=_NOW)
assert view.runs == []
assert view.error is not None
assert len(view.unmatched_vms) == 1
assert calls == []
def test_gitea_url_derived_from_oidc_issuer(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No explicit ``gitea_url``: the OIDC issuer is the fallback base URL."""
calls = _install_urlopen(monkeypatch, _admin_ok([]))
config = _make_config(tmp_path, gitea_url=None)
config = Config(
paths=config.paths,
backend=config.backend,
cargo_cache=config.cargo_cache,
web=WebConfig(
oidc=WebOidcConfig(
issuer="https://gitea.example",
client_id="cid",
redirect_url="http://host/auth/callback",
)
),
)
view = list_executions(config, [], token="tok", now=_NOW)
assert view.error is None
assert calls[0][0].startswith("https://gitea.example/api/v1/admin/")
def test_401_sets_auth_failed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_install_urlopen(monkeypatch, lambda _url: _http_error(401))
view = list_executions(_make_config(tmp_path), [], token="stale", now=_NOW)
assert view.auth_failed is True
assert view.error is not None
assert view.runs == []
def test_network_error_degrades(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_install_urlopen(monkeypatch, lambda _url: urllib.error.URLError("no route to host"))
vm = _vm("Clone_412-1_20260725_115000_linux")
view = list_executions(_make_config(tmp_path), [vm], token="tok", now=_NOW)
assert view.runs == []
assert view.unmatched_vms == [vm]
assert view.error is not None
assert view.auth_failed is False
def test_timeout_degrades(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_install_urlopen(monkeypatch, lambda _url: TimeoutError("timed out"))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.runs == []
assert view.error is not None
def test_malformed_json_degrades(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
class _BadBody:
def __enter__(self) -> Any:
return self
def __exit__(self, *_exc: object) -> None:
return None
def read(self) -> bytes:
return b"{not json"
monkeypatch.setattr(
ex.urllib.request, "urlopen", lambda _req, timeout=None: _BadBody()
)
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.runs == []
assert view.error is not None
# ── VM correlation ──────────────────────────────────────────────────────────
def test_vm_attached_to_its_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_install_urlopen(monkeypatch, _admin_ok([_run(run_id=412, attempt=1)]))
vm = _vm("Clone_412-1_20260725_115000_linux")
view = list_executions(_make_config(tmp_path), [vm], token="tok", now=_NOW)
assert view.runs[0].vms == [vm]
assert view.unmatched_vms == []
def test_matrix_run_collects_several_vms(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_install_urlopen(monkeypatch, _admin_ok([_run(run_id=412)]))
win = _vm("Clone_412-1-win_20260725_115000_a")
linux = _vm("Clone_412-1-linux_20260725_115005_b")
view = list_executions(_make_config(tmp_path), [win, linux], token="tok", now=_NOW)
assert view.runs[0].vms == [win, linux]
assert view.unmatched_vms == []
def test_attempt_is_part_of_the_match(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A clone from attempt 1 must not attach to attempt 2 of the same run."""
_install_urlopen(monkeypatch, _admin_ok([_run(run_id=412, attempt=2)]))
vm = _vm("Clone_412-1_20260725_115000_linux")
view = list_executions(_make_config(tmp_path), [vm], token="tok", now=_NOW)
assert view.runs[0].vms == []
assert view.unmatched_vms == [vm]
def test_unparseable_and_stale_vms_are_unmatched(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_install_urlopen(monkeypatch, _admin_ok([_run(run_id=412)]))
stale = _vm("Clone_370-1_20260722_101500_linux")
weird = _vm("some-manually-made-vm")
view = list_executions(_make_config(tmp_path), [stale, weird], token="tok", now=_NOW)
assert view.runs[0].vms == []
assert view.unmatched_vms == [stale, weird]
# ── TTL cache ───────────────────────────────────────────────────────────────
def test_second_call_within_ttl_reuses_the_fetch(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
calls = _install_urlopen(monkeypatch, _admin_ok([_run(run_id=412)]))
clock = [1000.0]
monkeypatch.setattr(ex, "_monotonic", lambda: clock[0])
config = _make_config(tmp_path)
vm = _vm("Clone_412-1_20260725_115000_linux")
first = list_executions(config, [], token="tok", now=_NOW)
clock[0] += ex._TTL_SEC / 2
second = list_executions(config, [vm], token="tok", now=_NOW)
assert len(calls) == 1
assert first.runs[0].vms == []
# Cached *runs*, freshly correlated: the VM shows up without a new fetch.
assert second.runs[0].vms == [vm]
def test_expired_ttl_refetches(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
calls = _install_urlopen(monkeypatch, _admin_ok([_run()]))
clock = [1000.0]
monkeypatch.setattr(ex, "_monotonic", lambda: clock[0])
config = _make_config(tmp_path)
list_executions(config, [], token="tok", now=_NOW)
clock[0] += ex._TTL_SEC + 0.1
list_executions(config, [], token="tok", now=_NOW)
assert len(calls) == 2
def test_cache_is_keyed_by_token_and_limit(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
calls = _install_urlopen(monkeypatch, _admin_ok([_run()]))
monkeypatch.setattr(ex, "_monotonic", lambda: 1000.0)
config = _make_config(tmp_path)
list_executions(config, [], token="tok", now=_NOW)
list_executions(config, [], token="other", now=_NOW)
list_executions(config, [], token="other", limit=5, now=_NOW)
assert len(calls) == 3
def test_errors_are_cached_too(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A Gitea outage must not turn every 5s poll into an HTTP attempt."""
calls = _install_urlopen(monkeypatch, lambda _url: urllib.error.URLError("down"))
monkeypatch.setattr(ex, "_monotonic", lambda: 1000.0)
config = _make_config(tmp_path)
first = list_executions(config, [], token="tok", now=_NOW)
second = list_executions(config, [], token="tok", now=_NOW)
assert first.error == second.error
assert len(calls) == 1