Coverage for app/backend/src/tests/test_search.py: 100%

459 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-19 15:47 +0000

1from datetime import timedelta 

2from typing import Any 

3 

4import grpc 

5import pytest 

6from google.protobuf import empty_pb2, wrappers_pb2 

7from psycopg.types.range import TimestamptzRange 

8from sqlalchemy import select 

9 

10from couchers.db import session_scope 

11from couchers.materialized_views import refresh_materialized_views, refresh_materialized_views_rapid 

12from couchers.models import EventOccurrence, HostingStatus, LanguageAbility, LanguageFluency, MeetupStatus 

13from couchers.proto import api_pb2, communities_pb2, events_pb2, search_pb2 

14from couchers.utils import Timestamp_from_datetime, create_coordinate, datetime_to_iso8601_local, now 

15from tests.fixtures.db import generate_user 

16from tests.fixtures.misc import Moderator 

17from tests.fixtures.sessions import communities_session, events_session, search_session 

18from tests.test_communities import create_community, testing_communities # noqa 

19from tests.test_references import create_friend_reference 

20 

21 

22class TestSearchInCommunities: 

23 """The tests that search the whole community tree, grouped so they share one copy of it.""" 

24 

25 @staticmethod 

26 def test_Search(testing_communities): 

27 user, token = generate_user() 

28 with search_session(token) as api: 

29 res = api.Search( 

30 search_pb2.SearchReq( 

31 query="Country 1, Region 1", 

32 include_users=True, 

33 include_communities=True, 

34 include_groups=True, 

35 include_places=True, 

36 include_guides=True, 

37 ) 

38 ) 

39 res = api.Search( 

40 search_pb2.SearchReq( 

41 query="Country 1, Region 1, Attraction", 

42 title_only=True, 

43 include_users=True, 

44 include_communities=True, 

45 include_groups=True, 

46 include_places=True, 

47 include_guides=True, 

48 ) 

49 ) 

50 

51 @staticmethod 

52 def test_UserSearch(testing_communities): 

53 """Test that UserSearch returns all users if no filter is set.""" 

54 user, token = generate_user() 

55 

56 refresh_materialized_views_rapid(empty_pb2.Empty()) 

57 refresh_materialized_views(empty_pb2.Empty()) 

58 

59 with search_session(token) as api: 

60 res = api.UserSearch(search_pb2.UserSearchReq()) 

61 assert len(res.results) > 0 

62 assert res.total_items == len(res.results) 

63 res = api.UserSearchV2(search_pb2.UserSearchReq()) 

64 assert len(res.results) > 0 

65 assert res.total_items == len(res.results) 

66 

67 @staticmethod 

68 def test_EventSearch_no_filters(testing_communities): 

69 """Test that EventSearch returns all events if no filter is set.""" 

70 user, token = generate_user() 

71 with search_session(token) as api: 

72 res = api.EventSearch(search_pb2.EventSearchReq()) 

73 assert len(res.events) > 0 

74 

75 

76def test_regression_search_in_area(db): 

77 """ 

78 Makes sure search_in_area works. 

79 

80 At the equator/prime meridian intersection (0,0), one degree is roughly 111 km. 

81 """ 

82 

83 # outside 

84 user1, token1 = generate_user(geom=create_coordinate(1, 0), geom_radius=100) 

85 # outside 

86 user2, token2 = generate_user(geom=create_coordinate(0, 1), geom_radius=100) 

87 # inside 

88 user3, token3 = generate_user(geom=create_coordinate(0.1, 0), geom_radius=100) 

89 # inside 

90 user4, token4 = generate_user(geom=create_coordinate(0, 0.1), geom_radius=100) 

91 # outside 

92 user5, token5 = generate_user(geom=create_coordinate(10, 10), geom_radius=100) 

93 

94 refresh_materialized_views_rapid(empty_pb2.Empty()) 

95 refresh_materialized_views(empty_pb2.Empty()) 

96 

97 with search_session(token5) as api: 

98 res = api.UserSearch( 

99 search_pb2.UserSearchReq( 

100 search_in_area=search_pb2.Area( 

101 lat=0, 

102 lng=0, 

103 radius=100000, 

104 ) 

105 ) 

106 ) 

107 assert [result.user.user_id for result in res.results] == [user3.id, user4.id] 

108 

109 res = api.UserSearchV2( 

110 search_pb2.UserSearchReq( 

111 search_in_area=search_pb2.Area( 

112 lat=0, 

113 lng=0, 

114 radius=100000, 

115 ) 

116 ) 

117 ) 

118 assert [result.user_id for result in res.results] == [user3.id, user4.id] 

119 

120 

121def test_user_search_in_rectangle(db): 

122 """ 

123 Makes sure search_in_rectangle works as expected. 

124 """ 

125 

126 # outside 

127 user1, token1 = generate_user(geom=create_coordinate(-1, 0), geom_radius=100) 

128 # outside 

129 user2, token2 = generate_user(geom=create_coordinate(0, -1), geom_radius=100) 

130 # inside 

131 user3, token3 = generate_user(geom=create_coordinate(0.1, 0.1), geom_radius=100) 

132 # inside 

133 user4, token4 = generate_user(geom=create_coordinate(1.2, 0.1), geom_radius=100) 

134 # outside (not fully inside) 

135 user5, token5 = generate_user(geom=create_coordinate(0, 0), geom_radius=100) 

