The test suite silently depended on the gitignored dev instance/plugins.json to know which plugins to load - green on the dev box, red on any fresh clone (CI caught it). conftest now seeds a registry enabling all bundled plugins when none exists; an existing dev registry is left untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
149 lines
4.3 KiB
Python
149 lines
4.3 KiB
Python
"""Pytest configuration and fixtures for shopdb-flask.
|
|
|
|
Strategy: in-memory SQLite via StaticPool (configured in TestingConfig)
|
|
so the database is shared across the connection. Each test drops and
|
|
recreates the schema. Simple, totally isolated, fast enough for a small
|
|
schema. Switch to savepoint-per-test if test count grows past a few
|
|
hundred.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from werkzeug.security import generate_password_hash
|
|
|
|
# Force testing config before any shopdb import touches the env.
|
|
os.environ['FLASK_ENV'] = 'testing'
|
|
|
|
from shopdb import create_app
|
|
from shopdb.extensions import db as _db
|
|
|
|
|
|
def _bootstrap_plugin_registry():
|
|
# Fresh clones (CI) have no instance/plugins.json - the registry is
|
|
# gitignored dev state - so no plugins would load and every plugin-backed
|
|
# test fails. Seed a registry enabling all bundled plugins. An existing
|
|
# dev registry is left untouched.
|
|
repo = Path(__file__).resolve().parent.parent
|
|
state_file = repo / 'instance' / 'plugins.json'
|
|
if state_file.exists():
|
|
return
|
|
stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
|
|
plugins = {}
|
|
for manifest_path in sorted((repo / 'plugins').glob('*/manifest.json')):
|
|
manifest = json.loads(manifest_path.read_text())
|
|
plugins[manifest['name']] = {
|
|
'name': manifest['name'],
|
|
'version': manifest.get('version', '1.0.0'),
|
|
'installed_at': stamp,
|
|
'enabled': True,
|
|
'migrations_applied': [],
|
|
'config': {},
|
|
}
|
|
state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
state_file.write_text(json.dumps({'plugins': plugins}, indent=2))
|
|
|
|
|
|
_bootstrap_plugin_registry()
|
|
|
|
|
|
@pytest.fixture(scope='session')
|
|
def app():
|
|
"""Create the Flask application for the test session."""
|
|
application = create_app('testing')
|
|
return application
|
|
|
|
|
|
@pytest.fixture(scope='function')
|
|
def db(app):
|
|
"""Provide a fresh database per test. Drops and recreates schema each run."""
|
|
with app.app_context():
|
|
_db.create_all()
|
|
yield _db
|
|
_db.session.remove()
|
|
_db.drop_all()
|
|
|
|
|
|
@pytest.fixture
|
|
def client(app):
|
|
"""Flask test client."""
|
|
return app.test_client()
|
|
|
|
|
|
@pytest.fixture
|
|
def runner(app):
|
|
"""Flask CLI test runner."""
|
|
return app.test_cli_runner()
|
|
|
|
|
|
@pytest.fixture
|
|
def admin_user(db):
|
|
"""Create an admin user for authenticated tests.
|
|
|
|
The user has username 'testadmin' and password 'testpass'.
|
|
"""
|
|
from shopdb.core.models import User, Role
|
|
|
|
role = Role(rolename='admin', description='Administrator')
|
|
db.session.add(role)
|
|
db.session.flush()
|
|
|
|
user = User(
|
|
username='testadmin',
|
|
email='admin@test.local',
|
|
passwordhash=generate_password_hash('testpass'),
|
|
)
|
|
user.roles.append(role)
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
|
|
return user
|
|
|
|
|
|
@pytest.fixture
|
|
def auth_headers(client, admin_user):
|
|
"""Log in as admin_user and return Authorization headers."""
|
|
response = client.post(
|
|
'/api/auth/login',
|
|
json={'username': 'testadmin', 'password': 'testpass'},
|
|
)
|
|
assert response.status_code == 200, f'Login failed: {response.get_json()}'
|
|
payload = response.get_json()
|
|
token = payload['data']['access_token']
|
|
return {'Authorization': f'Bearer {token}'}
|
|
|
|
|
|
@pytest.fixture
|
|
def member_user(db):
|
|
"""Create an authenticated user with NO roles/permissions.
|
|
|
|
Username 'testmember', password 'testpass'. Used to prove that
|
|
authentication alone does not grant write access (authorization gating).
|
|
"""
|
|
from shopdb.core.models import User
|
|
|
|
user = User(
|
|
username='testmember',
|
|
email='member@test.local',
|
|
passwordhash=generate_password_hash('testpass'),
|
|
)
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
|
|
return user
|
|
|
|
|
|
@pytest.fixture
|
|
def member_headers(client, member_user):
|
|
"""Log in as member_user (no roles) and return Authorization headers."""
|
|
response = client.post(
|
|
'/api/auth/login',
|
|
json={'username': 'testmember', 'password': 'testpass'},
|
|
)
|
|
assert response.status_code == 200, f'Login failed: {response.get_json()}'
|
|
token = response.get_json()['data']['access_token']
|
|
return {'Authorization': f'Bearer {token}'}
|