mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-17 18:55:37 +08:00
feat: expose health view API and streaming CSV exports
This commit is contained in:
1 parent
9dbfcd73f8
commit
c9ca625de1
5 files changed
+489
-1
No files matched your search
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in new issue
Block a user