Converts the ad-hoc scratch-script verification pattern used throughout this session's development (temp SQLite DB + create_app() + monkeypatched send_message/fiscalize_in_background/get_invoice_status, print-based check() assertions) into a real, repo-committed pytest suite under tests/. Covers: claim_order_paid() atomicity, cross-path payment race dedup (webhook/checkout-poll/reconciliation all observing the same paid invoice), fiscalization error alerting + cooldown, admin login brute-force lockout, the insecure-defaults startup check, supplier-import path-traversal protection, basic checkout smoke, and the supplier restock -> stock/supplier-link bug fixed earlier this session. Adds requirements-dev.txt (pytest only, keeping the existing monkeypatch-only style rather than a new mocking framework), pytest.ini so `pytest` runs from the repo root with no flags, and docs/TESTING.md covering how to run it and what's explicitly not covered yet (the React webapp, bot conversation flows beyond the checkout API, live Checkbox/Monobank/Google Drive integrations). 38 tests, all passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
116 lines
4.6 KiB
Python
116 lines
4.6 KiB
Python
"""Shared pytest fixtures for the Bober BBQ test suite.
|
|
|
|
Converts the ad-hoc "scratch smoke script" pattern used throughout this
|
|
project's development (a temp SQLite file per run, `create_app()`,
|
|
`db.create_all()` + `migrate()` + `seed()`, monkeypatching modules'
|
|
`send_message` / `fiscalize_in_background` / `get_invoice_status`
|
|
attributes directly instead of a mocking framework) into a proper,
|
|
repo-committed pytest fixture.
|
|
|
|
Each test gets its OWN fresh temp SQLite database (safer against state
|
|
leakage between tests than a session-scoped DB, at the cost of a little
|
|
extra setup time — `db.create_all()` + `seed()` against SQLite is fast
|
|
enough that this doesn't matter in practice).
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
if str(REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
# Must be set before the FIRST import of bober_bbq.config anywhere in the
|
|
# process, since Config's class attributes are evaluated once at import
|
|
# time. conftest.py is imported by pytest before any test module gets
|
|
# collected, so this is early enough.
|
|
os.environ.setdefault("SECRET_KEY", "test-secret")
|
|
os.environ.setdefault("BOT_TOKEN", "")
|
|
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
|
# NOT "admin" on purpose: run_web.py's module-level `_warn_on_insecure_
|
|
# defaults()` call (see below) must find nothing wrong the moment run_web
|
|
# is first imported, or it consumes error_alerts' one-per-cooldown-window
|
|
# alert slot before test_startup_checks.py ever gets to exercise it.
|
|
os.environ.setdefault("FLASK_ADMIN_PASSWORD", "a-real-non-default-test-password")
|
|
|
|
from bober_bbq.app import create_app # noqa: E402
|
|
from bober_bbq.config import config # noqa: E402
|
|
from bober_bbq.extensions import db # noqa: E402
|
|
from bober_bbq.migrate import migrate # noqa: E402
|
|
from bober_bbq.seed import seed # noqa: E402
|
|
from bober_bbq.utils import checkbox_prro, error_alerts, login_throttle, reconciliation # noqa: E402
|
|
|
|
# Imported here, once, with the safe SECRET_KEY/FLASK_ADMIN_PASSWORD env
|
|
# defaults already in place above — run_web.py runs `_warn_on_insecure_
|
|
# defaults()` unconditionally at module import time (not just under
|
|
# `if __name__ == "__main__"`), so importing it for the first time with
|
|
# insecure values already in place would fire (and consume the cooldown
|
|
# for) the very alert test_startup_checks.py exists to test. Tests call
|
|
# `run_web._warn_on_insecure_defaults()` directly afterward against
|
|
# whatever config values they set up.
|
|
import run_web # noqa: E402
|
|
|
|
|
|
def _reset_module_level_state():
|
|
"""These modules intentionally use module-level dicts as shared state
|
|
across request threads in production (see their own docstrings/
|
|
comments) — e.g. login_throttle's lockout tracking, error_alerts'
|
|
per-(exception, source) cooldown. That state must NOT leak between
|
|
tests, or an early test's alert cooldown / lockout would silently
|
|
suppress a later, unrelated test's assertions."""
|
|
login_throttle._failures.clear()
|
|
login_throttle._locked_until.clear()
|
|
error_alerts._last_sent.clear()
|
|
checkbox_prro._token_cache.update({"token": None, "at": 0.0})
|
|
reconciliation._last_token_ok = None
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
"""A fresh Flask app wired to its own temp SQLite DB, seeded with the
|
|
normal default data (categories/products/settings/admin user)."""
|
|
tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
|
tmp_db.close()
|
|
|
|
# config is a module-level singleton (bober_bbq.config.config); override
|
|
# the instance attribute directly rather than the env var, since the
|
|
# Config class attribute was already evaluated at import time.
|
|
config.SQLALCHEMY_DATABASE_URI = f"sqlite:///{tmp_db.name}"
|
|
config.OWNER_IDS = []
|
|
|
|
flask_app = create_app()
|
|
flask_app.config["WTF_CSRF_ENABLED"] = False
|
|
flask_app.testing = True
|
|
# Enables the ?dev_user_id=... Telegram-auth bypass in
|
|
# api/auth.require_telegram_auth (see its own comment) — there is no
|
|
# real Telegram WebView in tests to sign init data with.
|
|
flask_app.debug = True
|
|
|
|
with flask_app.app_context():
|
|
db.create_all()
|
|
migrate()
|
|
seed()
|
|
|
|
_reset_module_level_state()
|
|
|
|
yield flask_app
|
|
|
|
with flask_app.app_context():
|
|
db.session.remove()
|
|
db.engine.dispose()
|
|
try:
|
|
os.unlink(tmp_db.name)
|
|
except PermissionError:
|
|
# Windows sometimes still holds the file handle briefly after
|
|
# dispose() — matches the original scratch-script behavior of
|
|
# tolerating this rather than failing the whole run over cleanup.
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def client(app):
|
|
return app.test_client()
|