Files
hockey_new/tests/test_analytics.py
2026-09-27 00:47:30 +03:00

126 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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