Skip to content

Commit 20ac332

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 4bbb456 commit 20ac332

13 files changed

Lines changed: 520 additions & 31 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: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,45 @@
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
1011

1112
search_bp = Blueprint("search", __name__)
1213

14+
_DEFAULT_LIMIT = 50
15+
_MAX_LIMIT = 500
16+
17+
18+
def _parse_limit(raw: str | None) -> int:
19+
"""Parse positive integer limit query param; raise ValueError if invalid."""
20+
if raw is None or raw.strip() == "":
21+
return _DEFAULT_LIMIT
22+
try:
23+
n = int(raw.strip())
24+
except ValueError as exc:
25+
raise ValueError("limit must be a positive integer") from exc
26+
if n < 1:
27+
raise ValueError("limit must be a positive integer")
28+
return min(n, _MAX_LIMIT)
29+
1330

1431
@search_bp.route("/api/search")
1532
def search():
1633
query = request.args.get("q", "").strip().lower()
1734
if not query:
1835
return jsonify([])
1936

20-
max_results = int(request.args.get("limit", 50))
37+
try:
38+
max_results = _parse_limit(request.args.get("limit"))
39+
except ValueError:
40+
return error_response(
41+
ErrorCode.SEARCH_INVALID_LIMIT,
42+
"Invalid limit: must be a positive integer",
43+
400,
44+
)
45+
2146
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
2247
projects = list_projects(base)
2348

@@ -39,7 +64,6 @@ def search():
3964
for msg in session["messages"]:
4065
text = msg.get("text", "") or msg.get("content", "")
4166
if query in text.lower():
42-
# Find the matching snippet
4367
idx = text.lower().index(query)
4468
start = max(0, idx - 80)
4569
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: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Shared pytest fixtures for API and integration tests."""
2+
3+
from __future__ import annotations
4+
5+
import shutil
6+
from pathlib import Path
7+
8+
import pytest
9+
10+
from app import create_app
11+
12+
FIXTURES = Path(__file__).parent / "fixtures"
13+
14+
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+
35+
@pytest.fixture
36+
def client(tmp_path, export_state_file):
37+
"""Flask test client with two seeded sessions in test-project."""
38+
project_dir = tmp_path / "test-project"
39+
project_dir.mkdir(parents=True)
40+
shutil.copy(FIXTURES / "session_minimal.jsonl", project_dir / "session_abc123.jsonl")
41+
shutil.copy(FIXTURES / "session_with_tools.jsonl", project_dir / "session_def456.jsonl")
42+
app = create_app(base_dir=str(tmp_path))
43+
app.config["TESTING"] = True
44+
return app.test_client()
45+
46+
47+
@pytest.fixture
48+
def client_single(tmp_path, export_state_file):
49+
"""Flask test client with one session — for search/limit tests."""
50+
project_dir = tmp_path / "test-project"
51+
project_dir.mkdir(parents=True)
52+
shutil.copy(FIXTURES / "session_minimal.jsonl", project_dir / "session_abc123.jsonl")
53+
app = create_app(base_dir=str(tmp_path))
54+
app.config["TESTING"] = True
55+
return app.test_client()
56+
57+
58+
@pytest.fixture
59+
def client_empty(tmp_path, export_state_file):
60+
"""Flask test client with an empty projects directory."""
61+
app = create_app(base_dir=str(tmp_path))
62+
app.config["TESTING"] = True
63+
return app.test_client()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
{"type": "user", "uuid": "user-hello-001", "sessionId": "session_abc123", "timestamp": "2026-05-19T10:00:00.000Z", "cwd": "/home/user/test-project", "message": {"role": "user", "content": [{"type": "text", "text": "Hello"}]}}
2+
{"type": "assistant", "uuid": "asst-hello-001", "parentUuid": "user-hello-001", "sessionId": "session_abc123", "timestamp": "2026-05-19T10:00:01.000Z", "message": {"role": "assistant", "model": "claude-opus-4-5", "content": [{"type": "text", "text": "Hi there!"}], "stop_reason": "end_turn", "usage": {"input_tokens": 10, "output_tokens": 5, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0}}}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{"type": "user", "uuid": "user-tools-001", "sessionId": "session_def456", "timestamp": "2026-05-19T11:00:00.000Z", "cwd": "/home/user/test-project", "message": {"role": "user", "content": [{"type": "text", "text": "Run a command"}]}}
2+
{"type": "assistant", "uuid": "asst-tools-001", "parentUuid": "user-tools-001", "sessionId": "session_def456", "timestamp": "2026-05-19T11:00:02.000Z", "message": {"role": "assistant", "model": "claude-opus-4-5", "content": [{"type": "tool_use", "id": "toolu_01", "name": "Bash", "input": {"command": "echo hello"}}], "stop_reason": "tool_use", "usage": {"input_tokens": 20, "output_tokens": 10, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0}}}
3+
{"type": "user", "uuid": "user-tools-002", "parentUuid": "asst-tools-001", "sessionId": "session_def456", "timestamp": "2026-05-19T11:00:03.000Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": "hello\n"}]}}

0 commit comments

Comments
 (0)