from datetime import date import json from types import SimpleNamespace from uuid import uuid4 from fastapi import FastAPI, HTTPException, Request from fastapi.testclient import TestClient import pytest from sqlalchemy import event, select from hockey_data.analytics import game_analytics, season_values from hockey_data.auth_bridge import HockeyUser from hockey_data.models import AnalyticsDraft, Game, MatchControlLease, Player, PlayerSeasonStatistic from hockey_data.router import create_hockey_router from tests.test_khl_refresh_and_career import career_env def test_season_missing_cells_are_not_zero_and_invalid_numbers_are_ignored(): value = season_values({'gp': 10, 'g': 0, 'a': 2, 'pim': '', 'sv_pct': 'NaN', 'sog_pct': '12,5', 'pm': -2}) assert value == {'available': True, 'games': 10, 'goals': 0, 'assists': 2, 'points': 2, 'shot_pct': 12.5, 'plus_minus': -2} assert season_values({'gp': '-', 'sv': -4, 'sog': 'inf'}) == {'available': False} assert season_values({'gp': 0, 'sog': 0}, True)['shots_against'] == 0 def test_analytics_matches_current_tournament_and_team_without_player_raw_fallback(career_env): db, _, _ = career_env with db.session() as session: p = session.scalar(select(Player).where(Player.external_id == 'external-10')) p.raw_payload = json.dumps({'gp': 999, 'g': 999}) p.first_name_en = 'Ivan'; p.last_name_en = 'Ivanov' session.scalar(select(Game)).game_date = date(2026, 9, 27) for tournament, entry, games in [('old', 'entry', 999), ('t', 'other', 888), ('t', 'entry', 12)]: session.add(PlayerSeasonStatistic(player_id=p.id, player_external_id=p.external_id, tournament_external_id=tournament, team_entry_external_id=entry, club_external_id='club', raw_payload=json.dumps({'gp': games, 'g': 4, 'a': 8}))) data = game_analytics(db, 'g', all_members=True) p, extra = data['home']['skaters'] assert p['names']['en'] == 'Ivan Ivanov' assert p['season_statistics']['games'] == 12 assert p['career_statistics']['games'] == 10 assert extra['season_statistics'] == {'available': False} assert data['game_date'] == '2026-09-27' and p['birth_date'] == '2000-01-01' with db.session() as session: assert session.scalar(select(MatchControlLease)).revision == 4 def test_query_count_does_not_grow_per_player(career_env): db, _, _ = career_env calls = [] def trace(conn, cursor, statement, parameters, context, executemany): calls.append(statement) event.listen(db.engine, 'before_cursor_execute', trace) try: game_analytics(db, 'g'); count_match = len(calls); calls.clear() game_analytics(db, 'g', all_members=True); count_all = len(calls) finally: event.remove(db.engine, 'before_cursor_execute', trace) assert count_match <= 8 and count_all <= count_match + 2 @pytest.fixture def api(career_env): db, _, _ = career_env async def auth(request: Request): user = request.headers.get('X-User') if not user: raise HTTPException(401, 'Login required') return HockeyUser(id=user, login=user, display_name=user) app = FastAPI(); app.include_router(create_hockey_router(SimpleNamespace(database=db), auth_dependency=auth)) with TestClient(app) as client: yield client, db def draft(**values): return dict(expected_revision=0, kind='hint', source_key='birthday:player:2026', title='День рождения', text_ru='В день матча — 26 лет', text_en='Turns 26 on game day', source='Карточка игрока') | values def test_viewer_private_drafts_persist_and_cannot_touch_owner_or_match(api): client, db = api path = '/api/hockey/games/g/analytics' identity = str(uuid4()); target = f'{path}/drafts/{identity}' assert client.get(path).status_code == 401 assert client.put(target, json=draft()).status_code == 401 assert client.get(path, headers={'X-User': 'viewer'}).status_code == 200 one = client.put(target, json=draft(), headers={'X-User': 'operator'}) assert one.status_code == 200 and one.json()['revision'] == 1 assert client.get(f'{path}/drafts', headers={'X-User': 'viewer'}).json()['items'] == [] assert client.delete(target+'?expected_revision=1', headers={'X-User': 'viewer'}).status_code == 404 assert client.put(target, json=draft(expected_revision=1), headers={'X-User': 'viewer'}).status_code == 409 # A caller-supplied UUID has no authority: the same UUID belongs to a separate account namespace. assert client.put(target, json=draft(text_ru='Текст зрителя'), headers={'X-User': 'viewer'}).status_code == 200 with db.session() as session: lease = session.scalar(select(MatchControlLease)) assert (lease.owner_user_id, lease.revision, lease.generation) == ('operator', 4, 1) assert session.get(AnalyticsDraft, ('operator','g',identity)).text_ru == 'В день матча — 26 лет' assert session.get(AnalyticsDraft, ('viewer','g',identity)).text_ru == 'Текст зрителя' # Reconstruct the router (as on restart), leaving only the durable database. def auth(): return HockeyUser(id='viewer', login='viewer', display_name='Viewer') restarted = FastAPI(); restarted.include_router(create_hockey_router(SimpleNamespace(database=db), auth_dependency=auth)) with TestClient(restarted) as again: assert again.get(f'{path}/drafts').json()['items'][0]['text_en'] == 'Turns 26 on game day' def test_revision_protects_updates_and_deletes_and_match_scope(api): client, db = api with db.session() as session: session.add(Game(external_id='other',tournament_external_id='t')) client.headers['X-User'] = 'viewer' path = f'/api/hockey/games/g/analytics/drafts/{uuid4()}' assert client.put(path, json=draft()).status_code == 200 assert client.put(path, json=draft()).status_code == 409 response = client.put(path, json=draft(expected_revision=1, text_ru='Обновлено')) assert response.json()['revision'] == 2 assert client.delete(path+'?expected_revision=1').status_code == 409 assert client.get('/api/hockey/games/other/analytics/drafts').json()['items'] == [] assert client.put(path.replace('/games/g/','/games/missing/'), json=draft()).status_code == 404 assert client.delete(path+'?expected_revision=2').status_code == 200 assert client.get('/api/hockey/games/g/analytics/drafts').json()['items'] == [] def test_validation_blocks_identity_injection_oversize_and_invalid_scope(api): client, _ = api; client.headers['X-User'] = 'viewer' path = f'/api/hockey/games/g/analytics/drafts/{uuid4()}' for bad in [draft(user_id='operator'),draft(text_ru='x'*4001),draft(title=' '),draft(text_ru='',text_en=''),draft(kind='command')]: assert client.put(path,json=bad).status_code == 422 assert client.get('/api/hockey/games/g/analytics?mode=wrong').status_code == 422 assert client.get('/api/hockey/games/missing/analytics').status_code == 404