DATA-004, DATA-005, DATA-006. L'audit les classait "forte probabilite" faute de pouvoir les executer. Les tests les confirment : ce sont des bugs averes, declenchables par tout manager ou administrateur depuis l'interface. Erreurs reellement obtenues avant correction : NOT NULL constraint failed: match_participants.match_id NOT NULL constraint failed: team_matches.org_team_id Supprimer un match (DATA-004) Match.participants n'avait pas de cascade. SQLAlchemy tentait donc de detacher les participants en mettant match_id a NULL, ce que la colonne refuse. Tout match ayant eu des participants etait indestructible. TeamMatch.participants declarait deja delete-orphan ; Match non. Supprimer un tryout (DATA-006) Les PersonalNote pointant vers ses matchs, equipes ou vers lui-meme n'etaient pas traitees. Supprimer une equipe (DATA-005) TeamNote.org_team_id et TeamMatch.org_team_id sont NOT NULL et n'etaient pas traites du tout. De plus la fonction validait trois fois : un echec au troisieme temps laissait les tryouts detaches et les joueurs retires sans que l'equipe soit supprimee -- un etat incoherent que rien ne rattrapait. Une seule transaction desormais. Regle appliquee, uniforme Ce qui n'a de sens que dans le parent est supprime avec lui : participants, membres, notes d'equipe, matchs de saison. Ce qui lui survit est seulement detache : les notes personnelles sont les observations d'un coach sur un joueur, pas des donnees de tryout. Les supprimer avec le tryout detruirait du contenu sans rapport. Idem pour les contrats et les demandes de rencontre individuelle. Fidelite des tests conftest.py active PRAGMA foreign_keys=ON. SQLite ignore les cles etrangeres par defaut ; PostgreSQL les applique toujours. Sans ce reglage, la suite pouvait valider une suppression qui echoue en production -- precisement la classe de bug corrigee ici. Les 146 tests passent avec les contraintes actives. 9 tests, dont trois qui verifient que les entites survivantes survivent vraiment : une note garde son contenu et perd son contexte, un tryout survit a l'equipe qu'il visait. Co-Authored-By: Claude Opus 5 <[email protected]>
194 lines
5.5 KiB
Python
194 lines
5.5 KiB
Python
"""Shared pytest fixtures for the Team Tryouts test suite.
|
|
|
|
The application factory is driven entirely through the ``config`` argument
|
|
here: no environment variable is required to run the suite, no database
|
|
server is needed, and the Discord bot never starts.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
# Make the project root importable as the 'app' package.
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from sqlalchemy import event # noqa: E402
|
|
from sqlalchemy.engine import Engine # noqa: E402
|
|
|
|
from app.app import create_app # noqa: E402
|
|
from app.extensions import db as _db # noqa: E402
|
|
from app.models import Admin, Coach, Manager, Player, Scout # noqa: E402
|
|
|
|
|
|
@event.listens_for(Engine, 'connect')
|
|
def _enforce_sqlite_foreign_keys(dbapi_connection, connection_record):
|
|
"""Make SQLite behave like PostgreSQL about foreign keys.
|
|
|
|
SQLite ignores foreign key constraints unless asked not to. Production
|
|
runs on PostgreSQL, which always enforces them, so without this a test
|
|
suite could pass over a deletion that fails in production — exactly the
|
|
class of bug DATA-004 and DATA-005 turned out to be.
|
|
"""
|
|
import sqlite3
|
|
|
|
if isinstance(dbapi_connection, sqlite3.Connection):
|
|
cursor = dbapi_connection.cursor()
|
|
cursor.execute('PRAGMA foreign_keys=ON')
|
|
cursor.close()
|
|
|
|
|
|
ROLE_CLASSES = {
|
|
'admin': Admin,
|
|
'manager': Manager,
|
|
'coach': Coach,
|
|
'player': Player,
|
|
'scout': Scout,
|
|
}
|
|
|
|
#: Satisfies the documented policy (8+ chars, upper, lower, digit).
|
|
VALID_PASSWORD = 'Password123'
|
|
|
|
|
|
def _base_config(db_path, csrf=False):
|
|
return {
|
|
'SECRET_KEY': 'test-secret-not-used-anywhere-real',
|
|
'SQLALCHEMY_DATABASE_URI': f'sqlite:///{db_path}',
|
|
'TESTING': True,
|
|
'WTF_CSRF_ENABLED': csrf,
|
|
# Without these three the suite would 301 every request, start a
|
|
# Discord bot, and refuse to issue cookies over the test client.
|
|
'FORCE_HTTPS': False,
|
|
'SESSION_COOKIE_SECURE': False,
|
|
'ENABLE_DISCORD_BOT': False,
|
|
# The schema still comes from create_all() until Alembic lands (DB-002).
|
|
'AUTO_CREATE_TABLES': True,
|
|
'CORS_ALLOWED_ORIGINS': '',
|
|
'RATELIMIT_ENABLED': False,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
"""A fully configured application backed by a throwaway SQLite file.
|
|
|
|
A file rather than :memory: because Flask-SQLAlchemy hands out a
|
|
connection per thread, and an in-memory database is not shared between
|
|
them — tables created on one connection would be invisible to the next.
|
|
"""
|
|
fd, db_path = tempfile.mkstemp(suffix='.sqlite')
|
|
os.close(fd)
|
|
|
|
application = create_app(_base_config(db_path))
|
|
|
|
yield application
|
|
|
|
with application.app_context():
|
|
_db.session.remove()
|
|
_db.engine.dispose()
|
|
try:
|
|
os.unlink(db_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def app_with_csrf():
|
|
"""Same application, with CSRF protection left switched on."""
|
|
fd, db_path = tempfile.mkstemp(suffix='.sqlite')
|
|
os.close(fd)
|
|
|
|
application = create_app(_base_config(db_path, csrf=True))
|
|
|
|
yield application
|
|
|
|
with application.app_context():
|
|
_db.session.remove()
|
|
_db.engine.dispose()
|
|
try:
|
|
os.unlink(db_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def client(app):
|
|
return app.test_client()
|
|
|
|
|
|
@pytest.fixture
|
|
def db(app):
|
|
"""Database handle bound to an active application context."""
|
|
with app.app_context():
|
|
yield _db
|
|
|
|
|
|
@pytest.fixture
|
|
def make_user(app):
|
|
"""Factory creating a user of a given role and returning its id.
|
|
|
|
Returns the primary key rather than the instance: the object would be
|
|
detached once the fixture's application context is popped, and every
|
|
caller wants to look it up inside its own context anyway.
|
|
"""
|
|
counter = {'n': 0}
|
|
|
|
def _make(role='player', password=VALID_PASSWORD, **kwargs):
|
|
from app.extensions import hash_password
|
|
|
|
counter['n'] += 1
|
|
n = counter['n']
|
|
cls = ROLE_CLASSES[role]
|
|
with app.app_context():
|
|
user = cls(
|
|
username=kwargs.pop('username', f'{role}{n}'),
|
|
password_hash=hash_password(password),
|
|
role=role,
|
|
full_name=kwargs.pop('full_name', f'{role.title()} {n}'),
|
|
email=kwargs.pop('email', f'{role}{n}@example.test'),
|
|
**kwargs,
|
|
)
|
|
_db.session.add(user)
|
|
_db.session.commit()
|
|
return user.id
|
|
|
|
return _make
|
|
|
|
|
|
@pytest.fixture
|
|
def login(client):
|
|
"""Log a user in through the real login form.
|
|
|
|
Deliberately exercises the actual authentication path rather than
|
|
poking flask_login's session key, so that session handling itself
|
|
stays under test.
|
|
"""
|
|
|
|
def _login(username, password=VALID_PASSWORD):
|
|
return client.post(
|
|
'/auth/login',
|
|
data={'username': username, 'password': password},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
return _login
|
|
|
|
|
|
@pytest.fixture
|
|
def as_role(app, client, make_user, login):
|
|
"""Create a user of the given role, log in, and return its id."""
|
|
|
|
def _as(role='player', **kwargs):
|
|
user_id = make_user(role, **kwargs)
|
|
with app.app_context():
|
|
from app.models import User
|
|
username = _db.session.get(User, user_id).username
|
|
response = login(username)
|
|
assert response.status_code in (301, 302), (
|
|
f'login for {username} did not redirect: {response.status_code}'
|
|
)
|
|
return user_id
|
|
|
|
return _as
|