|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
| 5 | +import io |
5 | 6 | import json |
6 | 7 | import sys |
| 8 | +import zipfile |
7 | 9 | from pathlib import Path |
8 | 10 |
|
9 | 11 | import pytest |
|
13 | 15 |
|
14 | 16 | from flask import Flask |
15 | 17 |
|
16 | | -from api.export_api import export_bp |
| 18 | +from api.error_codes import ErrorCode |
| 19 | +from api.export_api import _export_warnings_header_payload, export_bp |
| 20 | +from utils.export_engine import ExportFailure |
| 21 | +from utils.jsonl_parser import parse_session |
17 | 22 |
|
18 | 23 |
|
19 | 24 | @pytest.fixture |
@@ -69,6 +74,132 @@ def test_bulk_export_empty_returns_422_json(isolated_state, tmp_path): |
69 | 74 | assert body["since"] == "all" |
70 | 75 |
|
71 | 76 |
|
| 77 | +def test_bulk_export_all_succeed_no_warnings_header(client): |
| 78 | + resp = client.post("/api/export", json={"since": "all"}) |
| 79 | + assert resp.status_code == 200 |
| 80 | + assert resp.content_type.startswith("application/zip") |
| 81 | + assert "X-Export-Warnings" not in resp.headers |
| 82 | + zf = zipfile.ZipFile(io.BytesIO(resp.data)) |
| 83 | + md_files = [name for name in zf.namelist() if name.endswith(".md")] |
| 84 | + assert len(md_files) == 2 |
| 85 | + |
| 86 | + |
| 87 | +def test_bulk_export_partial_fail_returns_warning_header(client, monkeypatch): |
| 88 | + real_parse = parse_session |
| 89 | + |
| 90 | + def flaky_parse(path: str): |
| 91 | + if path.endswith("session_def456.jsonl"): |
| 92 | + raise json.JSONDecodeError("bad", "doc", 0) |
| 93 | + return real_parse(path) |
| 94 | + |
| 95 | + monkeypatch.setattr("utils.export_engine.parse_session", flaky_parse) |
| 96 | + resp = client.post("/api/export", json={"since": "all"}) |
| 97 | + assert resp.status_code == 200 |
| 98 | + assert "X-Export-Warnings" in resp.headers |
| 99 | + header = json.loads(resp.headers["X-Export-Warnings"]) |
| 100 | + assert header["total_failures"] == 1 |
| 101 | + assert header["truncated"] is False |
| 102 | + assert len(header["failures"]) == 1 |
| 103 | + assert header["failures"][0]["session_id"] == "session_def456" |
| 104 | + assert header["failures"][0]["code"] == "PARSE_ERROR" |
| 105 | + assert header["failures"][0]["message"] == "Failed to parse session" |
| 106 | + zf = zipfile.ZipFile(io.BytesIO(resp.data)) |
| 107 | + assert len([name for name in zf.namelist() if name.endswith(".md")]) == 1 |
| 108 | + zip_warnings = json.loads(zf.read("export-warnings.json").decode("utf-8")) |
| 109 | + assert len(zip_warnings) == 1 |
| 110 | + assert zip_warnings[0]["session_id"] == "session_def456" |
| 111 | + assert "bad" not in zip_warnings[0]["message"] |
| 112 | + |
| 113 | + |
| 114 | +def test_bulk_export_all_fail_returns_422(client, monkeypatch): |
| 115 | + def always_fail(path: str): |
| 116 | + raise json.JSONDecodeError("bad", "doc", 0) |
| 117 | + |
| 118 | + monkeypatch.setattr("utils.export_engine.parse_session", always_fail) |
| 119 | + resp = client.post("/api/export", json={"since": "all"}) |
| 120 | + assert resp.status_code == 422 |
| 121 | + body = resp.get_json() |
| 122 | + assert body["code"] == "EXPORT_ALL_FAILED" |
| 123 | + assert body["since"] == "all" |
| 124 | + assert len(body["failures"]) == 2 |
| 125 | + assert {item["code"] for item in body["failures"]} == {"PARSE_ERROR"} |
| 126 | + assert all(item["message"] == "Failed to parse session" for item in body["failures"]) |
| 127 | + |
| 128 | + |
| 129 | +def test_export_warnings_header_payload_truncates_at_entry_limit(): |
| 130 | + failures = [ |
| 131 | + ExportFailure( |
| 132 | + session_id=f"sess_{i:04d}", |
| 133 | + message="Failed to parse session", |
| 134 | + code=ErrorCode.PARSE_ERROR, |
| 135 | + ) |
| 136 | + for i in range(25) |
| 137 | + ] |
| 138 | + payload = _export_warnings_header_payload(failures) |
| 139 | + assert payload["total_failures"] == 25 |
| 140 | + assert payload["truncated"] is True |
| 141 | + assert len(payload["failures"]) <= 20 |
| 142 | + |
| 143 | + |
| 144 | +def test_export_warnings_header_payload_byte_overflow_fallback(monkeypatch): |
| 145 | + monkeypatch.setattr("api.export_api._EXPORT_WARNINGS_HEADER_MAX_BYTES", 80) |
| 146 | + failures = [ |
| 147 | + ExportFailure( |
| 148 | + session_id="x" * 200, |
| 149 | + message="Failed to parse session", |
| 150 | + code=ErrorCode.PARSE_ERROR, |
| 151 | + ) |
| 152 | + ] |
| 153 | + payload = _export_warnings_header_payload(failures) |
| 154 | + assert payload["truncated"] is True |
| 155 | + assert payload["failures"] == [] |
| 156 | + assert len(json.dumps(payload, separators=(",", ":"))) <= 80 |
| 157 | + |
| 158 | + |
| 159 | +def test_bulk_export_partial_fail_incremental_excludes_failed_from_state( |
| 160 | + client, monkeypatch, export_state_file |
| 161 | +): |
| 162 | + export_state_file.write_text( |
| 163 | + json.dumps({"sessions": {}, "exportedCount": 0}), |
| 164 | + encoding="utf-8", |
| 165 | + ) |
| 166 | + real_parse = parse_session |
| 167 | + |
| 168 | + def flaky_parse(path: str): |
| 169 | + if path.endswith("session_def456.jsonl"): |
| 170 | + raise json.JSONDecodeError("bad", "doc", 0) |
| 171 | + return real_parse(path) |
| 172 | + |
| 173 | + monkeypatch.setattr("utils.export_engine.parse_session", flaky_parse) |
| 174 | + resp = client.post("/api/export", json={"since": "incremental"}) |
| 175 | + assert resp.status_code == 200 |
| 176 | + |
| 177 | + state = json.loads(export_state_file.read_text(encoding="utf-8")) |
| 178 | + sessions = state.get("sessions", {}) |
| 179 | + assert "session_abc123" in sessions |
| 180 | + assert "session_def456" not in sessions |
| 181 | + |
| 182 | + |
| 183 | +def test_bulk_export_partial_fail_excludes_failed_from_state( |
| 184 | + client, monkeypatch, export_state_file |
| 185 | +): |
| 186 | + real_parse = parse_session |
| 187 | + |
| 188 | + def flaky_parse(path: str): |
| 189 | + if path.endswith("session_def456.jsonl"): |
| 190 | + raise json.JSONDecodeError("bad", "doc", 0) |
| 191 | + return real_parse(path) |
| 192 | + |
| 193 | + monkeypatch.setattr("utils.export_engine.parse_session", flaky_parse) |
| 194 | + resp = client.post("/api/export", json={"since": "all"}) |
| 195 | + assert resp.status_code == 200 |
| 196 | + |
| 197 | + state = json.loads(export_state_file.read_text(encoding="utf-8")) |
| 198 | + sessions = state.get("sessions", {}) |
| 199 | + assert "session_abc123" in sessions |
| 200 | + assert "session_def456" not in sessions |
| 201 | + |
| 202 | + |
72 | 203 | def test_export_state_json_fields(isolated_state): |
73 | 204 | isolated_state.write_text( |
74 | 205 | json.dumps( |
|
0 commit comments