Coverage for functions \ flipdare \ service \ processor \ friend_processor.py: 0%

162 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-05-08 12:22 +1000

1#!/usr/bin/env python 

2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved. 

3# 

4# This file is part of Flipdare's proprietary software and contains 

5# confidential and copyrighted material. Unauthorised copying, 

6# modification, distribution, or use of this file is strictly 

7# prohibited without prior written permission from Flipdare Pty Ltd. 

8# 

9# This software includes third-party components licensed under MIT, 

10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details. 

11# 

12 

13""" 

14FriendProcessor - Handles all friend-related processing logic. 

15Extracted from FriendAdmin for better testability and maintainability. 

16""" 

17 

18from typing import Any 

19 

20from flipdare.backend.indexer_service import IndexerService 

21from flipdare.service.notification_service import NotificationService 

22from flipdare.service.user_summary_service import UserSummaryService 

23from flipdare.app_log import LOG 

24from flipdare.app_types import DatabaseDict, FriendBridge 

25from flipdare.constants import NO_DOC_ID 

26from flipdare.result.app_result import AppResult 

27from flipdare.result.job_result import JobResult 

28from flipdare.service.core.step_processor import ProcessingStep, StepProcessor 

29from flipdare.firestore.context.friend_context import FriendContext, FriendContextFactory 

30from flipdare.generated import AppErrorCode, RequestStatus 

31from flipdare.generated.model.friend_model import FriendKeys 

32from flipdare.wrapper import ( 

33 FriendWrapper, 

34 NotificationWrapper, 

35 PersistedGuard, 

36) 

37from flipdare.wrapper.backend.user_summary_entry_wrapper import UserSummaryEntryWrapper 

38 

39_K = FriendKeys 

40 

41 

42class FriendProcessor: 

43 """Handles friend workflow processing with automatic state management.""" 

44 

45 def __init__( 

46 self, 

47 friend_bridge: FriendBridge, 

48 notification_service: NotificationService, 

49 indexer_service: IndexerService, 

50 summary_service: UserSummaryService, 

51 ) -> None: 

52 self.friend_bridge = friend_bridge 

53 self.notification_service = notification_service 

54 self.indexer_service = indexer_service 

55 self.summary_service = summary_service 

56 

57 def process_new_friend(self, friend: FriendWrapper) -> JobResult[FriendWrapper]: 

58 """ 

59 Process new friend request (Step 1: Initial). 

60 

61 Steps: 

62 1a. Verify friend record exists 

63 1b. Send email to requested friend 

64 1c. Send notification to requested friend 

65 """ 

66 friend_id = friend.doc_id 

67 

68 processor = self._build_processor( 

69 friend_id=friend_id, 

70 friend_model=friend, 

71 is_update=False, 

72 ) 

73 result = processor.execute() 

74 

75 if result.is_error: 

76 return JobResult.from_result( 

77 result, 

78 doc_id=friend_id, 

79 data=friend.to_dict_with_id(), 

80 ) 

81 return JobResult.ok(doc_id=friend_id) 

82 

83 def process_friend_update( 

84 self, friend_obj: FriendWrapper | DatabaseDict 

85 ) -> JobResult[FriendWrapper]: 

86 """ 

87 Process friend update (Step 2: Friend accepts/rejects/blocks). 

88 

89 Steps for ACCEPTED status: 

90 2a. Get context 

91 2b. Create reciprocal FriendModel 

92 2c. Send email to owner 

93 2d. Send notification to owner 

94 2e. Update search index 

95 """ 

96 main_result = AppResult[FriendWrapper]() 

97 

98 # Parse input 

99 friend_model: FriendWrapper | None = None 

100 

101 if PersistedGuard.is_friend(friend_obj): 

102 friend_model = friend_obj 

103 else: 

104 assert isinstance(friend_obj, dict) # narrowing 

105 friend_data: DatabaseDict = friend_obj 

106 try: 

107 friend_model = FriendWrapper.from_dict(friend_data) 

108 except Exception as e: 

109 msg = f"Exception creating FriendModel: {e}" 

110 main_result.add_error(AppErrorCode.INVALID_DATA, msg) 

111 return JobResult.from_result(main_result, data=friend_data, message=msg) 

112 

113 friend_id = friend_model.doc_id 

114 status = friend_model.status 

115 main_result.doc_id = friend_id 

116 

117 # Check if update is needed 

118 if status != RequestStatus.ACCEPTED and not status.remove_from_search: 

119 msg = f"Friend update not required for status={status}" 

120 return JobResult.skip_doc(doc_id=friend_id, message=msg) 

121 

