Files
team-tryouts/tests/conftest.py
GGThed 39808dd04e ops: un deploiement qui refuse de partir casse, et qui se verifie
OPS-011, en partie. Ce que le workflow garantit maintenant :

- rien ne part d un arbre casse. La suite, ruff check et ruff format
  tournent sur le runner de deploiement avant tout envoi. Une CI verte sur
  GitHub ne prouve rien ici : le deploiement se declenche a la main, sur ce
  que la branche contient a cet instant ;
- seuls les fichiers nommes partent. La charge est une liste blanche —
  app/, wsgi.py, requirements.txt — et non l arbre de travail moins neuf
  exclusions. C est par cette porte que clear_db.py, la suite de tests et
  les definitions de CI se sont retrouves sur le noeud de production ;
- le deploiement est verifie. /health est interroge pendant deux minutes
  apres l envoi et le job echoue s il ne repond jamais « healthy ». Avant,
  un arbre a moitie televerse etait un deploiement vert.

Ce qui n est pas garanti, et c est ecrit dans le fichier : la bascule n est
pas atomique. Le miroir se fait sur place, donc pendant le transfert la
production execute un melange de deux versions.

En cherchant a fermer ce point, un defaut a part entiere est apparu. Les
contrats etaient ranges a os.getcwd()/documents et leur chemin absolu
ecrit en base. La racine de stockage suivait donc le repertoire depuis
lequel le processus avait ete lance : redemarrer le serveur ailleurs
envoie les nouveaux contrats dans un nouvel arbre et rend les anciens
illisibles — la base continuant d affirmer qu ils sont la, la panne se
manifeste par un 500 au telechargement, pas par quelque chose
d actionnable.

app/storage.py fixe la racine et DOCUMENTS_ROOT la deplace. Les nouvelles
lignes gardent un chemin relatif, les anciennes gardent leur chemin absolu
et continuent de resoudre : aucune migration de donnees n est necessaire,
donc ce changement n attend pas Alembic.

C etait aussi le troisieme pre-requis de la bascule par repertoires de
version. Les deux autres sont hors d atteinte d ici — la commande de
demarrage Pterodactyl doit pointer sur current/, et les repertoires
partages doivent etre installes sur le noeud. Les deux sont decrits dans
docs/deployment.md, avec la procedure de retour arriere qui manquait.

511 tests.
2026-08-11 13:58:54 -04:00

204 lines
6.0 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 contextlib
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(autouse=True)
def documents_in_a_throwaway_directory(tmp_path, monkeypatch):
"""Keep uploads out of the repository.
Contract uploads land under app.storage.documents_root(), which without
this is `documents/` beside the package — i.e. inside the checkout. No
test writes a file there today; this is so that the first one that does
cannot leave a PDF in someone's working tree.
"""
monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'documents'))
@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()
with contextlib.suppress(OSError):
os.unlink(db_path)
@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()
with contextlib.suppress(OSError):
os.unlink(db_path)
@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