背景:文档停在 Node.js 时代或甲骨文 8123 部署,与生产(NAS :8124 + Flask + auth-hub + ai-gateway)严重脱节,曾导致凭旧记忆误判'无线上环境'。 - CLAUDE.md 重写:技术栈/结构/命令/部署事实/关键坑(F7 button、UTC 日期、 429 退避以 DB 为准、迁移幂等、AI 生成耗时) - docs/ARCHITECTURE.md 重写为 Flask 蓝图+services+可插拔数据层 + NAS 部署 - docs/DEVELOPMENT.md 重写为 Flask/CRA 开发指南 + push.sh 部署流程 - docs/REQUIREMENTS.md:部署条目改 NAS 8124;补 auth-hub/AI 教练/新修复 - docs/AUTH_HUB_INTEGRATION.md 新增(补 .env.example 悬空引用) - README.md:技术栈/DB/auth-hub/API 清单/部署节修正 - backend/config.py 与 .env.example:AUTH_HUB_REDIRECT_URI 默认 8123→8124, MariaDB 注释 Oracle→NAS - tests:GatewayCourtesy 并发测试对齐 MAX_CONCURRENT(AI_JOB_CONCURRENCY=2); conftest 禁用 create_app 后台队列线程,修整库测试 flaky(585 passed)
164 lines
4.8 KiB
Python
164 lines
4.8 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.
|
|
_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
|