From 97b62b6fbc885898b6d4567e29c8b01972f59bdc Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Thu, 11 Jun 2026 13:30:28 -0400 Subject: [PATCH 1/4] feat: initial implementation of test cases for the apis --- .github/workflows/tests.yml | 2 +- tests/conftest.py | 14 ++ tests/test_api_endpoints.py | 248 ----------------------------------- tests/test_api_export.py | 141 ++++++++++++++++++++ tests/test_api_search.py | 95 ++++++++++++++ tests/test_api_workspaces.py | 145 ++++++++++++++++++++ 6 files changed, 396 insertions(+), 249 deletions(-) delete mode 100644 tests/test_api_endpoints.py create mode 100644 tests/test_api_export.py create mode 100644 tests/test_api_search.py create mode 100644 tests/test_api_workspaces.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9a353a7..2bdbb5e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -114,7 +114,7 @@ jobs: # Pytest fixtures (tests/conftest.py) build a temp workspaceStorage and # exercise Flask routes via app.test_client(). Only listed files — not # `pytest tests/` — to avoid re-collecting unittest.TestCase classes above. - run: python -m pytest tests/test_api_endpoints.py tests/test_pdf_export.py tests/test_search_helpers.py -v --tb=short + run: python -m pytest tests/test_api_search.py tests/test_api_workspaces.py tests/test_api_export.py tests/test_pdf_export.py tests/test_search_helpers.py -v --tb=short # ── PyInstaller desktop build (Windows only, once per workflow) ──────── # Closes #44. Builds the onedir bundle and smoke-tests --help so the diff --git a/tests/conftest.py b/tests/conftest.py index df87e3b..aae9f5c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,7 @@ HAPPY_COMPOSER_ID, HAPPY_WORKSPACE_ID, ) +from utils.exclusion_rules import tokenize_rule def _make_global_state_db(path: str) -> None: @@ -141,6 +142,19 @@ def client(workspace_storage: str): return app.test_client() +def client_with_rules(rule_lines: list[str]) -> FlaskClient: + """Flask test client with EXCLUSION_RULES parsed from the given lines. + + Requires WORKSPACE_PATH / CLI_CHATS_PATH to already be set (e.g. by + ``workspace_storage`` fixture). + """ + parsed = [tokenize_rule(line) for line in rule_lines] + app = create_app() + app.config["TESTING"] = True + app.config["EXCLUSION_RULES"] = [r for r in parsed if r] + return app.test_client() + + @pytest.fixture def empty_workspace_client() -> Generator[FlaskClient, None, None]: """Flask test client bound to a workspaceStorage with no workspaces. diff --git a/tests/test_api_endpoints.py b/tests/test_api_endpoints.py deleted file mode 100644 index 3c1e537..0000000 --- a/tests/test_api_endpoints.py +++ /dev/null @@ -1,248 +0,0 @@ -from __future__ import annotations - -from app import create_app -from tests._fixture_ids import HAPPY_BUBBLE_ID, HAPPY_COMPOSER_ID, HAPPY_WORKSPACE_ID -from utils.exclusion_rules import tokenize_rule - - -# --------------------------------------------------------------------------- -# GET /api/workspaces -# --------------------------------------------------------------------------- - -class TestListWorkspaces: - def test_happy_path_returns_workspace_list(self, client): - response = client.get("/api/workspaces") - assert response.status_code == 200 - body = response.get_json() - assert isinstance(body, dict) - projects = body["projects"] - assert isinstance(projects, list) - - ids = [p["id"] for p in projects] - assert HAPPY_WORKSPACE_ID in ids, f"expected {HAPPY_WORKSPACE_ID} in {ids}" - - ws = next(p for p in projects if p["id"] == HAPPY_WORKSPACE_ID) - assert "name" in ws - assert "conversationCount" in ws and isinstance(ws["conversationCount"], int) - assert "lastModified" in ws and "T" in ws["lastModified"] - - def test_empty_storage_returns_empty_list(self, empty_workspace_client): - response = empty_workspace_client.get("/api/workspaces") - assert response.status_code == 200 - assert response.get_json() == {"projects": []} - - -# --------------------------------------------------------------------------- -# GET /api/workspaces/ -# --------------------------------------------------------------------------- - -class TestGetWorkspace: - def test_happy_path_returns_workspace_details(self, client): - response = client.get(f"/api/workspaces/{HAPPY_WORKSPACE_ID}") - assert response.status_code == 200 - body = response.get_json() - assert body["id"] == HAPPY_WORKSPACE_ID - assert "name" in body - assert "folder" in body - assert "lastModified" in body and "T" in body["lastModified"] - - def test_unknown_id_returns_404(self, client): - response = client.get("/api/workspaces/nonexistent-workspace-id") - assert response.status_code == 404 - body = response.get_json() - assert "error" in body - - def test_global_returns_other_chats(self, client): - response = client.get("/api/workspaces/global") - assert response.status_code == 200 - body = response.get_json() - assert body["id"] == "global" - assert body["name"] == "Other chats" - - -# --------------------------------------------------------------------------- -# GET /api/workspaces//tabs -# --------------------------------------------------------------------------- - -class TestGetWorkspaceTabs: - def test_happy_path_returns_tabs(self, client): - response = client.get(f"/api/workspaces/{HAPPY_WORKSPACE_ID}/tabs") - assert response.status_code == 200 - body = response.get_json() - assert "tabs" in body and isinstance(body["tabs"], list) - - tab_ids = [t["id"] for t in body["tabs"]] - assert HAPPY_COMPOSER_ID in tab_ids, f"expected {HAPPY_COMPOSER_ID} in {tab_ids}" - - tab = next(t for t in body["tabs"] if t["id"] == HAPPY_COMPOSER_ID) - assert "title" in tab - assert "timestamp" in tab and isinstance(tab["timestamp"], int) - assert "bubbles" in tab and isinstance(tab["bubbles"], list) - # The seeded user bubble must be present - bubble_types = [b["type"] for b in tab["bubbles"]] - assert "user" in bubble_types - - def test_global_returns_tabs(self, client): - response = client.get("/api/workspaces/global/tabs") - assert response.status_code == 200 - body = response.get_json() - assert "tabs" in body and isinstance(body["tabs"], list) - # Isolation: HAPPY_COMPOSER_ID is assigned to HAPPY_WORKSPACE_ID via the - # local ItemTable allComposers row, so it must NOT also surface in the - # /global bucket. If it does, workspace-assignment is leaking unassigned - # composers into both buckets. - global_tab_ids = [t["id"] for t in body["tabs"]] - assert HAPPY_COMPOSER_ID not in global_tab_ids, ( - f"{HAPPY_COMPOSER_ID} leaked into /global tabs: {global_tab_ids}" - ) - - def test_missing_global_storage_returns_404(self, empty_workspace_client): - response = empty_workspace_client.get("/api/workspaces/global/tabs") - assert response.status_code == 404 - body = response.get_json() - assert "error" in body - - def test_summary_query_returns_tab_list_without_bubbles(self, client): - response = client.get(f"/api/workspaces/{HAPPY_WORKSPACE_ID}/tabs?summary=1") - assert response.status_code == 200 - body = response.get_json() - assert "tabs" in body and isinstance(body["tabs"], list) - assert body["tabs"], "expected at least one summary tab" - tab = next(t for t in body["tabs"] if t["id"] == HAPPY_COMPOSER_ID) - assert "title" in tab - assert "timestamp" in tab and isinstance(tab["timestamp"], int) - assert "messageCount" in tab and isinstance(tab["messageCount"], int) - assert "bubbles" not in tab - - def test_summary_query_global_workspace(self, client): - response = client.get("/api/workspaces/global/tabs?summary=1") - assert response.status_code == 200 - body = response.get_json() - assert "tabs" in body and isinstance(body["tabs"], list) - - -class TestGetWorkspaceTab: - def test_happy_path_returns_single_tab(self, client): - response = client.get( - f"/api/workspaces/{HAPPY_WORKSPACE_ID}/tabs/{HAPPY_COMPOSER_ID}" - ) - assert response.status_code == 200 - body = response.get_json() - assert "tab" in body - tab = body["tab"] - assert tab["id"] == HAPPY_COMPOSER_ID - assert "title" in tab - assert "timestamp" in tab and isinstance(tab["timestamp"], int) - assert "bubbles" in tab and isinstance(tab["bubbles"], list) - assert "codeBlockDiffs" in tab - - def test_unknown_composer_returns_404(self, client): - response = client.get( - f"/api/workspaces/{HAPPY_WORKSPACE_ID}/tabs/no-such-composer" - ) - assert response.status_code == 404 - assert "error" in response.get_json() - - def test_cli_workspace_returns_400(self, client): - response = client.get("/api/workspaces/cli:proj-1/tabs/cmp-happy") - assert response.status_code == 400 - assert "error" in response.get_json() - - -# --------------------------------------------------------------------------- -# GET /api/search?q=... -# --------------------------------------------------------------------------- - -class TestSearch: - def test_happy_path_finds_seeded_term(self, client): - response = client.get("/api/search?q=sentinel-grep") - assert response.status_code == 200 - body = response.get_json() - assert "results" in body and isinstance(body["results"], list) - assert len(body["results"]) >= 1, f"expected sentinel match, got {body}" - - def test_no_match_returns_empty_results(self, client): - response = client.get("/api/search?q=does-not-match-any-content-xyzzy") - assert response.status_code == 200 - body = response.get_json() - assert "results" in body and body["results"] == [] - - def test_missing_q_returns_400(self, client): - response = client.get("/api/search") - assert response.status_code == 400 - body = response.get_json() - assert "error" in body - assert body["error"] == "No search query provided" - - def test_empty_q_returns_400(self, client): - response = client.get("/api/search?q=") - assert response.status_code == 400 - body = response.get_json() - assert body.get("error") == "No search query provided" - - def test_whitespace_only_q_returns_400(self, client): - # api/search.py strips q before the empty-check, so " " is rejected. - response = client.get("/api/search?q=%20%20%20") - assert response.status_code == 400 - body = response.get_json() - assert body.get("error") == "No search query provided" - - -# --------------------------------------------------------------------------- -# Exclusion rules — must be applied across endpoints -# --------------------------------------------------------------------------- - -def _client_with_rules(rule_lines): - """Build a Flask test client whose EXCLUSION_RULES match the given lines. - - The standard `client` fixture sets EXCLUSION_RULES = [] because no - rules file exists under the temp workspace. This helper builds a fresh - app on top of the same env (already pointed at workspace_storage) and - overrides the config with parsed rules — exercising the same code path - a real `exclusion-rules.txt` file would. - """ - parsed = [tokenize_rule(line) for line in rule_lines] - app = create_app() - app.config["TESTING"] = True - app.config["EXCLUSION_RULES"] = [r for r in parsed if r] - return app.test_client() - - -class TestExclusionRules: - def test_workspace_matching_rule_is_filtered_out_of_list(self, workspace_storage): - # The seeded workspace's display name resolves to "happy-project" - # (the basename of the folder linked from workspace.json). A rule of - # "happy-project" must drop it from /api/workspaces entirely. - excluded_client = _client_with_rules(["happy-project"]) - response = excluded_client.get("/api/workspaces") - assert response.status_code == 200 - body = response.get_json() - ids = [w["id"] for w in body["projects"]] - assert HAPPY_WORKSPACE_ID not in ids, ( - f"exclusion rule did not filter {HAPPY_WORKSPACE_ID}; got {ids}" - ) - - def test_workspace_not_matching_rule_still_listed(self, workspace_storage): - # Negative control: a rule that doesn't match must leave the workspace - # visible, so the test above can't pass for the wrong reason - # (e.g. listing always returning []). - kept_client = _client_with_rules(["unrelated-project-name-xyzzy"]) - response = kept_client.get("/api/workspaces") - assert response.status_code == 200 - body = response.get_json() - ids = [w["id"] for w in body["projects"]] - assert HAPPY_WORKSPACE_ID in ids, ( - f"non-matching rule filtered the workspace; got {ids}" - ) - - def test_search_skips_conversations_matching_rule(self, workspace_storage): - # The seeded conversation's name is "Happy conversation". Excluding by - # "Happy" must drop the seeded match from /api/search even though the - # bubble text still contains "sentinel-grep". - excluded_client = _client_with_rules(["Happy"]) - response = excluded_client.get("/api/search?q=sentinel-grep") - assert response.status_code == 200 - body = response.get_json() - assert body.get("results") == [], ( - f"exclusion rule did not filter seeded chat from search: {body}" - ) diff --git a/tests/test_api_export.py b/tests/test_api_export.py new file mode 100644 index 0000000..cc97790 --- /dev/null +++ b/tests/test_api_export.py @@ -0,0 +1,141 @@ +"""Flask test-client coverage for /api/export routes (issue #101).""" + +from __future__ import annotations + +import contextlib +import io +import json +import os +import sqlite3 +import zipfile +from unittest.mock import patch + +import pytest +from flask.testing import FlaskClient + +from app import create_app +from tests._fixture_ids import HAPPY_COMPOSER_ID + + +@pytest.fixture +def export_state_dir(tmp_path, monkeypatch): + """Redirect export state reads/writes to a temp directory.""" + state_dir = tmp_path / ".cursor-chat-browser" + state_dir.mkdir() + monkeypatch.setattr("api.export_api._get_state_dir", lambda: str(state_dir)) + return state_dir + + +def _post_export(client: FlaskClient, body: dict | None = None): + return client.post( + "/api/export", + json=body if body is not None else {}, + content_type="application/json", + ) + + +def _read_zip_entries(data: bytes) -> list[str]: + with zipfile.ZipFile(io.BytesIO(data)) as zf: + return zf.namelist() + + +class TestExportState: + def test_get_state_returns_json_object(self, client, export_state_dir): + response = client.get("/api/export/state") + assert response.status_code == 200 + body = response.get_json() + assert isinstance(body, dict) + + def test_get_state_reflects_saved_export(self, client, export_state_dir): + state_path = export_state_dir / "export_state.json" + state_path.write_text( + json.dumps({"lastExportTime": "2026-01-01T12:00:00", "exportedCount": 3}), + encoding="utf-8", + ) + response = client.get("/api/export/state") + assert response.status_code == 200 + body = response.get_json() + assert body["exportedCount"] == 3 + assert body["lastExportTime"] == "2026-01-01T12:00:00" + + +class TestExportHappyPath: + def test_post_returns_zip_with_markdown_entry(self, client, export_state_dir): + response = _post_export(client) + assert response.status_code == 200 + assert response.content_type.startswith("application/zip") + assert ( + 'attachment; filename="cursor-export.zip"' + in response.headers.get("Content-Disposition", "") + ) + assert int(response.headers.get("X-Export-Count", "0")) >= 1 + + names = _read_zip_entries(response.data) + assert any(name.endswith(".md") for name in names) + assert any(HAPPY_COMPOSER_ID[:8] in name for name in names) + + state_path = export_state_dir / "export_state.json" + assert state_path.is_file() + saved = json.loads(state_path.read_text(encoding="utf-8")) + assert saved["exportedCount"] >= 1 + assert isinstance(saved["lastExportTime"], str) + + +class TestExportErrorResponses: + def test_missing_global_storage_returns_404(self, empty_workspace_client): + response = _post_export(empty_workspace_client) + assert response.status_code == 404 + assert response.get_json().get("error") == "Cursor global storage not found" + + def test_no_conversations_returns_404(self, workspace_storage, export_state_dir): + """Global DB exists but has no exportable composer rows.""" + ws_root = workspace_storage + parent = os.path.dirname(ws_root) + global_db = os.path.join(parent, "globalStorage", "state.vscdb") + with contextlib.closing(sqlite3.connect(global_db)) as conn: + conn.execute("DELETE FROM cursorDiskKV") + conn.commit() + + app = create_app() + app.config["TESTING"] = True + app.config["EXCLUSION_RULES"] = [] + response = _post_export(app.test_client()) + assert response.status_code == 404 + body = response.get_json() + assert body.get("error") == "No conversations to export" + + def test_internal_failure_returns_500(self, client, export_state_dir): + with patch( + "api.export_api.resolve_workspace_context_minimal", + side_effect=RuntimeError("simulated export failure"), + ): + response = _post_export(client) + assert response.status_code == 500 + assert response.get_json().get("error") == "Export failed" + + +class TestExportEdgeCases: + def test_since_last_with_no_prior_state_exports_all( + self, client, export_state_dir + ): + response = _post_export(client, {"since": "last"}) + assert response.status_code == 200 + assert int(response.headers.get("X-Export-Count", "0")) >= 1 + + def test_since_last_after_export_returns_404_when_nothing_new( + self, client, export_state_dir + ): + first = _post_export(client, {"since": "all"}) + assert first.status_code == 200 + + second = _post_export(client, {"since": "last"}) + assert second.status_code == 404 + body = second.get_json() + assert body.get("error") == "No conversations to export since last export" + + def test_empty_json_body_defaults_to_export_all( + self, client, export_state_dir + ): + response = _post_export(client, {}) + assert response.status_code == 200 + assert response.data.startswith(b"PK") # ZIP magic diff --git a/tests/test_api_search.py b/tests/test_api_search.py new file mode 100644 index 0000000..c69fa69 --- /dev/null +++ b/tests/test_api_search.py @@ -0,0 +1,95 @@ +"""Flask test-client coverage for GET /api/search (issue #101).""" + +from __future__ import annotations + +from unittest.mock import patch + +from tests._fixture_ids import HAPPY_COMPOSER_ID, HAPPY_WORKSPACE_ID +from tests.conftest import client_with_rules + + +def _assert_search_result_shape(hit: dict) -> None: + """Verify one /api/search hit matches the SearchResult contract.""" + assert isinstance(hit["workspaceId"], str) + assert hit.get("workspaceFolder") is None or isinstance(hit["workspaceFolder"], str) + assert isinstance(hit["chatId"], str) + assert isinstance(hit["chatTitle"], str) + assert isinstance(hit["timestamp"], (int, str)) + assert isinstance(hit["matchingText"], str) + assert hit["type"] in ("composer", "chat", "cli_agent") + if "source" in hit: + assert hit["source"] == "cli" + + +class TestSearchHappyPath: + def test_finds_seeded_term_and_result_shape(self, client): + response = client.get("/api/search?q=sentinel-grep") + assert response.status_code == 200 + body = response.get_json() + assert isinstance(body, dict) + assert "results" in body and isinstance(body["results"], list) + assert len(body["results"]) >= 1, f"expected sentinel match, got {body}" + + hit = body["results"][0] + _assert_search_result_shape(hit) + assert hit["chatId"] == HAPPY_COMPOSER_ID + assert "sentinel-grep" in hit["matchingText"].lower() + + def test_type_composer_still_finds_global_match(self, client): + response = client.get("/api/search?q=sentinel-grep&type=composer") + assert response.status_code == 200 + body = response.get_json() + assert isinstance(body["results"], list) + assert len(body["results"]) >= 1 + assert all(r["type"] == "composer" for r in body["results"]) + + +class TestSearchErrorResponses: + def test_missing_q_returns_400(self, client): + response = client.get("/api/search") + assert response.status_code == 400 + body = response.get_json() + assert body.get("error") == "No search query provided" + assert "results" not in body + + def test_empty_q_returns_400(self, client): + response = client.get("/api/search?q=") + assert response.status_code == 400 + assert response.get_json().get("error") == "No search query provided" + + def test_whitespace_only_q_returns_400(self, client): + response = client.get("/api/search?q=%20%20%20") + assert response.status_code == 400 + assert response.get_json().get("error") == "No search query provided" + + def test_internal_failure_returns_500_with_empty_results(self, client): + with patch( + "api.search.search_global_storage", + side_effect=RuntimeError("simulated DB failure"), + ): + response = client.get("/api/search?q=sentinel-grep") + assert response.status_code == 500 + body = response.get_json() + assert body.get("error") == "Search failed" + assert body.get("results") == [] + + +class TestSearchEdgeCases: + def test_no_match_returns_empty_results(self, client): + response = client.get("/api/search?q=does-not-match-any-content-xyzzy") + assert response.status_code == 200 + body = response.get_json() + assert body.get("results") == [] + + def test_exclusion_rule_filters_matching_conversation(self, workspace_storage): + excluded_client = client_with_rules(["Happy"]) + response = excluded_client.get("/api/search?q=sentinel-grep") + assert response.status_code == 200 + assert response.get_json().get("results") == [] + + def test_workspace_scoped_hit_has_workspace_id(self, client): + response = client.get("/api/search?q=sentinel-grep") + assert response.status_code == 200 + results = response.get_json()["results"] + workspace_ids = {r["workspaceId"] for r in results} + assert HAPPY_WORKSPACE_ID in workspace_ids or "global" in workspace_ids diff --git a/tests/test_api_workspaces.py b/tests/test_api_workspaces.py new file mode 100644 index 0000000..be1b342 --- /dev/null +++ b/tests/test_api_workspaces.py @@ -0,0 +1,145 @@ +"""Flask test-client coverage for /api/workspaces* routes (issue #101).""" + +from __future__ import annotations + +from unittest.mock import patch + +from tests._fixture_ids import HAPPY_COMPOSER_ID, HAPPY_WORKSPACE_ID +from tests.conftest import client_with_rules + + +def _assert_project_shape(project: dict) -> None: + assert isinstance(project["id"], str) + assert isinstance(project["name"], str) + assert isinstance(project["conversationCount"], int) + assert isinstance(project["lastModified"], str) + assert "T" in project["lastModified"] + + +class TestListWorkspaces: + def test_happy_path_returns_workspace_list(self, client): + response = client.get("/api/workspaces") + assert response.status_code == 200 + body = response.get_json() + assert isinstance(body, dict) + projects = body["projects"] + assert isinstance(projects, list) + + ids = [p["id"] for p in projects] + assert HAPPY_WORKSPACE_ID in ids, f"expected {HAPPY_WORKSPACE_ID} in {ids}" + + ws = next(p for p in projects if p["id"] == HAPPY_WORKSPACE_ID) + _assert_project_shape(ws) + + def test_empty_storage_returns_empty_list(self, empty_workspace_client): + response = empty_workspace_client.get("/api/workspaces") + assert response.status_code == 200 + assert response.get_json() == {"projects": []} + + def test_internal_failure_returns_500(self, client): + with patch( + "api.workspaces.list_workspace_projects", + side_effect=RuntimeError("simulated listing failure"), + ): + response = client.get("/api/workspaces") + assert response.status_code == 500 + assert response.get_json().get("error") == "Failed to get workspaces" + + +class TestGetWorkspace: + def test_happy_path_returns_workspace_details(self, client): + response = client.get(f"/api/workspaces/{HAPPY_WORKSPACE_ID}") + assert response.status_code == 200 + body = response.get_json() + assert body["id"] == HAPPY_WORKSPACE_ID + assert isinstance(body["name"], str) + assert "folder" in body + assert isinstance(body["lastModified"], str) and "T" in body["lastModified"] + + def test_unknown_id_returns_404(self, client): + response = client.get("/api/workspaces/nonexistent-workspace-id") + assert response.status_code == 404 + assert "error" in response.get_json() + + def test_global_returns_other_chats(self, client): + response = client.get("/api/workspaces/global") + assert response.status_code == 200 + body = response.get_json() + assert body["id"] == "global" + assert body["name"] == "Other chats" + + +class TestGetWorkspaceTabs: + def test_happy_path_returns_tabs(self, client): + response = client.get(f"/api/workspaces/{HAPPY_WORKSPACE_ID}/tabs") + assert response.status_code == 200 + body = response.get_json() + assert isinstance(body["tabs"], list) + + tab_ids = [t["id"] for t in body["tabs"]] + assert HAPPY_COMPOSER_ID in tab_ids + + tab = next(t for t in body["tabs"] if t["id"] == HAPPY_COMPOSER_ID) + assert isinstance(tab["title"], str) + assert isinstance(tab["timestamp"], int) + assert isinstance(tab["bubbles"], list) + assert "user" in [b["type"] for b in tab["bubbles"]] + + def test_global_returns_tabs_without_leaking_assigned_composer(self, client): + response = client.get("/api/workspaces/global/tabs") + assert response.status_code == 200 + global_tab_ids = [t["id"] for t in response.get_json()["tabs"]] + assert HAPPY_COMPOSER_ID not in global_tab_ids + + def test_missing_global_storage_returns_404(self, empty_workspace_client): + response = empty_workspace_client.get("/api/workspaces/global/tabs") + assert response.status_code == 404 + assert "error" in response.get_json() + + def test_summary_query_returns_tab_list_without_bubbles(self, client): + response = client.get(f"/api/workspaces/{HAPPY_WORKSPACE_ID}/tabs?summary=1") + assert response.status_code == 200 + body = response.get_json() + tab = next(t for t in body["tabs"] if t["id"] == HAPPY_COMPOSER_ID) + assert isinstance(tab["messageCount"], int) + assert "bubbles" not in tab + + +class TestGetWorkspaceTab: + def test_happy_path_returns_single_tab(self, client): + response = client.get( + f"/api/workspaces/{HAPPY_WORKSPACE_ID}/tabs/{HAPPY_COMPOSER_ID}" + ) + assert response.status_code == 200 + tab = response.get_json()["tab"] + assert tab["id"] == HAPPY_COMPOSER_ID + assert isinstance(tab["bubbles"], list) + assert "codeBlockDiffs" in tab + + def test_unknown_composer_returns_404(self, client): + response = client.get( + f"/api/workspaces/{HAPPY_WORKSPACE_ID}/tabs/no-such-composer" + ) + assert response.status_code == 404 + assert "error" in response.get_json() + + def test_cli_workspace_returns_400(self, client): + response = client.get("/api/workspaces/cli:proj-1/tabs/cmp-happy") + assert response.status_code == 400 + assert "error" in response.get_json() + + +class TestWorkspaceExclusionRules: + def test_matching_rule_filters_workspace_from_list(self, workspace_storage): + excluded_client = client_with_rules(["happy-project"]) + response = excluded_client.get("/api/workspaces") + assert response.status_code == 200 + ids = [w["id"] for w in response.get_json()["projects"]] + assert HAPPY_WORKSPACE_ID not in ids + + def test_non_matching_rule_leaves_workspace_visible(self, workspace_storage): + kept_client = client_with_rules(["unrelated-project-name-xyzzy"]) + response = kept_client.get("/api/workspaces") + assert response.status_code == 200 + ids = [w["id"] for w in response.get_json()["projects"]] + assert HAPPY_WORKSPACE_ID in ids From a30c6b469592f9eec78e7616f5a93ee509de3f8d Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Thu, 11 Jun 2026 16:26:31 -0400 Subject: [PATCH 2/4] fix: review findings --- .github/workflows/tests.yml | 4 +++- .gitignore | 1 - api/export_api.py | 11 +++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2bdbb5e..f525644 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -149,7 +149,9 @@ jobs: - name: Install runtime deps + mypy # Install from the pinned lock file for deterministic resolution, # then add mypy (dev-only; not in requirements-lock.txt). - # Flask 3.1+ ships inline types — do not install types-Flask (conflicts). + # Flask 3.1+ includes inline type hints — do not add or install types-Flask + # (it will conflict with our pip installs). Preventative guidance for future + # maintainers editing the steps below. run: | python -m pip install --upgrade pip python -m pip install -r requirements-lock.txt diff --git a/.gitignore b/.gitignore index 6e2abab..5fd078f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,6 @@ __pycache__/ venv/ .venv/ env/ -.mypy-ci-test/ # Packaging *.egg-info/ diff --git a/api/export_api.py b/api/export_api.py index a192e11..ba4ab39 100644 --- a/api/export_api.py +++ b/api/export_api.py @@ -12,7 +12,7 @@ import zipfile from datetime import datetime from pathlib import Path -from typing import Any, cast +from typing import Any from flask import Blueprint, Response, request @@ -45,7 +45,14 @@ def _get_export_state() -> dict[str, Any]: if os.path.isfile(state_path): try: with open(state_path, "r", encoding="utf-8") as f: - return cast(dict[str, Any], json.load(f)) + parsed = json.load(f) + if isinstance(parsed, dict): + return parsed + _logger.warning( + "Export state in %s is not a JSON object (got %s); ignoring", + state_path, + type(parsed).__name__, + ) except (json.JSONDecodeError, ValueError, OSError) as e: _logger.warning( "Could not read export state from %s: %s", From 94bb396b81563c8054f023bcac0dda4ec585c652 Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Thu, 11 Jun 2026 16:59:24 -0400 Subject: [PATCH 3/4] fix: nitpick findings --- tests/test_api_export.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_api_export.py b/tests/test_api_export.py index cc97790..842eed0 100644 --- a/tests/test_api_export.py +++ b/tests/test_api_export.py @@ -58,6 +58,15 @@ def test_get_state_reflects_saved_export(self, client, export_state_dir): assert body["exportedCount"] == 3 assert body["lastExportTime"] == "2026-01-01T12:00:00" + def test_get_state_handles_non_dict_json(self, client, export_state_dir): + state_path = export_state_dir / "export_state.json" + state_path.write_text('["not","a","dict"]', encoding="utf-8") + response = client.get("/api/export/state") + assert response.status_code == 200 + body = response.get_json() + assert isinstance(body, dict) + assert body == {} + class TestExportHappyPath: def test_post_returns_zip_with_markdown_entry(self, client, export_state_dir): From c7bd9d2a9e8541aa5f116e7ca64b1f5a9e4f867c Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Fri, 12 Jun 2026 12:27:11 -0400 Subject: [PATCH 4/4] fix: reviewer's findings --- tests/_helpers.py | 24 ++++++++++++++++++++++++ tests/conftest.py | 15 --------------- tests/test_api_export.py | 2 ++ tests/test_api_search.py | 2 +- tests/test_api_workspaces.py | 8 +++++++- 5 files changed, 34 insertions(+), 17 deletions(-) create mode 100644 tests/_helpers.py diff --git a/tests/_helpers.py b/tests/_helpers.py new file mode 100644 index 0000000..a015928 --- /dev/null +++ b/tests/_helpers.py @@ -0,0 +1,24 @@ +"""Shared test helpers that must not live in conftest. + +pytest treats conftest specially and it is not guaranteed to be importable as +``tests.conftest`` under non-default import modes (e.g. ``--import-mode=importlib``). +""" +from __future__ import annotations + +from flask.testing import FlaskClient + +from app import create_app +from utils.exclusion_rules import tokenize_rule + + +def client_with_rules(rule_lines: list[str]) -> FlaskClient: + """Flask test client with EXCLUSION_RULES parsed from the given lines. + + Requires WORKSPACE_PATH / CLI_CHATS_PATH to already be set (e.g. by + ``workspace_storage`` fixture). + """ + parsed = [tokenize_rule(line) for line in rule_lines] + app = create_app() + app.config["TESTING"] = True + app.config["EXCLUSION_RULES"] = [r for r in parsed if r] + return app.test_client() diff --git a/tests/conftest.py b/tests/conftest.py index aae9f5c..6f9b309 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,8 +22,6 @@ HAPPY_COMPOSER_ID, HAPPY_WORKSPACE_ID, ) -from utils.exclusion_rules import tokenize_rule - def _make_global_state_db(path: str) -> None: """globalStorage/state.vscdb with one composerData + one bubbleId row.""" @@ -142,19 +140,6 @@ def client(workspace_storage: str): return app.test_client() -def client_with_rules(rule_lines: list[str]) -> FlaskClient: - """Flask test client with EXCLUSION_RULES parsed from the given lines. - - Requires WORKSPACE_PATH / CLI_CHATS_PATH to already be set (e.g. by - ``workspace_storage`` fixture). - """ - parsed = [tokenize_rule(line) for line in rule_lines] - app = create_app() - app.config["TESTING"] = True - app.config["EXCLUSION_RULES"] = [r for r in parsed if r] - return app.test_client() - - @pytest.fixture def empty_workspace_client() -> Generator[FlaskClient, None, None]: """Flask test client bound to a workspaceStorage with no workspaces. diff --git a/tests/test_api_export.py b/tests/test_api_export.py index 842eed0..1e46c31 100644 --- a/tests/test_api_export.py +++ b/tests/test_api_export.py @@ -134,6 +134,8 @@ def test_since_last_with_no_prior_state_exports_all( def test_since_last_after_export_returns_404_when_nothing_new( self, client, export_state_dir ): + # Relies on the seeded composer's lastUpdatedAt (May 2024 in conftest) + # being older than the export state's lastExportTime set by the first call. first = _post_export(client, {"since": "all"}) assert first.status_code == 200 diff --git a/tests/test_api_search.py b/tests/test_api_search.py index c69fa69..9b92b0e 100644 --- a/tests/test_api_search.py +++ b/tests/test_api_search.py @@ -5,7 +5,7 @@ from unittest.mock import patch from tests._fixture_ids import HAPPY_COMPOSER_ID, HAPPY_WORKSPACE_ID -from tests.conftest import client_with_rules +from tests._helpers import client_with_rules def _assert_search_result_shape(hit: dict) -> None: diff --git a/tests/test_api_workspaces.py b/tests/test_api_workspaces.py index be1b342..7bdbd2c 100644 --- a/tests/test_api_workspaces.py +++ b/tests/test_api_workspaces.py @@ -5,7 +5,7 @@ from unittest.mock import patch from tests._fixture_ids import HAPPY_COMPOSER_ID, HAPPY_WORKSPACE_ID -from tests.conftest import client_with_rules +from tests._helpers import client_with_rules def _assert_project_shape(project: dict) -> None: @@ -104,6 +104,12 @@ def test_summary_query_returns_tab_list_without_bubbles(self, client): assert isinstance(tab["messageCount"], int) assert "bubbles" not in tab + def test_summary_query_global_workspace(self, client): + response = client.get("/api/workspaces/global/tabs?summary=1") + assert response.status_code == 200 + body = response.get_json() + assert "tabs" in body and isinstance(body["tabs"], list) + class TestGetWorkspaceTab: def test_happy_path_returns_single_tab(self, client):