Files
GarminHealthLab/backend/tests/conftest.py
ericwyuan 682936b0b6 fix(garmin): 刷新出来的令牌从来没写回库,于是每次连接都重换一次
「又被限流了」的根因找到了,不是请求量,是令牌。

`_connect` 里 `refresh_oauth2()` 换来的新 OAuth2 令牌只活在进程内存里——
`save_token` 只在绑定账号时调用过一次。于是每次客户端缓存过期(15 分钟)、
每个 gunicorn worker、每次部署重启,都从库里读回**同一个已过期的令牌**,
然后再做一次真实 SSO 换令牌。而 SSO 端点是按账号限流最狠的那个,社区报告能
封 48 小时(garth #217、python-garminconnect #337)。我今天为了部署重启了
八次服务,每次都清掉缓存。

- `refresh_oauth2()` 成功后 `_persist_token()` 写回。拆出这个函数是因为它和
  `save_token` 想要的正好相反:重新绑定要作废现有会话,持久化刷新结果必须
  保住刚刚产出它的那个会话
- 写回时不带 garmin_email,否则 upsert 会把绑定邮箱刷成 NULL,数据同步页会
  忘记绑的是哪个账号
- 刷新加进程内锁,并在拿到锁后重读一次库:另一个线程刚换过就直接用它的,
  不再自己去换一次
- 五条测试盯住这个不变量,包括「冷缓存不该再换一次」(这条如果回归,就是同一
  个 bug 再来一遍)

顺带把数据端点也节流了——那是另外一半问题,不是这次的病因,但一天历史要 9 次
调用,730 天全历史 6600 个请求全速打出去,不该指望佳明一直容忍:

- services/garmin_throttle.py:代理包住 client,所有调用(含以后新加的)都经
  同一个收口,按间隔排队并计数
- 0.5s 是查过的:garmin-data-export 默认 0.15s、garmin-connect-scraper 默认
  3s、官方合作方 API 100 次/分钟(0.6s)。依据写在文件顶部
- 单次同步 1200 个请求预算,跑满就干净收尾、下次接着跑(已存的天数本来就跳过)
- 运动详情每次最多补 40 条——新账号几百条,不限量就是一次性打光预算

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:24:48 +08:00

169 lines
5.0 KiB
Python

"""
Shared pytest fixtures.
Environment must be configured BEFORE `config` is imported, because config.py
reads os.environ at import time. Each test then gets its own SQLite file so
tests never share state.
"""
import os
import sys
import tempfile
import uuid
import pytest
_BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _BACKEND_DIR)
os.environ.setdefault("DB_TYPE", "sqlite")
os.environ.setdefault("JWT_SECRET", "test_secret_at_least_32_bytes_long_ok")
os.environ.setdefault("CORS_ORIGIN", "http://localhost:3000")
# Point at a throwaway path; the db_path fixture overrides it per test.
os.environ.setdefault(
"DATABASE_PATH", os.path.join(tempfile.mkdtemp(), "bootstrap.db")
)
import db as db_module # noqa: E402
from app import create_app # noqa: E402
from auth import sign_token # noqa: E402
# config.py calls load_dotenv() at import, so backend/.env leaks into the test
# process — a developer's real AI_MODEL_CHAIN or API keys would silently change
# what the suite exercises (and could bill real API calls). Clear them here;
# individual tests opt back in through the `keys` / `gateway` fixtures.
# Pacing is real time: at the default 0.5s a single 30-day sync test would
# sleep for over two minutes. Tests exercise the *accounting* (budgets, counts)
# with the wait set to zero.
os.environ.setdefault("GARMIN_MIN_INTERVAL_SECONDS", "0")
_AI_ENV_VARS = (
"AI_MODEL_CHAIN",
"AI_DAY_BUDGET",
"AI_TIMEOUT_SECONDS",
"GEMINI_API_KEY",
"NVIDIA_API_KEY",
"NVIDIA_BASE_URL",
"AI_GATEWAY_TOKEN",
"AI_GATEWAY_BASE_URL",
"AI_GATEWAY_MODEL",
"OLLAMA_BASE_URL",
"OLLAMA_MODEL",
)
@pytest.fixture(autouse=True)
def _isolate_ai_env(monkeypatch):
for var in _AI_ENV_VARS:
monkeypatch.delenv(var, raising=False)
@pytest.fixture(autouse=True)
def _no_ai_jobs_background_thread(monkeypatch):
"""create_app() starts a daemon queue-consumer thread that outlives the
test that launched it and races the *next* test for jobs on that test's
fresh database (with whatever _runner the previous stub left behind) —
which made queue tests flaky. Tests drive the queue themselves through
jobs.run_once(), so the thread is disabled here.
"""
from services import jobs
monkeypatch.setattr(jobs, "ENABLED", False)
@pytest.fixture(autouse=True)
def _clear_garmin_client_cache():
"""Drop cached Garmin sessions between tests.
`_connect` keeps an authenticated client per user for 15 minutes, so a
stub installed by one test would otherwise be handed to the next one —
and a test that expects `_connect` to be called would see it skipped.
"""
from services import garmin as garmin_svc
garmin_svc._clients.clear()
yield
garmin_svc._clients.clear()
@pytest.fixture
def db(tmp_path, monkeypatch):
"""A freshly initialized, isolated SQLite database for one test."""
path = str(tmp_path / "test.db")
monkeypatch.setattr(db_module, "SQLITE_PATH", path)
db_module.init_db()
return db_module
@pytest.fixture
def app(db):
application = create_app()
application.config.update(TESTING=True)
return application
@pytest.fixture
def client(app):
return app.test_client()
def _insert_user(email):
"""Create a user row directly, bypassing HTTP.
Accounts now come from auth-hub's OAuth dance (see routes/auth.py); the
test suite has no reason to exercise that network round trip just to get
a user id and a valid JWT.
"""
uid = str(uuid.uuid4())
token = sign_token(uid)
db_module.execute(
"INSERT INTO users (id, email, auth_hub_username, jwt_token) VALUES (?, ?, ?, ?)",
[uid, email, email, token],
)
return {"id": uid, "email": email, "token": token}
@pytest.fixture
def make_user(db):
"""Factory for creating additional users, e.g. for cross-account isolation tests."""
return _insert_user
@pytest.fixture
def user(db):
"""A user, as if they had signed in through auth-hub: {id, email, token}."""
return _insert_user("tester@example.com")
@pytest.fixture
def auth(user):
"""Authorization headers for the registered user."""
return {"Authorization": f"Bearer {user['token']}"}
@pytest.fixture
def seed_health(db, user):
"""Insert daily health rows. Returns the inserted records."""
def _seed(records):
for r in records:
db.execute(
"INSERT INTO health_data (id, user_id, date, steps, heart_rate, "
"heart_rate_variability, sleep_duration, sleep_quality, stress, "
"calories_burned) VALUES (?,?,?,?,?,?,?,?,?,?)",
[
f"{user['id']}-{r['date']}",
user["id"],
r["date"],
r.get("steps"),
r.get("heart_rate"),
r.get("hrv"),
r.get("sleep_duration"),
r.get("sleep_quality"),
r.get("stress"),
r.get("calories"),
],
)
return records
return _seed