mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-18 03:03:49 +08:00
保存 NAS 健康系统 UI7 代码与本机维护基线
This commit is contained in:
1 parent
1767b4921a
commit
8546ba23ff
80 files changed
+4984
-381
No files matched your search
@@ -0,0 +1,184 @@
|
||||
"""数据边界修复回归:全部为本机合成数据。"""
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / 'work/mi-fitness-mcp-cn/src'))
|
||||
from mi_fitness_mcp import api
|
||||
from mi_fitness_mcp.models import DailyActivity, StressSample, Workout, SleepSession
|
||||
from mi_fitness_mcp.services.query_service import QueryService
|
||||
from mi_fitness_mcp.services.sync_service import SyncService
|
||||
from mi_fitness_mcp.storage import Database
|
||||
|
||||
CN = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def database(tmp_path):
|
||||
db = Database(tmp_path / 'synthetic.sqlite')
|
||||
with db._get_connection() as conn:
|
||||
conn.execute("CREATE TABLE api_keys (key TEXT PRIMARY KEY,label TEXT,user_id TEXT,pass_token TEXT,region TEXT,created_at TEXT,last_used_at TEXT,revoked INTEGER DEFAULT 0)")
|
||||
conn.commit()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.mark.parametrize('existing', ['revoked_key', 'health_data'])
|
||||
def test_new_authorization_cannot_change_database_owner(tmp_path, monkeypatch, existing):
|
||||
db = database(tmp_path)
|
||||
if existing == 'revoked_key':
|
||||
with db._get_connection() as conn:
|
||||
conn.execute("INSERT INTO api_keys VALUES ('old','old','A','','cn','2026-10-01',NULL,1)")
|
||||
conn.commit()
|
||||
else:
|
||||
db.insert_daily_activity(DailyActivity(id='day-A', provider='mi_fitness', source_type='cloud_session',
|
||||
user_id='A', date='2026-10-16', steps=1111, active_kcal=100, distance_m=1000))
|
||||
stored = []
|
||||
monkeypatch.setattr(api, 'store_api_key_secret', lambda *args: stored.append(args) or True)
|
||||
with pytest.raises(api.HTTPException) as exc:
|
||||
api._create_key_record(db, 'B', 'synthetic', 'cn', 'other')
|
||||
assert exc.value.status_code == 409
|
||||
assert not stored
|
||||
|
||||
|
||||
def test_same_owner_reauthorization_allowed_but_region_switch_rejected(tmp_path, monkeypatch):
|
||||
db = database(tmp_path)
|
||||
monkeypatch.setattr(api, 'store_api_key_secret', lambda *args: True)
|
||||
first = api._create_key_record(db, 'A', 'synthetic', 'cn', 'first')
|
||||
second = api._create_key_record(db, 'A', 'synthetic', 'cn', 'second')
|
||||
assert first != second
|
||||
with pytest.raises(api.HTTPException) as exc:
|
||||
api._create_key_record(db, 'A', 'synthetic', 'us', 'other-region')
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_sync_refuses_context_for_another_data_owner(tmp_path):
|
||||
db = database(tmp_path)
|
||||
db.insert_daily_activity(DailyActivity(id='day-A', provider='mi_fitness', source_type='cloud_session',
|
||||
user_id='A', date='2026-10-16', steps=1111, active_kcal=100, distance_m=1000))
|
||||
ctx = SimpleNamespace(user_id='B', region='cn', sync_service=SimpleNamespace(db=db), adapter=None)
|
||||
with pytest.raises(api.HTTPException) as exc:
|
||||
await api._run_sync(ctx, None, {'sync_id': 'synthetic'})
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
|
||||
class Adapter:
|
||||
def __init__(self, owner):
|
||||
self.user_id = owner
|
||||
self.calls = []
|
||||
def is_connected(self): return True
|
||||
async def iter_heart_rate(self, start, end):
|
||||
self.calls.append((start, end))
|
||||
if False: yield None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_day_incremental_uses_calendar_day(tmp_path):
|
||||
db = database(tmp_path)
|
||||
adapter = Adapter('A')
|
||||
# 旧版全局游标也必须安全,不能将当天 09:00 与午夜比较后拒绝。
|
||||
db.update_sync_state('heart_rate', datetime(2026, 10, 16, 9, tzinfo=CN))
|
||||
db.update_sync_state('heart_rate', datetime(2026, 10, 16, 9, tzinfo=CN), user_id='A')
|
||||
await SyncService(adapter, db).sync_data_type('heart_rate', end_date='2026-10-16')
|
||||
assert adapter.calls
|
||||
assert adapter.calls[-1][1] == '2026-10-16'
|
||||
|
||||
|
||||
def test_direct_and_batch_writes_cannot_overwrite_another_owner(tmp_path):
|
||||
db = database(tmp_path)
|
||||
item = DailyActivity(id='same-day', provider='mi_fitness', source_type='cloud_session', user_id='A',
|
||||
date='2026-10-16', steps=1111, active_kcal=100, distance_m=1000)
|
||||
db.insert_daily_activity(item)
|
||||
other = item.model_copy(update={'user_id': 'B', 'steps': 2222})
|
||||
with pytest.raises(ValueError, match='账号'):
|
||||
db.insert_daily_activity(other)
|
||||
with pytest.raises(ValueError, match='账号'):
|
||||
db.insert_records_batch('daily_activity', [other])
|
||||
assert QueryService(db, 'A').get_daily_summaries('2026-10-16', '2026-10-16')[0]['steps'] == 1111
|
||||
|
||||
|
||||
def test_concurrent_first_authorizations_bind_exactly_one_owner(tmp_path, monkeypatch):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import threading
|
||||
db = database(tmp_path)
|
||||
monkeypatch.setattr(api, 'store_api_key_secret', lambda *args: True)
|
||||
barrier = threading.Barrier(2)
|
||||
def create(owner):
|
||||
barrier.wait()
|
||||
try:
|
||||
api._create_key_record(db, owner, 'synthetic', 'cn', owner)
|
||||
return 'ok'
|
||||
except api.HTTPException as exc:
|
||||
return exc.status_code
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
assert sorted(pool.map(create, ['A', 'B']), key=str) == [409, 'ok']
|
||||
with db._get_connection() as conn:
|
||||
assert conn.execute('SELECT count(*) FROM api_keys').fetchone()[0] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_another_account_cursor_is_not_used_for_first_incremental_sync(tmp_path):
|
||||
db = database(tmp_path)
|
||||
db.update_sync_state('heart_rate', datetime(2026, 10, 15, tzinfo=CN))
|
||||
adapter = Adapter('B')
|
||||
await SyncService(adapter, db).sync_data_type('heart_rate', end_date='2026-10-16')
|
||||
assert adapter.calls[0][0] == '2026-09-17'
|
||||
|
||||
|
||||
def test_cursor_storage_is_scoped_by_owner(tmp_path):
|
||||
db = database(tmp_path)
|
||||
stamp = datetime(2026, 10, 16, 9, tzinfo=CN)
|
||||
db.update_sync_state('heart_rate', stamp, user_id='A')
|
||||
assert db.get_sync_state('heart_rate', user_id='A')['last_record_timestamp'] == stamp.isoformat()
|
||||
assert db.get_sync_state('heart_rate', user_id='B') is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_mcp_status_reads_current_owner_cursor(tmp_path, monkeypatch):
|
||||
from mi_fitness_mcp import server
|
||||
db = database(tmp_path)
|
||||
db.update_sync_state('heart_rate', datetime(2026,10,16,9,tzinfo=CN), user_id='A')
|
||||
class DisconnectedAdapter:
|
||||
user_id = 'A'
|
||||
last_error = None
|
||||
last_health_check_at = None
|
||||
async def health_check(self): return False
|
||||
monkeypatch.setattr(server, 'db', db)
|
||||
monkeypatch.setattr(server, 'adapter', DisconnectedAdapter())
|
||||
monkeypatch.setattr(server, 'sync_service', None)
|
||||
monkeypatch.setattr(server, 'config', SimpleNamespace(mode='mi_fitness_cloud',region='cn',health_check_timeout_seconds=1))
|
||||
result = await server._handle_get_connection_status()
|
||||
assert result['last_sync_at'] is not None
|
||||
assert 'heart_rate' in result['available_data_types']
|
||||
|
||||
|
||||
def test_coverage_counts_local_measurement_and_sleep_wake_days(tmp_path):
|
||||
db = database(tmp_path)
|
||||
for index, hour in enumerate((1, 9)):
|
||||
db.insert_stress_sample(StressSample(id=f'stress-{index}', provider='mi_fitness', source_type='cloud_session',
|
||||
user_id='A', timestamp=datetime(2026, 10, 16, hour, tzinfo=CN), stress_score=30, level='medium'))
|
||||
db.insert_sleep_session(SleepSession(id='sleep-A', sleep_id='sleep-A', provider='mi_fitness', source_type='cloud_session',
|
||||
user_id='A', start_at=datetime(2026, 10, 15, 23, tzinfo=CN), end_at=datetime(2026, 10, 16, 7, tzinfo=CN),
|
||||
duration_minutes=480, time_asleep_minutes=480, time_awake_minutes=0))
|
||||
coverage = {row['data_type']: row for row in db.get_data_coverage('A')}
|
||||
for kind in ('stress', 'sleep'):
|
||||
assert coverage[kind]['first_date'] == coverage[kind]['last_date'] == '2026-10-16'
|
||||
assert coverage[kind]['days_with_data'] == 1
|
||||
|
||||
|
||||
def test_repeated_workout_sync_updates_all_mutable_fields(tmp_path):
|
||||
db = database(tmp_path)
|
||||
item = Workout(id='exercise', workout_id='exercise', provider='mi_fitness', source_type='cloud_session', user_id='A',
|
||||
activity_type='walking', start_at=datetime(2026, 10, 16, 9, tzinfo=CN),
|
||||
end_at=datetime(2026, 10, 16, 9, 30, tzinfo=CN), duration_minutes=30)
|
||||
db.insert_workout(item)
|
||||
changes = {'total_steps': 4000, 'avg_pace_sec_per_km': 300, 'max_pace_sec_per_km': 240,
|
||||
'activity_type': 'running', 'end_at': datetime(2026, 10, 16, 9, 40, tzinfo=CN)}
|
||||
db.insert_workout(item.model_copy(update=changes))
|
||||
result = QueryService(db, 'A').get_workouts('2026-10-16', '2026-10-16')[0]
|
||||
for key, value in changes.items():
|
||||
assert result[key] == (value.isoformat() if isinstance(value, datetime) else value)
|
||||
Reference in new issue
Block a user