mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-17 18:55:37 +08:00
feat: improve cloud sync reliability and background status
This commit is contained in:
1 parent
ebc94b50e9
commit
7fcd06980f
12 files changed
+711
-82
No files matched your search
@@ -1,6 +1,9 @@
|
||||
import pytest
|
||||
|
||||
from mi_fitness_mcp.adapters.mi_fitness_cloud import MiFitnessCloudAdapter
|
||||
from mi_fitness_mcp.adapters.mi_fitness_cloud import (
|
||||
MiFitnessCloudAdapter,
|
||||
_is_authentication_error,
|
||||
)
|
||||
|
||||
|
||||
async def _collect(async_iterable):
|
||||
@@ -29,6 +32,7 @@ def test_record_datetime_uses_zone_offset():
|
||||
dt = adapter._record_datetime({"time": 0, "zone_offset": 10800})
|
||||
assert dt.isoformat().startswith("1970-01-01T03:00:00")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_iter_daily_activity_aggregates_steps_and_calories(monkeypatch):
|
||||
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="p1")
|
||||
@@ -63,3 +67,36 @@ async def test_iter_daily_activity_aggregates_steps_and_calories(monkeypatch):
|
||||
assert items[0].steps == 30
|
||||
assert items[0].distance_m == 24
|
||||
assert items[0].active_kcal == 12
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_key_rejects_repeated_pagination_cursor(monkeypatch):
|
||||
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="p1", region="cn")
|
||||
adapter._client = object()
|
||||
|
||||
async def fake_request(base_url, api_path, payload):
|
||||
return {"data_list": [], "has_more": True, "next_key": "same"}
|
||||
|
||||
monkeypatch.setattr(adapter, "_request", fake_request)
|
||||
with pytest.raises(RuntimeError, match="cursor loop"):
|
||||
await adapter._fetch_key("steps", "2026-07-06", "2026-07-12")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_failure_closes_client(monkeypatch):
|
||||
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="bad", region="cn")
|
||||
|
||||
async def failed_login(user_id, pass_token):
|
||||
raise RuntimeError("invalid credentials")
|
||||
|
||||
monkeypatch.setattr(adapter, "_login_with_token", failed_login)
|
||||
assert await adapter.connect() is False
|
||||
assert adapter._client is None
|
||||
assert "invalid credentials" in adapter.last_error
|
||||
|
||||
|
||||
def test_authentication_error_detection():
|
||||
assert _is_authentication_error(401, "denied")
|
||||
assert _is_authentication_error(0, "session expired")
|
||||
assert _is_authentication_error(-10001, "unknown")
|
||||
assert not _is_authentication_error(500, "temporary server failure")
|
||||
@@ -0,0 +1,77 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mi_fitness_mcp import server
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_sync_can_be_polled(monkeypatch):
|
||||
server.sync_tasks.clear()
|
||||
|
||||
async def fake_run(arguments, sync_id=None):
|
||||
await asyncio.sleep(0)
|
||||
return {"status": "ok", "sync_id": sync_id, "records_added": 1}
|
||||
|
||||
monkeypatch.setattr(server, "_run_sync_data", fake_run)
|
||||
accepted = await server._handle_sync_data({"background": True})
|
||||
assert accepted["status"] == "accepted"
|
||||
await asyncio.sleep(0.01)
|
||||
state = server._handle_get_sync_status({"sync_id": accepted["sync_id"]})
|
||||
assert state["status"] == "ok"
|
||||
assert state["records_added"] == 1
|
||||
assert "task" not in state
|
||||
|
||||
|
||||
def test_unknown_background_sync_id():
|
||||
assert server._handle_get_sync_status({"sync_id": "missing"})["status"] == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_background_sync_is_rejected_while_queued(monkeypatch):
|
||||
server.sync_tasks.clear()
|
||||
gate = asyncio.Event()
|
||||
|
||||
async def fake_run(arguments, sync_id=None):
|
||||
await gate.wait()
|
||||
return {"status": "ok", "sync_id": sync_id}
|
||||
|
||||
monkeypatch.setattr(server, "_run_sync_data", fake_run)
|
||||
first = await server._handle_sync_data({"background": True})
|
||||
task = server.sync_tasks[first["sync_id"]]["task"]
|
||||
second = await server._handle_sync_data({"background": True})
|
||||
assert first["status"] == "accepted"
|
||||
assert second["status"] == "error"
|
||||
gate.set()
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_status_skips_health_check_during_sync(monkeypatch):
|
||||
class Adapter:
|
||||
last_error = None
|
||||
last_health_check_at = None
|
||||
|
||||
def is_connected(self):
|
||||
return True
|
||||
|
||||
def get_available_data_types(self):
|
||||
return []
|
||||
|
||||
async def health_check(self):
|
||||
raise AssertionError("health check must not run while syncing")
|
||||
|
||||
class Service:
|
||||
sync_in_progress = True
|
||||
|
||||
class Config:
|
||||
mode = "mi_fitness_cloud"
|
||||
region = "cn"
|
||||
health_check_timeout_seconds = 1
|
||||
|
||||
monkeypatch.setattr(server, "adapter", Adapter())
|
||||
monkeypatch.setattr(server, "sync_service", Service())
|
||||
monkeypatch.setattr(server, "config", Config())
|
||||
monkeypatch.setattr(server, "db", None)
|
||||
result = await server._handle_get_connection_status()
|
||||
assert result["connected"] is True
|
||||
@@ -0,0 +1,130 @@
|
||||
import pytest
|
||||
|
||||
from mi_fitness_mcp.services.sync_service import SyncService
|
||||
|
||||
|
||||
class FakeAdapter:
|
||||
def is_connected(self):
|
||||
return True
|
||||
|
||||
async def iter_daily_activity(self, start_date, end_date):
|
||||
if False:
|
||||
yield None
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def get_sync_state(self, data_type):
|
||||
return None
|
||||
|
||||
def update_sync_state(self, data_type, timestamp):
|
||||
raise AssertionError("empty sync must not advance cursor")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_lookback_and_date_validation():
|
||||
service = SyncService(FakeAdapter(), FakeDatabase(), default_lookback_days=7)
|
||||
result = await service.sync_data_type("daily_activity", end_date="2026-07-12")
|
||||
assert result["start_date"] == "2026-07-06"
|
||||
assert result["end_date"] == "2026-07-12"
|
||||
|
||||
with pytest.raises(ValueError, match="start_date"):
|
||||
await service.sync_data_type(
|
||||
"daily_activity", start_date="2026-07-13", end_date="2026-07-12"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_lock_rejects_overlap():
|
||||
service = SyncService(FakeAdapter(), FakeDatabase())
|
||||
await service._sync_lock.acquire()
|
||||
try:
|
||||
assert service.sync_in_progress is True
|
||||
with pytest.raises(RuntimeError, match="already in progress"):
|
||||
await service.sync_data_type(
|
||||
"daily_activity", start_date="2026-07-12", end_date="2026-07-12"
|
||||
)
|
||||
finally:
|
||||
service._sync_lock.release()
|
||||
assert service.sync_in_progress is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_splits_date_range_into_chunks(monkeypatch):
|
||||
service = SyncService(FakeAdapter(), FakeDatabase(), chunk_days=3)
|
||||
calls = []
|
||||
|
||||
async def fake_range(data_type, start_date, end_date):
|
||||
calls.append((start_date, end_date))
|
||||
return {
|
||||
"data_type": data_type,
|
||||
"added": 1,
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(service, "_sync_range", fake_range)
|
||||
result = await service.sync_data_type(
|
||||
"daily_activity", start_date="2026-07-06", end_date="2026-07-12"
|
||||
)
|
||||
assert calls == [
|
||||
("2026-07-06", "2026-07-08"),
|
||||
("2026-07-09", "2026-07-11"),
|
||||
("2026-07-12", "2026-07-12"),
|
||||
]
|
||||
assert result["added"] == 3
|
||||
assert len(result["chunks"]) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chunk_failure_returns_partial_statistics(monkeypatch):
|
||||
service = SyncService(FakeAdapter(), FakeDatabase(), chunk_days=2)
|
||||
calls = 0
|
||||
|
||||
async def fake_range(data_type, start_date, end_date):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 2:
|
||||
raise RuntimeError("cloud unavailable")
|
||||
return {
|
||||
"data_type": data_type,
|
||||
"added": 2,
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(service, "_sync_range", fake_range)
|
||||
result = await service.sync_data_type(
|
||||
"daily_activity", start_date="2026-07-06", end_date="2026-07-09"
|
||||
)
|
||||
assert result["status"] == "partial"
|
||||
assert result["added"] == 2
|
||||
assert result["chunks"][0]["status"] == "ok"
|
||||
assert result["chunks"][1]["status"] == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_sync_is_rejected_instead_of_queued(monkeypatch):
|
||||
service = SyncService(FakeAdapter(), FakeDatabase())
|
||||
entered = __import__("asyncio").Event()
|
||||
release = __import__("asyncio").Event()
|
||||
|
||||
async def blocked_range(data_type, start_date, end_date):
|
||||
entered.set()
|
||||
await release.wait()
|
||||
return {"data_type": data_type, "added": 0, "updated": 0, "skipped": 0}
|
||||
|
||||
monkeypatch.setattr(service, "_sync_range", blocked_range)
|
||||
first = __import__("asyncio").create_task(
|
||||
service.sync_data_type("daily_activity", "2026-07-12", "2026-07-12")
|
||||
)
|
||||
await entered.wait()
|
||||
with pytest.raises(RuntimeError, match="already in progress"):
|
||||
await service.sync_data_type(
|
||||
"daily_activity", "2026-07-12", "2026-07-12"
|
||||
)
|
||||
release.set()
|
||||
await first
|
||||
Reference in new issue
Block a user