136 # outside 

137 user6, token6 = generate_user(geom=create_coordinate(0.1, 1.2), geom_radius=100) 

138 # outside 

139 user7, token7 = generate_user(geom=create_coordinate(10, 10), geom_radius=100) 

140 

141 refresh_materialized_views_rapid(empty_pb2.Empty()) 

142 refresh_materialized_views(empty_pb2.Empty()) 

143 

144 with search_session(token5) as api: 

145 res = api.UserSearch( 

146 search_pb2.UserSearchReq( 

147 search_in_rectangle=search_pb2.RectArea( 

148 lat_min=0, 

149 lat_max=2, 

150 lng_min=0, 

151 lng_max=1, 

152 ) 

153 ) 

154 ) 

155 assert [result.user.user_id for result in res.results] == [user3.id, user4.id] 

156 

157 res = api.UserSearchV2( 

158 search_pb2.UserSearchReq( 

159 search_in_rectangle=search_pb2.RectArea( 

160 lat_min=0, 

161 lat_max=2, 

162 lng_min=0, 

163 lng_max=1, 

164 ) 

165 ) 

166 ) 

167 assert [result.user_id for result in res.results] == [user3.id, user4.id] 

168 

169 

170def test_user_filter_complete_profile(db): 

171 """ 

172 Make sure the completed profile flag returns only completed user profile 

173 """ 

174 user_complete_profile, token6 = generate_user(complete_profile=True) 

175 

176 user_incomplete_profile, token7 = generate_user(complete_profile=False) 

177 

178 refresh_materialized_views_rapid(empty_pb2.Empty()) 

179 refresh_materialized_views(empty_pb2.Empty()) 

180 

181 with search_session(token7) as api: 

182 res = api.UserSearch(search_pb2.UserSearchReq(profile_completed=wrappers_pb2.BoolValue(value=False))) 

183 assert user_incomplete_profile.id in [result.user.user_id for result in res.results] 

184 

185 res = api.UserSearchV2(search_pb2.UserSearchReq(profile_completed=wrappers_pb2.BoolValue(value=False))) 

186 assert user_incomplete_profile.id in [result.user_id for result in res.results] 

187 

188 with search_session(token6) as api: 

189 res = api.UserSearch(search_pb2.UserSearchReq(profile_completed=wrappers_pb2.BoolValue(value=True))) 

190 assert [result.user.user_id for result in res.results] == [user_complete_profile.id] 

191 

192 res = api.UserSearchV2(search_pb2.UserSearchReq(profile_completed=wrappers_pb2.BoolValue(value=True))) 

193 assert [result.user_id for result in res.results] == [user_complete_profile.id] 

194 

195 

196def test_user_filter_meetup_status(db): 

197 """ 

198 Make sure the completed profile flag returns only completed user profile 

199 """ 

200 user_wants_to_meetup, token8 = generate_user(meetup_status=MeetupStatus.wants_to_meetup) 

201 

202 user_does_not_want_to_meet, token9 = generate_user(meetup_status=MeetupStatus.does_not_want_to_meetup) 

203 

204 refresh_materialized_views_rapid(empty_pb2.Empty()) 

205 refresh_materialized_views(empty_pb2.Empty()) 

206 

207 with search_session(token8) as api: 

208 res = api.UserSearch(search_pb2.UserSearchReq(meetup_status_filter=[api_pb2.MEETUP_STATUS_WANTS_TO_MEETUP])) 

209 assert user_wants_to_meetup.id in [result.user.user_id for result in res.results] 

210 

211 res = api.UserSearchV2(search_pb2.UserSearchReq(meetup_status_filter=[api_pb2.MEETUP_STATUS_WANTS_TO_MEETUP])) 

212 assert user_wants_to_meetup.id in [result.user_id for result in res.results] 

213 

214 with search_session(token9) as api: 

215 res = api.UserSearch( 

216 search_pb2.UserSearchReq(meetup_status_filter=[api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP]) 

217 ) 

218 assert [result.user.user_id for result in res.results] == [user_does_not_want_to_meet.id] 

219 

220 res = api.UserSearchV2( 

221 search_pb2.UserSearchReq(meetup_status_filter=[api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP]) 

222 ) 

223 assert [result.user_id for result in res.results] == [user_does_not_want_to_meet.id] 

224 

225 

226def test_user_filter_language(db): 

227 """ 

228 Test filtering users by language ability. 

229 """ 

230 user_with_german_beginner, token11 = generate_user(hosting_status=HostingStatus.can_host) 

231 user_with_japanese_conversational, token12 = generate_user(hosting_status=HostingStatus.can_host) 

232 user_with_german_fluent, token13 = generate_user(hosting_status=HostingStatus.can_host) 

233 

234 with session_scope() as session: 

235 session.add( 

236 LanguageAbility( 

237 user_id=user_with_german_beginner.id, language_code="deu", fluency=LanguageFluency.beginner 

238 ), 

239 ) 

240 session.add( 

241 LanguageAbility( 

242 user_id=user_with_japanese_conversational.id, 

243 language_code="jpn", 

244 fluency=LanguageFluency.fluent, 

245 ) 

246 ) 

247 session.add( 

248 LanguageAbility(user_id=user_with_german_fluent.id, language_code="deu", fluency=LanguageFluency.fluent) 

249 ) 

250 

