Skip to content

Commit 0018ce7

Browse files
authored
feat(tests): pytest endpoint coverage via Flask test client (closes #26) (#32)
* feat(tests): pytest endpoint coverage via Flask test client (closes #26) Adds pytest-based integration tests for the four conversation-browsing HTTP routes the dashboard reads. Tests run alongside the existing ``unittest discover`` suite — both gates required in CI. - tests/conftest.py: pytest fixtures. * ``workspace_storage`` builds a temp layout matching what ``utils/workspace_path.resolve_workspace_path`` reads: <tmp>/workspaceStorage/<HAPPY_WORKSPACE_ID>/state.vscdb (ItemTable with composer.composerData → allComposers row) <tmp>/workspaceStorage/<HAPPY_WORKSPACE_ID>/workspace.json ({"folder": <tmp>/happy-project}) <tmp>/globalStorage/state.vscdb (cursorDiskKV with one composerData:<id> row + matching bubbleId:<cid>:<bid> row; bubble text contains a searchable sentinel) <tmp>/cli_chats/ (empty — overrides CLI_CHATS_PATH so the live ~/.cursor/chats doesn't leak into tests) Sets WORKSPACE_PATH + CLI_CHATS_PATH env vars for the duration of the test; restores prior values on cleanup. * ``client`` returns a Flask test client bound to the temp workspace_storage (app.create_app() with TESTING=True and empty EXCLUSION_RULES). * ``empty_workspace_client`` returns a Flask test client over an empty workspaceStorage — exercises the 404 / empty-list paths. - tests/test_api_endpoints.py: 11 tests across 4 endpoints. * GET /api/workspaces — list contains the seeded workspace (happy); empty storage returns ``[]`` (empty). * GET /api/workspaces/<id> — returns full record (happy); unknown id returns 404; ``global`` returns the "Other chats" virtual workspace. * GET /api/workspaces/<id>/tabs — returns tabs list with the seeded composer (happy); global also returns tabs; missing global storage returns 404. * GET /api/search?q=... — finds the seeded sentinel (happy); non-matching query returns ``{"results": []}``; missing q is tolerated (200 with empty or 400 — pin whichever shipped). - .github/workflows/tests.yml: add ``pytest>=8`` to the dependency install + new "Run pytest integration suite" step (``python -m pytest tests/ -v --tb=short``) immediately after the unittest step. Both suites are merge gates; pytest does not replace unittest (existing 178 tests use unittest.TestCase classes which pytest also collects, so the new pytest run actually executes 178 + 11 = 189 tests on top of the unittest run's 178). Verified locally: - ``python -m unittest discover tests -v`` → 178 pass - ``python -m pytest tests/ -v --tb=short`` → 189 pass - ``mypy --ignore-missing-imports --no-strict-optional`` on the new files introduces zero new errors (the 4 pre-existing errors in utils/exclusion_rules.py and api/search.py are unchanged). Production code (api/, services/, utils/, models/) is untouched — this PR is test infrastructure only. * review: pin /api/search missing-q contract to 400 (#32) CodeRabbit flagged that the permissive ``assert status_code in (200, 400)`` let either contract pass, so a drift between the two behaviours would go unnoticed. The shipped implementation (api/search.py:74-75) returns ``400 with {"error": "No search query provided"}`` for missing or empty q. Replaced the single permissive test with three concrete ones, each asserting the same 400 + error-body contract: - test_missing_q_returns_400 — GET /api/search (no q at all) - test_empty_q_returns_400 — GET /api/search?q= (empty) - test_whitespace_only_q_returns_400 — GET /api/search?q=%20%20%20 (api/search.py strips before checking) 191 pytest tests pass (was 189; +2 from the split). * chore: drop issue-number reference from tests.yml comment The "Closes #26." header in the pytest step comment belongs in the PR description, not in CI config — comments shouldn't reference the task that introduced them. * review: six follow-ups on Brad's PR #32 pass 1. Scope pytest step to tests/test_api_endpoints.py — `pytest tests/` was also re-collecting the 178 unittest.TestCase subclasses already covered by the unittest discover step, ~2× CI minutes for no signal. 2. Extract HAPPY_* ID constants into tests/_fixture_ids.py and import from there in both conftest.py and test_api_endpoints.py. conftest is special to pytest and is not guaranteed importable as `tests.conftest` under --import-mode=importlib; a regular helper module sidesteps that. 3. Wrap both DB-seeding helpers (_make_global_state_db, _make_workspace) in contextlib.closing(sqlite3.connect(...)) so a mid-setup exception can't leak the handle and lock the tempdir against cleanup. 4. Tighten test_global_returns_tabs from a shape-only check into an isolation assertion: HAPPY_COMPOSER_ID is assigned to HAPPY_WORKSPACE_ID, so it must NOT appear in the /global bucket. 5. New TestExclusionRules class — three tests covering the EXCLUSION_RULES path on /api/workspaces and /api/search: workspace filtered by matching rule, negative control (non-matching rule leaves it visible), seeded chat dropped from search by rule. 6. empty_workspace_client now annotates the yield type as Generator[FlaskClient, None, None] to match workspace_storage's parameterised Generator[str, None, None]. Verified locally: - pytest tests/test_api_endpoints.py: 16 passed - pytest --import-mode=importlib: 16 passed - unittest discover tests: 178 passed, OK
1 parent 7777ebc commit 0018ce7

4 files changed

Lines changed: 384 additions & 1 deletion

File tree

.github/workflows/tests.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,19 @@ jobs:
4646
# system packages on Linux — out of scope for the unittest suite.
4747
run: |
4848
python -m pip install --upgrade pip
49-
python -m pip install 'flask>=3.0' 'fpdf2>=2.7'
49+
python -m pip install 'flask>=3.0' 'fpdf2>=2.7' 'pytest>=8'
5050
5151
- name: Run unittest suite
5252
run: python -m unittest discover tests -v
5353

54+
- name: Run pytest integration suite
55+
# Pytest fixtures (tests/conftest.py) build a temp workspaceStorage
56+
# and exercise the Flask routes via app.test_client(). Scoped to the
57+
# new endpoint file because `pytest tests/` would also re-collect the
58+
# 178 unittest.TestCase subclasses already run in the step above —
59+
# ~2× the CI minutes for zero extra signal.
60+
run: python -m pytest tests/test_api_endpoints.py -v --tb=short
61+
5462
# ── Typecheck: mypy ───────────────────────────────────────────────────────
5563
# Codebase already has type hints across most of the surface (~70+ typed
5664
# functions). Mypy runs in lenient mode (--ignore-missing-imports for

tests/_fixture_ids.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Shared composer/bubble/workspace IDs used by both the pytest fixture
2+
(`tests/conftest.py`) and the tests that introspect the seeded data.
3+
4+
Lives in a regular module rather than inside conftest because conftest is
5+
special to pytest and is not guaranteed to be importable as `tests.conftest`
6+
under non-default import modes (e.g. `--import-mode=importlib`)."""
7+
from __future__ import annotations
8+
9+
HAPPY_COMPOSER_ID = "cmp-happy"
10+
HAPPY_BUBBLE_ID = "bub-happy"
11+
HAPPY_WORKSPACE_ID = "ws-happy"

tests/conftest.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
from __future__ import annotations
2+
3+
import contextlib
4+
import json
5+
import os
6+
import sqlite3
7+
import sys
8+
import tempfile
9+
from pathlib import Path
10+
from typing import Generator
11+
12+
import pytest
13+
from flask.testing import FlaskClient
14+
15+
REPO_ROOT = str(Path(__file__).resolve().parent.parent)
16+
if REPO_ROOT not in sys.path:
17+
sys.path.insert(0, REPO_ROOT)
18+
19+
from app import create_app
20+
from tests._fixture_ids import ( # noqa: E402,F401 (re-export for legacy importers)
21+
HAPPY_BUBBLE_ID,
22+
HAPPY_COMPOSER_ID,
23+
HAPPY_WORKSPACE_ID,
24+
)
25+
26+
27+
def _make_global_state_db(path: str) -> None:
28+
"""globalStorage/state.vscdb with one composerData + one bubbleId row."""
29+
# contextlib.closing guarantees conn.close() even if an exec/commit raises
30+
# mid-setup, so a failed fixture build can't leak a handle and lock the
31+
# tempdir against cleanup.
32+
with contextlib.closing(sqlite3.connect(path)) as conn:
33+
conn.execute("CREATE TABLE cursorDiskKV ([key] TEXT PRIMARY KEY, value TEXT)")
34+
conn.execute(
35+
"INSERT INTO cursorDiskKV ([key], value) VALUES (?, ?)",
36+
(
37+
f"composerData:{HAPPY_COMPOSER_ID}",
38+
json.dumps({
39+
"name": "Happy conversation",
40+
"createdAt": 1_715_000_000_000,
41+
"lastUpdatedAt": 1_715_000_500_000,
42+
"fullConversationHeadersOnly": [
43+
{"bubbleId": HAPPY_BUBBLE_ID, "type": 1},
44+
],
45+
"modelConfig": {"modelName": "gpt-4o"},
46+
}),
47+
),
48+
)
49+
conn.execute(
50+
"INSERT INTO cursorDiskKV ([key], value) VALUES (?, ?)",
51+
(
52+
f"bubbleId:{HAPPY_COMPOSER_ID}:{HAPPY_BUBBLE_ID}",
53+
json.dumps({
54+
"text": "find me by search term sentinel-grep",
55+
"type": "user",
56+
"createdAt": 1_715_000_400_000,
57+
}),
58+
),
59+
)
60+
conn.commit()
61+
62+
63+
def _make_workspace(parent: str, workspace_id: str, project_folder: str) -> None:
64+
"""One per-workspace directory: workspace.json + minimal state.vscdb."""
65+
ws_dir = os.path.join(parent, workspace_id)
66+
os.makedirs(ws_dir, exist_ok=True)
67+
with open(os.path.join(ws_dir, "workspace.json"), "w", encoding="utf-8") as f:
68+
json.dump({"folder": project_folder}, f)
69+
db = os.path.join(ws_dir, "state.vscdb")
70+
with contextlib.closing(sqlite3.connect(db)) as conn:
71+
conn.execute("CREATE TABLE ItemTable ([key] TEXT PRIMARY KEY, value TEXT)")
72+
conn.execute(
73+
"INSERT INTO ItemTable ([key], value) VALUES (?, ?)",
74+
(
75+
"composer.composerData",
76+
json.dumps({"allComposers": [{"composerId": HAPPY_COMPOSER_ID}]}),
77+
),
78+
)
79+
conn.commit()
80+
81+
82+
@pytest.fixture
83+
def workspace_storage() -> Generator[str, None, None]:
84+
"""Build a temp workspaceStorage layout and yield the workspace path.
85+
86+
Layout:
87+
<tmp>/workspaceStorage/<HAPPY_WORKSPACE_ID>/workspace.json
88+
<tmp>/workspaceStorage/<HAPPY_WORKSPACE_ID>/state.vscdb
89+
<tmp>/globalStorage/state.vscdb
90+
<tmp>/cli_chats/ (empty — keeps live ~/.cursor leaking out)
91+
92+
Sets ``WORKSPACE_PATH`` and ``CLI_CHATS_PATH`` env vars for the duration of
93+
the test and restores them on cleanup.
94+
"""
95+
with tempfile.TemporaryDirectory() as tmp:
96+
ws_root = os.path.join(tmp, "workspaceStorage")
97+
global_root = os.path.join(tmp, "globalStorage")
98+
cli_root = os.path.join(tmp, "cli_chats")
99+
os.makedirs(ws_root, exist_ok=True)
100+
os.makedirs(global_root, exist_ok=True)
101+
os.makedirs(cli_root, exist_ok=True)
102+
103+
project_folder = os.path.join(tmp, "happy-project")
104+
os.makedirs(project_folder, exist_ok=True)
105+
106+
_make_workspace(ws_root, HAPPY_WORKSPACE_ID, project_folder)
107+
_make_global_state_db(os.path.join(global_root, "state.vscdb"))
108+
109+
prior_ws = os.environ.get("WORKSPACE_PATH")
110+
prior_cli = os.environ.get("CLI_CHATS_PATH")
111+
os.environ["WORKSPACE_PATH"] = ws_root
112+
os.environ["CLI_CHATS_PATH"] = cli_root
113+
try:
114+
yield ws_root
115+
finally:
116+
if prior_ws is None:
117+
os.environ.pop("WORKSPACE_PATH", None)
118+
else:
119+
os.environ["WORKSPACE_PATH"] = prior_ws
120+
if prior_cli is None:
121+
os.environ.pop("CLI_CHATS_PATH", None)
122+
else:
123+
os.environ["CLI_CHATS_PATH"] = prior_cli
124+
125+
126+
@pytest.fixture
127+
def client(workspace_storage: str):
128+
"""Flask test client bound to the temp workspace_storage fixture."""
129+
app = create_app()
130+
app.config["TESTING"] = True
131+
app.config["EXCLUSION_RULES"] = []
132+
return app.test_client()
133+
134+
135+
@pytest.fixture
136+
def empty_workspace_client() -> Generator[FlaskClient, None, None]:
137+
"""Flask test client bound to a workspaceStorage with no workspaces.
138+
139+
Useful for 404 tests where the workspace id is unknown.
140+
"""
141+
with tempfile.TemporaryDirectory() as tmp:
142+
ws_root = os.path.join(tmp, "workspaceStorage")
143+
cli_root = os.path.join(tmp, "cli_chats")
144+
os.makedirs(ws_root, exist_ok=True)
145+
os.makedirs(cli_root, exist_ok=True)
146+
147+
prior_ws = os.environ.get("WORKSPACE_PATH")
148+
prior_cli = os.environ.get("CLI_CHATS_PATH")
149+
os.environ["WORKSPACE_PATH"] = ws_root
150+
os.environ["CLI_CHATS_PATH"] = cli_root
151+
try:
152+
app = create_app()
153+
app.config["TESTING"] = True
154+
app.config["EXCLUSION_RULES"] = []
155+
yield app.test_client()
156+
finally:
157+
if prior_ws is None:
158+
os.environ.pop("WORKSPACE_PATH", None)
159+
else:
160+
os.environ["WORKSPACE_PATH"] = prior_ws
161+
if prior_cli is None:
162+
os.environ.pop("CLI_CHATS_PATH", None)
163+
else:
164+
os.environ["CLI_CHATS_PATH"] = prior_cli

tests/test_api_endpoints.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
from __future__ import annotations
2+
3+
from app import create_app
4+
from tests._fixture_ids import HAPPY_BUBBLE_ID, HAPPY_COMPOSER_ID, HAPPY_WORKSPACE_ID
5+
from utils.exclusion_rules import _tokenize_rule
6+
7+
8+
# ---------------------------------------------------------------------------
9+
# GET /api/workspaces
10+
# ---------------------------------------------------------------------------
11+
12+
class TestListWorkspaces:
13+
def test_happy_path_returns_workspace_list(self, client):
14+
response = client.get("/api/workspaces")
15+
assert response.status_code == 200
16+
body = response.get_json()
17+
assert isinstance(body, list)
18+
19+
ids = [p["id"] for p in body]
20+
assert HAPPY_WORKSPACE_ID in ids, f"expected {HAPPY_WORKSPACE_ID} in {ids}"
21+
22+
ws = next(p for p in body if p["id"] == HAPPY_WORKSPACE_ID)
23+
assert "name" in ws
24+
assert "conversationCount" in ws and isinstance(ws["conversationCount"], int)
25+
assert "lastModified" in ws and "T" in ws["lastModified"]
26+
27+
def test_empty_storage_returns_empty_list(self, empty_workspace_client):
28+
response = empty_workspace_client.get("/api/workspaces")
29+
assert response.status_code == 200
30+
assert response.get_json() == []
31+
32+
33+
# ---------------------------------------------------------------------------
34+
# GET /api/workspaces/<id>
35+
# ---------------------------------------------------------------------------
36+
37+
class TestGetWorkspace:
38+
def test_happy_path_returns_workspace_details(self, client):
39+
response = client.get(f"/api/workspaces/{HAPPY_WORKSPACE_ID}")
40+
assert response.status_code == 200
41+
body = response.get_json()
42+
assert body["id"] == HAPPY_WORKSPACE_ID
43+
assert "name" in body
44+
assert "folder" in body
45+
assert "lastModified" in body and "T" in body["lastModified"]
46+
47+
def test_unknown_id_returns_404(self, client):
48+
response = client.get("/api/workspaces/nonexistent-workspace-id")
49+
assert response.status_code == 404
50+
body = response.get_json()
51+
assert "error" in body
52+
53+
def test_global_returns_other_chats(self, client):
54+
response = client.get("/api/workspaces/global")
55+
assert response.status_code == 200
56+
body = response.get_json()
57+
assert body["id"] == "global"
58+
assert body["name"] == "Other chats"
59+
60+
61+
# ---------------------------------------------------------------------------
62+
# GET /api/workspaces/<id>/tabs
63+
# ---------------------------------------------------------------------------
64+
65+
class TestGetWorkspaceTabs:
66+
def test_happy_path_returns_tabs(self, client):
67+
response = client.get(f"/api/workspaces/{HAPPY_WORKSPACE_ID}/tabs")
68+
assert response.status_code == 200
69+
body = response.get_json()
70+
assert "tabs" in body and isinstance(body["tabs"], list)
71+
72+
tab_ids = [t["id"] for t in body["tabs"]]
73+
assert HAPPY_COMPOSER_ID in tab_ids, f"expected {HAPPY_COMPOSER_ID} in {tab_ids}"
74+
75+
tab = next(t for t in body["tabs"] if t["id"] == HAPPY_COMPOSER_ID)
76+
assert "title" in tab
77+
assert "timestamp" in tab and isinstance(tab["timestamp"], int)
78+
assert "bubbles" in tab and isinstance(tab["bubbles"], list)
79+
# The seeded user bubble must be present
80+
bubble_types = [b["type"] for b in tab["bubbles"]]
81+
assert "user" in bubble_types
82+
83+
def test_global_returns_tabs(self, client):
84+
response = client.get("/api/workspaces/global/tabs")
85+
assert response.status_code == 200
86+
body = response.get_json()
87+
assert "tabs" in body and isinstance(body["tabs"], list)
88+
# Isolation: HAPPY_COMPOSER_ID is assigned to HAPPY_WORKSPACE_ID via the
89+
# local ItemTable allComposers row, so it must NOT also surface in the
90+
# /global bucket. If it does, workspace-assignment is leaking unassigned
91+
# composers into both buckets.
92+
global_tab_ids = [t["id"] for t in body["tabs"]]
93+
assert HAPPY_COMPOSER_ID not in global_tab_ids, (
94+
f"{HAPPY_COMPOSER_ID} leaked into /global tabs: {global_tab_ids}"
95+
)
96+
97+
def test_missing_global_storage_returns_404(self, empty_workspace_client):
98+
response = empty_workspace_client.get("/api/workspaces/global/tabs")
99+
assert response.status_code == 404
100+
body = response.get_json()
101+
assert "error" in body
102+
103+
104+
# ---------------------------------------------------------------------------
105+
# GET /api/search?q=...
106+
# ---------------------------------------------------------------------------
107+
108+
class TestSearch:
109+
def test_happy_path_finds_seeded_term(self, client):
110+
response = client.get("/api/search?q=sentinel-grep")
111+
assert response.status_code == 200
112+
body = response.get_json()
113+
assert "results" in body and isinstance(body["results"], list)
114+
assert len(body["results"]) >= 1, f"expected sentinel match, got {body}"
115+
116+
def test_no_match_returns_empty_results(self, client):
117+
response = client.get("/api/search?q=does-not-match-any-content-xyzzy")
118+
assert response.status_code == 200
119+
body = response.get_json()
120+
assert "results" in body and body["results"] == []
121+
122+
def test_missing_q_returns_400(self, client):
123+
response = client.get("/api/search")
124+
assert response.status_code == 400
125+
body = response.get_json()
126+
assert "error" in body
127+
assert body["error"] == "No search query provided"
128+
129+
def test_empty_q_returns_400(self, client):
130+
response = client.get("/api/search?q=")
131+
assert response.status_code == 400
132+
body = response.get_json()
133+
assert body.get("error") == "No search query provided"
134+
135+
def test_whitespace_only_q_returns_400(self, client):
136+
# api/search.py strips q before the empty-check, so " " is rejected.
137+
response = client.get("/api/search?q=%20%20%20")
138+
assert response.status_code == 400
139+
body = response.get_json()
140+
assert body.get("error") == "No search query provided"
141+
142+
143+
# ---------------------------------------------------------------------------
144+
# Exclusion rules — must be applied across endpoints
145+
# ---------------------------------------------------------------------------
146+
147+
def _client_with_rules(rule_lines):
148+
"""Build a Flask test client whose EXCLUSION_RULES match the given lines.
149+
150+
The standard `client` fixture sets EXCLUSION_RULES = [] because no
151+
rules file exists under the temp workspace. This helper builds a fresh
152+
app on top of the same env (already pointed at workspace_storage) and
153+
overrides the config with parsed rules — exercising the same code path
154+
a real `exclusion-rules.txt` file would.
155+
"""
156+
parsed = [_tokenize_rule(line) for line in rule_lines]
157+
app = create_app()
158+
app.config["TESTING"] = True
159+
app.config["EXCLUSION_RULES"] = [r for r in parsed if r]
160+
return app.test_client()
161+
162+
163+
class TestExclusionRules:
164+
def test_workspace_matching_rule_is_filtered_out_of_list(self, workspace_storage):
165+
# The seeded workspace's display name resolves to "happy-project"
166+
# (the basename of the folder linked from workspace.json). A rule of
167+
# "happy-project" must drop it from /api/workspaces entirely.
168+
excluded_client = _client_with_rules(["happy-project"])
169+
response = excluded_client.get("/api/workspaces")
170+
assert response.status_code == 200
171+
body = response.get_json()
172+
ids = [w["id"] for w in body]
173+
assert HAPPY_WORKSPACE_ID not in ids, (
174+
f"exclusion rule did not filter {HAPPY_WORKSPACE_ID}; got {ids}"
175+
)
176+
177+
def test_workspace_not_matching_rule_still_listed(self, workspace_storage):
178+
# Negative control: a rule that doesn't match must leave the workspace
179+
# visible, so the test above can't pass for the wrong reason
180+
# (e.g. listing always returning []).
181+
kept_client = _client_with_rules(["unrelated-project-name-xyzzy"])
182+
response = kept_client.get("/api/workspaces")
183+
assert response.status_code == 200
184+
body = response.get_json()
185+
ids = [w["id"] for w in body]
186+
assert HAPPY_WORKSPACE_ID in ids, (
187+
f"non-matching rule filtered the workspace; got {ids}"
188+
)
189+
190+
def test_search_skips_conversations_matching_rule(self, workspace_storage):
191+
# The seeded conversation's name is "Happy conversation". Excluding by
192+
# "Happy" must drop the seeded match from /api/search even though the
193+
# bubble text still contains "sentinel-grep".
194+
excluded_client = _client_with_rules(["Happy"])
195+
response = excluded_client.get("/api/search?q=sentinel-grep")
196+
assert response.status_code == 200
197+
body = response.get_json()
198+
assert body.get("results") == [], (
199+
f"exclusion rule did not filter seeded chat from search: {body}"
200+
)

0 commit comments

Comments
 (0)