feat: improve cloud sync reliability and background status

This commit is contained in:
binglua committed 2026-07-13 04:03:57 +00:00
1 parent ebc94b50e9
commit 7fcd06980f
12 files changed
+711 -82

No files matched your search

+38 -1
View File
@@ -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")
+77
View File
@@ -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
+130
View File
@@ -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