update 6
This commit is contained in:
125
tests/test_analytics.py
Normal file
125
tests/test_analytics.py
Normal file
@@ -0,0 +1,125 @@
|
||||
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
|
||||
Reference in New Issue
Block a user