Skip to content

Commit ca71a5f

Browse files
authored
Merge pull request #19 from kundhu-codes/routes_tests
tests: add async route tests
2 parents 910941e + e009bf1 commit ca71a5f

6 files changed

Lines changed: 497 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,7 @@ include = ["inkwell*"]
5555
[tool.ruff]
5656
line-length = 100
5757
target-version = "py311"
58+
59+
[tool.pytest.ini_options]
60+
asyncio_mode = "auto"
61+
asyncio_default_fixture_loop_scope = "function"

tests/test_routes/__init__.py

Whitespace-only changes.
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import pytest
2+
import httpx
3+
from unittest.mock import patch
4+
5+
from inkwell.app import create_app
6+
from inkwell.routes.api_profile import _is_meaningfully_configured
7+
8+
# ------------------------------------------------------------------
9+
# Fixtures
10+
# ------------------------------------------------------------------
11+
12+
13+
@pytest.fixture
14+
async def client():
15+
"""Create a fresh Async httpx Client for each test."""
16+
app = create_app()
17+
transport = httpx.ASGITransport(app=app)
18+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac:
19+
yield ac
20+
21+
22+
# ------------------------------------------------------------------
23+
# Logic Tests
24+
# ------------------------------------------------------------------
25+
26+
27+
@pytest.mark.parametrize(
28+
"personality_dict, expected",
29+
[
30+
({"bio": "I am a dev", "example_comments": ["Comment 1"]}, True),
31+
({"bio": "", "example_comments": ["Comment 1"]}, False),
32+
({"bio": "I am a dev", "example_comments": []}, False),
33+
({"bio": " ", "example_comments": ["Comment 1"]}, False),
34+
({}, False),
35+
],
36+
ids=[
37+
"fully_configured_profile",
38+
"missing_bio_content",
39+
"missing_example_comments",
40+
"bio_with_only_whitespace",
41+
"totally_empty_dictionary",
42+
],
43+
)
44+
def test_is_meaningfully_configured(personality_dict, expected):
45+
assert _is_meaningfully_configured(personality_dict) is expected
46+
47+
48+
# ------------------------------------------------------------------
49+
# Tests for GET /api/profile
50+
# ------------------------------------------------------------------
51+
52+
53+
@pytest.mark.asyncio
54+
@patch("inkwell.routes.api_profile.load_personality")
55+
async def test_get_profile_success(mock_load, client):
56+
mock_load.return_value = {
57+
"name": "Name",
58+
"bio": "A multi-line bio string\nthat represents a persona.\n",
59+
"example_comments": ["Example 1"],
60+
}
61+
62+
response = await client.get("/api/profile")
63+
assert response.status_code == 200
64+
65+
data = response.json()
66+
assert data["profile"]["name"] == "Name"
67+
assert data["configured"] is True
68+
69+
70+
@pytest.mark.asyncio
71+
@patch("inkwell.routes.api_profile.load_personality")
72+
async def test_get_profile_not_configured(mock_load, client):
73+
mock_load.return_value = {}
74+
75+
response = await client.get("/api/profile")
76+
assert response.status_code == 200
77+
assert response.json()["configured"] is False
78+
79+
80+
# ------------------------------------------------------------------
81+
# Tests for POST /api/profile
82+
# ------------------------------------------------------------------
83+
84+
85+
@pytest.mark.asyncio
86+
@pytest.mark.parametrize(
87+
"payload, expected_tone_structure",
88+
[
89+
(
90+
{"name": "Name", "tone": {"style": "witty", "humor": "dry", "formality": "casual"}},
91+
{"style": "witty", "humor": "dry", "formality": "casual"},
92+
),
93+
({"name": "Name1", "tone": {"style": "", "humor": "", "formality": ""}}, {}),
94+
],
95+
ids=["save_full", "save_empty_tone"],
96+
)
97+
@patch("inkwell.routes.api_profile.write_yaml")
98+
async def test_save_profile_scenarios(mock_write, client, payload, expected_tone_structure):
99+
mock_write.return_value = "personality.yml"
100+
101+
# Await the post call
102+
response = await client.post("/api/profile", json=payload)
103+
104+
assert response.status_code == 200
105+
actual_data_saved = mock_write.call_args[0][1]
106+
assert actual_data_saved["tone"] == expected_tone_structure
107+
108+
109+
@pytest.mark.asyncio
110+
@patch("inkwell.routes.api_profile.write_yaml")
111+
async def test_save_profile_internal_error_handling(mock_write, client):
112+
mock_write.side_effect = Exception("Disk Full")
113+
114+
response = await client.post("/api/profile", json={"name": "User"})
115+
116+
assert response.status_code == 500
117+
assert "Write failed" in response.json()["detail"]
118+
119+
120+
@pytest.mark.asyncio
121+
async def test_save_profile_validation_error(client):
122+
bad_payload = {"name": "User", "interests": "not-a-list"}
123+
124+
response = await client.post("/api/profile", json=bad_payload)
125+
assert response.status_code == 422

