Files
mi-fitness-health/tests/test_view_export.py
T

151 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()