126 lines
6.9 KiB
Python
126 lines
6.9 KiB
Python
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
|