Skip to content

Commit 137f924

Browse files
authored
test(e2e): add API end-to-end test layer (#1107)
* test(e2e): add API end-to-end harness and boot smoke test * test(e2e): task CRUD round-trip * test(e2e): lifecycle start/pause/complete transitions * test(e2e): dependency wiring * test(e2e): schedule optimization * test(e2e): auth enforcement * test(e2e): drain server output to DEVNULL and harden process teardown * ci: run API e2e tests in a dedicated job * test(e2e): capture server output to a log file for startup diagnostics
1 parent 8c212ac commit 137f924

12 files changed

Lines changed: 330 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,29 @@ jobs:
109109
- name: Run tests
110110
run: make test-${{ matrix.package }}
111111

112+
e2e:
113+
name: E2E
114+
runs-on: ubuntu-latest
115+
steps:
116+
- name: Checkout code
117+
uses: actions/checkout@v7
118+
119+
- name: Set up Python
120+
uses: actions/setup-python@v6
121+
with:
122+
python-version: ${{ env.PYTHON_VERSION }}
123+
124+
- name: Install uv
125+
uses: astral-sh/setup-uv@v7
126+
with:
127+
enable-cache: true
128+
129+
- name: Install dependencies
130+
run: uv sync --all-packages --all-extras --dev
131+
132+
- name: Run E2E tests
133+
run: make test-e2e
134+
112135
security:
113136
name: Security Scan
114137
runs-on: ubuntu-latest

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: help test test-core test-server test-ui test-client test-mcp test-all \
1+
.PHONY: help test test-core test-server test-ui test-client test-mcp test-e2e test-all \
22
install install-dev install-hooks install-core install-server install-ui install-client install-mcp \
33
install-ui-only install-server-only reinstall \
44
tool-install-ui tool-install-server check-deps \
@@ -211,6 +211,9 @@ test-server: test-taskdog-server ## Run taskdog-server tests
211211
test-ui: test-taskdog-ui ## Run taskdog-ui tests
212212
test-mcp: test-taskdog-mcp ## Run taskdog-mcp tests
213213

214+
test-e2e: ## Run API end-to-end tests (spawns a real server)
215+
uv run --all-packages pytest tests/e2e -v
216+
214217
# ============================================================================
215218
# Code Quality Targets (recursive)
216219
# ============================================================================

tests/__init__.py

Whitespace-only changes.

tests/e2e/__init__.py

Whitespace-only changes.

tests/e2e/conftest.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Fixtures for API end-to-end tests.
2+
3+
Spawns the production taskdog-server against a temporary SQLite database and
4+
drives it with the shipped TaskdogApiClient over real HTTP.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import TYPE_CHECKING
10+
11+
import pytest
12+
from taskdog_client.taskdog_api_client import TaskdogApiClient
13+
14+
from tests.e2e.harness import spawn_server, terminate_server
15+
16+
if TYPE_CHECKING:
17+
from collections.abc import Iterator
18+
19+
20+
@pytest.fixture(scope="session")
21+
def live_server(tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]:
22+
"""Session-scoped production server on an ephemeral port, temp SQLite DB."""
23+
root = tmp_path_factory.mktemp("e2e-server")
24+
db_path = root / "tasks.db"
25+
cfg_dir = root / "config"
26+
cfg_dir.mkdir()
27+
process, base_url = spawn_server(db_path, cfg_dir, auth=False)
28+
try:
29+
yield base_url
30+
finally:
31+
terminate_server(process)
32+
33+
34+
@pytest.fixture
35+
def client(live_server: str) -> Iterator[TaskdogApiClient]:
36+
"""Real API client bound to the live server."""
37+
api = TaskdogApiClient(base_url=live_server)
38+
try:
39+
yield api
40+
finally:
41+
api.close()
42+
43+
44+
@pytest.fixture(autouse=True)
45+
def clean_db(client: TaskdogApiClient) -> None:
46+
"""Remove all tasks (including archived) so each test starts clean."""
47+
listing = client.list_tasks(include_archived=True)
48+
for task in listing.tasks:
49+
client.remove_task(task.id)

tests/e2e/harness.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Helpers for spawning a production taskdog-server subprocess in E2E tests.
2+
3+
Kept out of conftest.py so both the fixtures and individual test modules can
4+
import spawn_server without relying on pytest's conftest import machinery.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import os
10+
import socket
11+
import subprocess
12+
import sys
13+
import time
14+
from typing import TYPE_CHECKING
15+
16+
from taskdog_client.taskdog_api_client import TaskdogApiClient
17+
18+
if TYPE_CHECKING:
19+
from pathlib import Path
20+
21+
_READINESS_TIMEOUT_S = 15.0
22+
23+
24+
def _free_port() -> int:
25+
"""Reserve an ephemeral TCP port and release it for the server to bind."""
26+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
27+
sock.bind(("127.0.0.1", 0))
28+
return int(sock.getsockname()[1])
29+
30+
31+
def _wait_until_ready(base_url: str, process: subprocess.Popen[bytes]) -> None:
32+
"""Poll /health until the server responds or the timeout elapses."""
33+
probe = TaskdogApiClient(base_url=base_url)
34+
deadline = time.monotonic() + _READINESS_TIMEOUT_S
35+
try:
36+
while time.monotonic() < deadline:
37+
if process.poll() is not None:
38+
raise RuntimeError(
39+
f"server exited early with code {process.returncode}"
40+
)
41+
if probe.check_health():
42+
return
43+
time.sleep(0.1)
44+
raise RuntimeError("server did not become healthy in time")
45+
finally:
46+
probe.close()
47+
48+
49+
def spawn_server(
50+
db_path: Path, cfg_dir: Path, *, auth: bool
51+
) -> tuple[subprocess.Popen[bytes], str]:
52+
"""Launch a taskdog-server subprocess and wait until it is healthy.
53+
54+
Args:
55+
db_path: SQLite file the server should use.
56+
cfg_dir: Empty XDG_CONFIG_HOME so host config never leaks in.
57+
auth: When True, enable auth via env override.
58+
59+
Returns:
60+
(process, base_url).
61+
"""
62+
port = _free_port()
63+
env = {
64+
**os.environ,
65+
"TASKDOG_STORAGE_DATABASE_URL": f"sqlite:///{db_path}",
66+
"XDG_CONFIG_HOME": str(cfg_dir),
67+
}
68+
if auth:
69+
env["TASKDOG_AUTH_ENABLED"] = "true"
70+
# Redirect server output to a file rather than a PIPE: an unread PIPE
71+
# deadlocks the server once the OS buffer fills with access logs, while
72+
# DEVNULL would discard the diagnostics we need when startup fails.
73+
log_path = db_path.parent / "server.log"
74+
with log_path.open("w") as log_file:
75+
process = subprocess.Popen(
76+
[
77+
sys.executable,
78+
"-m",
79+
"taskdog_server.main",
80+
"--host",
81+
"127.0.0.1",
82+
"--port",
83+
str(port),
84+
],
85+
env=env,
86+
stdout=log_file,
87+
stderr=subprocess.STDOUT,
88+
)
89+
base_url = f"http://127.0.0.1:{port}"
90+
try:
91+
_wait_until_ready(base_url, process)
92+
except Exception as exc:
93+
if process.poll() is None:
94+
terminate_server(process)
95+
raise RuntimeError(
96+
f"taskdog-server failed to start (log at {log_path}):\n"
97+
f"{log_path.read_text()[-2000:]}"
98+
) from exc
99+
return process, base_url
100+
101+
102+
def terminate_server(process: subprocess.Popen[bytes]) -> None:
103+
"""Terminate a spawned server process, escalating to kill if needed."""
104+
process.terminate()
105+
try:
106+
process.wait(timeout=5)
107+
except subprocess.TimeoutExpired:
108+
process.kill()
109+
process.wait()

tests/e2e/test_auth.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""E2E: an auth-enabled server rejects unauthenticated requests."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
import pytest
8+
from taskdog_client.taskdog_api_client import TaskdogApiClient
9+
10+
from taskdog_core.domain.exceptions.task_exceptions import AuthenticationError
11+
from tests.e2e.harness import spawn_server, terminate_server
12+
13+
if TYPE_CHECKING:
14+
from collections.abc import Iterator
15+
16+
_API_KEY = "e2e-test-key"
17+
18+
19+
@pytest.fixture
20+
def auth_server(tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]:
21+
root = tmp_path_factory.mktemp("e2e-auth")
22+
cfg_dir = root / "config" / "taskdog"
23+
cfg_dir.mkdir(parents=True)
24+
(cfg_dir / "server.toml").write_text(
25+
"[auth]\n"
26+
"enabled = true\n\n"
27+
"[[auth.api_keys]]\n"
28+
'name = "e2e"\n'
29+
f'key = "{_API_KEY}"\n'
30+
)
31+
process, base_url = spawn_server(root / "tasks.db", root / "config", auth=True)
32+
try:
33+
yield base_url
34+
finally:
35+
terminate_server(process)
36+
37+
38+
def test_missing_key_is_rejected(auth_server: str) -> None:
39+
api = TaskdogApiClient(base_url=auth_server)
40+
try:
41+
with pytest.raises(AuthenticationError):
42+
api.create_task(name="no key")
43+
finally:
44+
api.close()
45+
46+
47+
def test_valid_key_is_accepted(auth_server: str) -> None:
48+
api = TaskdogApiClient(base_url=auth_server, api_key=_API_KEY)
49+
try:
50+
created = api.create_task(name="with key")
51+
assert created.id > 0
52+
finally:
53+
api.close()

tests/e2e/test_dependencies.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""E2E: dependency wiring persists through the real server."""
2+
3+
from taskdog_client.taskdog_api_client import TaskdogApiClient
4+
5+
6+
def test_add_dependency(client: TaskdogApiClient) -> None:
7+
prerequisite = client.create_task(name="prerequisite")
8+
dependent = client.create_task(name="dependent")
9+
10+
result = client.add_dependency(dependent.id, prerequisite.id)
11+
assert prerequisite.id in result.depends_on
12+
13+
fetched = client.get_task_by_id(dependent.id)
14+
assert prerequisite.id in fetched.task.depends_on

tests/e2e/test_lifecycle.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""E2E: lifecycle state transitions persist through the real server."""
2+
3+
from taskdog_client.taskdog_api_client import TaskdogApiClient
4+
5+
from taskdog_core.domain.entities.task import TaskStatus
6+
7+
8+
def test_start_pause_complete(client: TaskdogApiClient) -> None:
9+
task = client.create_task(name="lifecycle task")
10+
11+
started = client.start_task(task.id)
12+
assert started.status == TaskStatus.IN_PROGRESS
13+
14+
paused = client.pause_task(task.id)
15+
assert paused.status == TaskStatus.PENDING
16+
17+
client.start_task(task.id)
18+
completed = client.complete_task(task.id)
19+
assert completed.status == TaskStatus.COMPLETED
20+
21+
fetched = client.get_task_by_id(task.id)
22+
assert fetched.task.status == TaskStatus.COMPLETED

tests/e2e/test_optimize.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""E2E: schedule optimization runs against real data via the real server."""
2+
3+
from taskdog_client.taskdog_api_client import TaskdogApiClient
4+
5+
6+
def test_optimize_runs(client: TaskdogApiClient) -> None:
7+
client.create_task(name="opt a", estimated_duration=2.0)
8+
client.create_task(name="opt b", estimated_duration=3.0)
9+
10+
algorithms = client.get_algorithm_metadata()
11+
assert algorithms, "server should expose at least one algorithm"
12+
algorithm_key = algorithms[0][0]
13+
14+
result = client.optimize_schedule(
15+
algorithm=algorithm_key,
16+
start_date=None,
17+
max_hours_per_day=8.0,
18+
)
19+
20+
assert result is not None
21+
assert len(result.successful_tasks) == 2, (
22+
"Both tasks should be successfully scheduled"
23+
)

0 commit comments

Comments
 (0)