「又被限流了」的根因找到了,不是请求量,是令牌。 `_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>
169 lines
5.0 KiB
Python
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
|