Skip to content

Commit fb549ec

Browse files
committed
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.
1 parent 95d3140 commit fb549ec

3 files changed

Lines changed: 287 additions & 1 deletion

File tree

.github/workflows/tests.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,17 @@ 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+
# Closes #26. Pytest fixtures (tests/conftest.py) build a temp
56+
# workspaceStorage and exercise the Flask routes via app.test_client().
57+
# Runs alongside unittest, not instead of — both suites are merge gates.
58+
run: python -m pytest tests/ -v --tb=short
59+
5460
# ── Typecheck: mypy ───────────────────────────────────────────────────────
5561
# Codebase already has type hints across most of the surface (~70+ typed
5662
# functions). Mypy runs in lenient mode (--ignore-missing-imports for

tests/conftest.py

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

tests/test_api_endpoints.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
from __future__ import annotations
2+
3+
from tests.conftest import HAPPY_BUBBLE_ID, HAPPY_COMPOSER_ID, HAPPY_WORKSPACE_ID
4+
5+
6+
# ---------------------------------------------------------------------------
7+
# GET /api/workspaces
8+
# ---------------------------------------------------------------------------
9+
10+
class TestListWorkspaces:
11+
def test_happy_path_returns_workspace_list(self, client):
12+
response = client.get("/api/workspaces")
13+
assert response.status_code == 200
14+
body = response.get_json()
15+
assert isinstance(body, list)
16+
17+
ids = [p["id"] for p in body]
18+
assert HAPPY_WORKSPACE_ID in ids, f"expected {HAPPY_WORKSPACE_ID} in {ids}"
19+
20+
ws = next(p for p in body if p["id"] == HAPPY_WORKSPACE_ID)
21+
assert "name" in ws
22+
assert "conversationCount" in ws and isinstance(ws["conversationCount"], int)
23+
assert "lastModified" in ws and "T" in ws["lastModified"]
24+
25+
def test_empty_storage_returns_empty_list(self, empty_workspace_client):
26+
response = empty_workspace_client.get("/api/workspaces")
27+
assert response.status_code == 200
28+
assert response.get_json() == []
29+
30+
31+
# ---------------------------------------------------------------------------
32+
# GET /api/workspaces/<id>
33+
# ---------------------------------------------------------------------------
34+
35+
class TestGetWorkspace:
36+
def test_happy_path_returns_workspace_details(self, client):
37+
response = client.get(f"/api/workspaces/{HAPPY_WORKSPACE_ID}")
38+
assert response.status_code == 200
39+
body = response.get_json()
40+
assert body["id"] == HAPPY_WORKSPACE_ID
41+
assert "name" in body
42+
assert "folder" in body
43+
assert "lastModified" in body and "T" in body["lastModified"]
44+
45+
def test_unknown_id_returns_404(self, client):
46+
response = client.get("/api/workspaces/nonexistent-workspace-id")
47+
assert response.status_code == 404
48+
body = response.get_json()
49+
assert "error" in body
50+
51+
def test_global_returns_other_chats(self, client):
52+
response = client.get("/api/workspaces/global")
53+
assert response.status_code == 200
54+
body = response.get_json()
55+
assert body["id"] == "global"
56+
assert body["name"] == "Other chats"
57+
58+
59+
# ---------------------------------------------------------------------------
60+
# GET /api/workspaces/<id>/tabs
61+
# ---------------------------------------------------------------------------
62+
63+
class TestGetWorkspaceTabs:
64+
def test_happy_path_returns_tabs(self, client):
65+
response = client.get(f"/api/workspaces/{HAPPY_WORKSPACE_ID}/tabs")
66+
assert response.status_code == 200
67+
body = response.get_json()
68+
assert "tabs" in body and isinstance(body["tabs"], list)
69+
70+
tab_ids = [t["id"] for t in body["tabs"]]
71+
assert HAPPY_COMPOSER_ID in tab_ids, f"expected {HAPPY_COMPOSER_ID} in {tab_ids}"
72+
73+
tab = next(t for t in body["tabs"] if t["id"] == HAPPY_COMPOSER_ID)
74+
assert "title" in tab
75+
assert "timestamp" in tab and isinstance(tab["timestamp"], int)
76+
assert "bubbles" in tab and isinstance(tab["bubbles"], list)
77+
# The seeded user bubble must be present
78+
bubble_types = [b["type"] for b in tab["bubbles"]]
79+
assert "user" in bubble_types
80+
81+
def test_global_returns_tabs(self, client):
82+
response = client.get("/api/workspaces/global/tabs")
83+
assert response.status_code == 200
84+
body = response.get_json()
85+
assert "tabs" in body and isinstance(body["tabs"], list)
86+
87+
def test_missing_global_storage_returns_404(self, empty_workspace_client):
88+
response = empty_workspace_client.get("/api/workspaces/global/tabs")
89+
assert response.status_code == 404
90+
body = response.get_json()
91+
assert "error" in body
92+
93+
94+
# ---------------------------------------------------------------------------
95+
# GET /api/search?q=...
96+
# ---------------------------------------------------------------------------
97+
98+
class TestSearch:
99+
def test_happy_path_finds_seeded_term(self, client):
100+
response = client.get("/api/search?q=sentinel-grep")
101+
assert response.status_code == 200
102+
body = response.get_json()
103+
assert "results" in body and isinstance(body["results"], list)
104+
assert len(body["results"]) >= 1, f"expected sentinel match, got {body}"
105+
106+
def test_no_match_returns_empty_results(self, client):
107+
response = client.get("/api/search?q=does-not-match-any-content-xyzzy")
108+
assert response.status_code == 200
109+
body = response.get_json()
110+
assert "results" in body and body["results"] == []
111+
112+
def test_missing_q_returns_400_or_empty(self, client):
113+
response = client.get("/api/search")
114+
# Implementation may return 400 (missing required param) or 200 with empty.
115+
# Both are reasonable for "no query supplied"; pin whichever shipped.
116+
assert response.status_code in (200, 400)
117+
if response.status_code == 200:
118+
body = response.get_json()
119+
assert "results" in body

0 commit comments

Comments
 (0)