Skip to content

Commit 051cbd9

Browse files
committed
feat: add PublisherOfflineCheck tests
1 parent 381f199 commit 051cbd9

File tree

1 file changed

+220
-0
lines changed

1 file changed

+220
-0
lines changed

tests/test_checks_publisher.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import random
22
import time
3+
from datetime import datetime
34
from unittest.mock import patch
5+
from zoneinfo import ZoneInfo
46

57
import pytest
68
from pythclient.market_schedule import MarketSchedule
@@ -10,6 +12,7 @@
1012
from pyth_observer.check.publisher import (
1113
PUBLISHER_CACHE,
1214
PriceUpdate,
15+
PublisherOfflineCheck,
1316
PublisherPriceCheck,
1417
PublisherStalledCheck,
1518
PublisherState,
@@ -195,3 +198,220 @@ def test_redemption_rate_passes_check(self):
195198

196199
# Should pass even after long period without changes
197200
self.run_check(check, 3600, True) # 1 hour
201+
202+
203+
class TestPublisherOfflineCheck:
204+
"""Test suite for PublisherOfflineCheck covering various scenarios."""
205+
206+
def make_state(
207+
self,
208+
publisher_slot: int,
209+
latest_block_slot: int,
210+
schedule: MarketSchedule | None = None,
211+
publisher_name: str = "test_publisher",
212+
symbol: str = "Crypto.BTC/USD",
213+
) -> PublisherState:
214+
"""Helper to create PublisherState for testing."""
215+
if schedule is None:
216+
schedule = MarketSchedule("America/New_York;O,O,O,O,O,O,O;")
217+
return PublisherState(
218+
publisher_name=publisher_name,
219+
symbol=symbol,
220+
asset_type="Crypto",
221+
schedule=schedule,
222+
public_key=SolanaPublicKey("2hgu6Umyokvo8FfSDdMa9nDKhcdv9Q4VvGNhRCeSWeD3"),
223+
status=PythPriceStatus.TRADING,
224+
aggregate_status=PythPriceStatus.TRADING,
225+
slot=publisher_slot,
226+
aggregate_slot=latest_block_slot - 5,
227+
latest_block_slot=latest_block_slot,
228+
price=100.0,
229+
price_aggregate=100.0,
230+
confidence_interval=1.0,
231+
confidence_interval_aggregate=1.0,
232+
)
233+
234+
def make_check(
235+
self,
236+
state: PublisherState,
237+
max_slot_distance: int = 10,
238+
abandoned_slot_distance: int = 100,
239+
) -> PublisherOfflineCheck:
240+
"""Helper to create PublisherOfflineCheck with config."""
241+
return PublisherOfflineCheck(
242+
state,
243+
{
244+
"max_slot_distance": max_slot_distance,
245+
"abandoned_slot_distance": abandoned_slot_distance,
246+
},
247+
)
248+
249+
def run_check_with_datetime(
250+
self,
251+
check: PublisherOfflineCheck,
252+
check_datetime: datetime,
253+
expected: bool | None = None,
254+
) -> bool:
255+
"""Run check with mocked datetime and optionally assert result."""
256+
with patch("pyth_observer.check.publisher.datetime") as mock_datetime:
257+
mock_datetime.now.return_value = check_datetime
258+
result = check.run()
259+
if expected is not None:
260+
assert result is expected
261+
return result
262+
263+
def test_market_closed_passes_check(self):
264+
"""Test that check passes when market is closed."""
265+
# Market schedule that's always closed (C = closed)
266+
closed_schedule = MarketSchedule("America/New_York;C,C,C,C,C,C,C;")
267+
state = self.make_state(
268+
publisher_slot=100,
269+
latest_block_slot=200,
270+
schedule=closed_schedule,
271+
)
272+
check = self.make_check(state, max_slot_distance=10, abandoned_slot_distance=50)
273+
274+
# Should pass regardless of slot distance when market is closed
275+
assert check.run() is True
276+
277+
def test_market_open_within_max_distance_passes(self):
278+
"""Test that check passes when slot distance is within max_slot_distance."""
279+
state = self.make_state(publisher_slot=100, latest_block_slot=105)
280+
check = self.make_check(
281+
state, max_slot_distance=10, abandoned_slot_distance=100
282+
)
283+
284+
assert check.run() is True
285+
286+
def test_market_open_exceeds_max_distance_fails(self):
287+
"""Test that check fails when slot distance exceeds max_slot_distance but not abandoned."""
288+
state = self.make_state(publisher_slot=100, latest_block_slot=120)
289+
check = self.make_check(
290+
state, max_slot_distance=10, abandoned_slot_distance=100
291+
)
292+
293+
assert check.run() is False
294+
295+
def test_market_open_exceeds_abandoned_distance_passes(self):
296+
"""Test that check passes when slot distance exceeds abandoned_slot_distance."""
297+
state = self.make_state(publisher_slot=100, latest_block_slot=250)
298+
check = self.make_check(
299+
state, max_slot_distance=10, abandoned_slot_distance=100
300+
)
301+
302+
assert check.run() is True
303+
304+
def test_boundary_at_max_slot_distance(self):
305+
"""Test boundary condition at max_slot_distance."""
306+
state = self.make_state(publisher_slot=100, latest_block_slot=110)
307+
check = self.make_check(
308+
state, max_slot_distance=10, abandoned_slot_distance=100
309+
)
310+
311+
assert check.run() is False
312+
313+
def test_boundary_below_max_slot_distance(self):
314+
"""Test boundary condition just below max_slot_distance."""
315+
state = self.make_state(publisher_slot=100, latest_block_slot=109)
316+
check = self.make_check(
317+
state, max_slot_distance=10, abandoned_slot_distance=100
318+
)
319+
320+
assert check.run() is True
321+
322+
def test_boundary_at_abandoned_slot_distance(self):
323+
"""Test boundary condition at abandoned_slot_distance."""
324+
state = self.make_state(publisher_slot=100, latest_block_slot=200)
325+
check = self.make_check(
326+
state, max_slot_distance=10, abandoned_slot_distance=100
327+
)
328+
329+
# Distance is exactly 100, which is not > 100, so should fail
330+
assert check.run() is False
331+
332+
def test_boundary_above_abandoned_slot_distance(self):
333+
"""Test boundary condition just above abandoned_slot_distance."""
334+
state = self.make_state(publisher_slot=100, latest_block_slot=201)
335+
check = self.make_check(
336+
state, max_slot_distance=10, abandoned_slot_distance=100
337+
)
338+
339+
# Distance is 101, which is > 100, so should pass (abandoned)
340+
assert check.run() is True
341+
342+
def test_different_configurations(self):
343+
"""Test with different configuration values."""
344+
state = self.make_state(publisher_slot=100, latest_block_slot=150)
345+
346+
# Test with larger max_slot_distance - distance is 50, which is < 60, so should pass
347+
check1 = self.make_check(
348+
state, max_slot_distance=60, abandoned_slot_distance=200
349+
)
350+
assert check1.run() is True
351+
352+
# Test with smaller abandoned_slot_distance - distance is 50, which is > 40, so should pass (abandoned)
353+
check2 = self.make_check(
354+
state, max_slot_distance=10, abandoned_slot_distance=40
355+
)
356+
assert check2.run() is True
357+
358+
def test_zero_distance_passes(self):
359+
"""Test that zero slot distance passes the check."""
360+
state = self.make_state(publisher_slot=100, latest_block_slot=100)
361+
check = self.make_check(
362+
state, max_slot_distance=10, abandoned_slot_distance=100
363+
)
364+
365+
assert check.run() is True
366+
367+
def test_market_schedule_variations(self):
368+
"""Test with different market schedule patterns."""
369+
# Test with weekday-only schedule (Mon-Fri open)
370+
weekday_schedule = MarketSchedule("America/New_York;O,O,O,O,O,C,C;")
371+
state = self.make_state(
372+
publisher_slot=100,
373+
latest_block_slot=120,
374+
schedule=weekday_schedule,
375+
)
376+
check = self.make_check(
377+
state, max_slot_distance=10, abandoned_slot_distance=100
378+
)
379+
380+
# Test on a Monday (market open) - should fail because market is open and distance exceeds max
381+
monday_open = datetime(
382+
2024, 1, 15, 14, 0, 0, tzinfo=ZoneInfo("America/New_York")
383+
)
384+
self.run_check_with_datetime(check, monday_open, expected=False)
385+
386+
# Test on a Sunday (market closed) - should pass because market is closed
387+
sunday_closed = datetime(
388+
2024, 1, 14, 14, 0, 0, tzinfo=ZoneInfo("America/New_York")
389+
)
390+
self.run_check_with_datetime(check, sunday_closed, expected=True)
391+
392+
def test_market_opening_detects_offline_publisher(self):
393+
"""Test that when market opens, an offline publisher triggers the check."""
394+
# Use a weekday-only schedule (Mon-Fri open, weekends closed)
395+
weekday_schedule = MarketSchedule("America/New_York;O,O,O,O,O,C,C;")
396+
# Create a state where publisher is offline (slot distance exceeds max)
397+
state = self.make_state(
398+
publisher_slot=100,
399+
latest_block_slot=120,
400+
schedule=weekday_schedule,
401+
)
402+
check = self.make_check(
403+
state, max_slot_distance=10, abandoned_slot_distance=100
404+
)
405+
406+
# First, verify market closed - should pass even with offline publisher
407+
market_closed_time = datetime(
408+
2024, 1, 14, 23, 59, 59, tzinfo=ZoneInfo("America/New_York")
409+
) # Sunday night (market closed)
410+
self.run_check_with_datetime(check, market_closed_time, expected=True)
411+
412+
# Now market opens - check should fire because publisher is offline
413+
# Distance is 20, which exceeds max_slot_distance of 10
414+
market_open_time = datetime(
415+
2024, 1, 15, 0, 0, 0, tzinfo=ZoneInfo("America/New_York")
416+
) # Monday morning (market open)
417+
self.run_check_with_datetime(check, market_open_time, expected=False)

0 commit comments

Comments
 (0)