Skip to content

Commit 4bac1d0

Browse files
committed
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 6a806dd commit 4bac1d0

4 files changed

Lines changed: 137 additions & 51 deletions

File tree

.github/workflows/tests.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,11 @@ jobs:
5353

5454
- name: Run pytest integration suite
5555
# Pytest fixtures (tests/conftest.py) build a temp workspaceStorage
56-
# and exercise the Flask routes via app.test_client(). Runs alongside
57-
# unittest, not instead of — both suites are merge gates.
58-
run: python -m pytest tests/ -v --tb=short
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
5961

6062
# ── Typecheck: mypy ───────────────────────────────────────────────────────
6163
# Codebase already has type hints across most of the surface (~70+ typed

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: 50 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import contextlib
34
import json
45
import os
56
import sqlite3
@@ -9,51 +10,54 @@
910
from typing import Generator
1011

1112
import pytest
13+
from flask.testing import FlaskClient
1214

1315
REPO_ROOT = str(Path(__file__).resolve().parent.parent)
1416
if REPO_ROOT not in sys.path:
1517
sys.path.insert(0, REPO_ROOT)
1618

1719
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"
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+
)
2325

2426

2527
def _make_global_state_db(path: str) -> None:
2628
"""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()
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()
5761

5862

5963
def _make_workspace(parent: str, workspace_id: str, project_folder: str) -> None:
@@ -63,17 +67,16 @@ def _make_workspace(parent: str, workspace_id: str, project_folder: str) -> None
6367
with open(os.path.join(ws_dir, "workspace.json"), "w", encoding="utf-8") as f:
6468
json.dump({"folder": project_folder}, f)
6569
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()
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()
7780

7881

7982
@pytest.fixture
@@ -130,7 +133,7 @@ def client(workspace_storage: str):
130133

131134

132135
@pytest.fixture
133-
def empty_workspace_client() -> Generator:
136+
def empty_workspace_client() -> Generator[FlaskClient, None, None]:
134137
"""Flask test client bound to a workspaceStorage with no workspaces.
135138
136139
Useful for 404 tests where the workspace id is unknown.

tests/test_api_endpoints.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

3-
from tests.conftest import HAPPY_BUBBLE_ID, HAPPY_COMPOSER_ID, HAPPY_WORKSPACE_ID
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
46

57

68
# ---------------------------------------------------------------------------
@@ -83,6 +85,14 @@ def test_global_returns_tabs(self, client):
8385
assert response.status_code == 200
8486
body = response.get_json()
8587
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+
)
8696

8797
def test_missing_global_storage_returns_404(self, empty_workspace_client):
8898
response = empty_workspace_client.get("/api/workspaces/global/tabs")
@@ -128,3 +138,63 @@ def test_whitespace_only_q_returns_400(self, client):
128138
assert response.status_code == 400
129139
body = response.get_json()
130140
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)