Coverage for app/backend/src/tests/test_public.py: 100%
255 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
1import json
2from datetime import UTC, date, datetime
3from math import sqrt
4from unittest.mock import patch
6import grpc
7import pytest
8from google.protobuf import empty_pb2
10from couchers.db import session_scope
11from couchers.jobs.enqueue import queue_job
12from couchers.jobs.handlers import update_randomized_locations
13from couchers.materialized_views import refresh_materialized_views_rapid
14from couchers.models import (
15 Invoice,
16 InvoiceType,
17 ModerationObjectType,
18 ModerationState,
19 ModerationVisibility,
20 ProfilePublicVisibility,
21 Reference,
22 ReferenceType,
23)
24from couchers.proto import api_pb2, public_pb2
25from couchers.servicers.public import _get_donation_stats, _get_public_users, _get_signup_page_info, _get_volunteers
26from tests.fixtures.db import generate_user, make_volunteer
27from tests.fixtures.misc import process_jobs
28from tests.fixtures.sessions import public_session
31@pytest.fixture(autouse=True)
32def _(testconfig):
33 pass
36def test_GetPublicMapLayer(db):
37 user1, _ = generate_user()
38 user2, _ = generate_user(username="user2", public_visibility=ProfilePublicVisibility.nothing)
39 user3, _ = generate_user()
40 user4, _ = generate_user(username="user4", public_visibility=ProfilePublicVisibility.limited)
41 user5, _ = generate_user()
43 # these are hardcoded in test_fixtures
44 test_user_coordinates = [-73.9740, 40.7108]
46 with session_scope() as session:
47 queue_job(session, job=update_randomized_locations, payload=empty_pb2.Empty())
49 process_jobs()
51 with public_session() as public:
52 http_body = public.GetPublicUsers(empty_pb2.Empty())
53 assert http_body.content_type == "application/json"
54 data = json.loads(http_body.data)
55 # Sort to ensure a deterministic order
56 data["features"].sort(key=lambda f: f["geometry"]["coordinates"][0])
57 assert data == {
58 "type": "FeatureCollection",
59 "features": [
60 {
61 "type": "Feature",
62 "geometry": {"type": "Point", "coordinates": [-74.042643848, 40.706241098]},
63 "properties": {"username": None},
64 },
65 {
66 "type": "Feature",
67 "geometry": {"type": "Point", "coordinates": [-73.974, 40.7108]},
68 "properties": {"username": "user4"},
69 },
70 {
71 "type": "Feature",
72 "geometry": {"type": "Point", "coordinates": [-73.955417734, 40.691831306]},
73 "properties": {"username": None},
74 },
75 {
76 "type": "Feature",
77 "geometry": {"type": "Point", "coordinates": [-73.928380198, 40.729706144]},
78 "properties": {"username": None},
79 },
80 ],
81 }
83 for user in data["features"]:
84 coords = user["geometry"]["coordinates"]
85 if user["properties"]["username"]:
86 assert coords == test_user_coordinates
87 else:
88 xdiff = coords[0] - test_user_coordinates[0]
89 ydiff = coords[1] - test_user_coordinates[1]
90 dist = sqrt(xdiff**2 + ydiff**2)
91 assert dist > 0.02 and dist < 0.1
94def test_GetDonationStats_empty(db, feature_flags):
95 """Test GetDonationStats with no donations returns zero and goal"""
96 _get_donation_stats.cache_clear()
98 feature_flags.set("donation_goal_usd", 2500)
99 feature_flags.set("donation_offset_usd", 700)
100 with public_session() as public:
101 res = public.GetDonationStats(empty_pb2.Empty())
102 assert res.total_donated_ytd == 0
103 assert res.goal == 2500
106def test_GetDonationStats_with_donations(db, feature_flags):
107 """Test GetDonationStats sums on_platform donations correctly"""
108 _get_donation_stats.cache_clear()
109 user, _ = generate_user()
111 with session_scope() as session:
112 # Add some on_platform donations (should be counted)
113 session.add(
114 Invoice(
115 user_id=user.id,
116 amount=100,
117 stripe_payment_intent_id="pi_test_1",
118 stripe_receipt_url="https://example.com/receipt/1",
119 invoice_type=InvoiceType.on_platform,
120 )
121 )
122 session.add(
123 Invoice(
124 user_id=user.id,
125 amount=250,
126 stripe_payment_intent_id="pi_test_2",
127 stripe_receipt_url="https://example.com/receipt/2",
128 invoice_type=InvoiceType.on_platform,
129 )
130 )
131 session.add(
132 Invoice(
133 user_id=user.id,
134 amount=500,
135 stripe_payment_intent_id="pi_test_3",
136 stripe_receipt_url="https://example.com/receipt/3",
137 invoice_type=InvoiceType.on_platform,
138 )
139 )
141 feature_flags.set("donation_goal_usd", 5000)
142 feature_flags.set("donation_offset_usd", 0)
143 with public_session() as public:
144 res = public.GetDonationStats(empty_pb2.Empty())
145 assert res.total_donated_ytd == 850
146 assert res.goal == 5000
149def test_GetDonationStats_excludes_merch(db, feature_flags):
150 """Test GetDonationStats excludes external_shop (merch) invoices"""
151 _get_donation_stats.cache_clear()
152 user, _ = generate_user()
154 with session_scope() as session:
155 # Add on_platform donation (should be counted)
156 session.add(
157 Invoice(
158 user_id=user.id,
159 amount=200,
160 stripe_payment_intent_id="pi_test_donation",
161 stripe_receipt_url="https://example.com/receipt/donation",
162 invoice_type=InvoiceType.on_platform,
163 )
164 )
165 # Add external_shop/merch purchase (should NOT be counted)
166 session.add(
167 Invoice(
168 user_id=user.id,
169 amount=50,
170 stripe_payment_intent_id="pi_test_merch",
171 stripe_receipt_url="https://example.com/receipt/merch",
172 invoice_type=InvoiceType.external_shop,
173 )
174 )
176 feature_flags.set("donation_goal_usd", 5000)
177 feature_flags.set("donation_offset_usd", 0)
178 with public_session() as public:
179 res = public.GetDonationStats(empty_pb2.Empty())
180 # Should only count the on_platform donation, not the merch
181 assert res.total_donated_ytd == 200
182 assert res.goal == 5000
185def test_GetDonationStats_excludes_previous_years(db, feature_flags):
186 """Test GetDonationStats only counts current year donations"""
187 _get_donation_stats.cache_clear()
188 user, _ = generate_user()
190 with session_scope() as session:
191 # Add donation from this year (should be counted)
192 session.add(
193 Invoice(
194 user_id=user.id,
195 amount=300,
196 stripe_payment_intent_id="pi_test_this_year",
197 stripe_receipt_url="https://example.com/receipt/this_year",
198 invoice_type=InvoiceType.on_platform,
199 )
200 )
201 # Add donation from last year (should NOT be counted)
202 last_year = datetime(datetime.now(UTC).year - 1, 6, 15, tzinfo=UTC)
203 invoice = Invoice(
204 user_id=user.id,
205 amount=1000,
206 stripe_payment_intent_id="pi_test_last_year",
207 stripe_receipt_url="https://example.com/receipt/last_year",
208 invoice_type=InvoiceType.on_platform,
209 )
210 session.add(invoice)
211 session.flush()
212 # Manually set the created date to last year
213 invoice.created = last_year
215 feature_flags.set("donation_goal_usd", 5000)
216 feature_flags.set("donation_offset_usd", 0)
217 with public_session() as public:
218 res = public.GetDonationStats(empty_pb2.Empty())
219 # Should only count this year's donation
220 assert res.total_donated_ytd == 300
221 assert res.goal == 5000
224def test_GetDonationStats_uses_flags(db, feature_flags):
225 """Goal and offset come from the donation_goal_usd / donation_offset_usd flags when configured"""
226 _get_donation_stats.cache_clear()
227 user, _ = generate_user()
229 with session_scope() as session:
230 session.add(
231 Invoice(
232 user_id=user.id,
233 amount=1000,
234 stripe_payment_intent_id="pi_test_flag",
235 stripe_receipt_url="https://example.com/receipt/flag",
236 invoice_type=InvoiceType.on_platform,
237 )
238 )
240 feature_flags.set("donation_goal_usd", 12000)
241 feature_flags.set("donation_offset_usd", 300)
243 with public_session() as public:
244 res = public.GetDonationStats(empty_pb2.Empty())
245 assert res.goal == 12000
246 assert res.total_donated_ytd == 700 # 1000 donated minus the 300 offset
248 _get_donation_stats.cache_clear()
251def test_GetVolunteers_mixed_current_and_past(db):
252 """Test GetVolunteers with both current and past volunteers"""
254 _get_volunteers.cache_clear()
256 current1, _ = generate_user(username="current1")
257 current2, _ = generate_user(username="current2")
258 past1, _ = generate_user(username="past1")
259 past2, _ = generate_user(username="past2")
261 with session_scope() as session:
262 session.add(
263 make_volunteer(
264 user_id=current1.id,
265 role="Current Role 1",
266 started_volunteering=date(2023, 1, 1),
267 )
268 )
269 session.add(
270 make_volunteer(
271 user_id=current2.id,
272 role="Current Role 2",
273 started_volunteering=date(2024, 1, 1),
274 )
275 )
276 session.add(
277 make_volunteer(
278 user_id=past1.id,
279 role="Past Role 1",
280 started_volunteering=date(2020, 1, 1),
281 stopped_volunteering=date(2022, 6, 1),
282 )
283 )
284 session.add(
285 make_volunteer(
286 user_id=past2.id,
287 role="Past Role 2",
288 started_volunteering=date(2021, 1, 1),
289 stopped_volunteering=date(2023, 12, 31),
290 )
291 )
293 refresh_materialized_views_rapid(empty_pb2.Empty())
295 with public_session() as public:
296 res = public.GetVolunteers(empty_pb2.Empty())
297 assert len(res.current_volunteers) == 2
298 assert len(res.past_volunteers) == 2
300 # Past volunteers are sorted by stopped_volunteering descending
301 assert res.past_volunteers[0].username == "past2"
302 assert res.past_volunteers[1].username == "past1"
305def test_GetVolunteers_custom_sort_key(db):
306 """Test GetVolunteers respects custom sort_key"""
308 _get_volunteers.cache_clear()
310 user1, _ = generate_user(username="user1")
311 user2, _ = generate_user(username="user2")
312 user3, _ = generate_user(username="user3")
314 with session_scope() as session:
315 # user2 should be first (lowest sort_key)
316 session.add(
317 make_volunteer(
318 user_id=user2.id,
319 role="Role 2",
320 started_volunteering=date(2023, 3, 1),
321 sort_key=1.0,
322 )
323 )
324 # user3 should be second
325 session.add(
326 make_volunteer(
327 user_id=user3.id,
328 role="Role 3",
329 started_volunteering=date(2023, 1, 1),
330 sort_key=2.0,
331 )
332 )
333 # user1 should be last (no sort_key, falls back to started_volunteering)
334 session.add(
335 make_volunteer(
336 user_id=user1.id,
337 role="Role 1",
338 started_volunteering=date(2023, 2, 1),
339 )
340 )
342 refresh_materialized_views_rapid(empty_pb2.Empty())
344 with public_session() as public:
345 res = public.GetVolunteers(empty_pb2.Empty())
346 assert len(res.current_volunteers) == 3
347 assert res.current_volunteers[0].username == "user2"
348 assert res.current_volunteers[1].username == "user3"
349 assert res.current_volunteers[2].username == "user1"
352def test_GetVolunteers_excludes_hidden(db):
353 """Test GetVolunteers excludes volunteers with show_on_team_page=False"""
355 _get_volunteers.cache_clear()
357 user1, _ = generate_user(username="visible")
358 user2, _ = generate_user(username="hidden")
360 with session_scope() as session:
361 session.add(
362 make_volunteer(
363 user_id=user1.id,
364 role="Visible Role",
365 started_volunteering=date(2023, 1, 1),
366 )
367 )
368 session.add(
369 make_volunteer(
370 user_id=user2.id,
371 role="Hidden Role",
372 started_volunteering=date(2023, 1, 1),
373 show_on_team_page=False,
374 )
375 )
377 refresh_materialized_views_rapid(empty_pb2.Empty())
379 with public_session() as public:
380 res = public.GetVolunteers(empty_pb2.Empty())
381 assert len(res.current_volunteers) == 1
382 assert res.current_volunteers[0].username == "visible"
385def test_GetVolunteers_link_types(db):
386 """Test GetVolunteers handles different link types"""
388 _get_volunteers.cache_clear()
390 user_default, _ = generate_user(username="default_link")
391 user_custom, _ = generate_user(username="custom_link")
393 with session_scope() as session:
394 # Volunteer with default couchers link
395 session.add(
396 make_volunteer(
397 user_id=user_default.id,
398 role="Default Link",
399 started_volunteering=date(2023, 1, 1),
400 )
401 )
402 # Volunteer with custom link
403 session.add(
404 make_volunteer(
405 user_id=user_custom.id,
406 role="Custom Link",
407 started_volunteering=date(2023, 1, 1),
408 link_type="email",
409 link_text="contact@example.com",
410 link_url="mailto:contact@example.com",
411 )
412 )
414 refresh_materialized_views_rapid(empty_pb2.Empty())
416 with public_session() as public:
417 res = public.GetVolunteers(empty_pb2.Empty())
418 assert len(res.current_volunteers) == 2
420 # Check default link
421 default_vol = next(v for v in res.current_volunteers if v.username == "default_link")
422 assert default_vol.link_type == "couchers"
423 assert default_vol.link_text == "@default_link"
424 assert "default_link" in default_vol.link_url
426 # Check custom link
427 custom_vol = next(v for v in res.current_volunteers if v.username == "custom_link")
428 assert custom_vol.link_type == "email"
429 assert custom_vol.link_text == "contact@example.com"
430 assert custom_vol.link_url == "mailto:contact@example.com"
433def test_GetVolunteers_board_member_flag(db):
434 """Test GetVolunteers correctly identifies board members"""
436 _get_volunteers.cache_clear()
438 board_member, _ = generate_user(username="board_member")
439 regular_volunteer, _ = generate_user(username="regular")
441 with session_scope() as session:
442 session.add(
443 make_volunteer(
444 user_id=board_member.id,
445 role="Board Member Role",
446 started_volunteering=date(2023, 1, 1),
447 )
448 )
449 session.add(
450 make_volunteer(
451 user_id=regular_volunteer.id,
452 role="Regular Role",
453 started_volunteering=date(2023, 1, 1),
454 )
455 )
457 refresh_materialized_views_rapid(empty_pb2.Empty())
459 # Mock the static badge dict to include board_member
460 with patch("couchers.servicers.public.get_static_badge_dict", return_value={"board_member": [board_member.id]}):
461 with public_session() as public:
462 res = public.GetVolunteers(empty_pb2.Empty())
463 assert len(res.current_volunteers) == 2
465 board_vol = next(v for v in res.current_volunteers if v.username == "board_member")
466 assert board_vol.is_board_member is True
468 regular_vol = next(v for v in res.current_volunteers if v.username == "regular")
469 assert regular_vol.is_board_member is False
472def test_GetSignupPageInfo(db):
473 """Test GetSignupPageInfo returns a correct user count and last signup info"""
475 _get_signup_page_info.cache_clear()
477 user1, _ = generate_user(username="user1")
478 user2, _ = generate_user(username="user2")
479 user3, _ = generate_user(username="user3")
481 refresh_materialized_views_rapid(empty_pb2.Empty())
483 with public_session() as public:
484 res = public.GetSignupPageInfo(empty_pb2.Empty())
485 # user3 should be the last signup (highest id)
486 assert res.user_count >= 3
487 assert res.last_location # Should have some location
488 assert res.last_signup # Should have a timestamp
491def test_GetSignupPageInfo_excludes_invisible_users(db):
492 """Test GetSignupPageInfo excludes deleted/banned users from count"""
493 _get_signup_page_info.cache_clear()
495 visible_user, _ = generate_user(username="visible")
496 deleted_user, _ = generate_user(username="deleted", delete_user=True)
498 with public_session() as public:
499 res = public.GetSignupPageInfo(empty_pb2.Empty())
500 # Deleted user should not be counted or be the last signup
501 assert res.user_count >= 1
504def test_GetPublicUser_not_found(db):
505 """Test GetPublicUser returns NOT_FOUND for nonexistent user"""
506 with public_session() as public:
507 with pytest.raises(grpc.RpcError) as exc:
508 public.GetPublicUser(public_pb2.GetPublicUserReq(user="nonexistent_user"))
509 assert exc.value.code() == grpc.StatusCode.NOT_FOUND
512def test_GetPublicUser_invisible_user(db):
513 """Test GetPublicUser returns NOT_FOUND for deleted/banned user"""
514 deleted_user, _ = generate_user(username="deleted", delete_user=True)
516 with public_session() as public:
517 with pytest.raises(grpc.RpcError) as exc:
518 public.GetPublicUser(public_pb2.GetPublicUserReq(user="deleted"))
519 assert exc.value.code() == grpc.StatusCode.NOT_FOUND
522def test_GetPublicUser_limited_visibility(db):
523 """Test GetPublicUser returns limited_user for user with limited visibility"""
525 user, _ = generate_user(
526 username="limited_user",
527 name="Limited User",
528 public_visibility=ProfilePublicVisibility.limited,
529 )
531 # Add a reference to test reference counting
532 referrer, _ = generate_user(username="referrer")
533 with session_scope() as session:
534 moderation_state = ModerationState(
535 object_type=ModerationObjectType.reference,
536 object_id=0,
537 visibility=ModerationVisibility.visible,
538 )
539 session.add(moderation_state)
540 session.flush()
541 reference = Reference(
542 from_user_id=referrer.id,
543 to_user_id=user.id,
544 reference_type=ReferenceType.friend,
545 text="Great host!",
546 rating=0.8,
547 was_appropriate=True,
548 moderation_state_id=moderation_state.id,
549 )
550 session.add(reference)
551 session.flush()
552 moderation_state.object_id = reference.id
554 with public_session() as public:
555 res = public.GetPublicUser(public_pb2.GetPublicUserReq(user="limited_user"))
556 assert res.HasField("limited_user")
557 assert res.limited_user.username == "limited_user"
558 assert res.limited_user.name == "Limited User"
559 assert res.limited_user.city == "Testing city"
560 assert res.limited_user.hometown == "Test hometown"
561 assert res.limited_user.num_references == 1
562 assert res.limited_user.hosting_status == api_pb2.HOSTING_STATUS_CANT_HOST
563 assert len(res.limited_user.badges) == 0
566def test_GetPublicUser_most_visibility(db):
567 """Test GetPublicUser returns most_user for user with most visibility"""
568 user, _ = generate_user(
569 username="most_user",
570 name="Most User",
571 public_visibility=ProfilePublicVisibility.most,
572 )
574 with public_session() as public:
575 res = public.GetPublicUser(public_pb2.GetPublicUserReq(user="most_user"))
576 assert res.HasField("most_user")
577 assert res.most_user.username == "most_user"
578 assert res.most_user.name == "Most User"
579 assert res.most_user.city == "Testing city"
580 assert res.most_user.hosting_status == api_pb2.HOSTING_STATUS_CANT_HOST
583def test_GetPublicUser_full_visibility(db):
584 """Test GetPublicUser returns full_user for user with full visibility"""
585 _get_public_users.cache_clear()
587 user, _ = generate_user(
588 username="full_user",
589 name="Full User",
590 public_visibility=ProfilePublicVisibility.full,
591 )
593 with public_session() as public:
594 res = public.GetPublicUser(public_pb2.GetPublicUserReq(user="full_user"))
595 assert res.HasField("full_user")
596 assert res.full_user.username == "full_user"
597 assert res.full_user.name == "Full User"
598 assert res.full_user.city == "Testing city"
599 # Full user should have all the fields from the complete user profile
600 assert res.full_user.hosting_status == api_pb2.HOSTING_STATUS_CANT_HOST