Coverage for app/backend/src/tests/test_search.py: 100%
484 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
1from datetime import date, timedelta
3import grpc
4import pytest
5from google.protobuf import empty_pb2, wrappers_pb2
6from psycopg.types.range import TimestamptzRange
7from sqlalchemy import select, update
9from couchers.db import session_scope
10from couchers.materialized_views import refresh_materialized_views, refresh_materialized_views_rapid
11from couchers.models import (
12 EventOccurrence,
13 HostingStatus,
14 LanguageAbility,
15 LanguageFluency,
16 MeetupStatus,
17 PassportSex,
18 StrongVerificationAttempt,
19 StrongVerificationAttemptStatus,
20 User,
21 UserRankingOffset,
22)
23from couchers.proto import api_pb2, communities_pb2, events_pb2, search_pb2
24from couchers.servicers.search import USER_SEARCH_NOISE_PERIOD_DAYS
25from couchers.utils import Timestamp_from_datetime, create_coordinate, now, today
26from tests.fixtures.api import events as events_api
27from tests.fixtures.db import generate_user
28from tests.fixtures.misc import Moderator
29from tests.fixtures.sessions import communities_session, search_session
30from tests.test_communities import create_community, testing_communities # noqa
31from tests.test_references import create_friend_reference
32from tests.utils import local_date_time
35class TestSearchInCommunities:
36 """The tests that search the whole community tree, grouped so they share one copy of it."""
38 @staticmethod
39 def test_Search(testing_communities):
40 user, token = generate_user()
41 with search_session(token) as api:
42 res = api.Search(
43 search_pb2.SearchReq(
44 query="Country 1, Region 1",
45 include_users=True,
46 include_communities=True,
47 include_groups=True,
48 include_places=True,
49 include_guides=True,
50 )
51 )
52 res = api.Search(
53 search_pb2.SearchReq(
54 query="Country 1, Region 1, Attraction",
55 title_only=True,
56 include_users=True,
57 include_communities=True,
58 include_groups=True,
59 include_places=True,
60 include_guides=True,
61 )
62 )
64 @staticmethod
65 def test_UserSearch(testing_communities):
66 """Test that UserSearch returns all users if no filter is set."""
67 user, token = generate_user()
69 refresh_materialized_views_rapid(empty_pb2.Empty())
70 refresh_materialized_views(empty_pb2.Empty())
72 with search_session(token) as api:
73 res = api.UserSearchV2(search_pb2.UserSearchReq())
74 assert len(res.results) > 0
75 assert res.total_items == len(res.results)
77 @staticmethod
78 def test_EventSearch_no_filters(testing_communities):
79 """Test that EventSearch returns all events if no filter is set."""
80 user, token = generate_user()
81 with search_session(token) as api:
82 res = api.EventSearch(search_pb2.EventSearchReq())
83 assert len(res.events) > 0
86def test_regression_search_in_area(db):
87 """
88 Makes sure search_in_area works.
90 At the equator/prime meridian intersection (0,0), one degree is roughly 111 km.
91 """
93 # outside
94 user1, token1 = generate_user(geom=create_coordinate(1, 0), geom_radius=100)
95 # outside
96 user2, token2 = generate_user(geom=create_coordinate(0, 1), geom_radius=100)
97 # inside
98 user3, token3 = generate_user(geom=create_coordinate(0.1, 0), geom_radius=100)
99 # inside
100 user4, token4 = generate_user(geom=create_coordinate(0, 0.1), geom_radius=100)
101 # outside
102 user5, token5 = generate_user(geom=create_coordinate(10, 10), geom_radius=100)
104 refresh_materialized_views_rapid(empty_pb2.Empty())
105 refresh_materialized_views(empty_pb2.Empty())
107 with search_session(token5) as api:
108 res = api.UserSearchV2(
109 search_pb2.UserSearchReq(
110 search_in_area=search_pb2.Area(
111 lat=0,
112 lng=0,
113 radius=100000,
114 )
115 )
116 )
117 assert [result.user_id for result in res.results] == [user3.id, user4.id]
120def test_user_search_in_rectangle(db):
121 """
122 Makes sure search_in_rectangle works as expected.
123 """
125 # outside
126 user1, token1 = generate_user(geom=create_coordinate(-1, 0), geom_radius=100)
127 # outside
128 user2, token2 = generate_user(geom=create_coordinate(0, -1), geom_radius=100)
129 # inside
130 user3, token3 = generate_user(geom=create_coordinate(0.1, 0.1), geom_radius=100)
131 # inside
132 user4, token4 = generate_user(geom=create_coordinate(1.2, 0.1), geom_radius=100)
133 # outside (not fully inside)
134 user5, token5 = generate_user(geom=create_coordinate(0, 0), geom_radius=100)
135 # outside
136 user6, token6 = generate_user(geom=create_coordinate(0.1, 1.2), geom_radius=100)
137 # outside
138 user7, token7 = generate_user(geom=create_coordinate(10, 10), geom_radius=100)
140 refresh_materialized_views_rapid(empty_pb2.Empty())
141 refresh_materialized_views(empty_pb2.Empty())
143 with search_session(token5) as api:
144 res = api.UserSearchV2(
145 search_pb2.UserSearchReq(
146 search_in_rectangle=search_pb2.RectArea(
147 lat_min=0,
148 lat_max=2,
149 lng_min=0,
150 lng_max=1,
151 )
152 )
153 )
154 assert [result.user_id for result in res.results] == [user3.id, user4.id]
157def test_user_filter_complete_profile(db):
158 """
159 Make sure the completed profile flag returns only completed user profile
160 """
161 user_complete_profile, token6 = generate_user(complete_profile=True)
163 user_incomplete_profile, token7 = generate_user(complete_profile=False)
165 refresh_materialized_views_rapid(empty_pb2.Empty())
166 refresh_materialized_views(empty_pb2.Empty())
168 with search_session(token7) as api:
169 res = api.UserSearchV2(search_pb2.UserSearchReq(profile_completed=wrappers_pb2.BoolValue(value=False)))
170 assert user_incomplete_profile.id in [result.user_id for result in res.results]
172 with search_session(token6) as api:
173 res = api.UserSearchV2(search_pb2.UserSearchReq(profile_completed=wrappers_pb2.BoolValue(value=True)))
174 assert [result.user_id for result in res.results] == [user_complete_profile.id]
177def test_user_filter_meetup_status(db):
178 """
179 Make sure the completed profile flag returns only completed user profile
180 """
181 user_wants_to_meetup, token8 = generate_user(meetup_status=MeetupStatus.wants_to_meetup)
183 user_does_not_want_to_meet, token9 = generate_user(meetup_status=MeetupStatus.does_not_want_to_meetup)
185 refresh_materialized_views_rapid(empty_pb2.Empty())
186 refresh_materialized_views(empty_pb2.Empty())
188 with search_session(token8) as api:
189 res = api.UserSearchV2(search_pb2.UserSearchReq(meetup_status_filter=[api_pb2.MEETUP_STATUS_WANTS_TO_MEETUP]))
190 assert user_wants_to_meetup.id in [result.user_id for result in res.results]
192 with search_session(token9) as api:
193 res = api.UserSearchV2(
194 search_pb2.UserSearchReq(meetup_status_filter=[api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP])
195 )
196 assert [result.user_id for result in res.results] == [user_does_not_want_to_meet.id]
199def test_user_filter_language(db):
200 """
201 Test filtering users by language ability.
202 """
203 user_with_german_beginner, token11 = generate_user(hosting_status=HostingStatus.can_host)
204 user_with_japanese_conversational, token12 = generate_user(hosting_status=HostingStatus.can_host)
205 user_with_german_fluent, token13 = generate_user(hosting_status=HostingStatus.can_host)
207 with session_scope() as session:
208 session.add(
209 LanguageAbility(
210 user_id=user_with_german_beginner.id, language_code="deu", fluency=LanguageFluency.beginner
211 ),
212 )
213 session.add(
214 LanguageAbility(
215 user_id=user_with_japanese_conversational.id,
216 language_code="jpn",
217 fluency=LanguageFluency.fluent,
218 )
219 )
220 session.add(
221 LanguageAbility(user_id=user_with_german_fluent.id, language_code="deu", fluency=LanguageFluency.fluent)
222 )
224 refresh_materialized_views_rapid(empty_pb2.Empty())
225 refresh_materialized_views(empty_pb2.Empty())
227 with search_session(token11) as api:
228 res = api.UserSearchV2(
229 search_pb2.UserSearchReq(
230 language_ability_filter=[
231 api_pb2.LanguageAbility(
232 code="deu",
233 fluency=api_pb2.LanguageAbility.Fluency.FLUENCY_FLUENT,
234 )
235 ]
236 )
237 )
238 assert [result.user_id for result in res.results] == [user_with_german_fluent.id]
240 res = api.UserSearchV2(
241 search_pb2.UserSearchReq(
242 language_ability_filter=[
243 api_pb2.LanguageAbility(
244 code="jpn",
245 fluency=api_pb2.LanguageAbility.Fluency.FLUENCY_CONVERSATIONAL,
246 )
247 ]
248 )
249 )
250 assert [result.user_id for result in res.results] == [user_with_japanese_conversational.id]
253def test_user_filter_strong_verification(db):
254 user1, token1 = generate_user()
255 user2, _ = generate_user(strong_verification=True)
256 user3, _ = generate_user()
257 user4, _ = generate_user(strong_verification=True)
258 user5, _ = generate_user(strong_verification=True)
260 refresh_materialized_views_rapid(empty_pb2.Empty())
261 refresh_materialized_views(empty_pb2.Empty())
263 with search_session(token1) as api:
264 res = api.UserSearchV2(search_pb2.UserSearchReq(only_with_strong_verification=False))
265 assert [result.user_id for result in res.results] == [user1.id, user2.id, user3.id, user4.id, user5.id]
267 res = api.UserSearchV2(search_pb2.UserSearchReq(only_with_strong_verification=True))
268 assert [result.user_id for result in res.results] == [user2.id, user4.id, user5.id]
271def test_user_search_no_duplicates_from_multiple_matching_rows(db):
272 """
273 A user matching several requested languages, or with several valid strong verifications, is returned once.
274 """
275 languages = [("deu", LanguageFluency.fluent), ("jpn", LanguageFluency.fluent)]
276 user1, token1 = generate_user()
277 user2, _ = generate_user(strong_verification=True, language_abilities=languages)
278 user3, _ = generate_user(strong_verification=True, language_abilities=languages)
280 with session_scope() as session:
281 session.add(
282 StrongVerificationAttempt(
283 verification_attempt_token=f"second_verification_attempt_token_{user2.id}",
284 user_id=user2.id,
285 status=StrongVerificationAttemptStatus.succeeded,
286 has_full_data=True,
287 passport_encrypted_data=b"not real",
288 passport_date_of_birth=user2.birthdate,
289 passport_sex=PassportSex.unspecified,
290 has_minimal_data=True,
291 passport_expiry_date=date.today() + timedelta(days=20),
292 passport_nationality="UTO",
293 passport_last_three_document_chars="xyz",
294 iris_token=f"second_iris_token_{user2.id}",
295 iris_session_id=1000 + user2.id,
296 )
297 )
299 refresh_materialized_views_rapid(empty_pb2.Empty())
300 refresh_materialized_views(empty_pb2.Empty())
302 language_filter = [
303 api_pb2.LanguageAbility(code="deu", fluency=api_pb2.LanguageAbility.Fluency.FLUENCY_CONVERSATIONAL),
304 api_pb2.LanguageAbility(code="jpn", fluency=api_pb2.LanguageAbility.Fluency.FLUENCY_CONVERSATIONAL),
305 ]
306 with search_session(token1) as api:
307 for req in [
308 search_pb2.UserSearchReq(language_ability_filter=language_filter),
309 search_pb2.UserSearchReq(only_with_strong_verification=True),
310 ]:
311 res = api.UserSearchV2(req)
312 assert sorted(result.user_id for result in res.results) == [user2.id, user3.id]
313 assert res.total_items == 2
316def test_regression_search_only_with_references(db):
317 user1, token1 = generate_user()
318 user2, _ = generate_user()
319 user3, _ = generate_user()
320 user4, _ = generate_user(delete_user=True)
322 refresh_materialized_views_rapid(empty_pb2.Empty())
323 refresh_materialized_views(empty_pb2.Empty())
325 with session_scope() as session:
326 # user 2 has references
327 create_friend_reference(session, user1.id, user2.id, timedelta(days=1))
328 create_friend_reference(session, user3.id, user2.id, timedelta(days=1))
329 create_friend_reference(session, user4.id, user2.id, timedelta(days=1))
331 # user 3 only has reference from a deleted user
332 create_friend_reference(session, user4.id, user3.id, timedelta(days=1))
334 with search_session(token1) as api:
335 res = api.UserSearchV2(search_pb2.UserSearchReq(only_with_references=False))
336 assert [result.user_id for result in res.results] == [user1.id, user2.id, user3.id]
338 res = api.UserSearchV2(search_pb2.UserSearchReq(only_with_references=True))
339 assert [result.user_id for result in res.results] == [user2.id]
342def test_user_search_exactly_user_ids(db):
343 """
344 Test that UserSearch with exactly_user_ids returns only those users and ignores other filters.
345 """
346 # Create users with different properties
347 user1, token1 = generate_user()
348 user2, _ = generate_user(strong_verification=True)
349 user3, _ = generate_user(complete_profile=True)
350 user4, _ = generate_user(meetup_status=MeetupStatus.wants_to_meetup)
351 user5, _ = generate_user(delete_user=True) # Deleted user
353 refresh_materialized_views_rapid(empty_pb2.Empty())
354 refresh_materialized_views(empty_pb2.Empty())
356 with search_session(token1) as api:
357 # Test that exactly_user_ids returns only the specified users
358 res = api.UserSearchV2(search_pb2.UserSearchReq(exactly_user_ids=[user2.id, user3.id, user4.id]))
359 assert sorted([result.user_id for result in res.results]) == sorted([user2.id, user3.id, user4.id])
361 # Test that exactly_user_ids ignores other filters
362 res = api.UserSearchV2(
363 search_pb2.UserSearchReq(
364 exactly_user_ids=[user2.id, user3.id, user4.id],
365 only_with_strong_verification=True, # This would normally filter out user3 and user4
366 )
367 )
368 assert sorted([result.user_id for result in res.results]) == sorted([user2.id, user3.id, user4.id])
370 # Test with non-existent user IDs (should be ignored)
371 res = api.UserSearchV2(search_pb2.UserSearchReq(exactly_user_ids=[user1.id, 99999]))
372 assert [result.user_id for result in res.results] == [user1.id]
374 # Test with deleted user ID (should be ignored due to visibility filter)
375 res = api.UserSearchV2(search_pb2.UserSearchReq(exactly_user_ids=[user1.id, user5.id]))
376 assert [result.user_id for result in res.results] == [user1.id]
379def test_user_search_pagination_with_tied_scores(db, flags):
380 """
381 Paging through tied recommendation scores, including a tie block larger than the page size, returns every user
382 exactly once in a stable order.
383 """
384 flags.set_float("user_search_noise_scale", 0.0)
385 users = [generate_user()[0] for _ in range(7)]
386 token = generate_user()[1]
387 scores = [10.0, 5.0, 5.0, 5.0, 5.0, 5.0, -1.0]
389 with session_scope() as session:
390 for user, score in zip(users, scores):
391 session.execute(update(User).where(User.id == user.id).values(recommendation_score=score))
392 session.execute(update(User).where(User.id.not_in([u.id for u in users])).values(recommendation_score=0.0))
394 refresh_materialized_views_rapid(empty_pb2.Empty())
395 refresh_materialized_views(empty_pb2.Empty())
397 with session_scope() as session:
398 expected = list(
399 session.execute(select(User.id).order_by(User.recommendation_score.desc(), User.id.desc())).scalars()
400 )
402 with search_session(token) as api:
403 seen = []
404 page_token = ""
405 while True:
406 res = api.UserSearchV2(search_pb2.UserSearchReq(page_size=2, page_token=page_token))
407 seen += [result.user_id for result in res.results]
408 assert len(seen) <= len(expected)
409 page_token = res.next_page_token
410 if not page_token:
411 break
413 assert seen == expected
416def _user_search_order(token: str, page_size: int = 100) -> list[int]:
417 order: list[int] = []
418 page_token = ""
419 with search_session(token) as api:
420 while True:
421 res = api.UserSearchV2(search_pb2.UserSearchReq(page_size=page_size, page_token=page_token))
422 order += [result.user_id for result in res.results]
423 page_token = res.next_page_token
424 if not page_token:
425 return order
428def test_user_search_noise_is_per_viewer_and_stable_within_period(db, frozen_timewarp):
429 viewer1, token1 = generate_user()
430 _, token2 = generate_user()
431 candidate_ids = {generate_user()[0].id for _ in range(20)}
433 with session_scope() as session:
434 session.execute(update(User).values(recommendation_score=0))
436 refresh_materialized_views_rapid(empty_pb2.Empty())
438 order1 = _user_search_order(token1)
439 assert candidate_ids <= set(order1)
440 assert _user_search_order(token1) == order1
441 assert _user_search_order(token2) != order1
443 # step a day at a time, which also keeps the short-lived session alive, until this viewer's period rolls over
444 days_left = USER_SEARCH_NOISE_PERIOD_DAYS - (today().toordinal() + viewer1.id) % USER_SEARCH_NOISE_PERIOD_DAYS
445 for _ in range(days_left - 1):
446 frozen_timewarp.advance(timedelta(days=1))
447 assert _user_search_order(token1) == order1
448 frozen_timewarp.advance(timedelta(days=1))
449 assert _user_search_order(token1) != order1
452def test_user_search_pagination_with_noise(db):
453 _, token = generate_user()
454 for _ in range(10):
455 generate_user()
457 with session_scope() as session:
458 session.execute(update(User).values(recommendation_score=0))
460 refresh_materialized_views_rapid(empty_pb2.Empty())
462 full_order = _user_search_order(token)
463 assert len(full_order) == 11
464 assert _user_search_order(token, page_size=3) == full_order
467def test_user_search_noise_scale_flag(db, flags):
468 _, token = generate_user()
469 user_ids = [generate_user()[0].id for _ in range(10)]
471 with session_scope() as session:
472 session.execute(update(User).values(recommendation_score=-User.id))
474 refresh_materialized_views_rapid(empty_pb2.Empty())
476 def order() -> list[int]:
477 return [user_id for user_id in _user_search_order(token) if user_id in user_ids]
479 assert sorted(order()) == user_ids
480 assert order() != user_ids
482 flags.set_float("user_search_noise_scale", 0.0)
483 assert order() == user_ids
486def test_user_search_ranking_offsets_variant(db, flags):
487 flags.set_float("user_search_noise_scale", 0.0)
488 _, token = generate_user()
489 user_ids = [generate_user()[0].id for _ in range(5)]
491 with session_scope() as session:
492 session.execute(update(User).values(recommendation_score=-User.id))
493 # in variant "a", offsets reverse the order; variant "b" bumps only the last user to the top
494 for i, user_id in enumerate(user_ids):
495 session.add(UserRankingOffset(variant="a", user_id=user_id, offset=2.0 * i))
496 session.add(UserRankingOffset(variant="b", user_id=user_ids[-1], offset=100.0))
498 refresh_materialized_views_rapid(empty_pb2.Empty())
500 def order() -> list[int]:
501 return [user_id for user_id in _user_search_order(token, page_size=2) if user_id in user_ids]
503 assert order() == user_ids
505 flags.set_string("search_ranking_offsets_variant", "a")
506 assert order() == user_ids[::-1]
508 flags.set_string("search_ranking_offsets_variant", "b")
509 assert order() == user_ids[-1:] + user_ids[:-1]
511 flags.set_string("search_ranking_offsets_variant", "nonexistent")
512 assert order() == user_ids
515@pytest.fixture
516def sample_community(db) -> int:
517 """Create large community spanning from (-50, 0) to (50, 2) as events can only be created within communities."""
518 user, _ = generate_user()
519 with session_scope() as session:
520 return create_community(session, -50, 50, "Community", [user], [], None).id
523def test_event_search_by_query(sample_community):
524 """Test that EventSearch finds events by title (and content if query_title_only=False)."""
525 user, token = generate_user()
527 event1 = events_api.create_event(token=token, title="Lorem Ipsum")
528 event2 = events_api.create_event(token=token, content="Lorem Ipsum")
529 events_api.create_event(token=token)
531 with search_session(token) as api:
532 res = api.EventSearch(search_pb2.EventSearchReq(query=wrappers_pb2.StringValue(value="Ipsum")))
533 assert len(res.events) == 2
534 assert {result.event_id for result in res.events} == {event1.event_id, event2.event_id}
536 res = api.EventSearch(
537 search_pb2.EventSearchReq(query=wrappers_pb2.StringValue(value="Ipsum"), query_title_only=True)
538 )
539 assert len(res.events) == 1
540 assert res.events[0].event_id == event1.event_id
543def test_event_search_by_time(sample_community):
544 """Test that EventSearch filters with the given time range."""
545 user, token = generate_user()
547 event1 = events_api.create_event(
548 token=token,
549 start_date_time=local_date_time(now() + timedelta(hours=1)),
550 end_date_time=local_date_time(now() + timedelta(hours=2)),
551 )
552 event2 = events_api.create_event(
553 token=token,
554 start_date_time=local_date_time(now() + timedelta(hours=4)),
555 end_date_time=local_date_time(now() + timedelta(hours=5)),
556 )
557 event3 = events_api.create_event(
558 token=token,
559 start_date_time=local_date_time(now() + timedelta(hours=7)),
560 end_date_time=local_date_time(now() + timedelta(hours=8)),
561 )
563 with search_session(token) as api:
564 res = api.EventSearch(search_pb2.EventSearchReq(before=Timestamp_from_datetime(now() + timedelta(hours=6))))
565 assert len(res.events) == 2
566 assert {result.event_id for result in res.events} == {event1.event_id, event2.event_id}
568 res = api.EventSearch(search_pb2.EventSearchReq(after=Timestamp_from_datetime(now() + timedelta(hours=3))))
569 assert len(res.events) == 2
570 assert {result.event_id for result in res.events} == {event2.event_id, event3.event_id}
572 res = api.EventSearch(
573 search_pb2.EventSearchReq(
574 before=Timestamp_from_datetime(now() + timedelta(hours=6)),
575 after=Timestamp_from_datetime(now() + timedelta(hours=3)),
576 )
577 )
578 assert len(res.events) == 1
579 assert res.events[0].event_id == event2.event_id
582def test_event_search_by_circle(sample_community):
583 """Test that EventSearch only returns events within the given circle."""
584 user, token = generate_user()
586 inside_pts = [(0.1, 0.01), (0.01, 0.1)]
587 for i, (lat, lng) in enumerate(inside_pts):
588 events_api.create_event(
589 token=token,
590 title=f"Inside area {i}",
591 location=events_pb2.EventLocation(lat=lat, lng=lng, address=f"Inside area {i}"),
592 )
594 outside_pts = [(1, 0.1), (0.1, 1), (10, 1)]
595 for i, (lat, lng) in enumerate(outside_pts):
596 events_api.create_event(
597 token=token,
598 title=f"Outside area {i}",
599 location=events_pb2.EventLocation(lat=lat, lng=lng, address=f"Outside area {i}"),
600 )
602 with search_session(token) as api:
603 res = api.EventSearch(search_pb2.EventSearchReq(search_in_area=search_pb2.Area(lat=0, lng=0, radius=100000)))
604 assert len(res.events) == len(inside_pts)
605 assert all(event.title.startswith("Inside area") for event in res.events)
608def test_event_search_by_rectangle(sample_community):
609 """Test that EventSearch only returns events within the given rectangular area."""
610 user, token = generate_user()
612 inside_pts = [(0.1, 0.2), (1.2, 0.2)]
613 for i, (lat, lng) in enumerate(inside_pts):
614 events_api.create_event(
615 token=token,
616 title=f"Inside area {i}",
617 location=events_pb2.EventLocation(lat=lat, lng=lng, address=f"Inside area {i}"),
618 )
620 outside_pts = [(-1, 0.1), (0.1, 0.01), (-0.01, 0.01), (0.1, 1.2), (10, 1)]
621 for i, (lat, lng) in enumerate(outside_pts):
622 events_api.create_event(
623 token=token,
624 title=f"Outside area {i}",
625 location=events_pb2.EventLocation(lat=lat, lng=lng, address=f"Outside area {i}"),
626 )
628 with search_session(token) as api:
629 res = api.EventSearch(
630 search_pb2.EventSearchReq(
631 search_in_rectangle=search_pb2.RectArea(lat_min=0, lat_max=2, lng_min=0.1, lng_max=1)
632 )
633 )
634 assert len(res.events) == len(inside_pts)
635 assert all(event.title.startswith("Inside area") for event in res.events)
638def test_event_search_pagination(sample_community):
639 """Test that EventSearch paginates correctly.
641 Check that
642 - <page_size> events are returned, if available
643 - sort order is applied (default: past=False)
644 - the next page token continues where the previous page left off
645 """
646 user, token = generate_user()
648 anchor_time = now().replace(second=0, microsecond=0) # Events are created at minute granularity
649 for i in range(5):
650 events_api.create_event(
651 token=token,
652 title=f"Event {i + 1}",
653 start_date_time=local_date_time(anchor_time + timedelta(hours=i + 1)),
654 end_date_time=local_date_time(anchor_time + timedelta(hours=i + 1, minutes=30)),
655 )
657 with search_session(token) as api:
658 res = api.EventSearch(search_pb2.EventSearchReq(past=False, page_size=4))
659 assert len(res.events) == 4
660 assert [event.title for event in res.events] == ["Event 1", "Event 2", "Event 3", "Event 4"]
661 assert res.next_page_token
663 res = api.EventSearch(search_pb2.EventSearchReq(page_size=4, page_token=res.next_page_token))
664 assert len(res.events) == 1
665 assert res.events[0].title == "Event 5"
666 assert res.next_page_token == ""
668 # move all the events into the past to test past pagination
669 with session_scope() as session:
670 for occurrence in session.execute(select(EventOccurrence)).scalars().all():
671 occurrence.during = TimestamptzRange(
672 occurrence.start_time - timedelta(days=30), occurrence.end_time - timedelta(days=30)
673 )
675 with search_session(token) as api:
676 res = api.EventSearch(search_pb2.EventSearchReq(past=True, page_size=2))
677 assert [event.title for event in res.events] == ["Event 5", "Event 4"]
678 assert res.next_page_token
680 res = api.EventSearch(search_pb2.EventSearchReq(past=True, page_size=2, page_token=res.next_page_token))
681 assert [event.title for event in res.events] == ["Event 3", "Event 2"]
682 assert res.next_page_token
684 res = api.EventSearch(search_pb2.EventSearchReq(past=True, page_size=2, page_token=res.next_page_token))
685 assert [event.title for event in res.events] == ["Event 1"]
686 assert res.next_page_token == ""
689def test_event_search_pagination_with_page_number(sample_community):
690 """Test that EventSearch paginates correctly with page number.
692 Check that
693 - <page_size> events are returned, if available
694 - sort order is applied (default: past=False)
695 - <page_number> is respected
696 - <total_items> is correct
697 """
698 user, token = generate_user()
700 anchor_time = now()
701 for i in range(5):
702 events_api.create_event(
703 token=token,
704 title=f"Event {i + 1}",
705 start_date_time=local_date_time(anchor_time + timedelta(hours=i + 1)),
706 end_date_time=local_date_time(anchor_time + timedelta(hours=i + 1, minutes=30)),
707 )
709 with search_session(token) as api:
710 res = api.EventSearch(search_pb2.EventSearchReq(page_size=2, page_number=1))
711 assert len(res.events) == 2
712 assert [event.title for event in res.events] == ["Event 1", "Event 2"]
713 assert res.total_items == 5
715 res = api.EventSearch(search_pb2.EventSearchReq(page_size=2, page_number=2))
716 assert len(res.events) == 2
717 assert [event.title for event in res.events] == ["Event 3", "Event 4"]
718 assert res.total_items == 5
720 res = api.EventSearch(search_pb2.EventSearchReq(page_size=2, page_number=3))
721 assert len(res.events) == 1
722 assert [event.title for event in res.events] == ["Event 5"]
723 assert res.total_items == 5
725 # Verify no more pages
726 res = api.EventSearch(search_pb2.EventSearchReq(page_size=2, page_number=4))
727 assert not res.events
728 assert res.total_items == 5
731def test_event_search_filter_subscription_attendance_organizing_my_communities(sample_community, moderator: Moderator):
732 """Test that EventSearch respects subscribed, attending, organizing and my_communities filters and by default
733 returns all events.
734 """
735 _, token = generate_user()
736 other_user, other_token = generate_user()
738 with communities_session(token) as api:
739 api.JoinCommunity(communities_pb2.JoinCommunityReq(community_id=sample_community))
741 with session_scope() as session:
742 create_community(session, 55, 60, "Other community", [other_user], [], None)
744 e_subscribed = events_api.create_event(token=other_token, title="Subscribed event")
745 e_attending = events_api.create_event(token=other_token, title="Attending event")
746 events_api.create_event(token=other_token, title="Community event")
747 events_api.create_event(
748 token=other_token,
749 title="Other community event",
750 location=events_pb2.EventLocation(lat=58, lng=1, address="Somewhere"),
751 )
753 # Approve all events so they're visible to other users
754 with session_scope() as session:
755 occurrence_ids = session.execute(select(EventOccurrence.id)).scalars().all()
756 for oid in occurrence_ids:
757 moderator.approve_event_occurrence(oid)
759 events_api.create_event(token=token, title="Organized event")
760 events_api.set_event_subscription(token=token, event_id=e_subscribed.event_id, subscribe=True)
761 events_api.set_event_attendance(
762 token=token, event_id=e_attending.event_id, attendance_state=events_pb2.ATTENDANCE_STATE_GOING
763 )
765 with search_session(token) as api:
766 res = api.EventSearch(search_pb2.EventSearchReq())
767 assert {event.title for event in res.events} == {
768 "Subscribed event",
769 "Attending event",
770 "Community event",
771 "Other community event",
772 "Organized event",
773 }
775 res = api.EventSearch(search_pb2.EventSearchReq(subscribed=True))
776 assert {event.title for event in res.events} == {"Subscribed event", "Organized event"}
778 res = api.EventSearch(search_pb2.EventSearchReq(attending=True))
779 assert {event.title for event in res.events} == {"Attending event", "Organized event"}
781 res = api.EventSearch(search_pb2.EventSearchReq(organizing=True))
782 assert {event.title for event in res.events} == {"Organized event"}
784 res = api.EventSearch(search_pb2.EventSearchReq(my_communities=True))
785 assert {event.title for event in res.events} == {
786 "Subscribed event",
787 "Attending event",
788 "Community event",
789 "Organized event",
790 }
792 res = api.EventSearch(search_pb2.EventSearchReq(subscribed=True, attending=True))
793 assert {event.title for event in res.events} == {"Subscribed event", "Attending event", "Organized event"}
796def test_event_search_exclude_attending(sample_community, moderator: Moderator):
797 """Test that exclude_attending removes events the user is attending or organizing."""
798 user, token = generate_user()
799 other_user, other_token = generate_user()
801 with communities_session(token) as api:
802 api.JoinCommunity(communities_pb2.JoinCommunityReq(community_id=sample_community))
804 with session_scope() as session:
805 create_community(session, 55, 60, "Other community", [other_user], [], None)
807 e_attending = events_api.create_event(token=other_token, title="Attending event")
808 e_community_only = events_api.create_event(token=other_token, title="Community only event")
809 events_api.create_event(
810 token=other_token,
811 title="Other community event",
812 location=events_pb2.EventLocation(lat=58, lng=1, address="Somewhere"),
813 )
815 with session_scope() as session:
816 occurrence_ids = session.execute(select(EventOccurrence.id)).scalars().all()
817 for oid in occurrence_ids:
818 moderator.approve_event_occurrence(oid)
820 e_organized = events_api.create_event(token=token, title="Organized event")
821 events_api.set_event_attendance(
822 token=token, event_id=e_attending.event_id, attendance_state=events_pb2.ATTENDANCE_STATE_GOING
823 )
825 with search_session(token) as api:
826 # baseline: my_communities returns all community events including attended/organized
827 res = api.EventSearch(search_pb2.EventSearchReq(my_communities=True))
828 assert {event.title for event in res.events} == {
829 "Attending event",
830 "Community only event",
831 "Organized event",
832 }
834 # my_communities + exclude_attending: drops attended and organized events
835 res = api.EventSearch(search_pb2.EventSearchReq(my_communities=True, exclude_attending=True))
836 assert {event.title for event in res.events} == {"Community only event"}
838 # exclude_attending alone (no other filter = all events): drops attended and organized
839 res = api.EventSearch(search_pb2.EventSearchReq(exclude_attending=True))
840 assert {event.title for event in res.events} == {"Community only event", "Other community event"}
842 # attending + exclude_attending is invalid
843 with pytest.raises(grpc.RpcError) as e:
844 api.EventSearch(search_pb2.EventSearchReq(attending=True, exclude_attending=True))
845 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
848def test_regression_search_multiple_pages(db):
849 """
850 There was a bug when there are multiple pages of results
851 """
852 user, token = generate_user()
853 user_ids = [user.id]
854 for _ in range(10):
855 other_user, _ = generate_user()
856 user_ids.append(other_user.id)
858 refresh_materialized_views_rapid(empty_pb2.Empty())
859 refresh_materialized_views(empty_pb2.Empty())
861 with search_session(token) as api:
862 res = api.UserSearchV2(search_pb2.UserSearchReq(page_size=5))
863 assert [result.user_id for result in res.results] == user_ids[:5]
864 assert res.next_page_token
867def test_regression_search_no_results(db):
868 """
869 There was a bug when there were no results
870 """
871 # put us far away
872 user, token = generate_user()
874 refresh_materialized_views_rapid(empty_pb2.Empty())
875 refresh_materialized_views(empty_pb2.Empty())
877 with search_session(token) as api:
878 res = api.UserSearchV2(search_pb2.UserSearchReq(only_with_references=True))
879 assert len(res.results) == 0
882def test_user_filter_same_gender_only(db):
883 """Test that same_gender_only filter works correctly"""
884 # Create users with different genders and strong verification status
885 woman_with_sv, token_woman_with_sv = generate_user(strong_verification=True, gender="Woman")
886 woman_without_sv, token_woman_without_sv = generate_user(strong_verification=False, gender="Woman")
887 man_with_sv, token_man_with_sv = generate_user(strong_verification=True, gender="Man")
888 man_without_sv, _ = generate_user(strong_verification=False, gender="Man")
889 other_woman_with_sv, _ = generate_user(strong_verification=True, gender="Woman")
891 refresh_materialized_views_rapid(empty_pb2.Empty())
892 refresh_materialized_views(empty_pb2.Empty())
894 # Test 1: Woman with strong verification should see only women when same_gender_only=True
895 with search_session(token_woman_with_sv) as api:
896 res = api.UserSearchV2(search_pb2.UserSearchReq(same_gender_only=True))
897 result_ids = [result.user_id for result in res.results]
898 assert woman_with_sv.id in result_ids
899 assert woman_without_sv.id in result_ids
900 assert other_woman_with_sv.id in result_ids
901 assert man_with_sv.id not in result_ids
902 assert man_without_sv.id not in result_ids
904 # Test 2: Man with strong verification should see only men when same_gender_only=True
905 with search_session(token_man_with_sv) as api:
906 res = api.UserSearchV2(search_pb2.UserSearchReq(same_gender_only=True))
907 result_ids = [result.user_id for result in res.results]
908 assert man_with_sv.id in result_ids
909 assert man_without_sv.id in result_ids
910 assert woman_with_sv.id not in result_ids
911 assert woman_without_sv.id not in result_ids
912 assert other_woman_with_sv.id not in result_ids
914 # Test 3: Woman without strong verification should get an error
915 with search_session(token_woman_without_sv) as api:
916 with pytest.raises(Exception) as e:
917 api.UserSearchV2(search_pb2.UserSearchReq(same_gender_only=True))
918 assert "NEED_STRONG_VERIFICATION" in str(e.value) or "FAILED_PRECONDITION" in str(e.value)
920 # Test 4: When same_gender_only=False, should see all users
921 with search_session(token_woman_with_sv) as api:
922 res = api.UserSearchV2(search_pb2.UserSearchReq(same_gender_only=False))
923 result_ids = [result.user_id for result in res.results]
924 assert woman_with_sv.id in result_ids
925 assert woman_without_sv.id in result_ids
926 assert other_woman_with_sv.id in result_ids
927 assert man_with_sv.id in result_ids
928 assert man_without_sv.id in result_ids
931def test_user_filter_same_gender_only_with_other_filters(db):
932 """Test that same_gender_only filter works correctly combined with other filters"""
933 # Create users with different properties
934 woman_host, token_woman = generate_user(
935 strong_verification=True, gender="Woman", hosting_status=HostingStatus.can_host
936 )
937 woman_cant_host, _ = generate_user(strong_verification=True, gender="Woman", hosting_status=HostingStatus.cant_host)
938 man_host, _ = generate_user(strong_verification=True, gender="Man", hosting_status=HostingStatus.can_host)
940 refresh_materialized_views_rapid(empty_pb2.Empty())
941 refresh_materialized_views(empty_pb2.Empty())
943 # Test: Combine same_gender_only with hosting_status filter
944 with search_session(token_woman) as api:
945 res = api.UserSearchV2(
946 search_pb2.UserSearchReq(same_gender_only=True, hosting_status_filter=[api_pb2.HOSTING_STATUS_CAN_HOST])
947 )
948 result_ids = [result.user_id for result in res.results]
949 assert woman_host.id in result_ids
950 assert woman_cant_host.id not in result_ids
951 assert man_host.id not in result_ids