251 refresh_materialized_views_rapid(empty_pb2.Empty()) 

252 refresh_materialized_views(empty_pb2.Empty()) 

253 

254 with search_session(token11) as api: 

255 res = api.UserSearch( 

256 search_pb2.UserSearchReq( 

257 language_ability_filter=[ 

258 api_pb2.LanguageAbility( 

259 code="deu", 

260 fluency=api_pb2.LanguageAbility.Fluency.FLUENCY_FLUENT, 

261 ) 

262 ] 

263 ) 

264 ) 

265 assert [result.user.user_id for result in res.results] == [user_with_german_fluent.id] 

266 

267 res = api.UserSearchV2( 

268 search_pb2.UserSearchReq( 

269 language_ability_filter=[ 

270 api_pb2.LanguageAbility( 

271 code="deu", 

272 fluency=api_pb2.LanguageAbility.Fluency.FLUENCY_FLUENT, 

273 ) 

274 ] 

275 ) 

276 ) 

277 assert [result.user_id for result in res.results] == [user_with_german_fluent.id] 

278 

279 res = api.UserSearch( 

280 search_pb2.UserSearchReq( 

281 language_ability_filter=[ 

282 api_pb2.LanguageAbility( 

283 code="jpn", 

284 fluency=api_pb2.LanguageAbility.Fluency.FLUENCY_CONVERSATIONAL, 

285 ) 

286 ] 

287 ) 

288 ) 

289 assert [result.user.user_id for result in res.results] == [user_with_japanese_conversational.id] 

290 

291 res = api.UserSearchV2( 

292 search_pb2.UserSearchReq( 

293 language_ability_filter=[ 

294 api_pb2.LanguageAbility( 

295 code="jpn", 

296 fluency=api_pb2.LanguageAbility.Fluency.FLUENCY_CONVERSATIONAL, 

297 ) 

298 ] 

299 ) 

300 ) 

301 assert [result.user_id for result in res.results] == [user_with_japanese_conversational.id] 

302 

303 

304def test_user_filter_strong_verification(db): 

305 user1, token1 = generate_user() 

306 user2, _ = generate_user(strong_verification=True) 

307 user3, _ = generate_user() 

308 user4, _ = generate_user(strong_verification=True) 

309 user5, _ = generate_user(strong_verification=True) 

310 

311 refresh_materialized_views_rapid(empty_pb2.Empty()) 

312 refresh_materialized_views(empty_pb2.Empty()) 

313 

314 with search_session(token1) as api: 

315 res = api.UserSearch(search_pb2.UserSearchReq(only_with_strong_verification=False)) 

316 assert [result.user.user_id for result in res.results] == [user1.id, user2.id, user3.id, user4.id, user5.id] 

317 

318 res = api.UserSearchV2(search_pb2.UserSearchReq(only_with_strong_verification=False)) 

319 assert [result.user_id for result in res.results] == [user1.id, user2.id, user3.id, user4.id, user5.id] 

320 

321 res = api.UserSearch(search_pb2.UserSearchReq(only_with_strong_verification=True)) 

322 assert [result.user.user_id for result in res.results] == [user2.id, user4.id, user5.id] 

323 

324 res = api.UserSearchV2(search_pb2.UserSearchReq(only_with_strong_verification=True)) 

325 assert [result.user_id for result in res.results] == [user2.id, user4.id, user5.id] 

326 

327 

328def test_regression_search_only_with_references(db): 

329 user1, token1 = generate_user() 

330 user2, _ = generate_user() 

331 user3, _ = generate_user() 

332 user4, _ = generate_user(delete_user=True) 

333 

334 refresh_materialized_views_rapid(empty_pb2.Empty()) 

335 refresh_materialized_views(empty_pb2.Empty()) 

336 

337 with session_scope() as session: 

338 # user 2 has references 

339 create_friend_reference(session, user1.id, user2.id, timedelta(days=1)) 

340 create_friend_reference(session, user3.id, user2.id, timedelta(days=1)) 

341 create_friend_reference(session, user4.id, user2.id, timedelta(days=1)) 

342 

343 # user 3 only has reference from a deleted user 

344 create_friend_reference(session, user4.id, user3.id, timedelta(days=1)) 

345 

346 with search_session(token1) as api: 

347 res = api.UserSearch(search_pb2.UserSearchReq(only_with_references=False)) 

348 assert [result.user.user_id for result in res.results] == [user1.id, user2.id, user3.id] 

349 

350 res = api.UserSearchV2(search_pb2.UserSearchReq(only_with_references=False)) 

351 assert [result.user_id for result in res.results] == [user1.id, user2.id, user3.id] 

352 

353 res = api.UserSearch(search_pb2.UserSearchReq(only_with_references=True)) 

354 assert [result.user.user_id for result in res.results] == [user2.id] 

355 

356 res = api.UserSearchV2(search_pb2.UserSearchReq(only_with_references=True)) 

357 assert [result.user_id for result in res.results] == [user2.id] 

358 

359 

360def test_user_search_exactly_user_ids(db): 

361 """ 

362 Test that UserSearch with exactly_user_ids returns only those users and ignores other filters. 

363 """ 

364 # Create users with different properties 

365 user1, token1 = generate_user() 

366 user2, _ = generate_user(strong_verification=True) 

367 user3, _ = generate_user(complete_profile=True) 

