feat: expose health view API and streaming CSV exports

This commit is contained in:
weiyuqing committed 2026-10-17 14:02:40 +08:00
1 parent 9dbfcd73f8
commit c9ca625de1
5 files changed
+489 -1

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
import pytest
from fastapi.testclient import TestClient
from mi_fitness_mcp import api
from mi_fitness_mcp.services.query_service import QueryService
from mi_fitness_mcp.storage import Database
from test_view_queries import insert
@pytest.fixture
def api_client(tmp_path, monkeypatch):
db = Database(tmp_path / "synthetic.db")
monkeypatch.setenv("MI_FITNESS_API_KEY", "synthetic-api-key")
context = api.UserContext("owner", "synthetic-unused", "cn", query_service=QueryService(db, "owner"))
monkeypatch.setattr(api.app.state, "db", db, raising=False)
monkeypatch.setattr(api.app.state, "default_context", context, raising=False)
client = TestClient(api.app, base_url="http://localhost", headers={"X-API-Key": "synthetic-api-key"})
yield client, db
client.close()
def test_view_auth_validation_and_legacy_compatibility(api_client):
client, db = api_client
insert(db, "heart_rate_samples", id="sample", timestamp="2026-10-15T02:00:00Z", bpm=72, sample_type="passive")
query = "?start_date=2026-10-15&end_date=2026-10-15"
for path in ["/api/views/heartrate", "/api/views/heartrate/records", "/api/views/heartrate/export", "/api/overview"]:
request = client.build_request("GET", path + query)
request.headers.pop("X-API-Key")
assert client.send(request).status_code == 401
result = client.get("/api/views/heartrate" + query)
assert result.status_code == 200
assert result.json()["statistics"]["primary_mean"] == 72
assert result.json()["page"]["size"] == 10
assert result.json()["range"] == {"start": "2026-10-15", "end": "2026-10-15"}
old = client.get("/api/heart-rate" + query)
assert old.status_code == 200
assert old.json() == {"status": "ok", "count": 1, "data": [dict(timestamp="2026-10-15T02:00:00Z", bpm=72, sample_type="passive")]}
page = client.get("/api/views/heartrate/records" + query).json()
assert set(page) == {"status", "kind", "range", "page"}
overview = client.get("/api/overview" + query).json()
assert overview["status"] == "ok"
assert overview["range"] == {"start": "2026-10-15", "end": "2026-10-15"}
assert overview["data"]["resting_mean_bpm"] is None
assert set(overview["data"]) == {"steps", "active_kcal", "distance_m", "resting_mean_bpm", "mean_daily_asleep_minutes", "mean_spo2_pct", "mean_stress", "activity_days", "sleep_days", "resting_count", "spo2_count"}
@pytest.mark.parametrize("changes", [dict(kind="unknown"), dict(start_date="2026-02-30"), dict(start_date="2026-10-16"),
dict(start_date="2000-01-01"), dict(page=0), dict(page=1000001), dict(limit=0), dict(limit=-1),
dict(pressure_level="invalid"), dict(page="1.5"), dict(include_naps="invalid")])
def test_invalid_view_query_rejected(api_client, changes):
client, _ = api_client
values = dict(kind="stress", start_date="2026-10-15", end_date="2026-10-15") | changes
kind = values.pop("kind")
response = client.get(f"/api/views/{kind}", params=values)
assert response.status_code in (400, 422)
assert "detail" in response.json()
def test_blank_filters_and_empty_values_are_supported(api_client):
client, _ = api_client
response = client.get("/api/views/stress", params=dict(start_date="2026-10-15", end_date="2026-10-15", sample_type="", pressure_level=""))
assert response.status_code == 200
assert response.json()["statistics"]["primary_mean"] is None
assert response.json()["page"]["rows"] == []
def test_records_endpoint_does_not_reaggregate(api_client, monkeypatch):
client, _ = api_client
from mi_fitness_mcp.storage.view_queries import ViewRepository
def forbidden(*args, **kwargs):
raise AssertionError("翻页不能重新聚合")
monkeypatch.setattr(ViewRepository, "statistics", forbidden)
monkeypatch.setattr(ViewRepository, "points", forbidden)
assert client.get("/api/views/stress/records", params=dict(start_date="2026-10-15", end_date="2026-10-15", page=2)).status_code == 200
+150
View File
@@ -0,0 +1,150 @@
import asyncio
import csv
import io
import threading
from contextlib import contextmanager
import pytest
from starlette.requests import Request
from mi_fitness_mcp import api
from mi_fitness_mcp.storage.view_queries import ViewRepository, ViewSelection
from test_view_api import api_client as api_client
from test_view_queries import insert
def test_export_matches_filtered_set_and_escapes_formula(api_client):
client, db = api_client
for n in range(31):
insert(db, "stress_samples", id=str(n), timestamp=f"2026-10-15T00:{n:02}:00Z", stress_score=26 + n if n < 25 else 99, level="=1+1")
params = dict(start_date="2026-10-15", end_date="2026-10-15", pressure_level="mild", limit=11, page=2)
response = client.get("/api/views/stress/export", params=params)
assert response.status_code == 200
assert response.content.startswith(b"\xef\xbb\xbf")
assert response.headers["content-type"].startswith("text/csv")
assert "attachment" in response.headers["content-disposition"]
assert "content-length" not in response.headers
records = list(csv.DictReader(io.StringIO(response.content.decode("utf-8-sig"))))
assert len(records) == 11
assert [int(row["压力指数"]) for row in records] == list(range(50, 39, -1))
assert all(row["源等级(原值)"] == "'=1+1" for row in records)
assert all(row["压力等级(当前分界)"] == "轻度" for row in records)
assert not set(records[0]) & {"id", "user_id", "device_id", "pass_token", "api_key"}
assert response.headers["cache-control"] == "no-store"
@pytest.mark.parametrize("text", ["=1+1", "+SUM(A1)", "-1+1", "@SUM(A1)", "\t =1+1", "\n=1+1"])
def test_formula_prefixes_in_source_text_are_safe(api_client, text):
client, db = api_client
insert(db, "heart_rate_samples", id="sample", timestamp="2026-10-15T00:00:00Z", bpm=72, sample_type=text)
response = client.get("/api/views/heartrate/export", params=dict(start_date="2026-10-15", end_date="2026-10-15"))
rows = list(csv.DictReader(io.StringIO(response.content.decode("utf-8-sig"))))
assert rows[0]["采样类型"] == "'" + text
assert rows[0]["心率(bpm)"] == "72"
def test_sleep_export_keeps_source_and_normalized_fields(api_client):
client, db = api_client
insert(db, "sleep_sessions", id="sample", sleep_id="synthetic-sleep", start_at="2026-10-14T23:00:00+08:00", end_at="2026-10-15T07:15:00+08:00", duration_minutes=495, time_asleep_minutes=480, time_awake_minutes=15, stages='[{"stage":"deep","minutes":120}]')
response = client.get("/api/views/sleep/export", params=dict(start_date="2026-10-15", end_date="2026-10-15"))
rows = list(csv.DictReader(io.StringIO(response.content.decode("utf-8-sig"))))
assert rows[0]["实睡时长(分钟)"] == "480"
assert rows[0]["源时长(分钟)"] == "495"
assert rows[0]["深睡(分钟)"] == "120"
assert '"deep"' in rows[0]["睡眠阶段(源字段)"]
@pytest.mark.asyncio
async def test_stream_disconnect_closes_cursor_and_proxy_response(api_client, monkeypatch):
_, db = api_client
insert(db, "stress_samples", id="sample", timestamp="2026-10-15T00:00:00Z", stress_score=26, level="source")
original = ViewRepository.snapshot
opened, closed, threads = [], [], []
@contextmanager
def observed(repository):
with original(repository) as connection:
threads.append(threading.get_ident())
opened.append(connection)
try:
yield connection
finally:
threads.append(threading.get_ident())
closed.append(connection)
monkeypatch.setattr(ViewRepository, "snapshot", observed)
request = Request({"type": "http", "app": api.app, "headers": [(b"x-api-key", b"synthetic-api-key")]})
response = api.get_view_export(request, ViewSelection("stress", "2026-10-15", "2026-10-15"))
assert not opened
await response.body_iterator.__anext__()
await response.body_iterator.__anext__()
assert len(opened) == 1
assert not closed
await response.body_iterator.aclose()
assert closed == opened
assert len(set(threads)) == 1
assert threads[0] != threading.get_ident()
@pytest.mark.asyncio
async def test_stream_send_error_closes_database_in_original_worker(api_client, monkeypatch):
_, db = api_client
insert(db, "stress_samples", id="sample", timestamp="2026-10-15T00:00:00Z", stress_score=26, level="source")
original = ViewRepository.snapshot
opened, closed = [], []
@contextmanager
def observed(repository):
with original(repository) as connection:
opened.append(threading.get_ident())
try:
yield connection
finally:
closed.append(threading.get_ident())
monkeypatch.setattr(ViewRepository, "snapshot", observed)
request = Request({"type": "http", "app": api.app, "headers": [(b"x-api-key", b"synthetic-api-key")]})
response = api.get_view_export(request, ViewSelection("stress", "2026-10-15", "2026-10-15"))
chunks = 0
async def send(message):
nonlocal chunks
if message["type"] == "http.response.body":
chunks += 1
if chunks == 2:
raise OSError("synthetic disconnect")
with pytest.raises(OSError, match="synthetic disconnect"):
await response.stream_response(send)
assert closed == opened
assert len(opened) == 1
assert opened[0] != threading.get_ident()
@pytest.mark.asyncio
async def test_cancelled_read_still_queues_close_on_original_worker(api_client, monkeypatch):
_, db = api_client
original = ViewRepository.iter_records
started, released, closed = threading.Event(), threading.Event(), threading.Event()
threads = []
def slow_records(repository, selection, connection):
threads.append(threading.get_ident())
try:
started.set()
released.wait(5)
yield from original(repository, selection, connection)
finally:
threads.append(threading.get_ident())
closed.set()
monkeypatch.setattr(ViewRepository, "iter_records", slow_records)
insert(db, "stress_samples", id="sample", timestamp="2026-10-15T00:00:00Z", stress_score=26, level="source")
request = Request({"type": "http", "app": api.app, "headers": [(b"x-api-key", b"synthetic-api-key")]})
response = api.get_view_export(request, ViewSelection("stress", "2026-10-15", "2026-10-15"))
await response.body_iterator.__anext__()
pending = asyncio.create_task(response.body_iterator.__anext__())
try:
assert await asyncio.to_thread(started.wait, 3)
pending.cancel()
released.set()
with pytest.raises(asyncio.CancelledError):
await pending
assert await asyncio.to_thread(closed.wait, 3)
assert len(set(threads)) == 1
assert threads[0] != threading.get_ident()
finally:
released.set()
await response.body_iterator.aclose()
+80
View File
@@ -0,0 +1,80 @@
import httpx
import pytest
import respx
from mi_fitness_mcp.web import create_app
class TrackedStream(httpx.SyncByteStream):
def __init__(self, fail=False):
self.consumed = 0
self.closed = False
self.fail = fail
def __iter__(self):
for n in range(30):
if self.fail and n == 1:
raise httpx.ReadError("synthetic read failure")
self.consumed += 1
yield f"synthetic-row-{n}\r\n".encode()
def close(self):
self.closed = True
def make_client(monkeypatch):
monkeypatch.setenv("MI_FITNESS_WEB_PASSWORD", "synthetic-web-password")
app = create_app(backend_url="http://synthetic-backend", api_key="synthetic-api-key", admin_key="synthetic-admin-key")
app.config["TESTING"] = True
return app.test_client()
@respx.mock
def test_stream_disconnect_closes_cursor_and_proxy_response(monkeypatch):
stream = TrackedStream()
route = respx.get("http://synthetic-backend/api/views/stress/export").mock(return_value=httpx.Response(200,
stream=stream, headers={"Content-Type": "text/csv; charset=utf-8", "Content-Disposition": 'attachment; filename="synthetic.csv"', "X-API-Key": "must-not-leak", "X-Admin-Key": "must-not-leak", "Authorization": "must-not-leak", "Set-Cookie": "must-not-leak"}))
client = make_client(monkeypatch)
response = client.get("/proxy/api/views/stress/export", auth=("admin", "synthetic-web-password"), buffered=False)
assert response.status_code == 200
assert response.is_streamed
assert 0 < stream.consumed < 30
assert not stream.closed
assert response.headers["Content-Disposition"].startswith("attachment")
assert not any(name.lower() in {"x-api-key", "x-admin-key", "authorization", "set-cookie"} for name in response.headers.keys())
assert route.calls[0].request.headers["X-API-Key"] == "synthetic-api-key"
response.close()
assert stream.closed
assert stream.consumed < 30
@respx.mock
def test_stream_normal_completion_and_error_release_upstream(monkeypatch):
client = make_client(monkeypatch)
for fail in [False, True]:
stream = TrackedStream(fail)
respx.get("http://synthetic-backend/api/views/body/export").mock(return_value=httpx.Response(200, stream=stream, headers={"Content-Type": "text/csv"}))
response = client.get("/proxy/api/views/body/export", auth=("admin", "synthetic-web-password"), buffered=False)
if fail:
with pytest.raises(httpx.ReadError):
list(response.response)
else:
assert len(b"".join(response.response).splitlines()) == 30
assert stream.closed
response.close()
@respx.mock
def test_export_still_requires_web_auth_and_same_origin(monkeypatch):
client = make_client(monkeypatch)
assert client.get("/proxy/api/views/stress/export").status_code == 401
assert client.get("/proxy/api/views/stress/export", auth=("admin", "synthetic-web-password"), headers={"Origin": "https://untrusted.example"}).status_code == 403
@respx.mock
def test_export_connection_failure_uses_existing_error_shape(monkeypatch):
respx.get("http://synthetic-backend/api/views/stress/export").mock(side_effect=httpx.ConnectError("synthetic unavailable"))
response = make_client(monkeypatch).get("/proxy/api/views/stress/export", auth=("admin", "synthetic-web-password"))
assert response.status_code == 502
assert response.json["error"] == "Flask 代理无法访问后端"
assert "X-Upstream-Time-Ms" in response.headers