update
This commit is contained in:
@@ -6,6 +6,7 @@ import pytest
|
||||
|
||||
from api.utils import official_gold as gold
|
||||
from api.core.Gzip import gzip_zip
|
||||
from api.utils.gold_time import refresh_slot
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
@@ -67,14 +68,37 @@ def test_fetch_official_gold_uses_async_http_with_isolated_forms(monkeypatch):
|
||||
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 int(result[0]['updataTime']) == refresh_slot()
|
||||
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 result[0]['max'] == round(10000 / 100.5, 2)
|
||||
assert result[0]['min'] == round(10000 / 204, 2)
|
||||
assert sessions[0].kwargs['timeout'].total == 10
|
||||
|
||||
|
||||
def test_official_batch_timestamp_comes_from_start_not_fetch_completion(monkeypatch):
|
||||
started_at = 1793766300 # 与真实结束时间不同的固定开始时间。
|
||||
monkeypatch.setattr(gold.aiohttp, 'ClientSession', lambda **kwargs: FakeSession([], **kwargs))
|
||||
rows = asyncio.run(gold.fetch_gold(started_at=started_at))
|
||||
assert {row['updataTime'] for row in rows} == {str(refresh_slot(started_at))}
|
||||
|
||||
|
||||
def test_preloaded_next_slot_cache_is_servable_before_the_half_hour(monkeypatch):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
china = timezone(timedelta(hours=8))
|
||||
now = datetime(2026, 11, 4, 1, 25, tzinfo=china).timestamp()
|
||||
monkeypatch.setattr(gold.time, 'time', lambda: now)
|
||||
rows = cached_rows()
|
||||
redis = FakeRedis()
|
||||
redis.values[gold.CACHE_KEY] = gzip_zip(json.dumps(rows))
|
||||
assert gold.is_fresh(asyncio.run(gold.read_cache(redis)))
|
||||
target = int(rows[0]['updataTime'])
|
||||
assert target > now
|
||||
assert target - now == 300
|
||||
monkeypatch.setattr(gold.time, 'time', lambda: now + 1800)
|
||||
assert not gold.is_fresh(rows)
|
||||
|
||||
|
||||
def test_official_route_uses_new_cache_contract_and_numeric_rank_arrays(monkeypatch, client):
|
||||
calls = []
|
||||
|
||||
@@ -82,7 +106,7 @@ def test_official_route_uses_new_cache_contract_and_numeric_rank_arrays(monkeypa
|
||||
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)}]
|
||||
'max': round(10000 / 100.5, 2), 'min': round(10000 / 201, 2)}]
|
||||
|
||||
monkeypatch.setattr(gold, 'get_data', cached)
|
||||
body = client.get('/api/open/gold/official/').json()
|
||||
@@ -95,8 +119,8 @@ def test_official_route_uses_new_cache_contract_and_numeric_rank_arrays(monkeypa
|
||||
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)
|
||||
assert row['max'] == round(10000 / 100.5, 2)
|
||||
assert row['min'] == round(10000 / 201, 2)
|
||||
for rank in (row['goldDealRank'], row['goldTopDealRank']):
|
||||
assert len(rank) <= 5
|
||||
assert all(isinstance(value, (int, float)) for value in rank)
|
||||
@@ -140,7 +164,7 @@ class FakeRedis:
|
||||
|
||||
|
||||
def cached_rows(age=0):
|
||||
return [{'updataTime': str(int(time.time() - age)), 'region': f'跨{region}',
|
||||
return [{'updataTime': str(refresh_slot(time.time() - age)), 'region': f'跨{region}',
|
||||
'code': f'k{region.lower()}', 'goldDealRank': [100.5], 'goldTopDealRank': [200.25]}
|
||||
for region in gold.REGIONS]
|
||||
|
||||
@@ -169,8 +193,8 @@ def test_old_official_cache_gets_max_and_min_without_refetch():
|
||||
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)
|
||||
assert rows[0]['max'] == round(10000 / 100.5, 2)
|
||||
assert rows[0]['min'] == round(10000 / 200.25, 2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('payload', [
|
||||
@@ -206,7 +230,7 @@ def test_refresh_updates_cache_without_http_request_and_skips_when_fresh(monkeyp
|
||||
redis = FakeRedis()
|
||||
calls = []
|
||||
|
||||
async def fetch():
|
||||
async def fetch(*, started_at=None):
|
||||
calls.append(1)
|
||||
return cached_rows()
|
||||
|
||||
@@ -225,7 +249,7 @@ def test_failed_refresh_preserves_cache_and_notifies_once_per_fetch(monkeypatch)
|
||||
redis.values[gold.CACHE_KEY] = old
|
||||
alerts = []
|
||||
|
||||
async def fetch():
|
||||
async def fetch(*, started_at=None):
|
||||
raise gold.TokenExpired()
|
||||
|
||||
async def notify(redis):
|
||||
@@ -247,7 +271,7 @@ def test_network_failure_preserves_cache_without_token_alert(monkeypatch):
|
||||
old = gzip_zip(json.dumps(cached_rows(age=1900)))
|
||||
redis.values[gold.CACHE_KEY] = old
|
||||
|
||||
async def fetch():
|
||||
async def fetch(*, started_at=None):
|
||||
raise TimeoutError('upstream unavailable')
|
||||
|
||||
async def notify(redis):
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from api.types.colg import OfficialGold, RealTimeGold
|
||||
from api.utils.gold_time import half_hour_start, refresh_slot
|
||||
|
||||
CHINA = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def stamp(hour, minute, second=0):
|
||||
return int(datetime(2026, 11, 4, hour, minute, second, tzinfo=CHINA).timestamp())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(('hour', 'minute', 'second', 'target_hour', 'target_minute'), [
|
||||
(1, 24, 59, 1, 0), (1, 25, 0, 1, 30), (1, 29, 59, 1, 30),
|
||||
(1, 30, 0, 1, 30), (1, 54, 59, 1, 30), (1, 55, 0, 2, 0), (2, 0, 0, 2, 0),
|
||||
(23, 55, 0, 0, 0),
|
||||
])
|
||||
def test_refresh_slot_advances_five_minutes_before_boundary(hour, minute, second, target_hour, target_minute):
|
||||
expected = stamp(target_hour, target_minute)
|
||||
if hour == 23:
|
||||
expected += 24 * 60 * 60
|
||||
assert refresh_slot(stamp(hour, minute, second)) == expected
|
||||
|
||||
|
||||
def test_response_types_round_price_values_and_keep_timestamp_types():
|
||||
realtime = RealTimeGold(updataTime=stamp(1, 30), region='跨1', code='k1', goldPrice=.016789)
|
||||
assert realtime.goldPrice == .02
|
||||
assert realtime.updataTime == stamp(1, 30)
|
||||
assert isinstance(realtime.updataTime, int)
|
||||
official = OfficialGold(updataTime=str(stamp(1, 30)), region='跨1', code='k1',
|
||||
goldDealRank=[125.556], goldTopDealRank=[250.123], max=79.64444, min=39.9876)
|
||||
assert official.goldDealRank == [125.56]
|
||||
assert official.goldTopDealRank == [250.12]
|
||||
assert official.max == 79.64 and official.min == 39.99
|
||||
assert official.updataTime == str(stamp(1, 30))
|
||||
assert half_hour_start(stamp(1, 29, 59)) == stamp(1, 0)
|
||||
@@ -9,16 +9,17 @@ from fastapi import BackgroundTasks
|
||||
from api.routes import colg
|
||||
from api.types.colg import GOLD_REGION_CODES
|
||||
from api.utils import thirdpart_gold as gold
|
||||
from api.utils.gold_time import refresh_slot
|
||||
|
||||
|
||||
def offer(price, url='listing', platform=17, money=1):
|
||||
def offer(price, url='listing', platform=17, money=10):
|
||||
return {'Price': price, 'Money': money, 'Number': 1, 'PfId': platform, 'BuyUrl': url}
|
||||
|
||||
|
||||
def cached_item(region='跨1', age=0):
|
||||
fetched_at = time.time() - age
|
||||
return {'region': region, 'goldPrice': 100.0, 'sampleCount': 10,
|
||||
'code': GOLD_REGION_CODES[region], 'updataTime': int(fetched_at),
|
||||
return {'region': region, 'goldPrice': .01, 'sampleCount': 7,
|
||||
'code': GOLD_REGION_CODES[region], 'updataTime': refresh_slot(fetched_at),
|
||||
'fetchedAt': fetched_at}
|
||||
|
||||
|
||||
@@ -53,22 +54,28 @@ class FakeRedis:
|
||||
return 0
|
||||
|
||||
|
||||
def test_average_is_mean_of_ratios_without_minimum_money_filter():
|
||||
# 100 and 50 万/元 => 75; inverse mean price would incorrectly give 66.67.
|
||||
assert gold.first_page_average([offer(.01), offer(.02)]) == (75.0, 2)
|
||||
def test_average_uses_displayed_unit_prices_at_indices_three_through_nine():
|
||||
rows = [offer(i / 100) for i in range(1, 12)]
|
||||
assert gold.first_page_average(rows) == (.07, 7)
|
||||
invalid = [offer(0), offer(-1), offer(float('nan')), offer(float('inf')),
|
||||
offer(.01, money=-1), offer(None), offer(.01, money=float('nan'))]
|
||||
assert gold.first_page_average(invalid + [offer(.01, money=.5)]) == (100.0, 1)
|
||||
# 页面允许Money=-1的库存占位报价;另行排除不显示的低额订单。
|
||||
invalid.pop(4)
|
||||
assert gold.first_page_average(invalid + [offer(.01, money=.5)] + rows) == (.07, 7)
|
||||
assert gold.first_page_average([offer(.016789)] * 10) == (.02, 7)
|
||||
assert gold.first_page_average(rows[:4]) == (.04, 1)
|
||||
with pytest.raises(ValueError, match='有效报价'):
|
||||
gold.first_page_average(invalid)
|
||||
with pytest.raises(ValueError, match='有效报价'):
|
||||
gold.first_page_average(rows[:3])
|
||||
|
||||
|
||||
def test_page_deduplicates_before_cutoff_and_preserves_7881_variants():
|
||||
rows = [offer(.01, 'duplicate', 40), offer(.01, 'duplicate', 40)]
|
||||
rows += [offer(.02, str(i), 17) for i in range(199)]
|
||||
rows += [offer(.5, 'outside', 17)]
|
||||
assert gold.first_page_average(rows) == (50.25, 200)
|
||||
assert gold.first_page_average([offer(.01, platform=44), offer(.02, platform=44)]) == (75.0, 2)
|
||||
rows += [offer(i / 100, str(i), 17) for i in range(2, 12)]
|
||||
assert gold.first_page_average(rows) == (.07, 7)
|
||||
rows = [offer(.01, platform=44)] * 3 + [offer(.04, platform=44)] * 7
|
||||
assert gold.first_page_average(rows) == (.04, 7)
|
||||
|
||||
|
||||
def test_request_signature_matches_observed_browser_request():
|
||||
@@ -118,14 +125,20 @@ def test_region_fetches_its_own_seed_config_and_all_initial_channels():
|
||||
|
||||
def post(self, url, json, **kwargs):
|
||||
posts.append((url, json))
|
||||
return FakeResponse('{"success":1,"data":[{"Price":0.01,"Money":1,"Number":1}]}')
|
||||
return FakeResponse('{"success":1,"data":' + json_module.dumps([
|
||||
{'Price': .01, 'Money': 10, 'Number': 1} for _ in range(10)
|
||||
]) + '}')
|
||||
|
||||
result = asyncio.run(gold.fetch_region(Session(), 'kua8'))
|
||||
json_module = json
|
||||
started_at = 1793766590 # 固定开始时间,与响应读取完成的当前时间不同。
|
||||
result = asyncio.run(gold.fetch_region(Session(), 'kua8', started_at=started_at))
|
||||
assert result['region'] == '跨8'
|
||||
assert result['code'] == 'k8'
|
||||
assert isinstance(result['updataTime'], int)
|
||||
assert result['goldPrice'] == 100
|
||||
assert result['sampleCount'] == 1
|
||||
assert result['goldPrice'] == .01
|
||||
assert result['sampleCount'] == 7
|
||||
assert result['updataTime'] == refresh_slot(started_at)
|
||||
assert result['fetchedAt'] == started_at
|
||||
assert gets == [gold.BASE_URL + '/bijiaqi/dnf/youxibi/kua8',
|
||||
gold.BASE_URL + '/cate/1838/CoinSale_yxb_254_0.js?ver=935']
|
||||
assert posts[0][1]['secret'] == hashlib.md5(b'fresh-seed_17_config-sign').hexdigest()
|
||||
@@ -148,9 +161,25 @@ def test_cache_omits_missing_regions_marks_old_values_and_retains_order():
|
||||
assert [row['stale'] for row in rows] == [True, False]
|
||||
|
||||
|
||||
def test_realtime_prefetches_at_25_and_remains_fresh_at_30(monkeypatch):
|
||||
base = 1793764800 // 1800 * 1800
|
||||
clock = {'now': base + 25 * 60}
|
||||
monkeypatch.setattr(gold.time, 'time', lambda: clock['now'])
|
||||
redis = FakeRedis()
|
||||
old = cached_item(age=25 * 60)
|
||||
redis.values[f'{gold.CACHE_PREFIX}:kua1'] = json.dumps(old)
|
||||
assert asyncio.run(gold.read_cache(redis))[0]['stale'] is True
|
||||
redis.values[f'{gold.CACHE_PREFIX}:kua1'] = json.dumps(cached_item())
|
||||
prefetched = asyncio.run(gold.read_cache(redis))[0]
|
||||
assert prefetched['updataTime'] == base + 30 * 60
|
||||
assert prefetched['stale'] is False
|
||||
clock['now'] = base + 30 * 60
|
||||
assert asyncio.run(gold.read_cache(redis))[0]['stale'] is False
|
||||
|
||||
|
||||
def test_invalid_cached_average_is_not_served():
|
||||
redis = FakeRedis()
|
||||
redis.values[f'{gold.CACHE_PREFIX}:kua1'] = json.dumps({**cached_item(), 'goldPrice': 0})
|
||||
redis.values[f'{gold.CACHE_PREFIX}:kua1'] = json.dumps({**cached_item(), 'goldPrice': -1})
|
||||
redis.values[f'{gold.CACHE_PREFIX}:kua8'] = json.dumps({**cached_item('跨8'), 'sampleCount': 0})
|
||||
assert asyncio.run(gold.read_cache(redis)) == []
|
||||
|
||||
@@ -164,7 +193,7 @@ def test_legacy_cache_gets_epoch_and_region_code_without_refetching():
|
||||
rows = asyncio.run(gold.read_cache(redis))
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['code'] == 'k3a'
|
||||
assert rows[0]['updataTime'] == int(legacy['fetchedAt'])
|
||||
assert rows[0]['updataTime'] == refresh_slot(legacy['fetchedAt'])
|
||||
assert not redis.writes
|
||||
|
||||
|
||||
@@ -203,7 +232,7 @@ def test_refresh_preserves_failed_region_and_releases_its_lock(monkeypatch, limi
|
||||
monkeypatch.setattr(gold, 'REGION_INTERVAL', 0)
|
||||
calls = []
|
||||
|
||||
async def fetch(session, region):
|
||||
async def fetch(session, region, *, started_at=None):
|
||||
calls.append(region)
|
||||
if region == 'kua1':
|
||||
raise gold.UpstreamLimited() if limited else ValueError('channel failed')
|
||||
@@ -357,7 +386,7 @@ def test_shutdown_cancels_refresh_and_releases_redis_lock(monkeypatch):
|
||||
async def run():
|
||||
started = asyncio.Event()
|
||||
|
||||
async def fetch(session, region):
|
||||
async def fetch(session, region, *, started_at=None):
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
@@ -382,7 +411,7 @@ def test_retry_only_fetches_missing_regions_instead_of_repeating_fresh_pages(mon
|
||||
monkeypatch.setattr(gold, 'create_redis', lambda: redis)
|
||||
calls = []
|
||||
|
||||
async def fetch(session, region):
|
||||
async def fetch(session, region, *, started_at=None):
|
||||
calls.append(region)
|
||||
return cached_item(gold.REGIONS[region])
|
||||
|
||||
@@ -392,6 +421,31 @@ def test_retry_only_fetches_missing_regions_instead_of_repeating_fresh_pages(mon
|
||||
assert len(asyncio.run(gold.read_cache(redis))) == 9
|
||||
|
||||
|
||||
def test_realtime_regions_share_batch_start_even_when_fetch_crosses_slot_boundary(monkeypatch):
|
||||
clock = {'now': 1793766590.0}
|
||||
started_at = clock['now']
|
||||
redis = FakeRedis()
|
||||
redis.values[gold.LOCK_KEY] = 'owner'
|
||||
monkeypatch.setattr(gold, 'create_redis', lambda: redis)
|
||||
monkeypatch.setattr(gold, 'REGIONS', {'kua1': '跨1', 'kua8': '跨8'})
|
||||
monkeypatch.setattr(gold, 'REGION_INTERVAL', 0)
|
||||
monkeypatch.setattr(gold.time, 'time', lambda: clock['now'])
|
||||
starts = []
|
||||
|
||||
async def fetch(session, region, *, started_at):
|
||||
starts.append(started_at)
|
||||
clock['now'] += 1800
|
||||
return {**cached_item(gold.REGIONS[region]), 'fetchedAt': started_at,
|
||||
'updataTime': refresh_slot(started_at)}
|
||||
|
||||
monkeypatch.setattr(gold, 'fetch_region', fetch)
|
||||
asyncio.run(gold.refresh_cache('owner'))
|
||||
assert starts == [started_at, started_at]
|
||||
timestamps = [json.loads(redis.values[f'{gold.CACHE_PREFIX}:{region}'])['updataTime']
|
||||
for region in gold.REGIONS]
|
||||
assert timestamps == [refresh_slot(started_at)] * 2
|
||||
|
||||
|
||||
def test_unresponsive_redis_returns_prompt_error_instead_of_hanging(monkeypatch):
|
||||
async def run():
|
||||
connections = []
|
||||
|
||||
Reference in New Issue
Block a user