368 user4, _ = generate_user(meetup_status=MeetupStatus.wants_to_meetup) 

369 user5, _ = generate_user(delete_user=True) # Deleted user 

370 

371 refresh_materialized_views_rapid(empty_pb2.Empty()) 

372 refresh_materialized_views(empty_pb2.Empty()) 

373 

374 with search_session(token1) as api: 

375 # Test that exactly_user_ids returns only the specified users 

376 res = api.UserSearch(search_pb2.UserSearchReq(exactly_user_ids=[user2.id, user3.id, user4.id])) 

377 assert sorted([result.user.user_id for result in res.results]) == sorted([user2.id, user3.id, user4.id]) 

378 

379 res = api.UserSearchV2(search_pb2.UserSearchReq(exactly_user_ids=[user2.id, user3.id, user4.id])) 

380 assert sorted([result.user_id for result in res.results]) == sorted([user2.id, user3.id, user4.id]) 

381 

382 # Test that exactly_user_ids ignores other filters 

383 res = api.UserSearch( 

384 search_pb2.UserSearchReq( 

385 exactly_user_ids=[user2.id, user3.id, user4.id], 

386 only_with_strong_verification=True, # This would normally filter out user3 and user4 

387 ) 

388 ) 

389 assert sorted([result.user.user_id for result in res.results]) == sorted([user2.id, user3.id, user4.id]) 

390 

391 res = api.UserSearchV2( 

392 search_pb2.UserSearchReq( 

393 exactly_user_ids=[user2.id, user3.id, user4.id], 

394 only_with_strong_verification=True, # This would normally filter out user3 and user4 

395 ) 

396 ) 

397 assert sorted([result.user_id for result in res.results]) == sorted([user2.id, user3.id, user4.id]) 

398 

399 # Test with non-existent user IDs (should be ignored) 

400 res = api.UserSearch(search_pb2.UserSearchReq(exactly_user_ids=[user1.id, 99999])) 

401 assert [result.user.user_id for result in res.results] == [user1.id] 

402 

403 res = api.UserSearchV2(search_pb2.UserSearchReq(exactly_user_ids=[user1.id, 99999])) 

404 assert [result.user_id for result in res.results] == [user1.id] 

405 

406 # Test with deleted user ID (should be ignored due to visibility filter) 

407 res = api.UserSearch(search_pb2.UserSearchReq(exactly_user_ids=[user1.id, user5.id])) 

408 assert [result.user.user_id for result in res.results] == [user1.id] 

409 

410 res = api.UserSearchV2(search_pb2.UserSearchReq(exactly_user_ids=[user1.id, user5.id])) 

411 assert [result.user_id for result in res.results] == [user1.id] 

412 

413 

414@pytest.fixture 

415def sample_event_data() -> dict[str, Any]: 

416 """Dummy data for creating events.""" 

417 start_time = now() + timedelta(hours=2) 

418 end_time = start_time + timedelta(hours=3) 

419 return { 

420 "title": "Dummy Title", 

421 "content": "Dummy content.", 

422 "photo_key": None, 

423 "location": events_pb2.EventLocation(address="Near Null Island", lat=0.1, lng=0.2), 

424 "start_datetime_iso8601_local": datetime_to_iso8601_local(start_time), 

425 "end_datetime_iso8601_local": datetime_to_iso8601_local(end_time), 

426 } 

427 

428 

429@pytest.fixture 

430def create_event(sample_event_data): 

431 """Factory for creating events.""" 

432 

433 def _create_event(event_api, **kwargs) -> EventOccurrence: 

434 """Create an event with default values, unless overridden by kwargs.""" 

435 return event_api.CreateEvent(events_pb2.CreateEventReq(**{**sample_event_data, **kwargs})) # type: ignore 

436 

437 return _create_event 

438 

439 

440@pytest.fixture 

441def sample_community(db) -> int: 

442 """Create large community spanning from (-50, 0) to (50, 2) as events can only be created within communities.""" 

443 user, _ = generate_user() 

444 with session_scope() as session: 

445 return create_community(session, -50, 50, "Community", [user], [], None).id 

446 

447 

448def test_event_search_by_query(sample_community, create_event): 

449 """Test that EventSearch finds events by title (and content if query_title_only=False).""" 

450 user, token = generate_user() 

451 

452 with events_session(token) as api: 

453 event1 = create_event(api, title="Lorem Ipsum") 

454 event2 = create_event(api, content="Lorem Ipsum") 

455 create_event(api) 

456 

457 with search_session(token) as api: 

458 res = api.EventSearch(search_pb2.EventSearchReq(query=wrappers_pb2.StringValue(value="Ipsum"))) 

459 assert len(res.events) == 2 

460 assert {result.event_id for result in res.events} == {event1.event_id, event2.event_id} 

461 

462 res = api.EventSearch( 

463 search_pb2.EventSearchReq(query=wrappers_pb2.StringValue(value="Ipsum"), query_title_only=True) 

464 ) 

465 assert len(res.events) == 1 

466 assert res.events[0].event_id == event1.event_id 

467 

468 

469def test_event_search_by_time(sample_community, create_event): 

470 """Test that EventSearch filters with the given time range.""" 

471 user, token = generate_user() 

472 

473 with events_session(token) as api: 

