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
« 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#
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
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
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
49 msg = f"Known search returned {payload.found} results for {endpoint}."
50 LOG().debug(msg)
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 )
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
73 if IS_DEBUG:
74 LOG().debug(f"Retrieved {obj_type} with ID {obj_id} and type {obj_type}.")
76 processed.append(data)
77 except Exception as e:
78 LOG().warning(f"Error retrieving {obj_type} with ID {obj_id}: {e}")
79 continue
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 )
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)
101 return self._build_result(payload, results=processed, highlights=payload.hints())
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
112 if IS_DEBUG:
113 LOG().info(f"Found user {user.doc_id} for {obj_id}/{obj_type}")
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
125 if IS_DEBUG:
126 LOG().info(f"Found group {group.doc_id} for {obj_id}/{obj_type}")
128 return SearchDocumentModel(
129 obj_type=obj_type,
130 model=group.to_dict_with_id(),
131 )
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
139 if IS_DEBUG:
140 LOG().info(
141 f"Found dare {obj_type} (from_uid={dare.from_uid}) for {obj_id}",
142 )
144 return SearchDocumentModel(
145 obj_type=obj_type,
146 model=dare.to_dict_with_id(),
147 )
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
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 )