mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-18 03:03:49 +08:00
151 lines
6.9 KiB
Python
151 lines
6.9 KiB
Python
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()
|