474 event1 = create_event( 

475 api, 

476 start_datetime_iso8601_local=datetime_to_iso8601_local(now() + timedelta(hours=1)), 

477 end_datetime_iso8601_local=datetime_to_iso8601_local(now() + timedelta(hours=2)), 

478 ) 

479 event2 = create_event( 

480 api, 

481 start_datetime_iso8601_local=datetime_to_iso8601_local(now() + timedelta(hours=4)), 

482 end_datetime_iso8601_local=datetime_to_iso8601_local(now() + timedelta(hours=5)), 

483 ) 

484 event3 = create_event( 

485 api, 

486 start_datetime_iso8601_local=datetime_to_iso8601_local(now() + timedelta(hours=7)), 

487 end_datetime_iso8601_local=datetime_to_iso8601_local(now() + timedelta(hours=8)), 

488 ) 

489 

490 with search_session(token) as api: 

491 res = api.EventSearch(search_pb2.EventSearchReq(before=Timestamp_from_datetime(now() + timedelta(hours=6)))) 

492 assert len(res.events) == 2 

493 assert {result.event_id for result in res.events} == {event1.event_id, event2.event_id} 

494 

495 res = api.EventSearch(search_pb2.EventSearchReq(after=Timestamp_from_datetime(now() + timedelta(hours=3)))) 

496 assert len(res.events) == 2 

497 assert {result.event_id for result in res.events} == {event2.event_id, event3.event_id} 

498 

499 res = api.EventSearch( 

500 search_pb2.EventSearchReq( 

501 before=Timestamp_from_datetime(now() + timedelta(hours=6)), 

502 after=Timestamp_from_datetime(now() + timedelta(hours=3)), 

503 ) 

504 ) 

505 assert len(res.events) == 1 

506 assert res.events[0].event_id == event2.event_id 

507 

508 

509def test_event_search_by_circle(sample_community, create_event): 

510 """Test that EventSearch only returns events within the given circle.""" 

511 user, token = generate_user() 

512 

513 with events_session(token) as api: 

514 inside_pts = [(0.1, 0.01), (0.01, 0.1)] 

515 for i, (lat, lng) in enumerate(inside_pts): 

516 create_event( 

517 api, 

518 title=f"Inside area {i}", 

519 location=events_pb2.EventLocation(lat=lat, lng=lng, address=f"Inside area {i}"), 

520 ) 

521 

522 outside_pts = [(1, 0.1), (0.1, 1), (10, 1)] 

523 for i, (lat, lng) in enumerate(outside_pts): 

524 create_event( 

525 api, 

526 title=f"Outside area {i}", 

527 location=events_pb2.EventLocation(lat=lat, lng=lng, address=f"Outside area {i}"), 

528 ) 

529 

530 with search_session(token) as api: 

531 res = api.EventSearch(search_pb2.EventSearchReq(search_in_area=search_pb2.Area(lat=0, lng=0, radius=100000))) 

532 assert len(res.events) == len(inside_pts) 

533 assert all(event.title.startswith("Inside area") for event in res.events) 

534 

535 

536def test_event_search_by_rectangle(sample_community, create_event): 

537 """Test that EventSearch only returns events within the given rectangular area.""" 

538 user, token = generate_user() 

539 

540 with events_session(token) as api: 

541 inside_pts = [(0.1, 0.2), (1.2, 0.2)] 

542 for i, (lat, lng) in enumerate(inside_pts): 

543 create_event( 

544 api, 

545 title=f"Inside area {i}", 

546 location=events_pb2.EventLocation(lat=lat, lng=lng, address=f"Inside area {i}"), 

547 ) 

548 

549 outside_pts = [(-1, 0.1), (0.1, 0.01), (-0.01, 0.01), (0.1, 1.2), (10, 1)] 

550 for i, (lat, lng) in enumerate(outside_pts): 

551 create_event( 

552 api, 

553 title=f"Outside area {i}", 

554 location=events_pb2.EventLocation(lat=lat, lng=lng, address=f"Outside area {i}"), 

555 ) 

556 

557 with search_session(token) as api: 

558 res = api.EventSearch( 

559 search_pb2.EventSearchReq( 

560 search_in_rectangle=search_pb2.RectArea(lat_min=0, lat_max=2, lng_min=0.1, lng_max=1) 

561 ) 

562 ) 

563 assert len(res.events) == len(inside_pts) 

564 assert all(event.title.startswith("Inside area") for event in res.events) 

565 

566 

567def test_event_search_pagination(sample_community, create_event): 

568 """Test that EventSearch paginates correctly. 

569 

570 Check that 

571 - <page_size> events are returned, if available 

572 - sort order is applied (default: past=False) 

573 - the next page token continues where the previous page left off 

574 """ 

575 user, token = generate_user() 

576 

577 anchor_time = now().replace(second=0, microsecond=0) # Events are created at minute granularity 

578 with events_session(token) as api: 

579 for i in range(5): 

580 create_event( 

581 api, 

582 title=f"Event {i + 1}", 

583 start_datetime_iso8601_local=datetime_to_iso8601_local(anchor_time + timedelta(hours=i + 1)), 

584 end_datetime_iso8601_local=datetime_to_iso8601_local(anchor_time + timedelta(hours=i + 1, minutes=30)), 

585 ) 

586 

587 with search_session(token) as api: 

588 res = api.EventSearch(search_pb2.EventSearchReq(past=False, page_size=4)) 

589 assert len(res.events) == 4 