122 processor = self._build_processor( 

123 friend_id=friend_id, 

124 friend_model=friend_model, 

125 is_update=True, 

126 ) 

127 result = processor.execute() 

128 

129 if result.is_error: 

130 return JobResult.from_result( 

131 result, 

132 doc_id=friend_id, 

133 data=friend_model.to_dict_with_id(), 

134 ) 

135 return JobResult.ok(doc_id=friend_id) 

136 

137 def _build_processor( 

138 self, 

139 friend_id: str, 

140 friend_model: FriendWrapper, 

141 is_update: bool, 

142 ) -> StepProcessor[FriendWrapper]: 

143 """Helper to build StepProcessor for friend workflows.""" 

144 status = friend_model.status 

145 

146 shared_data: dict[str, Any] = {} 

147 

148 def get_context_step(model: FriendWrapper) -> AppResult[FriendContext]: 

149 result = self._get_friend_context(model) 

150 if result.is_ok and result.generated: 

151 shared_data["context"] = result.generated 

152 shared_data["doc_id"] = result.generated.doc_id 

153 return result 

154 

155 def update_search_step(model: FriendWrapper) -> AppResult[FriendWrapper]: 

156 context = shared_data.get("context") 

157 if not context: 

158 error_result = AppResult[FriendWrapper]( 

159 doc_id=shared_data.get("doc_id", NO_DOC_ID), 

160 ) 

161 error_result.add_error( 

162 AppErrorCode.CONTEXT, 

163 "No context available for search update", 

164 ) 

165 return error_result 

166 

167 return self._update_search_for_friend(model, context) 

168 

169 steps = [] 

170 if not is_update: 

171 steps = [ 

172 ProcessingStep[_K, FriendWrapper]( 

173 state_key=_K.CREATED, 

174 handler=lambda m: self._verify_friend(m), 

175 description="Verify friend record exists", 

176 required=True, 

177 ), 

178 ProcessingStep[_K, FriendWrapper]( 

179 state_key=_K.EMAIL_SENT, 

180 handler=lambda m: self._create_summary_entry(m), 

181 description="Create email summary entry", 

182 required=False, # Optional 

183 ), 

184 ProcessingStep[_K, FriendWrapper]( 

185 state_key=_K.NOTIFICATION_SENT, 

186 handler=lambda m: self._send_notifications(m, is_update=False), 

187 description="Send friend request notification", 

188 required=True, 

189 ), 

190 ] 

191 else: 

192 steps = [ 

193 ProcessingStep[_K, FriendWrapper]( 

194 state_key=_K.CONTEXT_CREATED, 

195 handler=get_context_step, 

196 description="Get friend context", 

197 required=True, 

198 ), 

199 ] 

200 

201 if status == RequestStatus.ACCEPTED: 

202 steps.extend( 

203 [ 

204 ProcessingStep[_K, FriendWrapper]( 

205 state_key=_K.EMAIL_SENT, 

206 handler=lambda m: self._create_summary_entry(m), 

207 description="Send acceptance email", 

208 required=False, 

209 ), 

210 ProcessingStep[_K, FriendWrapper]( 

211 state_key=_K.NOTIFICATION_SENT, 

212 handler=lambda m: self._send_notifications(m, is_update=True), 

213 description="Send notification", 

214 required=True, 

215 ), 

216 ], 

217 ) 

218 

219 steps.append( 

220 ProcessingStep[_K, FriendWrapper]( 

221 state_key=_K.SEARCH_INDEXED, 

222 handler=update_search_step, 

223 description="Update search index", 

224 required=False, 

225 ), 

226 ) 

227 

228 label = "new" if not is_update else "update" 

229 return StepProcessor( 

230 wrapper=friend_model, 

231 steps=steps, 

232 save_handler=lambda m: self.friend_bridge.update(m), 

233 process_name=f"{label}_friend_{friend_id}", 

234 ) 

235 

236 # ======================================================================== 

237 # Step Handlers 

238 # ======================================================================== 

239 

240 def _verify_friend(self, friend_model: FriendWrapper) -> AppResult[FriendWrapper]: 

241 """Verify friend record exists.""" 

242 return self._get_friend(friend_model) 

243 

244 def _send_notifications( 

245 self, 

246 friend_model: FriendWrapper, 

247 is_update: bool, 

248 ) -> AppResult[NotificationWrapper]: 

249 """Send app notifications for friend action.""" 

250 friend_id = friend_model.doc_id 

251 main_result = AppResult[NotificationWrapper](doc_id=friend_id) 

252 

253 context_result = self._get_friend_context(friend_model) 

254 if context_result.is_error: 

255 main_result.merge(context_result) 

256 return main_result 

