Coverage for packages / tab_client / mock_client.py: 92%
85 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-08 08:37 +1200
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-08 08:37 +1200
1"""Mock TAB API client for testing without API access.
3Provides realistic sample data for development and testing when TAB API
4access is not available or for faster testing.
5"""
7import asyncio
8import hashlib
9from datetime import datetime, timedelta
10from typing import Any
12from packages.core.common.logging import get_logger
13from packages.core.common.settings import get_settings
15logger = get_logger(__name__)
18class MockTABClient:
19 """Mock TAB API client that returns sample data."""
21 def __init__(self):
22 """Initialize mock client."""
23 self.settings = get_settings()
24 logger.info("Initialized MockTABClient - using sample data instead of real API")
26 async def __aenter__(self):
27 """Async context manager entry."""
28 return self
30 async def __aexit__(self, exc_type, exc_val, exc_tb):
31 """Async context manager exit."""
32 await self.close()
34 async def close(self):
35 """Close client (no-op for mock)."""
36 pass
38 def _generate_deterministic_id(self, seed: str) -> str:
39 """Generate a deterministic ID from a seed string.
41 This ensures the same input always produces the same ID,
42 making tests reproducible.
44 Args:
45 seed: String to hash
47 Returns:
48 8-character hex string ID
49 """
50 return hashlib.md5(seed.encode()).hexdigest()[:8]
52 async def get_meetings(
53 self,
54 date_from: str,
55 date_to: str,
56 category: str | None = None,
57 country: str | None = None,
58 ) -> list[dict[str, Any]]:
59 """Return mock meeting data matching TAB API structure.
61 Args:
62 date_from: Start date (YYYY-MM-DD)
63 date_to: End date (YYYY-MM-DD)
64 category: Racing category filter ("T", "H", "G")
65 country: Country filter
67 Returns:
68 List of mock meeting dictionaries
69 """
70 category = category or self.settings.tab.default_category
71 country = country or self.settings.tab.default_country
73 logger.info(
74 f"[MOCK] Fetching {category} meetings from {date_from} to {date_to}"
75 )
77 # Simulate API delay
78 await asyncio.sleep(0.1)
80 # Parse dates
81 start_date = datetime.strptime(date_from, "%Y-%m-%d")
82 end_date = datetime.strptime(date_to, "%Y-%m-%d")
84 # Venue names by category
85 venues = {
86 "H": ["Auckland", "Cambridge", "Addington", "Alexandra Park"],
87 "T": ["Ellerslie", "Trentham", "Riccarton", "Hastings"],
88 "G": ["Manukau", "Addington", "Wanganui", "Palmerston North"],
89 }
91 # Generate one meeting per day in range
92 meetings = []
93 current_date = start_date
95 while current_date <= end_date:
96 # Create 1-2 meetings per day
97 venue_list = venues.get(category, venues["H"])
98 num_meetings = 1 + (current_date.day % 2)
100 for venue_idx in range(num_meetings):
101 venue = venue_list[venue_idx % len(venue_list)]
102 meeting_seed = f"{current_date.isoformat()}-{venue}-{category}"
103 meeting_id = self._generate_deterministic_id(meeting_seed)
105 # Generate 8 races per meeting
106 races = []
107 for race_num in range(1, 9):
108 event_seed = f"{meeting_id}-R{race_num}"
109 event_id = self._generate_deterministic_id(event_seed)
110 races.append(
111 {
112 "id": event_id,
113 "race_number": race_num,
114 "name": f"Race {race_num}",
115 "distance": 2000 + (race_num - 1) * 200,
116 "country": country,
117 }
118 )
120 meetings.append(
121 {
122 "meeting": meeting_id,
123 "name": venue,
124 "date": f"{current_date.isoformat()}T00:00:00+13:00",
125 "meeting_date": current_date.strftime("%Y%m%d"),
126 "category": category,
127 "category_name": {
128 "H": "Harness",
129 "T": "Thoroughbreds",
130 "G": "Greyhounds",
131 }.get(category, "Harness"),
132 "country": country,
133 "state": "NZL",
134 "track_condition": "Good",
135 "races": races,
136 }
137 )
139 current_date += timedelta(days=1)
141 logger.info(f"[MOCK] Returning {len(meetings)} mock meetings")
142 return meetings
144 async def get_meeting(self, meeting_id: str) -> dict[str, Any]:
145 """Return mock meeting details with races.
147 Args:
148 meeting_id: Meeting ID
150 Returns:
151 Mock meeting dictionary with races array
152 """
153 logger.info(f"[MOCK] Fetching meeting {meeting_id}")
155 # Simulate API delay
156 await asyncio.sleep(0.05)
158 # Generate 8 races
159 races = []
160 for race_num in range(1, 9):
161 event_seed = f"{meeting_id}-R{race_num}"
162 event_id = self._generate_deterministic_id(event_seed)
163 races.append(
164 {
165 "id": event_id,
166 "race_number": race_num,
167 "name": f"Race {race_num}",
168 "distance": 2000 + (race_num - 1) * 200,
169 "country": "NZ",
170 }
171 )
173 return {
174 "meeting": meeting_id,
175 "name": "Auckland",
176 "date": "2024-12-26T00:00:00+13:00",
177 "meeting_date": "20241226",
178 "category": "H",
179 "category_name": "Harness",
180 "country": "NZ",
181 "state": "NZL",
182 "track_condition": "Good",
183 "races": races,
184 }
186 async def get_event(self, event_id: str) -> dict[str, Any]:
187 """Return mock event details with runners and results.
189 Args:
190 event_id: Event ID
192 Returns:
193 Mock event dictionary with race, runners, and results
194 """
195 logger.info(f"[MOCK] Fetching event {event_id}")
197 # Simulate API delay
198 await asyncio.sleep(0.05)
200 # Deterministically generate race number from event_id
201 race_number = (int(event_id[:2], 16) % 8) + 1
203 # Generate 8-12 runners with realistic data
204 num_runners = 8 + (int(event_id[2:4], 16) % 5)
205 runners = []
206 results = []
208 # Sample driver and trainer names for variety
209 driver_names = [
210 "John Smith",
211 "Mary Jones",
212 "David Brown",
213 "Sarah Wilson",
214 "Michael Taylor",
215 "Emma Davis",
216 "James Anderson",
217 "Lucy Martin",
218 "Robert White",
219 "Emily Jackson",
220 "William Harris",
221 "Sophie Clark",
222 ]
223 trainer_names = [
224 "Tom Mitchell",
225 "Jane Roberts",
226 "Peter Thompson",
227 "Lisa Walker",
228 "Mark Lewis",
229 "Amy Young",
230 "Chris Hall",
231 "Karen Allen",
232 ]
234 for i in range(1, num_runners + 1):
235 # Deterministic horse ID based on event and position
236 horse_seed = f"{event_id}-horse-{i}"
237 horse_id = (
238 int(hashlib.md5(horse_seed.encode()).hexdigest()[:8], 16) % 100000
239 )
241 # Check if scratched (deterministic)
242 is_scratched = i == num_runners and num_runners > 10
244 # Driver and trainer names (deterministic selection)
245 driver_idx = (int(event_id[:2], 16) + i) % len(driver_names)
246 trainer_idx = (int(event_id[2:4], 16) + i) % len(trainer_names)
248 runner = {
249 "entrant_id": f"entrant-{event_id}-{i}",
250 "horse_id": horse_id,
251 "name": f"Mock Horse {i}",
252 "runner_number": i,
253 "barrier": i,
254 "barrier_position": f"{i}{'F' if i <= 8 else 'B'}", # Harness-specific
255 "handicap": (i - 1) * 10,
256 "jockey": driver_names[
257 driver_idx
258 ], # TAB uses "jockey" even for harness
259 "trainer_name": trainer_names[trainer_idx],
260 "trainer_location": "Auckland",
261 "is_scratched": is_scratched,
262 "is_late_scratched": False,
263 "favourite": i == 1,
264 "mover": False,
265 "age": 4 + (i % 4),
266 "sex": "G" if i % 2 == 0 else "M",
267 "colour": "Brown",
268 "sire": f"Sire {i}",
269 "dam": f"Dam {i}",
270 "breeding": f"Sire {i} x Dam {i}",
271 "last_twenty_starts": "12345678x0",
272 "prize_money": str(10000 + i * 1000),
273 "gear": "",
274 "silk_colours": "Blue and White",
275 "silk_url_64x64": "",
276 "silk_url_128x128": "",
277 "class_level": "C1",
278 "apprentice_indicator": "",
279 "allowance_weight": "",
280 "owners": f"Owner {i}",
281 "country": "NZ",
282 "weight": {"allocated": 0, "carried": 0},
283 "form_indicators": [],
284 "market_name": "Win",
285 "primary_market": True,
286 "scratch_time": 0,
287 "odds": {"win": 3.0 + i * 0.5, "place": 1.5 + i * 0.2},
288 }
289 runners.append(runner)
291 # Generate results for non-scratched runners
292 if not is_scratched and i <= 8:
293 results.append(
294 {
295 "entrant_id": f"entrant-{event_id}-{i}",
296 "position": i,
297 "name": f"Mock Horse {i}",
298 "barrier": i,
299 "runner_number": i,
300 "margin_length": 0 if i == 1 else (i - 1) * 1.5,
301 }
302 )
304 # Determine gait and start type based on race number
305 gait = "Trot" if race_number % 2 == 0 else "Pace"
306 start_type = "Mobile" if race_number % 3 != 0 else "Standing"
308 race_data = {
309 "event_id": event_id,
310 "meeting_id": f"meeting-{event_id[:4]}",
311 "meeting_name": "Auckland",
312 "display_meeting_name": "Auckland",
313 "race_number": race_number,
314 "description": f"Mock Race {race_number}",
315 "status": "Resulted",
316 "type": "Harness",
317 "country": "NZ",
318 "state": "NZL",
319 "distance": 2000 + (race_number - 1) * 200,
320 "start_type": start_type,
321 "gait": gait,
322 "advertised_start": 1703570400, # Unix timestamp
323 "advertised_start_string": f"2024-12-26T{13 + race_number}:00:00+13:00",
324 "actual_start": 1703570400,
325 "actual_start_string": f"2024-12-26T{13 + race_number}:00:00+13:00",
326 "weather": "Fine",
327 "track_condition": "Good",
328 "track_direction": "Right",
329 "track_home_straight": 250.0,
330 "rail_position": "True",
331 "entrant_count": num_runners,
332 "positions_paid": 3,
333 "form_guide": "",
334 "comment": "",
335 "silk_base_url": "",
336 "silk_url": "",
337 "group": "",
338 "tips": [],
339 "prize_monies": {"1": 10000, "2": 3000, "3": 1500},
340 }
342 return {
343 "race": race_data,
344 "runners": runners,
345 "results": results,
346 "dividends": [],
347 "derivatives": [],
348 "favourite": runners[0] if runners else None,
349 "mover": None,
350 "big_bets": [],
351 "live_bets": [],
352 "error": "",
353 }
355 async def get_races_list(
356 self,
357 date_from: str,
358 date_to: str,
359 meet_types: str | None = None,
360 countries: str | None = None,
361 limit: int = 100,
362 ) -> dict[str, Any]:
363 """Return mock race list.
365 Args:
366 date_from: Start date (YYYY-MM-DD)
367 date_to: End date (YYYY-MM-DD)
368 meet_types: Racing type filter
369 countries: Country filter
370 limit: Maximum results
372 Returns:
373 Mock race list response
374 """
375 logger.info(f"[MOCK] Fetching race list from {date_from} to {date_to}")
377 # Get meetings first, then extract races
378 meetings = await self.get_meetings(date_from, date_to, meet_types, countries)
380 races = []
381 for meeting in meetings:
382 for race in meeting.get("races", []):
383 races.append(
384 {
385 **race,
386 "meeting_id": meeting["meeting"],
387 "meeting_name": meeting["name"],
388 "category": meeting["category"],
389 }
390 )
392 return {
393 "races": races[:limit],
394 "page_token": None, # No pagination in mock
395 }