590 assert [event.title for event in res.events] == ["Event 1", "Event 2", "Event 3", "Event 4"] 

591 assert res.next_page_token 

592 

593 res = api.EventSearch(search_pb2.EventSearchReq(page_size=4, page_token=res.next_page_token)) 

594 assert len(res.events) == 1 

595 assert res.events[0].title == "Event 5" 

596 assert res.next_page_token == "" 

597 

598 # move all the events into the past to test past pagination 

599 with session_scope() as session: 

600 for occurrence in session.execute(select(EventOccurrence)).scalars().all(): 

601 occurrence.during = TimestamptzRange( 

602 occurrence.start_time - timedelta(days=30), occurrence.end_time - timedelta(days=30) 

603 ) 

604 

605 with search_session(token) as api: 

606 res = api.EventSearch(search_pb2.EventSearchReq(past=True, page_size=2)) 

607 assert [event.title for event in res.events] == ["Event 5", "Event 4"] 

608 assert res.next_page_token 

609 

610 res = api.EventSearch(search_pb2.EventSearchReq(past=True, page_size=2, page_token=res.next_page_token)) 

611 assert [event.title for event in res.events] == ["Event 3", "Event 2"] 

612 assert res.next_page_token 

613 

614 res = api.EventSearch(search_pb2.EventSearchReq(past=True, page_size=2, page_token=res.next_page_token)) 

615 assert [event.title for event in res.events] == ["Event 1"] 

616 assert res.next_page_token == "" 

617 

618 

619def test_event_search_pagination_with_page_number(sample_community, create_event): 

620 """Test that EventSearch paginates correctly with page number. 

621 

622 Check that 

623 - <page_size> events are returned, if available 

624 - sort order is applied (default: past=False) 

625 - <page_number> is respected 

626 - <total_items> is correct 

627 """ 

628 user, token = generate_user() 

629 

630 anchor_time = now() 

631 with events_session(token) as api: 

632 for i in range(5): 

633 create_event( 

634 api, 

635 title=f"Event {i + 1}", 

636 start_datetime_iso8601_local=datetime_to_iso8601_local(anchor_time + timedelta(hours=i + 1)), 

637 end_datetime_iso8601_local=datetime_to_iso8601_local(anchor_time + timedelta(hours=i + 1, minutes=30)), 

638 ) 

639 

640 with search_session(token) as api: 

641 res = api.EventSearch(search_pb2.EventSearchReq(page_size=2, page_number=1)) 

642 assert len(res.events) == 2 

643 assert [event.title for event in res.events] == ["Event 1", "Event 2"] 

644 assert res.total_items == 5 

645 

646 res = api.EventSearch(search_pb2.EventSearchReq(page_size=2, page_number=2)) 

647 assert len(res.events) == 2 

648 assert [event.title for event in res.events] == ["Event 3", "Event 4"] 

649 assert res.total_items == 5 

650 

651 res = api.EventSearch(search_pb2.EventSearchReq(page_size=2, page_number=3)) 

652 assert len(res.events) == 1 

653 assert [event.title for event in res.events] == ["Event 5"] 

654 assert res.total_items == 5 

655 

656 # Verify no more pages 

657 res = api.EventSearch(search_pb2.EventSearchReq(page_size=2, page_number=4)) 

658 assert not res.events 

659 assert res.total_items == 5 

660 

661 

662def test_event_search_filter_subscription_attendance_organizing_my_communities( 

663 sample_community, create_event, moderator: Moderator 

664): 

665 """Test that EventSearch respects subscribed, attending, organizing and my_communities filters and by default 

666 returns all events. 

667 """ 

668 _, token = generate_user() 

669 other_user, other_token = generate_user() 

670 

671 with communities_session(token) as api: 

672 api.JoinCommunity(communities_pb2.JoinCommunityReq(community_id=sample_community)) 

673 

674 with session_scope() as session: 

675 create_community(session, 55, 60, "Other community", [other_user], [], None) 

676 

677 with events_session(other_token) as api: 

678 e_subscribed = create_event(api, title="Subscribed event") 

679 e_attending = create_event(api, title="Attending event") 

680 create_event(api, title="Community event") 

681 create_event( 

682 api, 

683 title="Other community event", 

684 location=events_pb2.EventLocation(lat=58, lng=1, address="Somewhere"), 

685 ) 

686 

687 # Approve all events so they're visible to other users 

688 with session_scope() as session: 

689 occurrence_ids = session.execute(select(EventOccurrence.id)).scalars().all() 

690 for oid in occurrence_ids: 

691 moderator.approve_event_occurrence(oid) 

692 

693 with events_session(token) as api: 

694 create_event(api, title="Organized event") 

695 api.SetEventSubscription(events_pb2.SetEventSubscriptionReq(event_id=e_subscribed.event_id, subscribe=True)) 

696 api.SetEventAttendance( 

697 events_pb2.SetEventAttendanceReq( 

698 event_id=e_attending.event_id, attendance_state=events_pb2.ATTENDANCE_STATE_GOING 

699 ) 

700 ) 

701 

702 with search_session(token) as api: 

703 res = api.EventSearch(search_pb2.EventSearchReq()) 

704 assert {event.title for event in res.events} == { 

705 "Subscribed event", 

706 "Attending event", 

707 "Community event", 

708 "Other community event", 

709 "Organized event", 

710 } 

711 

