Files
team-tryouts/tests/conftest.py
T
GGThedandClaude Opus 5 d15a3de2a1 fix(data): reparer les trois suppressions cassees
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]>
2026-08-07 20:37:04 -04:00

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