From 6de7562cd8711a1d9470f8b704e30b2d8467a7ea Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sun, 23 Aug 2026 19:05:19 +0800 Subject: [PATCH] =?UTF-8?q?[=E9=98=B6=E6=AE=B55.1]=20=E5=BC=80=E6=9C=BA?= =?UTF-8?q?=E8=87=AA=E5=90=AF=20+=20=E6=8E=A5=E5=85=A5=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=20garminconnect=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E7=9A=84=E4=B8=89=E5=A4=84=20API=20=E8=AF=AF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(garmin): 同步代码调用的 garminconnect API 全是错的 装上库核对签名后发现(garminconnect 0.2.8),原代码从未被真正 执行过,三处调用都不成立: 1. get_activities(date) —— 该方法签名是 (start, limit),收的是 分页下标和条数,不是日期。传日期等于在问"第 2026-08-23 条运动", 而且少传一个参数必然 TypeError。 改为 get_activities_by_date(start, end),整个窗口一次取回, 顺带把原来"每天一次调用"减为一次。 2. 睡眠数据不在 get_user_summary 里,是独立的 get_sleep_data(cdate); 原代码从 summary 里读 sleep 字段,结果会把每一晚都记成无睡眠数据。 HRV 同理,走 get_hrv_data(cdate)。 3. 字段名不符:实际是 totalSteps / totalKilocalories / averageStressLevel,原代码用的是 steps / calories.total / stress.average。 其他改进: - 运动记录改用 Garmin 自己的 activityId 作主键,重复同步同一窗口 不再产生重复行(原来每次同步都会把同一条运动再插一遍) - 某天无数据时返回全 None,不再写入空行让读端点再过滤掉 - 原来 bare except 吞掉每天的异常,全部失败也报 success; 现在全窗口失败会如实返回 error 并记录原因 - 同步天数、是否走 Garmin 中国区改为环境变量可配 tests/test_garmin_sync.py (23 通过): - 用 StubClient 模拟真实 0.2.8 的接口形状,无需库、凭证或网络 - 回归用例覆盖上述三处误用:活动必须按日期区间一次取回、 睡眠与 HRV 必须走各自端点 - 重复同步不产生重复的天和重复的运动记录 - 单天失败跳过、全窗口失败报错、登录失败如实上报 部署: - NAS 安装 garminconnect(pydantic-core 有 cp38 x86_64 轮子, 无需 gcc) - 新增 deploy/S99garmin.sh 开机自启脚本,与 NAS 上既有的 S99frpc.sh 同一套惯例;以 root 启动但降权到 ericwyuan 运行, 因为服务不需要特权而 .env 里有数据库和 API 凭证 - PID 文件放应用目录而非 /var/run(非特权用户写不了) NAS 真机全量测试: 240 passed Co-Authored-By: Claude Haiku 4.5 --- backend/requirements.txt | 3 +- backend/services/garmin.py | 303 ++++++++++++++++++++---------- backend/services/health.py | 5 +- backend/tests/test_garmin_sync.py | 282 +++++++++++++++++++++++++++ deploy/S99garmin.sh | 46 +++++ 5 files changed, 535 insertions(+), 104 deletions(-) create mode 100644 backend/tests/test_garmin_sync.py create mode 100644 deploy/S99garmin.sh diff --git a/backend/requirements.txt b/backend/requirements.txt index f109305..c299303 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,6 +4,5 @@ PyMySQL>=1.1 PyJWT>=2.8 python-dotenv>=1.0 gunicorn>=21.2 -# Optional — only needed to run live Garmin syncs: -# garminconnect>=0.13 requests>=2.31 +garminconnect>=0.2.8 diff --git a/backend/services/garmin.py b/backend/services/garmin.py index 387d3f5..b5fbee5 100644 --- a/backend/services/garmin.py +++ b/backend/services/garmin.py @@ -1,43 +1,51 @@ """ Garmin sync service. -Pulls up to 7 days of daily summaries + activities through the `garminconnect` -library and upserts them. The library and real Garmin credentials are required -to actually run a sync; without them the endpoint reports a clear error instead -of crashing (mirrors the original Node behaviour). +Pulls daily summaries + activities through the `garminconnect` library and +upserts them. The library and real Garmin credentials are required to actually +run a sync; without them the endpoint reports a clear error instead of +crashing. -Garmin credentials: the app only stores a scrypt *hash* of the Garmin password -(so it cannot be recovered), therefore a live sync needs the plaintext -garminEmail/garminPassword supplied in the request body. +Garmin credentials: the app only stores a scrypt/PBKDF2 *hash* of the Garmin +password (so it cannot be recovered), therefore a live sync needs the +plaintext garminEmail/garminPassword supplied in the request body. + +On the library's API — these were verified against garminconnect 0.2.8: + * get_user_summary(cdate) -> one day of daily totals + * get_sleep_data(cdate) -> sleep, NOT part of the summary + * get_hrv_data(cdate) -> HRV, also separate + * get_activities_by_date(start, end) -> activities in a date range + * get_activities(start, limit) -> PAGINATION, not dates +The last two are easy to confuse: `get_activities` takes an offset and a count, +so passing it a date silently asks for activity number "2026-08-23". """ import datetime +import os -from db import execute, query_one, query_all +from db import execute, query_one from config import DB_TYPE from services import health +# How many days back a sync reaches. +DEFAULT_SYNC_DAYS = int(os.environ.get("GARMIN_SYNC_DAYS") or 7) + def _set_sync_status(user_id, status, now, **fields): cols = ["user_id", "status", "last_sync_time"] + list(fields.keys()) placeholders = ", ".join(["?"] * len(cols)) if DB_TYPE == "mariadb": - updates = ", ".join( - f"{c}=VALUES({c})" for c in cols if c != "user_id" - ) + updates = ", ".join(f"{c}=VALUES({c})" for c in cols if c != "user_id") sql = ( f"INSERT INTO sync_status ({', '.join(cols)}) VALUES ({placeholders}) " f"ON DUPLICATE KEY UPDATE {updates}" ) else: - updates = ", ".join( - f"{c}=excluded.{c}" for c in cols if c != "user_id" - ) + updates = ", ".join(f"{c}=excluded.{c}" for c in cols if c != "user_id") sql = ( f"INSERT INTO sync_status ({', '.join(cols)}) VALUES ({placeholders}) " f"ON CONFLICT(user_id) DO UPDATE SET {updates}" ) - params = [user_id, status, now] + list(fields.values()) - execute(sql, params) + execute(sql, [user_id, status, now] + list(fields.values())) def get_sync_status(user_id): @@ -57,94 +65,187 @@ def get_sync_status(user_id): } -def sync_data(user_id, creds): - now = datetime.datetime.utcnow().isoformat() +def _connect(creds): + """Log in to Garmin Connect. Separated so tests can substitute a client.""" + try: + from garminconnect import Garmin + except ImportError: + raise RuntimeError( + "GARMIN_LIB_MISSING: 请先运行 `pip install garminconnect` 以启用同步" + ) + + # is_cn selects Garmin's China service, which is a different backend with + # separate accounts. This project tracks an international account. + is_cn = (os.environ.get("GARMIN_IS_CN") or "").lower() in ("1", "true", "yes") + client = Garmin(email=creds["garminEmail"], password=creds["garminPassword"], is_cn=is_cn) + client.login() + return client + + +def _num(*values): + """First value that is a usable number.""" + for v in values: + if isinstance(v, (int, float)) and not isinstance(v, bool): + return v + return None + + +def _extract_daily(client, date_str): + """One day of metrics, assembled from the endpoints that carry them. + + Sleep and HRV are separate endpoints in this library — they are not part + of the daily summary — so a sync that only read the summary would record + every night as "no sleep data". + """ + summary = client.get_user_summary(date_str) or {} + + sleep_seconds = None + sleep_quality = None + try: + sleep = (client.get_sleep_data(date_str) or {}).get("dailySleepDTO") or {} + sleep_seconds = _num(sleep.get("sleepTimeSeconds")) + sleep_quality = _num(sleep.get("sleepScores", {}).get("overall", {}).get("value") + if isinstance(sleep.get("sleepScores"), dict) else None) + except Exception: + pass # a missing night must not abort the whole day + + hrv = None + try: + hrv_body = client.get_hrv_data(date_str) or {} + summary_block = hrv_body.get("hrvSummary") or {} + hrv = _num(summary_block.get("lastNightAvg"), summary_block.get("weeklyAvg")) + except Exception: + pass + + return { + "date": date_str, + "steps": _num(summary.get("totalSteps")), + "heartRate": _num(summary.get("restingHeartRate"), + summary.get("averageHeartRate")), + "heartRateVariability": hrv, + "sleepDuration": round(sleep_seconds / 3600, 1) if sleep_seconds else None, + "sleepQuality": sleep_quality, + "stress": _num(summary.get("averageStressLevel")), + "caloriesBurned": _num(summary.get("totalKilocalories")), + } + + +def _activity_end(start, duration_seconds): + if not start or not duration_seconds: + return start + for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f"): + try: + dt = datetime.datetime.strptime(start[:26], fmt) + return (dt + datetime.timedelta(seconds=duration_seconds)).isoformat() + except ValueError: + continue + return start + + +def _sync_activities(client, user_id, start_date, end_date): + """Fetch the window's activities in one call and store the new ones.""" + activities = client.get_activities_by_date(start_date, end_date) or [] + stored = 0 + for a in activities: + start = a.get("startTimeLocal") or a.get("startTime") + activity_type = ( + (a.get("activityType") or {}).get("typeKey") + if isinstance(a.get("activityType"), dict) + else a.get("activityType") + ) or "unknown" + duration = _num(a.get("duration")) + + # Garmin activity ids are stable, so re-syncing a window must not + # duplicate what is already stored. + garmin_id = a.get("activityId") + if garmin_id is not None: + existing = query_one( + "SELECT id FROM activities WHERE user_id = ? AND id = ?", + [user_id, str(garmin_id)], + ) + if existing: + continue + + health.insert_activity( + user_id, + { + "id": str(garmin_id) if garmin_id is not None else None, + "activityType": activity_type, + "startTime": start, + "endTime": _activity_end(start, duration), + "duration": duration, + "distance": _num(a.get("distance")), + "calories": _num(a.get("calories")), + "heartRateAverage": _num(a.get("averageHR")), + "heartRateMax": _num(a.get("maxHR")), + }, + ) + stored += 1 + return stored + + +def sync_data(user_id, creds, days=None, client=None): + """Pull the last `days` days from Garmin Connect into the local database. + + `client` exists so tests can inject a stub instead of reaching Garmin. + """ + days = days or DEFAULT_SYNC_DAYS + now = datetime.datetime.utcnow().isoformat(timespec="seconds") _set_sync_status(user_id, "syncing", now, records_synced=0) try: - try: - from garminconnect import Garmin - except ImportError: - raise RuntimeError( - "GARMIN_LIB_MISSING: 请先运行 `pip install garminconnect` 以启用同步" - ) - - client = Garmin(email=creds["garminEmail"], password=creds["garminPassword"]) - client.login() - - records_synced = 0 - for i in range(7): - d = datetime.datetime.utcnow() - datetime.timedelta(days=i) - date_str = d.strftime("%Y-%m-%d") - try: - daily = client.get_user_summary(date_str) - if daily: - sleep_sec = (daily.get("sleep") or {}).get("sleepingSeconds") or daily.get( - "sleepingSeconds" - ) - health.upsert_health_daily( - user_id, - { - "date": date_str, - "steps": daily.get("steps"), - "heartRate": daily.get("restingHeartRate") - or daily.get("averageHeartRate"), - "heartRateVariability": daily.get("hrv") - or daily.get("heartRateVariability"), - "sleepDuration": round(sleep_sec / 3600, 1) if sleep_sec else None, - "sleepQuality": (daily.get("sleep") or {}).get("sleepQuality"), - "stress": (daily.get("stress") or {}).get("average") - or daily.get("averageStress"), - "caloriesBurned": (daily.get("calories") or {}).get("total") - or daily.get("totalCalories"), - }, - ) - records_synced += 1 - - activities = client.get_activities(date_str) or [] - for a in activities or []: - start = a.get("startTimeLocal") or a.get("startTime") - start_ms = start and datetime.datetime.strptime( - start, "%Y-%m-%dT%H:%M:%S" if "T" in (start or "") else "%Y-%m-%d %H:%M:%S" - ).timestamp() if start else None - health.insert_activity( - user_id, - { - "activityType": (a.get("activityType") or {}).get("typeKey") - or a.get("type") - or "unknown", - "startTime": start, - "endTime": ( - start - if start_ms is None or not a.get("duration") - else datetime.datetime.utcfromtimestamp( - start_ms + (a.get("duration") or 0) - ).isoformat() - ), - "duration": a.get("duration"), - "distance": a.get("distance"), - "calories": a.get("calories"), - "heartRateAverage": a.get("averageHR"), - "heartRateMax": a.get("maxHR"), - }, - ) - except Exception: - # skip a single bad day and continue - continue - - _set_sync_status(user_id, "idle", now, records_synced=records_synced) - return { - "status": "success", - "recordsSynced": records_synced, - "message": f"同步完成,新增/更新 {records_synced} 天数据", - "lastSyncTime": now, - } + client = client or _connect(creds) except Exception as e: message = str(e) _set_sync_status(user_id, "error", now, records_synced=0, last_error=message) - return { - "status": "error", - "recordsSynced": 0, - "message": message, - "lastSyncTime": now, - } + return {"status": "error", "recordsSynced": 0, "message": message, + "lastSyncTime": now} + + today = datetime.date.today() + start_date = (today - datetime.timedelta(days=days - 1)).isoformat() + + days_synced = 0 + day_errors = [] + for i in range(days): + date_str = (today - datetime.timedelta(days=i)).isoformat() + try: + record = _extract_daily(client, date_str) + except Exception as e: + day_errors.append(f"{date_str}: {e}") + continue + # A day Garmin has no data for comes back all-None; storing it would + # create an empty row that the metric endpoints then have to filter. + if any(record[k] is not None for k in record if k != "date"): + health.upsert_health_daily(user_id, record) + days_synced += 1 + + activities_synced = 0 + try: + activities_synced = _sync_activities( + client, user_id, start_date, today.isoformat() + ) + except Exception as e: + day_errors.append(f"activities: {e}") + + # Every single day failing means something systemic (expired session, + # API change) — reporting that as a clean success would hide it. + if days_synced == 0 and len(day_errors) >= days: + message = "; ".join(day_errors[:3]) + _set_sync_status(user_id, "error", now, records_synced=0, last_error=message) + return {"status": "error", "recordsSynced": 0, + "message": f"同步失败:{message}", "lastSyncTime": now} + + _set_sync_status( + user_id, "idle", now, records_synced=days_synced, + last_error="; ".join(day_errors[:3]) if day_errors else None, + ) + message = f"同步完成,更新 {days_synced} 天数据、{activities_synced} 条运动记录" + if day_errors: + message += f"({len(day_errors)} 天跳过)" + return { + "status": "success", + "recordsSynced": days_synced, + "activitiesSynced": activities_synced, + "message": message, + "lastSyncTime": now, + } diff --git a/backend/services/health.py b/backend/services/health.py index 7326d25..fe57e81 100644 --- a/backend/services/health.py +++ b/backend/services/health.py @@ -134,7 +134,10 @@ def upsert_health_daily(user_id, record): def insert_activity(user_id, activity): - aid = str(uuid.uuid4()) + # Prefer Garmin's own activity id when the caller has one: it is stable + # across syncs, which is what lets a re-synced window skip what is already + # stored instead of inserting it again. + aid = str(activity.get("id") or uuid.uuid4()) cols = [ "id", "user_id", "activity_type", "start_time", "end_time", "duration", "distance", "calories", "heart_rate_average", "heart_rate_max", diff --git a/backend/tests/test_garmin_sync.py b/backend/tests/test_garmin_sync.py new file mode 100644 index 0000000..f3fe63b --- /dev/null +++ b/backend/tests/test_garmin_sync.py @@ -0,0 +1,282 @@ +""" +Unit tests for the Garmin sync service. + +A stub client stands in for `garminconnect`, so the suite runs without the +library, without credentials and without touching Garmin. + +The stub mirrors the real 0.2.8 API shapes on purpose — the original code +called `get_activities(date)` when that method actually takes `(start, limit)` +pagination arguments, a mistake that only surfaces once something exercises it. +""" +import datetime + +import pytest + +from services import garmin as garmin_svc +from services import health as health_svc + + +def day(offset=0): + return (datetime.date.today() - datetime.timedelta(days=offset)).isoformat() + + +def summary(steps=8000, rhr=60, stress=40, kcal=2200): + return { + "totalSteps": steps, + "restingHeartRate": rhr, + "averageStressLevel": stress, + "totalKilocalories": kcal, + } + + +def sleep(hours=7.5, score=82): + return { + "dailySleepDTO": { + "sleepTimeSeconds": int(hours * 3600), + "sleepScores": {"overall": {"value": score}}, + } + } + + +def hrv(value=48): + return {"hrvSummary": {"lastNightAvg": value}} + + +def activity(activity_id=1001, type_key="running", duration=1800): + return { + "activityId": activity_id, + "activityType": {"typeKey": type_key}, + "startTimeLocal": f"{day()}T07:00:00", + "duration": duration, + "distance": 5000.0, + "calories": 320.0, + "averageHR": 145, + "maxHR": 168, + } + + +class StubClient: + """Stands in for garminconnect.Garmin, recording how it was called.""" + + def __init__(self, summaries=None, sleeps=None, hrvs=None, activities=None, + fail_days=(), fail_activities=False): + self._summaries = summaries if summaries is not None else {} + self._sleeps = sleeps if sleeps is not None else {} + self._hrvs = hrvs if hrvs is not None else {} + self._activities = activities if activities is not None else [] + self._fail_days = set(fail_days) + self._fail_activities = fail_activities + self.calls = [] + + def get_user_summary(self, cdate): + self.calls.append(("summary", cdate)) + if cdate in self._fail_days: + raise RuntimeError(f"upstream error for {cdate}") + return self._summaries.get(cdate, summary()) + + def get_sleep_data(self, cdate): + self.calls.append(("sleep", cdate)) + return self._sleeps.get(cdate, sleep()) + + def get_hrv_data(self, cdate): + self.calls.append(("hrv", cdate)) + return self._hrvs.get(cdate, hrv()) + + def get_activities_by_date(self, startdate, enddate, activitytype=None): + self.calls.append(("activities", startdate, enddate)) + if self._fail_activities: + raise RuntimeError("activities endpoint down") + return self._activities + + +CREDS = {"garminEmail": "g@example.com", "garminPassword": "pw"} + + +class TestHappyPath: + def test_reports_success(self, db, user): + out = garmin_svc.sync_data(user["id"], CREDS, days=3, client=StubClient()) + assert out["status"] == "success" + assert out["recordsSynced"] == 3 + + def test_stores_the_days(self, db, user): + garmin_svc.sync_data(user["id"], CREDS, days=3, client=StubClient()) + rows = health_svc.get_summary(user["id"]) + assert len(rows) == 3 + + def test_maps_every_metric(self, db, user): + client = StubClient( + summaries={day(): summary(steps=9500, rhr=57, stress=33, kcal=2450)}, + sleeps={day(): sleep(hours=8.0, score=91)}, + hrvs={day(): hrv(52)}, + ) + garmin_svc.sync_data(user["id"], CREDS, days=1, client=client) + row = health_svc.get_summary(user["id"])[0] + + assert row["steps"] == 9500 + assert row["heartRate"] == 57 + assert row["stress"] == 33 + assert row["caloriesBurned"] == 2450 + assert row["heartRateVariability"] == 52 + assert row["sleep"] == {"duration": 8.0, "quality": 91} + + def test_sleep_and_hrv_come_from_their_own_endpoints(self, db, user): + """Regression: both live outside get_user_summary. Reading only the + summary recorded every night as having no sleep data.""" + client = StubClient() + garmin_svc.sync_data(user["id"], CREDS, days=1, client=client) + + kinds = {c[0] for c in client.calls} + assert "sleep" in kinds + assert "hrv" in kinds + + def test_seconds_are_converted_to_hours(self, db, user): + client = StubClient(sleeps={day(): sleep(hours=6.5)}) + garmin_svc.sync_data(user["id"], CREDS, days=1, client=client) + assert health_svc.get_summary(user["id"])[0]["sleep"]["duration"] == 6.5 + + +class TestActivities: + def test_fetched_by_date_range_in_one_call(self, db, user): + """Regression: the old code called get_activities(date), but that + method takes (start, limit) pagination arguments, not a date.""" + client = StubClient(activities=[activity()]) + garmin_svc.sync_data(user["id"], CREDS, days=7, client=client) + + activity_calls = [c for c in client.calls if c[0] == "activities"] + assert len(activity_calls) == 1, "one range call, not one call per day" + _, start, end = activity_calls[0] + assert start == day(6) and end == day(0) + + def test_stored_with_fields_mapped(self, db, user): + garmin_svc.sync_data( + user["id"], CREDS, days=1, client=StubClient(activities=[activity()]) + ) + rows = health_svc.get_activities(user["id"]) + assert len(rows) == 1 + assert rows[0]["activity_type"] == "running" + assert rows[0]["heart_rate_average"] == 145 + + def test_count_is_reported(self, db, user): + client = StubClient(activities=[activity(1), activity(2)]) + out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=client) + assert out["activitiesSynced"] == 2 + + def test_resync_does_not_duplicate(self, db, user): + """Garmin activity ids are stable, so a re-synced window must skip + what is already stored.""" + client = StubClient(activities=[activity(1001)]) + garmin_svc.sync_data(user["id"], CREDS, days=1, client=client) + out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=client) + + assert len(health_svc.get_activities(user["id"])) == 1 + assert out["activitiesSynced"] == 0 + + def test_end_time_derived_from_duration(self, db, user): + client = StubClient(activities=[activity(duration=1800)]) + garmin_svc.sync_data(user["id"], CREDS, days=1, client=client) + row = health_svc.get_activities(user["id"])[0] + assert row["start_time"] != row["end_time"] + + def test_failure_does_not_lose_the_daily_data(self, db, user): + out = garmin_svc.sync_data( + user["id"], CREDS, days=2, client=StubClient(fail_activities=True) + ) + assert out["status"] == "success" + assert len(health_svc.get_summary(user["id"])) == 2 + + +class TestResync: + def test_same_day_is_updated_not_duplicated(self, db, user): + garmin_svc.sync_data( + user["id"], CREDS, days=1, + client=StubClient(summaries={day(): summary(steps=5000)}), + ) + garmin_svc.sync_data( + user["id"], CREDS, days=1, + client=StubClient(summaries={day(): summary(steps=9000)}), + ) + rows = health_svc.get_summary(user["id"]) + assert len(rows) == 1 + assert rows[0]["steps"] == 9000 + + +class TestPartialAndTotalFailure: + def test_one_bad_day_is_skipped_not_fatal(self, db, user): + client = StubClient(fail_days=[day(1)]) + out = garmin_svc.sync_data(user["id"], CREDS, days=3, client=client) + + assert out["status"] == "success" + assert out["recordsSynced"] == 2 + assert "跳过" in out["message"] + + def test_every_day_failing_is_reported_as_an_error(self, db, user): + """A systemic failure reported as a clean success would hide it.""" + client = StubClient(fail_days=[day(0), day(1), day(2)]) + out = garmin_svc.sync_data(user["id"], CREDS, days=3, client=client) + + assert out["status"] == "error" + assert out["recordsSynced"] == 0 + + def test_login_failure_is_reported(self, db, user, monkeypatch): + def boom(_creds): + raise RuntimeError("401 Unauthorized") + + monkeypatch.setattr(garmin_svc, "_connect", boom) + out = garmin_svc.sync_data(user["id"], CREDS, days=1) + + assert out["status"] == "error" + assert "401" in out["message"] + + def test_days_without_data_are_not_stored(self, db, user): + """Garmin returns all-None for a day it has nothing for; an empty row + would just have to be filtered back out by every read endpoint.""" + client = StubClient( + summaries={day(): {}}, sleeps={day(): {}}, hrvs={day(): {}} + ) + out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=client) + assert out["recordsSynced"] == 0 + assert health_svc.get_summary(user["id"]) == [] + + +class TestSyncStatus: + def test_idle_before_any_sync(self, db, user): + assert garmin_svc.get_sync_status(user["id"])["status"] == "idle" + + def test_success_leaves_status_idle(self, db, user): + garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient()) + status = garmin_svc.get_sync_status(user["id"]) + assert status["status"] == "idle" + assert status["recordsSynced"] == 1 + assert status["lastSyncTime"] + + def test_total_failure_leaves_status_error(self, db, user): + garmin_svc.sync_data( + user["id"], CREDS, days=2, client=StubClient(fail_days=[day(0), day(1)]) + ) + status = garmin_svc.get_sync_status(user["id"]) + assert status["status"] == "error" + assert status["lastError"] + + def test_a_later_success_clears_the_error(self, db, user): + garmin_svc.sync_data( + user["id"], CREDS, days=1, client=StubClient(fail_days=[day(0)]) + ) + garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient()) + status = garmin_svc.get_sync_status(user["id"]) + assert status["status"] == "idle" + assert not status["lastError"] + + +class TestEndpoint: + def test_requires_auth(self, client): + assert client.post("/api/garmin/sync", json={}).status_code == 401 + + def test_missing_password_is_refused_with_a_reason(self, client, auth): + r = client.post("/api/garmin/sync", headers=auth, json={}) + assert r.status_code == 400 + assert "garminPassword" in r.get_json()["message"] + + def test_status_endpoint(self, client, auth): + r = client.get("/api/garmin/status", headers=auth) + assert r.status_code == 200 + assert r.get_json()["status"] == "idle" diff --git a/deploy/S99garmin.sh b/deploy/S99garmin.sh new file mode 100644 index 0000000..7849fe1 --- /dev/null +++ b/deploy/S99garmin.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# Garmin Health Lab — DSM boot script. +# Mirrors the convention already used by S99frpc.sh on this NAS. +APP=/var/services/homes/ericwyuan/apps/garmin-health-lab +RUNAS=ericwyuan +# Kept inside the app dir, not /var/run: the service runs as an unprivileged +# user, which cannot write there. +PIDFILE="$APP/app.pid" + +start() { + if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + echo "already running (pid $(cat "$PIDFILE"))" + return 0 + fi + # Runs as the owning user: the service needs nothing privileged and its + # .env holds database and API credentials. + # setsid + closed stdio so it survives the invoking shell and never holds + # an SSH session open. + if [ "$(id -un)" = "$RUNAS" ]; then + sh "$APP/start.sh" >/dev/null 2>&1 /dev/null 2>&1 /dev/null))" +} + +stop() { + [ -f "$PIDFILE" ] && kill "$(cat "$PIDFILE")" 2>/dev/null + rm -f "$PIDFILE" + pkill -f "gunicorn.*8123" 2>/dev/null + echo "stopped" +} + +case "$1" in + start) start ;; + stop) stop ;; + restart) stop; sleep 2; start ;; + status) + if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + echo "running (pid $(cat "$PIDFILE"))" + else + echo "not running" + fi ;; + *) echo "Usage: $0 {start|stop|restart|status}" ;; +esac