712 res = api.EventSearch(search_pb2.EventSearchReq(subscribed=True)) 

713 assert {event.title for event in res.events} == {"Subscribed event", "Organized event"} 

714 

715 res = api.EventSearch(search_pb2.EventSearchReq(attending=True)) 

716 assert {event.title for event in res.events} == {"Attending event", "Organized event"} 

717 

718 res = api.EventSearch(search_pb2.EventSearchReq(organizing=True)) 

719 assert {event.title for event in res.events} == {"Organized event"} 

720 

721 res = api.EventSearch(search_pb2.EventSearchReq(my_communities=True)) 

722 assert {event.title for event in res.events} == { 

723 "Subscribed event", 

724 "Attending event", 

725 "Community event", 

726 "Organized event", 

727 } 

728 

729 res = api.EventSearch(search_pb2.EventSearchReq(subscribed=True, attending=True)) 

730 assert {event.title for event in res.events} == {"Subscribed event", "Attending event", "Organized event"} 

731 

732 

733def test_event_search_exclude_attending(sample_community, create_event, moderator: Moderator): 

734 """Test that exclude_attending removes events the user is attending or organizing.""" 

735 user, token = generate_user() 

736 other_user, other_token = generate_user() 

737 

738 with communities_session(token) as api: 

739 api.JoinCommunity(communities_pb2.JoinCommunityReq(community_id=sample_community)) 

740 

741 with session_scope() as session: 

742 create_community(session, 55, 60, "Other community", [other_user], [], None) 

743 

744 with events_session(other_token) as api: 

745 e_attending = create_event(api, title="Attending event") 

746 e_community_only = create_event(api, title="Community only event") 

747 create_event( 

748 api, 

749 title="Other community event", 

750 location=events_pb2.EventLocation(lat=58, lng=1, address="Somewhere"), 

751 ) 

752 

753 with session_scope() as session: 

754 occurrence_ids = session.execute(select(EventOccurrence.id)).scalars().all() 

755 for oid in occurrence_ids: 

756 moderator.approve_event_occurrence(oid) 

757 

758 with events_session(token) as api: 

759 e_organized = create_event(api, title="Organized event") 

760 api.SetEventAttendance( 

761 events_pb2.SetEventAttendanceReq( 

762 event_id=e_attending.event_id, attendance_state=events_pb2.ATTENDANCE_STATE_GOING 

763 ) 

764 ) 

765 

766 with search_session(token) as api: 

767 # baseline: my_communities returns all community events including attended/organized 

768 res = api.EventSearch(search_pb2.EventSearchReq(my_communities=True)) 

769 assert {event.title for event in res.events} == { 

770 "Attending event", 

771 "Community only event", 

772 "Organized event", 

773 } 

774 

775 # my_communities + exclude_attending: drops attended and organized events 

776 res = api.EventSearch(search_pb2.EventSearchReq(my_communities=True, exclude_attending=True)) 

777 assert {event.title for event in res.events} == {"Community only event"} 

778 

779 # exclude_attending alone (no other filter = all events): drops attended and organized 

780 res = api.EventSearch(search_pb2.EventSearchReq(exclude_attending=True)) 

781 assert {event.title for event in res.events} == {"Community only event", "Other community event"} 

782 

783 # attending + exclude_attending is invalid 

784 with pytest.raises(grpc.RpcError) as e: 

785 api.EventSearch(search_pb2.EventSearchReq(attending=True, exclude_attending=True)) 

786 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT 

787 

788 

789def test_regression_search_multiple_pages(db): 

790 """ 

791 There was a bug when there are multiple pages of results 

792 """ 

793 user, token = generate_user() 

794 user_ids = [user.id] 

795 for _ in range(10): 

796 other_user, _ = generate_user() 

797 user_ids.append(other_user.id) 

798 

799 refresh_materialized_views_rapid(empty_pb2.Empty()) 

800 refresh_materialized_views(empty_pb2.Empty()) 

801 

802 with search_session(token) as api: 

803 res = api.UserSearchV2(search_pb2.UserSearchReq(page_size=5)) 

804 assert [result.user_id for result in res.results] == user_ids[:5] 

805 assert res.next_page_token 

806 

807 

808def test_regression_search_no_results(db): 

809 """ 

810 There was a bug when there were no results 

811 """ 

812 # put us far away 

813 user, token = generate_user() 

814 

815 refresh_materialized_views_rapid(empty_pb2.Empty()) 

816 refresh_materialized_views(empty_pb2.Empty()) 

817 

818 with search_session(token) as api: 

819 res = api.UserSearchV2(search_pb2.UserSearchReq(only_with_references=True)) 

820 assert len(res.results) == 0 

821 

822 

823def test_user_filter_same_gender_only(db): 

824 """Test that same_gender_only filter works correctly""" 

825 # Create users with different genders and strong verification status 

826 woman_with_sv, token_woman_with_sv = generate_user(strong_verification=True, gender="Woman") 

827 woman_without_sv, token_woman_without_sv = generate_user(strong_verification=False, gender="Woman") 

828 man_with_sv, token_man_with_sv = generate_user(strong_verification=True, gender="Man") 

829 man_without_sv, _ = generate_user(strong_verification=False, gender="Man") 

830 other_woman_with_sv, _ = generate_user(strong_verification=True, gender="Woman") 

831 

832 refresh_materialized_views_rapid(empty_pb2.Empty()) 

