feat(sync-history): 新增同步结果查询(每次自动/手动/立即同步的记录)
后端: - db.py SCHEMA 新增 sync_history 表(不可变,每次同步尝试一行) + 索引 - garmin.py: _log_sync_history() 在 sync_data 全部出口记录; trigger 区分 auto(调度器)/manual(同步页开始同步)/quick(设置页立即同步); start_sync 限流拦截分支同样留档; 写入失败只告警不影响同步 - scheduler.py 自动同步传 trigger="auto"; routes 新增 GET /garmin/sync-history 按时间倒序返回(默认 50 条,上限 200) 前端: - api.ts 增加 SyncHistoryItem 类型 + getSyncHistory() - 新页面 /sync-history/ 同步记录: 卡片列表, 状态(成功/失败/被限流)chip 配色, 时间本地化(今天/昨天/X月X日), 触发类型标签, 范围与耗时 - 同步页与设置页同步区块均加入口链接
This commit is contained in:
@@ -845,3 +845,80 @@ class TestSyncWindow:
|
||||
assert out["recordsSynced"] == 2, "the days pulled before the 429 are kept"
|
||||
assert len([c for c in client.calls if c[0] == "summary"]) == 3
|
||||
assert garmin_svc.rate_limited_until(user["id"]) is not None
|
||||
|
||||
|
||||
class TestSyncHistory:
|
||||
"""Every sync attempt gets one immutable row, whatever its outcome — the
|
||||
record is what lets the 同步记录 screen show what ran, when, and whether
|
||||
it worked. sync_status only keeps the latest state."""
|
||||
|
||||
def test_success_records_one_row(self, db, user):
|
||||
out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
|
||||
assert out["status"] == "success"
|
||||
|
||||
rows = garmin_svc.get_sync_history(user["id"])
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["triggerKind"] == "manual"
|
||||
assert row["status"] == "success"
|
||||
assert row["days"] == 1
|
||||
assert row["recordsSynced"] == 1
|
||||
assert row["startedAt"] and row["finishedAt"]
|
||||
assert row["durationSeconds"] >= 0
|
||||
|
||||
def test_auto_and_quick_triggers_are_tagged(self, db, user):
|
||||
garmin_svc.sync_data(
|
||||
user["id"], CREDS, days=1, client=StubClient(), trigger="auto"
|
||||
)
|
||||
garmin_svc.sync_data(
|
||||
user["id"], CREDS, days=1, client=StubClient(), trigger="quick"
|
||||
)
|
||||
rows = garmin_svc.get_sync_history(user["id"])
|
||||
assert [r["triggerKind"] for r in rows] == ["quick", "auto"], "newest first"
|
||||
|
||||
def test_newest_first(self, db, user):
|
||||
garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
|
||||
garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
|
||||
rows = garmin_svc.get_sync_history(user["id"])
|
||||
assert len(rows) == 2
|
||||
assert rows[0]["finishedAt"] >= rows[1]["finishedAt"]
|
||||
|
||||
def test_total_failure_is_recorded(self, db, user):
|
||||
out = garmin_svc.sync_data(
|
||||
user["id"], CREDS, days=2, client=StubClient(fail_days=[day(0), day(1)])
|
||||
)
|
||||
assert out["status"] == "error"
|
||||
assert garmin_svc.get_sync_history(user["id"])[0]["status"] == "error"
|
||||
|
||||
def test_a_429_mid_run_is_recorded(self, db, user):
|
||||
class Throttled(StubClient):
|
||||
def get_user_summary(self, cdate):
|
||||
self.calls.append(("summary", cdate))
|
||||
if cdate == day(1):
|
||||
raise RuntimeError("too many 429 error responses")
|
||||
return summary()
|
||||
|
||||
garmin_svc.sync_data(user["id"], CREDS, days=2, client=Throttled())
|
||||
assert garmin_svc.get_sync_history(user["id"])[0]["status"] == "rate_limited"
|
||||
garmin_svc._rate_limited_until.clear()
|
||||
|
||||
def test_a_refused_sync_is_still_a_record(self, db, user, monkeypatch):
|
||||
future = datetime.datetime.utcnow() + datetime.timedelta(hours=1)
|
||||
monkeypatch.setitem(garmin_svc._rate_limited_until, user["id"], future)
|
||||
|
||||
out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
|
||||
assert out["status"] == "rate_limited"
|
||||
assert garmin_svc.get_sync_history(user["id"])[0]["status"] == "rate_limited"
|
||||
|
||||
def test_history_is_per_user(self, db, user, make_user):
|
||||
garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
|
||||
other = make_user("other@example.com")
|
||||
assert garmin_svc.get_sync_history(other["id"]) == []
|
||||
|
||||
def test_history_endpoint_requires_auth(self, client):
|
||||
assert client.get("/api/garmin/sync-history").status_code == 401
|
||||
|
||||
def test_history_endpoint_returns_empty_list(self, client, auth):
|
||||
r = client.get("/api/garmin/sync-history", headers=auth)
|
||||
assert r.status_code == 200
|
||||
assert r.get_json() == {"items": []}
|
||||
|
||||
@@ -88,7 +88,7 @@ class TestSyncAllAccounts:
|
||||
seen = []
|
||||
monkeypatch.setattr(
|
||||
garmin_svc, "sync_data",
|
||||
lambda uid, creds, days=None, client=None: seen.append(uid)
|
||||
lambda uid, creds, days=None, client=None, trigger="manual": seen.append(uid)
|
||||
or {"status": "success", "recordsSynced": days},
|
||||
)
|
||||
|
||||
@@ -105,7 +105,7 @@ class TestSyncAllAccounts:
|
||||
garmin_svc.save_token(user["id"], "t1")
|
||||
garmin_svc.save_token(other["id"], "t2")
|
||||
|
||||
def flaky(uid, creds, days=None, client=None):
|
||||
def flaky(uid, creds, days=None, client=None, trigger="manual"):
|
||||
if uid == user["id"]:
|
||||
raise RuntimeError("token expired")
|
||||
return {"status": "success", "recordsSynced": 2}
|
||||
@@ -160,7 +160,7 @@ class TestEndpoints:
|
||||
garmin_svc.save_token(user["id"], "blob")
|
||||
monkeypatch.setattr(
|
||||
garmin_svc, "sync_data",
|
||||
lambda uid, creds, days=None, client=None: {
|
||||
lambda uid, creds, days=None, client=None, trigger="manual": {
|
||||
"status": "success", "recordsSynced": days, "message": "ok",
|
||||
},
|
||||
)
|
||||
@@ -173,7 +173,7 @@ class TestEndpoints:
|
||||
garmin_svc.save_token(user["id"], "blob")
|
||||
monkeypatch.setattr(
|
||||
garmin_svc, "sync_data",
|
||||
lambda uid, creds, days=None, client=None: {
|
||||
lambda uid, creds, days=None, client=None, trigger="manual": {
|
||||
"status": "success", "recordsSynced": days,
|
||||
},
|
||||
)
|
||||
@@ -200,8 +200,9 @@ class TestSyncWindowResolution:
|
||||
garmin_svc.save_token(user["id"], "t1")
|
||||
seen = {}
|
||||
|
||||
def record(uid, creds, days=None, client=None):
|
||||
def record(uid, creds, days=None, client=None, trigger="manual"):
|
||||
seen["days"] = days
|
||||
seen["trigger"] = trigger
|
||||
return {"status": "success", "recordsSynced": 0}
|
||||
|
||||
monkeypatch.setattr(garmin_svc, "sync_data", record)
|
||||
|
||||
Reference in New Issue
Block a user