Files
dcalc/backend/tests/unit/test_colg_official.py
T
2026-11-03 21:44:30 +08:00

316 lines
11 KiB
Python

import asyncio
import time
import json
import pytest
from api.utils import official_gold as gold
from api.core.Gzip import gzip_zip
class FakeResponse:
status = 200
def __init__(self, region):
self.region = region
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, traceback):
return False
def raise_for_status(self):
return None
async def json(self, content_type=None):
await asyncio.sleep(0)
return {
'data': {
'godDealRank': [{'price': value} for value in ['100.5', '101', '102', '103', '104', '105']],
'godTopDealRank': [{'price': value} for value in ['200.25', '201', '202', '203', '204', '205']],
}
}
class FakeSession:
def __init__(self, calls, **kwargs):
self.calls = calls
self.kwargs = kwargs
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, traceback):
return False
def post(self, url, data):
self.calls.append((url, data))
return FakeResponse(data['serverNo'])
def test_fetch_official_gold_uses_async_http_with_isolated_forms(monkeypatch):
calls = []
sessions = []
def create_session(**kwargs):
session = FakeSession(calls, **kwargs)
sessions.append(session)
return session
monkeypatch.setattr(gold.aiohttp, 'ClientSession', create_session)
result = asyncio.run(gold.fetch_gold())
assert [data['serverNo'] for _, data in calls] == list(gold.REGIONS)
assert all(url == gold.URL for url, _ in calls)
assert len({id(data) for _, data in calls}) == len(gold.REGIONS)
assert [item['region'] for item in result] == [f'跨{region}' for region in gold.REGIONS]
assert [item['code'] for item in result] == ['k1', 'k2', 'k3a', 'k3b', 'k4', 'k5', 'k6', 'k7']
assert all(isinstance(item['updataTime'], str) and item['updataTime'].isdigit() for item in result)
assert abs(int(result[0]['updataTime']) - time.time()) < 2
assert result[0]['goldDealRank'] == [100.5, 101, 102, 103, 104]
assert result[0]['goldTopDealRank'] == [200.25, 201, 202, 203, 204]
assert result[0]['max'] == round(10000 / 100.5, 4)
assert result[0]['min'] == round(10000 / 204, 4)
assert sessions[0].kwargs['timeout'].total == 10
def test_official_route_uses_new_cache_contract_and_numeric_rank_arrays(monkeypatch, client):
calls = []
async def cached(redis):
calls.append(redis)
return [{'updataTime': '1793711716', 'region': '跨3B', 'code': 'k3b',
'goldDealRank': [100.5, 101], 'goldTopDealRank': [200.25, 201],
'max': round(10000 / 100.5, 4), 'min': round(10000 / 201, 4)}]
monkeypatch.setattr(gold, 'get_data', cached)
body = client.get('/api/open/gold/official/').json()
assert set(body) == {'code', 'message', 'data'}
assert body['code'] == 200
assert len(calls) == 1
row = body['data'][0]
assert set(row) == {'updataTime', 'region', 'code', 'goldDealRank', 'goldTopDealRank', 'max', 'min'}
assert isinstance(row['updataTime'], str) and row['updataTime'].isdigit()
assert row['code'] == 'k3b'
assert row['goldDealRank'] == [100.5, 101]
assert row['goldTopDealRank'] == [200.25, 201]
assert row['max'] == round(10000 / 100.5, 4)
assert row['min'] == round(10000 / 201, 4)
for rank in (row['goldDealRank'], row['goldTopDealRank']):
assert len(rank) <= 5
assert all(isinstance(value, (int, float)) for value in rank)
def test_gold_openapi_matches_timestamp_types_and_required_region_codes(client):
schemas = client.app.openapi()['components']['schemas']
realtime = schemas['RealTimeGold']
official = schemas['OfficialGold']
assert realtime['properties']['updataTime']['type'] == 'integer'
assert official['properties']['updataTime']['type'] == 'string'
assert 'code' in realtime['required'] and 'code' in official['required']
assert realtime['properties']['goldPrice']['type'] == 'number'
for name in ('goldDealRank', 'goldTopDealRank'):
assert official['properties'][name]['maxItems'] == 5
assert official['properties'][name]['items']['type'] == 'number'
class FakeRedis:
def __init__(self):
self.values = {}
self.ttls = {}
async def get(self, key):
return self.values.get(key)
async def set(self, key, value, nx=False, ex=None):
if nx and key in self.values:
return False
self.values[key] = value
self.ttls[key] = ex
return True
async def delete(self, key):
return int(self.values.pop(key, None) is not None)
async def eval(self, script, number, key, owner):
if self.values.get(key) == owner:
return await self.delete(key)
return 0
def cached_rows(age=0):
return [{'updataTime': str(int(time.time() - age)), 'region': f'跨{region}',
'code': f'k{region.lower()}', 'goldDealRank': [100.5], 'goldTopDealRank': [200.25]}
for region in gold.REGIONS]
def test_official_converts_point_prices_to_gold_per_100_points():
row = {**cached_rows()[0], 'goldDealRank': [125, 150], 'goldTopDealRank': [200, 250]}
result = gold.normalize_row(row)
assert result['max'] == 80
assert result['min'] == 40
assert result['goldDealRank'] == [125, 150]
assert result['goldTopDealRank'] == [200, 250]
def test_conversion_ignores_nonpositive_invalid_prices_and_uses_null_when_empty():
row = {**cached_rows()[0], 'goldDealRank': [0, -1, float('nan'), float('inf')],
'goldTopDealRank': []}
result = gold.normalize_row(row)
assert result['max'] is None and result['min'] is None
row['goldTopDealRank'] = [125]
result = gold.normalize_row(row)
assert result['max'] == result['min'] == 80
def test_old_official_cache_gets_max_and_min_without_refetch():
redis = FakeRedis()
redis.values[gold.CACHE_KEY] = gzip_zip(json.dumps(cached_rows()))
rows = asyncio.run(gold.read_cache(redis))
assert len(rows) == 8
assert rows[0]['max'] == round(10000 / 100.5, 4)
assert rows[0]['min'] == round(10000 / 200.25, 4)
@pytest.mark.parametrize('payload', [
{'returnCode': -30003, 'returnMsg': 'auth verification failed'},
{'result': '-30003'}, {'message': 'token expired'}, {'msg': '请重新登录'},
])
def test_expired_credentials_are_classified(payload):
assert gold.is_auth_failure(payload)
@pytest.mark.parametrize('payload', [
{'returnCode': 500, 'message': 'server error'}, {'message': 'network timeout'}, {'data': {}},
])
def test_regular_upstream_failures_do_not_trigger_expiry_alert(payload):
assert not gold.is_auth_failure(payload)
def test_fetch_detects_actual_auth_failure_response(monkeypatch):
class Response(FakeResponse):
async def json(self, **kwargs):
return {'result': -30003, 'returnCode': -30003, 'returnMsg': 'auth verification failed'}
class Session(FakeSession):
def post(self, url, data):
return Response(data['serverNo'])
monkeypatch.setattr(gold.aiohttp, 'ClientSession', lambda **kwargs: Session([], **kwargs))
with pytest.raises(gold.TokenExpired):
asyncio.run(gold.fetch_gold())
def test_refresh_updates_cache_without_http_request_and_skips_when_fresh(monkeypatch):
redis = FakeRedis()
calls = []
async def fetch():
calls.append(1)
return cached_rows()
monkeypatch.setattr(gold, 'fetch_gold', fetch)
assert asyncio.run(gold.refresh_cache(redis)) is True
assert len(asyncio.run(gold.read_cache(redis))) == 8
assert redis.ttls[gold.CACHE_KEY] == gold.RETENTION_SECONDS
assert gold.LOCK_KEY not in redis.values
assert asyncio.run(gold.refresh_cache(redis)) is False
assert calls == [1]
def test_failed_refresh_preserves_cache_and_notifies_once_per_fetch(monkeypatch):
redis = FakeRedis()
old = gzip_zip(json.dumps(cached_rows(age=1900)))
redis.values[gold.CACHE_KEY] = old
alerts = []
async def fetch():
raise gold.TokenExpired()
async def notify(redis):
alerts.append(1)
monkeypatch.setattr(gold, 'fetch_gold', fetch)
monkeypatch.setattr(gold, 'notify_token_expired', notify)
assert asyncio.run(gold.refresh_cache(redis)) is False
assert redis.values[gold.CACHE_KEY] == old
assert alerts == [1]
assert gold.LOCK_KEY not in redis.values
assert redis.ttls[gold.COOLDOWN_KEY] == gold.ERROR_COOLDOWN_SECONDS
assert asyncio.run(gold.refresh_cache(redis)) is False
assert alerts == [1]
def test_network_failure_preserves_cache_without_token_alert(monkeypatch):
redis = FakeRedis()
old = gzip_zip(json.dumps(cached_rows(age=1900)))
redis.values[gold.CACHE_KEY] = old
async def fetch():
raise TimeoutError('upstream unavailable')
async def notify(redis):
raise AssertionError('network failures are not credential failures')
monkeypatch.setattr(gold, 'fetch_gold', fetch)
monkeypatch.setattr(gold, 'notify_token_expired', notify)
assert asyncio.run(gold.refresh_cache(redis)) is False
assert redis.values[gold.CACHE_KEY] == old
@pytest.mark.parametrize('url', [None, '', ' '])
def test_unconfigured_bark_never_creates_http_session(monkeypatch, url):
monkeypatch.setattr(gold.config, 'BARK_URL', url)
def session(**kwargs):
raise AssertionError('BARK must be completely skipped when not configured')
monkeypatch.setattr(gold.aiohttp, 'ClientSession', session)
redis = FakeRedis()
assert asyncio.run(gold.notify_token_expired(redis)) is False
assert not redis.values
def test_bark_alert_is_deduplicated_and_does_not_include_credentials(monkeypatch):
redis = FakeRedis()
posts = []
monkeypatch.setattr(gold.config, 'BARK_URL', 'https://bark.example/device-secret')
monkeypatch.setattr(gold.config, 'HELPER_TOKEN', 'private-token')
class Response(FakeResponse):
async def json(self, **kwargs):
return {'code': 200}
class Session(FakeSession):
def post(self, url, json, **kwargs):
posts.append((url, json))
return Response('push')
monkeypatch.setattr(gold.aiohttp, 'ClientSession', lambda **kwargs: Session([], **kwargs))
assert asyncio.run(gold.notify_token_expired(redis)) is True
assert asyncio.run(gold.notify_token_expired(redis)) is False
assert len(posts) == 1
assert posts[0][0] == gold.config.BARK_URL
assert 'private-token' not in json.dumps(posts[0][1])
assert 'device-secret' not in json.dumps(posts[0][1])
assert all('private-token' not in key for key in redis.values)
assert next(iter(redis.ttls.values())) == gold.ALERT_RETENTION_SECONDS
monkeypatch.setattr(gold.config, 'HELPER_TOKEN', 'new-token')
assert asyncio.run(gold.notify_token_expired(redis)) is True
assert len(posts) == 2
def test_failed_bark_push_can_retry_without_leaking_device_url(monkeypatch, caplog):
monkeypatch.setattr(gold.config, 'BARK_URL', 'https://bark.example/device-secret')
redis = FakeRedis()
class Session(FakeSession):
def post(self, url, **kwargs):
raise RuntimeError(url)
monkeypatch.setattr(gold.aiohttp, 'ClientSession', lambda **kwargs: Session([], **kwargs))
assert asyncio.run(gold.notify_token_expired(redis)) is False
assert not redis.values
assert 'device-secret' not in caplog.text