833 refresh_materialized_views(empty_pb2.Empty()) 

834 

835 # Test 1: Woman with strong verification should see only women when same_gender_only=True 

836 with search_session(token_woman_with_sv) as api: 

837 res = api.UserSearch(search_pb2.UserSearchReq(same_gender_only=True)) 

838 result_ids = [result.user.user_id for result in res.results] 

839 assert woman_with_sv.id in result_ids 

840 assert woman_without_sv.id in result_ids 

841 assert other_woman_with_sv.id in result_ids 

842 assert man_with_sv.id not in result_ids 

843 assert man_without_sv.id not in result_ids 

844 

845 res = api.UserSearchV2(search_pb2.UserSearchReq(same_gender_only=True)) 

846 result_ids = [result.user_id for result in res.results] 

847 assert woman_with_sv.id in result_ids 

848 assert woman_without_sv.id in result_ids 

849 assert other_woman_with_sv.id in result_ids 

850 assert man_with_sv.id not in result_ids 

851 assert man_without_sv.id not in result_ids 

852 

853 # Test 2: Man with strong verification should see only men when same_gender_only=True 

854 with search_session(token_man_with_sv) as api: 

855 res = api.UserSearch(search_pb2.UserSearchReq(same_gender_only=True)) 

856 result_ids = [result.user.user_id for result in res.results] 

857 assert man_with_sv.id in result_ids 

858 assert man_without_sv.id in result_ids 

859 assert woman_with_sv.id not in result_ids 

860 assert woman_without_sv.id not in result_ids 

861 assert other_woman_with_sv.id not in result_ids 

862 

863 res = api.UserSearchV2(search_pb2.UserSearchReq(same_gender_only=True)) 

864 result_ids = [result.user_id for result in res.results] 

865 assert man_with_sv.id in result_ids 

866 assert man_without_sv.id in result_ids 

867 assert woman_with_sv.id not in result_ids 

868 assert woman_without_sv.id not in result_ids 

869 assert other_woman_with_sv.id not in result_ids 

870 

871 # Test 3: Woman without strong verification should get an error 

872 with search_session(token_woman_without_sv) as api: 

873 with pytest.raises(Exception) as e: 

874 api.UserSearch(search_pb2.UserSearchReq(same_gender_only=True)) 

875 assert "NEED_STRONG_VERIFICATION" in str(e.value) or "FAILED_PRECONDITION" in str(e.value) 

876 

877 with pytest.raises(Exception) as e: 

878 api.UserSearchV2(search_pb2.UserSearchReq(same_gender_only=True)) 

879 assert "NEED_STRONG_VERIFICATION" in str(e.value) or "FAILED_PRECONDITION" in str(e.value) 

880 

881 # Test 4: When same_gender_only=False, should see all users 

882 with search_session(token_woman_with_sv) as api: 

883 res = api.UserSearch(search_pb2.UserSearchReq(same_gender_only=False)) 

884 result_ids = [result.user.user_id for result in res.results] 

885 assert woman_with_sv.id in result_ids 

886 assert woman_without_sv.id in result_ids 

887 assert other_woman_with_sv.id in result_ids 

888 assert man_with_sv.id in result_ids 

889 assert man_without_sv.id in result_ids 

890 

891 res = api.UserSearchV2(search_pb2.UserSearchReq(same_gender_only=False)) 

892 result_ids = [result.user_id for result in res.results] 

893 assert woman_with_sv.id in result_ids 

894 assert woman_without_sv.id in result_ids 

895 assert other_woman_with_sv.id in result_ids 

896 assert man_with_sv.id in result_ids 

897 assert man_without_sv.id in result_ids 

898 

899 

900def test_user_filter_same_gender_only_with_other_filters(db): 

901 """Test that same_gender_only filter works correctly combined with other filters""" 

902 # Create users with different properties 

903 woman_host, token_woman = generate_user( 

904 strong_verification=True, gender="Woman", hosting_status=HostingStatus.can_host 

905 ) 

906 woman_cant_host, _ = generate_user(strong_verification=True, gender="Woman", hosting_status=HostingStatus.cant_host) 

907 man_host, _ = generate_user(strong_verification=True, gender="Man", hosting_status=HostingStatus.can_host) 

908 

909 refresh_materialized_views_rapid(empty_pb2.Empty()) 

910 refresh_materialized_views(empty_pb2.Empty()) 

911 

912 # Test: Combine same_gender_only with hosting_status filter 

913 with search_session(token_woman) as api: 

914 res = api.UserSearch( 

915 search_pb2.UserSearchReq(same_gender_only=True, hosting_status_filter=[api_pb2.HOSTING_STATUS_CAN_HOST]) 

916 ) 

917 result_ids = [result.user.user_id for result in res.results] 

918 # Should only see woman who can host 

919 assert woman_host.id in result_ids 

920 assert woman_cant_host.id not in result_ids 

921 assert man_host.id not in result_ids 

922 

923 res = api.UserSearchV2( 

924 search_pb2.UserSearchReq(same_gender_only=True, hosting_status_filter=[api_pb2.HOSTING_STATUS_CAN_HOST]) 

925 ) 

926 result_ids = [result.user_id for result in res.results] 

927 assert woman_host.id in result_ids 

928 assert woman_cant_host.id not in result_ids 

929 assert man_host.id not in result_ids