Skip to content

Commit b2f1331

Browse files
test: HTTP/CLI route matrix and structured API error codes
- Add ErrorCode enum and error_response() helper; migrate api/search, api/sessions, and api/export_api error paths to include stable "code" - Validate search limit query param (400 SEARCH_INVALID_LIMIT; cap at 500) - Add tests/conftest.py, fixtures, test_api_routes, test_cli_e2e, test_error_codes, test_search; extend test_export_api_bulk for codes - Document error code catalog in README
1 parent d7bc647 commit b2f1331

12 files changed

Lines changed: 418 additions & 44 deletions

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,22 @@ Browse and export Claude Code chat history — Web GUI and CLI.
1919
- **Smooth transitions** — staggered card/message animations, crossfade content swaps
2020
- **Scroll-to-top button** in bottom-right corner
2121
- **Per-model badges** in session header
22-
- **Bulk export** — download all sessions, incremental updates, or latest-day slice as a zip; if there is nothing to export, the API returns **422** with JSON body `{"error": "Nothing to export", "since": "<mode>"}` (the `since` field echoes your request: `"all"`, `"last"`, or `"incremental"`) instead of an empty zip
22+
- **Bulk export** — download all sessions, incremental updates, or latest-day slice as a zip; if there is nothing to export, the API returns **422** with JSON body `{"error": "Nothing to export", "code": "EXPORT_NOTHING_TO_EXPORT", "since": "<mode>"}` (the `since` field echoes your request: `"all"`, `"last"`, or `"incremental"`) instead of an empty zip
23+
24+
### API error codes
25+
26+
JSON error responses include a machine-readable `"code"` (stable `UPPER_SNAKE_CASE`) and a human-readable `"error"` message. Common codes:
27+
28+
| Code | Typical HTTP | Meaning |
29+
|------|--------------|---------|
30+
| `SEARCH_INVALID_LIMIT` | 400 | Query param `limit` is not a positive integer |
31+
| `INVALID_PATH` | 400 | Path traversal or unsafe project/session path |
32+
| `SESSION_NOT_FOUND` | 404 | Session file missing or excluded |
33+
| `INVALID_REQUEST_BODY` | 400 | POST body is not a JSON object |
34+
| `INVALID_SINCE_MODE` | 400 | Bulk export `since` is not `all`, `last`, or `incremental` |
35+
| `EXPORT_NOTHING_TO_EXPORT` | 422 | No sessions matched the export scope |
36+
| `PARSE_ERROR` | 500 | Session file could not be parsed |
37+
| `INTERNAL_ERROR` | 500 | Unexpected failure (e.g. stats computation) |
2338

2439
### CLI Export
2540
- Standalone script to export all sessions to Markdown with YAML frontmatter

