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

+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()