tests/test_routes/test_api_scan.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import pytest
2+
import json
3+
import time
4+
from queue import Queue
5+
from unittest.mock import patch
6+
from httpx import AsyncClient, ASGITransport
7+
import pytest_asyncio
8+
9+
from inkwell.app import create_app
10+
from inkwell.routes.api_scan import _scan_state
11+
12+
# ------------------------------------------------------------------
13+
# Fixtures
14+
# ------------------------------------------------------------------
15+
16+
17+
@pytest_asyncio.fixture
18+
async def client():
19+
_scan_state["running"] = False
20+
_scan_state["started_at"] = None
21+
_scan_state["current_subreddit"] = None
22+
_scan_state["cancel"] = False
23+
_scan_state["queue"] = None
24+
25+
app = create_app()
26+
transport = ASGITransport(app=app)
27+
async with AsyncClient(transport=transport, base_url="http://test") as c:
28+
yield c
29+
30+
31+
# ------------------------------------------------------------------
32+
# Tests for GET /api/scan/status
33+
# ------------------------------------------------------------------
34+
@pytest.mark.asyncio
35+
async def test_get_scan_status_idle(client):
36+
response = await client.get("/api/scan/status")
37+
assert response.status_code == 200
38+
data = response.json()
39+
assert data["running"] is False
40+
assert data["current_subreddit"] is None
41+
42+
43+
@pytest.mark.asyncio
44+
async def test_get_scan_status_running(client):
45+
_scan_state["running"] = True
46+
_scan_state["current_subreddit"] = "r/python"
47+
48+
response = await client.get("/api/scan/status")
49+
data = response.json()
50+
assert data["running"] is True
51+
assert data["current_subreddit"] == "r/python"
52+
53+
54+
# ------------------------------------------------------------------
55+
# Tests for POST /api/scan
56+
# ------------------------------------------------------------------
57+
58+
59+
@pytest.mark.asyncio
60+
@patch("inkwell.routes.api_scan._run_scan_with_emit")
61+
async def test_start_scan_success(mock_run, client):
62+
63+
def mock_emit_logic(emit, options):
64+
emit({"kind": "start", "subreddit_count": 2})
65+
emit({"kind": "done", "scanned": 2, "new_signals": 0})
66+
67+
mock_run.side_effect = mock_emit_logic
68+
69+
response = await client.post("/api/scan", json={"limit_subreddits": 2})
70+
71+
assert response.status_code == 200
72+
assert "text/event-stream" in response.headers["content-type"]
73+
74+
events = response.text.strip().split("\n\n")
75+
assert 'data: {"kind": "start", "subreddit_count": 2}' in events[0]
76+
assert 'data: {"kind": "done"' in events[1]
77+
78+
assert _scan_state["running"] is False
79+
80+
81+
@pytest.mark.asyncio
82+
async def test_start_scan_conflict(client):
83+
_scan_state["running"] = True
84+
85+
response = await client.post("/api/scan", json={})
86+
assert response.status_code == 409
87+
assert "already running" in response.json()["detail"]
88+
89+
90+
@patch("inkwell.routes.api_scan.logger")
91+
@patch("inkwell.routes.api_scan._run_scan_with_emit")
92+
@pytest.mark.asyncio
93+
async def test_start_scan_exception_handling(mock_run, mock_logger, client):
94+
mock_run.side_effect = Exception("Reddit API Down/Timeout")
95+
96+
response = await client.post("/api/scan", json={})
97+
98+
assert 'data: {"kind": "error", "message": "Reddit API Down/Timeout"}' in response.text
99+
assert _scan_state["running"] is False
100+
101+
102+
@patch("inkwell.routes.api_scan._run_scan_with_emit")
103+
@pytest.mark.asyncio
104+
async def test_scan_heartbeat_precedes_completion_on_stall(mock_run, client):
105+
def stalling_scan(emit, options):
106+
# 7s stall > 5s heartbeat interval
107+
time.sleep(7.0)
108+
emit({"kind": "done", "scanned": 1})
109+
110+
mock_run.side_effect = stalling_scan
111+
112+
response = await client.post("/api/scan", json={"limit_subreddits": 1})
113+
114+
events = [ev for ev in response.text.strip().split("\n\n") if ev.startswith("data:")]
115+
116+
heartbeat_index = next((i for i, ev in enumerate(events) if '"kind": "heartbeat"' in ev), -1)
117+
done_index = next((i for i, ev in enumerate(events) if '"kind": "done"' in ev), -1)
118+
119+
assert heartbeat_index != -1, "No heartbeat detected"
120+
assert done_index != -1, "Scan never finished"
121+
122+
assert heartbeat_index < done_index, (
123+
f"Heartbeat (pos {heartbeat_index}) should have arrived "
124+
f"before scan completion (pos {done_index})"
125+
)
126+
127+
heartbeat_data = json.loads(events[heartbeat_index].replace("data: ", ""))
128+
assert heartbeat_data["elapsed_s"] >= 5.0
129+
130+
131+
@patch("inkwell.routes.api_scan._run_scan_with_emit")
132+
@pytest.mark.asyncio
133+
async def test_rapid_restart(mock_run, client):
134+
await client.post("/api/scan")
135+
response = await client.post("/api/scan")
136+
assert response.status_code == 200 # Should NOT be 409
137+
138+
139+
# ------------------------------------------------------------------
140+
# Tests for POST /api/scan/stop
141+
# ------------------------------------------------------------------
142+
143+
144+
@pytest.mark.asyncio
145+
async def test_stop_scan_not_running(client):
146+
response = await client.post("/api/scan/stop")
147+
assert response.json() == {"ok": False, "detail": "No scan is running."}
148+
149+
150+
@pytest.mark.asyncio
151+
async def test_stop_scan_success(client):
152+
"""Verify stop sets cancel flag and injects event into queue."""
153+
mock_queue = Queue()
154+
_scan_state["running"] = True
155+
_scan_state["queue"] = mock_queue
156+
157+
response = await client.post("/api/scan/stop")
158+
159+
assert response.status_code == 200
160+
assert response.json()["ok"] is True
161+
assert _scan_state["cancel"] is True
162+
163+
# Verify the 'cancelled' event was pushed to wake up the SSE stream
164+
injected_event = mock_queue.get()
165+
assert injected_event["kind"] == "cancelled"
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import pytest
2+
from unittest.mock import patch
3+
from httpx import AsyncClient, ASGITransport
4+
from inkwell.app import create_app
5+
import pytest_asyncio
6+
7+
8+
# ------------------------------------------------------------------
9+
# Fixtures
10+
# ------------------------------------------------------------------
11+
12+
13+
@pytest_asyncio.fixture
14+
async def client():
15+
app = create_app()
16+
transport = ASGITransport(app=app)
17+
async with AsyncClient(transport=transport, base_url="http://test") as c:
18+
yield c
19+
20+
21+
# ------------------------------------------------------------------
22+
# Tests for POST /api/settings/test-llm
23+
# ------------------------------------------------------------------
24+
@pytest.mark.asyncio
25+
@patch("inkwell.routes.api_settings.litellm.completion")
26+
async def test_llm_probe_success(mock_completion, client):
27+
mock_completion.return_value = {"choices": [{"message": {"content": "pong"}}]}
28+
29+
payload = {"model": "gpt-4o"}
30+
headers = {"X-LLM-Key": "sk-valid-key"}
31+
32+
response = await client.post("/api/settings/test-llm", json=payload, headers=headers)
33+
34+
assert response.status_code == 200
35+
data = response.json()
36+
assert data["ok"] is True
37+
assert "gpt-4o responded" in data["detail"]
38+
39+
args, kwargs = mock_completion.call_args
40+
assert kwargs["model"] == "gpt-4o"
41+
assert kwargs["api_key"] == "sk-valid-key"
42+
assert kwargs["max_tokens"] == 1
43+
44+
45+
@pytest.mark.asyncio
46+
@patch("inkwell.routes.api_settings.litellm.completion")
47+
async def test_llm_probe_handles_auth_failure_and_redacts_key(mock_completion, client):
48+
secret_key = "sk-very-secret-123"
49+
mock_completion.side_effect = Exception(f"Invalid Request: Unauthorized for key {secret_key}")
50+
51+
payload = {"model": "claude-3-opus"}
52+
headers = {"X-LLM-Key": secret_key}
53+
54+
response = await client.post("/api/settings/test-llm", json=payload, headers=headers)
55+
56+
assert response.status_code == 200
57+
data = response.json()
58+
assert data["ok"] is False
59+
assert secret_key not in data["detail"]
60+
assert "[redacted]" in data["detail"]
61+
62+
63+
@pytest.mark.asyncio
64+
async def test_llm_probe_fails_if_model_missing(client):
65+
response = await client.post("/api/settings/test-llm", json={"model": " "})
66+
67+
assert response.status_code == 200 # App logic returns ok: False instead of 422
68+
assert response.json()["ok"] is False
69+
assert "Model is required" in response.json()["detail"]
70+
71+
72+
@pytest.mark.asyncio
73+
@patch("inkwell.routes.api_settings.litellm.completion")
74+
async def test_llm_probe_works_without_header(mock_completion, client):
75+
mock_completion.return_value = {"ok": True}
76+
77+
response = await client.post("/api/settings/test-llm", json={"model": "gpt-3.5-turbo"})
78+
79+
assert response.status_code == 200
80+
# Ensure api_key was NOT passed to litellm if header was missing
81+
_, kwargs = mock_completion.call_args
82+
assert "api_key" not in kwargs

0 commit comments

Comments
 (0)