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

1"""Mock TAB API client for testing without API access. 

2 

3Provides realistic sample data for development and testing when TAB API 

4access is not available or for faster testing. 

5""" 

6 

7import asyncio 

8import hashlib 

9from datetime import datetime, timedelta 

10from typing import Any 

11 

12from packages.core.common.logging import get_logger 

13from packages.core.common.settings import get_settings 

14 

15logger = get_logger(__name__) 

16 

17 

18class MockTABClient: 

19 """Mock TAB API client that returns sample data.""" 

20 

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") 

25 

26 async def __aenter__(self): 

27 """Async context manager entry.""" 

28 return self 

29 

30 async def __aexit__(self, exc_type, exc_val, exc_tb): 

31 """Async context manager exit.""" 

32 await self.close() 

33 

34 async def close(self): 

35 """Close client (no-op for mock).""" 

36 pass 

37 

38 def _generate_deterministic_id(self, seed: str) -> str: 

39 """Generate a deterministic ID from a seed string. 

40 

41 This ensures the same input always produces the same ID, 

42 making tests reproducible. 

43 

44 Args: 

45 seed: String to hash 

46 

47 Returns: 

48 8-character hex string ID 

49 """ 

50 return hashlib.md5(seed.encode()).hexdigest()[:8] 

51 

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. 

60 

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 

66 

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 

72 

73 logger.info( 

74 f"[MOCK] Fetching {category} meetings from {date_from} to {date_to}" 

75 ) 

76 

77 # Simulate API delay 

78 await asyncio.sleep(0.1) 

79 

80 # Parse dates 

81 start_date = datetime.strptime(date_from, "%Y-%m-%d") 

82 end_date = datetime.strptime(date_to, "%Y-%m-%d") 

83 

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 } 

90 

91 # Generate one meeting per day in range 

92 meetings = [] 

93 current_date = start_date 

94 

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) 

99 

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) 

104 

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 ) 

119 

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 ) 

138 

139 current_date += timedelta(days=1) 

140 

141 logger.info(f"[MOCK] Returning {len(meetings)} mock meetings") 

142 return meetings 

143 

144 async def get_meeting(self, meeting_id: str) -> dict[str, Any]: 

145 """Return mock meeting details with races. 

146 

147 Args: 

148 meeting_id: Meeting ID 

149 

150 Returns: 

151 Mock meeting dictionary with races array 

152 """ 

153 logger.info(f"[MOCK] Fetching meeting {meeting_id}") 

154 

155 # Simulate API delay 

156 await asyncio.sleep(0.05) 

157 

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 ) 

172 

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 } 

185 

186 async def get_event(self, event_id: str) -> dict[str, Any]: 

187 """Return mock event details with runners and results. 

188 

189 Args: 

190 event_id: Event ID 

191 

192 Returns: 

193 Mock event dictionary with race, runners, and results 

194 """ 

195 logger.info(f"[MOCK] Fetching event {event_id}") 

196 

197 # Simulate API delay 

198 await asyncio.sleep(0.05) 

199 

200 # Deterministically generate race number from event_id 

201 race_number = (int(event_id[:2], 16) % 8) + 1 

202 

203 # Generate 8-12 runners with realistic data 

204 num_runners = 8 + (int(event_id[2:4], 16) % 5) 

205 runners = [] 

206 results = [] 

207 

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 ] 

233 

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 ) 

240 

241 # Check if scratched (deterministic) 

242 is_scratched = i == num_runners and num_runners > 10 

243 

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) 

247 

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) 

290 

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 ) 

303 

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" 

307 

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 } 

341 

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 } 

354 

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. 

364 

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 

371 

372 Returns: 

373 Mock race list response 

374 """ 

375 logger.info(f"[MOCK] Fetching race list from {date_from} to {date_to}") 

376 

377 # Get meetings first, then extract races 

378 meetings = await self.get_meetings(date_from, date_to, meet_types, countries) 

379 

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 ) 

391 

392 return { 

393 "races": races[:limit], 

394 "page_token": None, # No pagination in mock 

395 }