Coverage for functions \ flipdare \ search \ result \ search_response_builder.py: 83%

95 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 

14from flipdare.app_log import LOG 

15from flipdare.constants import IS_DEBUG 

16from flipdare.error.app_error import AppError, SearchError 

17from flipdare.firestore.dare_db import DareDb 

18from flipdare.firestore.group_db import GroupDb 

19from flipdare.firestore.user_db import UserDb 

20from flipdare.generated.model.search.result_hint_model import ResultHintModel 

21from flipdare.generated.model.search.search_document_model import SearchDocumentModel 

22from flipdare.generated.model.search.search_response_model import SearchResponseModel 

23from flipdare.generated.shared.app_error_code import AppErrorCode 

24from flipdare.generated.shared.search.search_obj_type import SearchObjType 

25from flipdare.message.error_message import ErrorMessage 

26from flipdare.search.result.typesense_payload import TypesensePayload 

27 

28 

29class SearchResponseBuilder: 

30 def __init__(self, user_db: UserDb, group_db: GroupDb, dare_db: DareDb) -> None: 

31 self.user_db = user_db 

32 self.group_db = group_db 

33 self.dare_db = dare_db 

34 

35 def process(self, endpoint: str, payload: TypesensePayload | None) -> SearchResponseModel: 

36 if payload is None: 

37 if IS_DEBUG: 

38 LOG().debug(f"Search result is None for {endpoint}") 

39 raise SearchError(endpoint, message=ErrorMessage.SEARCH_DOWN) 

40 if len(payload.hits) == 0: 

41 if IS_DEBUG: 

42 LOG().debug(f"Search result has no hits for {endpoint}") 

43 return self._build_result( 

44 payload, 

45 results=[], 

46 highlights=[], 

47 ) # empty result, not an error 

48 

49 msg = f"Known search returned {payload.found} results for {endpoint}." 

50 LOG().debug(msg) 

51 

52 general_docs = payload.general_docs() 

53 general_ct = len(general_docs) 

54 LOG().debug(f"Processing {general_ct} general search documents for {endpoint}.") 

55 if general_ct == 0: 

56 LOG().info(f"No valid documents found for {endpoint}.") 

57 raise SearchError( 

58 endpoint, 

59 message=ErrorMessage.BAD_SEARCH_DATA, 

60 error_code=AppErrorCode.BAD_SEARCH_DATA, 

61 ) 

62 

63 processed: list[SearchDocumentModel] = [] 

64 for document in general_docs: 

65 obj_type = document.obj_type 

66 obj_id = document.obj_id 

67 try: 

68 data = self._get_object(obj_type, obj_id) 

69 if data is None: 

70 LOG().warning(f"{obj_type} with ID {obj_id} not found in database.") 

71 continue 

72 

73 if IS_DEBUG: 

74 LOG().debug(f"Retrieved {obj_type} with ID {obj_id} and type {obj_type}.") 

75 

76 processed.append(data) 

77 except Exception as e: 

78 LOG().warning(f"Error retrieving {obj_type} with ID {obj_id}: {e}") 

79 continue 

80 

81 proc_ct = len(processed) 

82 if proc_ct == 0: 

83 # this is an error, since we had results but none could be processed 

84 msg = f"No valid documents processed for {endpoint} from {general_ct} search results." 

85 LOG().error(msg) 

86 raise SearchError( 

87 endpoint, 

88 message=ErrorMessage.BAD_SEARCH_DATA, 

89 error_code=AppErrorCode.BAD_SEARCH_DATA, 

90 ) 

91 

92 if general_ct != proc_ct: 

93 LOG().warning( 

94 f"Search result processing mismatch for {endpoint}: " 

95 f"{general_ct} documents vs {proc_ct} processed.", 

96 ) 

97 else: 

98 msg = f"Processed {proc_ct} documents for {endpoint} from {general_ct} search results." 

99 LOG().info(msg) 

100 

101 return self._build_result(payload, results=processed, highlights=payload.hints()) 

102 

103 def _get_object(self, obj_type: SearchObjType, obj_id: str) -> SearchDocumentModel | None: 

104 try: 

105 match obj_type: 

106 case SearchObjType.USER | SearchObjType.FRIEND: 

107 user = self.user_db.get(obj_id) 

108 if user is None: 

109 LOG().warning(f"Failed to retrieve user with id={obj_id}") 

110 return None 

111 

112 if IS_DEBUG: 

113 LOG().info(f"Found user {user.doc_id} for {obj_id}/{obj_type}") 

114 

115 return SearchDocumentModel( 

116 obj_type=obj_type, 

117 model=user.to_dict_with_id(), 

118 ) 

119 case SearchObjType.GROUP: 

120 group = self.group_db.get(obj_id) 

121 if group is None: 

122 LOG().warning(f"Failed to retrieve group with id={obj_id}") 

123 return None 

124 

125 if IS_DEBUG: 

126 LOG().info(f"Found group {group.doc_id} for {obj_id}/{obj_type}") 

127 

128 return SearchDocumentModel( 

129 obj_type=obj_type, 

130 model=group.to_dict_with_id(), 

131 ) 

132 

133 case SearchObjType.DARE | SearchObjType.GROUP_DARE: 

134 dare = self.dare_db.get(obj_id) 

135 if dare is None: 

136 LOG().warning(f"Failed to retrieve dare with id={obj_id}") 

137 return None 

138 

139 if IS_DEBUG: 

140 LOG().info( 

141 f"Found dare {obj_type} (from_uid={dare.from_uid}) for {obj_id}", 

142 ) 

143 

144 return SearchDocumentModel( 

145 obj_type=obj_type, 

146 model=dare.to_dict_with_id(), 

147 ) 

148 

149 except AppError as e: 

150 LOG().warning(f"AppError retrieving {obj_type} with ID {obj_id}: {e}") 

151 return None 

152 except Exception as e: 

153 LOG().warning(f"Unexpected error retrieving {obj_type} with ID {obj_id}: {e}") 

154 return None 

155 

156 def _build_result( 

157 self, 

158 payload: TypesensePayload, 

159 results: list[SearchDocumentModel] | None = None, 

160 highlights: list[ResultHintModel] | None = None, 

161 ) -> SearchResponseModel: 

162 return SearchResponseModel( 

163 q=payload.query, 

164 collection_name=payload.collection_name, 

165 found=payload.found, 

166 page=payload.page, 

167 out_of=payload.out_of, 

168 results=results or [], 

169 highlights=highlights or [], 

170 )