api/error_codes.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Stable machine-readable error codes for API JSON error responses."""
2+
3+
from __future__ import annotations
4+
5+
from enum import StrEnum
6+
7+
from flask import Response, jsonify
8+
9+
10+
class ErrorCode(StrEnum):
11+
SEARCH_INVALID_LIMIT = "SEARCH_INVALID_LIMIT"
12+
INVALID_PATH = "INVALID_PATH"
13+
SESSION_NOT_FOUND = "SESSION_NOT_FOUND"
14+
INVALID_REQUEST_BODY = "INVALID_REQUEST_BODY"
15+
INVALID_SINCE_MODE = "INVALID_SINCE_MODE"
16+
PARSE_ERROR = "PARSE_ERROR"
17+
EXPORT_NOTHING_TO_EXPORT = "EXPORT_NOTHING_TO_EXPORT"
18+
INTERNAL_ERROR = "INTERNAL_ERROR"
19+
20+
21+
def error_response(
22+
code: ErrorCode,
23+
message: str,
24+
status: int,
25+
**extra: object,
26+
) -> tuple[Response, int]:
27+
body: dict[str, object] = {"error": message, "code": str(code)}
28+
body.update(extra)
29+
return jsonify(body), status

api/export_api.py

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from flask import Blueprint, current_app, jsonify, request, send_file
1010

11+
from api.error_codes import ErrorCode, error_response
1112
from utils.export_state_store import (
1213
EXPORT_STATE_FILE,
1314
atomic_write_export_state,
@@ -80,11 +81,20 @@ def bulk_export():
8081
if body is None:
8182
body = {}
8283
if not isinstance(body, dict):
83-
return jsonify({"error": "Invalid request body"}), 400
84+
return error_response(
85+
ErrorCode.INVALID_REQUEST_BODY,
86+
"Invalid request body",
87+
400,
88+
)
8489

8590
since = body.get("since", "all")
8691
if since not in ("all", "last", "incremental"):
87-
return jsonify({"error": "Invalid since mode", "since": since}), 400
92+
return error_response(
93+
ErrorCode.INVALID_SINCE_MODE,
94+
"Invalid since mode",
95+
400,
96+
since=since,
97+
)
8898

8999
base = (
90100
current_app.config.get("CLAUDE_PROJECTS_DIR")
@@ -226,14 +236,11 @@ def bulk_export():
226236
_write_state(new_sessions_map, count)
227237

228238
if count == 0:
229-
return (
230-
jsonify(
231-
{
232-
"error": "Nothing to export",
233-
"since": since,
234-
}
235-
),
239+
return error_response(
240+
ErrorCode.EXPORT_NOTHING_TO_EXPORT,
241+
"Nothing to export",
236242
422,
243+
since=since,
237244
)
238245

239246
buf.seek(0)
@@ -267,16 +274,24 @@ def export_session(project_name, session_id):
267274
try:
268275
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
269276
except ValueError:
270-
return jsonify({"error": "Invalid path"}), 400
277+
return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400)
271278

272279
if not os.path.isfile(filepath):
273-
return jsonify({"error": "Session not found"}), 404
280+
return error_response(
281+
ErrorCode.SESSION_NOT_FOUND,
282+
"Session not found",
283+
404,
284+
)
274285

275286
fmt = request.args.get("format", "md")
276287
session = parse_session(filepath)
277288
rules = current_app.config.get("EXCLUSION_RULES") or []
278289
if is_session_excluded(rules, session, project_name):
279-
return jsonify({"error": "Session not found"}), 404
290+
return error_response(
291+
ErrorCode.SESSION_NOT_FOUND,
292+
"Session not found",
293+
404,
294+
)
280295
stats = compute_stats(session)
281296
title_slug = slugify(session["title"], default="session")
282297

api/search.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from flask import Blueprint, current_app, jsonify, request
66

7+
from api.error_codes import ErrorCode, error_response
78
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions
89
from utils.jsonl_parser import parse_session
910
from utils.exclusion_rules import is_session_excluded
@@ -36,7 +37,12 @@ def search():
3637
try:
3738
max_results = _parse_limit(request.args.get("limit"))
3839
except ValueError:
39-
return jsonify({"error": "Invalid limit: must be a positive integer"}), 400
40+
return error_response(
41+
ErrorCode.SEARCH_INVALID_LIMIT,
42+
"Invalid limit: must be a positive integer",
43+
400,
44+
)
45+
4046
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
4147
projects = list_projects(base)
4248

@@ -58,7 +64,6 @@ def search():
5864
for msg in session["messages"]:
5965
text = msg.get("text", "") or msg.get("content", "")
6066
if query in text.lower():
61-
# Find the matching snippet
6267
idx = text.lower().index(query)
6368
start = max(0, idx - 80)
6469
end = min(len(text), idx + len(query) + 80)

api/sessions.py

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
import os
44

5-
from flask import Blueprint, current_app, jsonify, abort
5+
from flask import Blueprint, current_app, jsonify
66

7+
from api.error_codes import ErrorCode, error_response
78
from utils.session_path import get_claude_projects_dir, safe_join
89
from utils.jsonl_parser import parse_session
910
from utils.session_stats import compute_stats
@@ -18,25 +19,32 @@ def get_session(project_name, session_id):
1819
try:
1920
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
2021
except ValueError:
21-
return jsonify({"error": "Invalid path"}), 400
22+
return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400)
2223

2324
if not os.path.isfile(filepath):
24-
return jsonify({"error": f"Session {session_id} not found"}), 404
25+
return error_response(
26+
ErrorCode.SESSION_NOT_FOUND,
27+
f"Session {session_id} not found",
28+
404,
29+
)
2530

2631
try:
2732
session = parse_session(filepath)
2833
rules = current_app.config.get("EXCLUSION_RULES") or []
2934
if is_session_excluded(rules, session, project_name):
30-
return jsonify({"error": "Session not found"}), 404
35+
return error_response(
36+
ErrorCode.SESSION_NOT_FOUND,
37+
"Session not found",
38+
404,
39+
)
3140
return jsonify(session)
3241
except Exception:
33-
# Full traceback (class name, message, stack) goes to the server log
34-
# via logger.exception. The HTTP body returns a stable, generic
35-
# message — never the class name or `e` itself, which would leak
36-
# internal field names, file paths, and user values to any client
37-
# (issue #25).
3842
current_app.logger.exception("Failed to parse session %s", session_id)
39-
return jsonify({"error": "Failed to parse session"}), 500
43+
return error_response(
44+
ErrorCode.PARSE_ERROR,
45+
"Failed to parse session",
46+
500,
47+
)
4048

4149

4250
@sessions_bp.route("/api/sessions/<path:project_name>/<session_id>/stats")
@@ -45,17 +53,23 @@ def get_session_stats(project_name, session_id):
4553
try:
4654
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
4755
except ValueError:
48-
return jsonify({"error": "Invalid path"}), 400
56+
return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400)
4957

5058
if not os.path.isfile(filepath):
51-
return jsonify({"error": f"Session {session_id} not found"}), 404
59+
return error_response(
60+
ErrorCode.SESSION_NOT_FOUND,
61+
f"Session {session_id} not found",
62+
404,
63+
)
5264

5365
try:
5466
session = parse_session(filepath)
5567
stats = compute_stats(session)
5668
return jsonify(stats)
5769
except Exception:
58-
# Same pattern as get_session above — full detail to the server log,
59-
# generic message in the HTTP body (issue #25).
6070
current_app.logger.exception("Failed to compute stats for %s", session_id)
61-
return jsonify({"error": "Failed to compute session stats"}), 500
71+
return error_response(
72+
ErrorCode.INTERNAL_ERROR,
73+
"Failed to compute session stats",
74+
500,
75+
)

tests/conftest.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,28 @@
1212
FIXTURES = Path(__file__).parent / "fixtures"
1313

1414

15+
def assert_error_response(resp, *, expected_code: str | None = None):
16+
"""Assert JSON error body has error + code; optionally match code string."""
17+
assert resp.status_code >= 400
18+
body = resp.get_json()
19+
assert body is not None
20+
assert "error" in body
21+
assert isinstance(body["error"], str)
22+
assert "code" in body
23+
if expected_code is not None:
24+
assert body["code"] == expected_code
25+
26+
27+
@pytest.fixture
28+
def export_state_file(tmp_path, monkeypatch):
29+
"""Isolate export state JSON to tmp_path for full-app export tests."""
30+
path = tmp_path / "export_state.json"
31+
monkeypatch.setattr("api.export_api._STATE_FILE", str(path))
32+
return path
33+
34+
1535
@pytest.fixture
16-
def client(tmp_path):
36+
def client(tmp_path, export_state_file):
1737
"""Flask test client with two seeded sessions in 'test-project'."""
1838
project_dir = tmp_path / "test-project"
1939
project_dir.mkdir(parents=True)
@@ -25,7 +45,7 @@ def client(tmp_path):
2545

2646

2747
@pytest.fixture
28-
def client_single(tmp_path):
48+
def client_single(tmp_path, export_state_file):
2949
"""Flask test client with one seeded session — for search/limit tests."""
3050
project_dir = tmp_path / "test-project"
3151
project_dir.mkdir(parents=True)
@@ -36,15 +56,15 @@ def client_single(tmp_path):
3656

3757

3858
@pytest.fixture
39-
def client_empty(tmp_path):
59+
def client_empty(tmp_path, export_state_file):
4060
"""Flask test client with an empty projects directory."""
4161
app = create_app(base_dir=str(tmp_path))
4262
app.config["TESTING"] = True
4363
return app.test_client()
4464

4565

4666
@pytest.fixture
47-
def client_thinking(tmp_path):
67+
def client_thinking(tmp_path, export_state_file):
4868
"""Flask test client with a session containing thinking content blocks."""
4969
project_dir = tmp_path / "test-project"
5070
project_dir.mkdir(parents=True)

tests/test_api_integration.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,7 @@
1010
from __future__ import annotations
1111

1212

13-
def _assert_error_shape(resp):
14-
body = resp.get_json()
15-
assert body is not None
16-
assert "error" in body
17-
# Wednesday will add "code" field — uncomment after structured error codes land.
13+
from tests.conftest import assert_error_response as _assert_error_shape
1814

1915

2016
# --- /api/projects ---

0 commit comments

Comments
 (0)