257 

258 context = context_result.generated 

259 assert context 

260 

261 try: 

262 self.notification_service.send_friend_notif(context, is_update=is_update) 

263 except Exception as e: 

264 msg = f"Exception sending notification for friend {friend_id}: {e}" 

265 main_result.add_error(AppErrorCode.NOTIFICATION, msg) 

266 

267 return main_result 

268 

269 def _create_summary_entry( 

270 self, 

271 friend_obj: FriendWrapper | str, 

272 ) -> AppResult[UserSummaryEntryWrapper]: 

273 """Create email summary entry for friend action.""" 

274 doc_id = friend_obj.doc_id if PersistedGuard.is_friend(friend_obj) else NO_DOC_ID 

275 main_result = AppResult[UserSummaryEntryWrapper](doc_id=doc_id) 

276 

277 friend_result = self._get_friend(friend_obj) 

278 if friend_result.is_error: 

279 main_result.merge(friend_result) 

280 return main_result 

281 

282 friend = friend_result.generated 

283 assert friend 

284 assert friend.doc_id 

285 

286 context_result = self._get_friend_context(friend) 

287 if context_result.is_error: 

288 main_result.merge(context_result) 

289 return main_result 

290 

291 context = context_result.generated 

292 assert context 

293 

294 try: 

295 summary_result = self.summary_service.create_friend_request(context) 

296 if summary_result.is_error: 

297 main_result.merge(summary_result) 

298 else: 

299 assert summary_result.generated 

300 main_result.generated = summary_result.generated 

301 except Exception as e: 

302 cause = f"Exception creating summary entry for friend {friend.doc_id}: {e}" 

303 main_result.add_error(AppErrorCode.DATABASE_EX, cause) 

304 

305 return main_result 

306 

307 def _update_search_for_friend( 

308 self, 

309 friend: FriendWrapper, 

310 context: FriendContext, 

311 ) -> AppResult[FriendWrapper]: 

312 """Update or remove friend from search index.""" 

313 doc_id = friend.doc_id 

314 status = friend.status 

315 

316 main_result = AppResult[FriendWrapper](doc_id=doc_id) 

317 

318 try: 

319 if status.remove_from_search: 

320 delete_result = self.indexer_service.delete_friend(friend) 

321 if delete_result.is_error: 

322 main_result.merge(delete_result) 

323 else: 

324 self.indexer_service.process_friend(context, updated=True) 

325 except Exception as e: 

326 msg = f"Exception updating search for friend {doc_id}: {e}" 

327 main_result.add_error(AppErrorCode.SEARCH, msg) 

328 

329 return main_result 

330 

331 # ======================================================================== 

332 # Helper Methods 

333 # ======================================================================== 

334 

335 def _get_friend(self, friend_obj: FriendWrapper | str) -> AppResult[FriendWrapper]: 

336 """Get FriendModel from object or ID.""" 

337 doc_id = friend_obj.doc_id if PersistedGuard.is_friend(friend_obj) else str(friend_obj) 

338 main_result: AppResult[FriendWrapper] = AppResult(doc_id=doc_id) 

339 

340 if PersistedGuard.is_friend(friend_obj): 

341 LOG().debug(f"Using provided FriendModel for friend {friend_obj.doc_id}") 

342 main_result.generated = friend_obj 

343 return main_result 

344 

345 assert isinstance(friend_obj, str) # narrowing 

346 LOG().debug(f"Fetching FriendModel for friend id {friend_obj}") 

347 friend_id = friend_obj 

348 get_result = self.friend_bridge.get(friend_id) 

349 if get_result.is_error: 

350 main_result.merge(get_result) 

351 

352 return main_result 

353 

354 def _get_friend_context(self, friend_model: FriendWrapper) -> AppResult[FriendContext]: 

355 """Create FriendContext from FriendModel.""" 

356 doc_id = friend_model.doc_id 

357 main_result = AppResult[FriendContext](doc_id=doc_id) 

358 

359 try: 

360 friend_context = FriendContextFactory().create(friend_model) 

361 if friend_context is not None: 

362 main_result.generated = friend_context 

363 else: 

364 cause = f"Failed to build FriendContext for {doc_id}" 

365 main_result.add_error( 

366 AppErrorCode.CONTEXT, 

367 cause, 

368 extra=friend_model.to_json_dict(), 

369 ) 

370 except Exception as e: 

371 cause = f"Exception creating FriendContext for {doc_id}: {e}" 

372 main_result.add_error( 

373 AppErrorCode.CONTEXT, 

374 cause, 

375 extra=friend_model.to_json_dict(), 

376 ) 

377 

378 return main_result