Coverage for app/backend/src/tests/test_public_trips.py: 99%
507 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-22 16:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-22 16:01 +0000
1from datetime import date, timedelta
2from unittest.mock import patch
4import grpc
5import pytest
6from sqlalchemy import select
8from couchers.constants import HOST_REQUEST_MIN_LENGTH_UTF16
9from couchers.db import session_scope
10from couchers.models import Cluster, ClusterRole, ClusterSubscription, Node, NodeType, User
11from couchers.models.host_requests import HostRequest, HostRequestStatus
12from couchers.models.public_trips import PublicTrip, PublicTripStatus
13from couchers.proto import public_trips_pb2, requests_pb2
14from couchers.utils import create_polygon_lat_lng, now, to_multi, today
15from tests.fixtures.db import generate_user
16from tests.fixtures.sessions import public_trips_session, requests_session
19def _valid_request_text(text: str = "Offer to host") -> str:
20 utf16_length = len(text.encode("utf-16-le")) // 2
21 if utf16_length >= HOST_REQUEST_MIN_LENGTH_UTF16: 21 ↛ 22line 21 didn't jump to line 22 because the condition on line 21 was never true
22 return text
23 return text + "_" * (HOST_REQUEST_MIN_LENGTH_UTF16 - utf16_length)
26@pytest.fixture(autouse=True)
27def _(testconfig):
28 pass
31# 150+ utf-16 code units to satisfy PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16.
32VALID_DESCRIPTION = (
33 "Visiting the area for a week for a music festival. I love meeting new people "
34 "and would really appreciate local tips. Happy to help with tasks in exchange."
35)
38def _make_node(node_type: NodeType = NodeType.locality, small_community_features_enabled: bool = True) -> int:
39 # Polygon inside the fake Europe/Helsinki timezone area so node.timezone resolves.
40 with session_scope() as session:
41 node = Node(
42 geom=to_multi(create_polygon_lat_lng([[60, 24], [60, 26], [62, 26], [62, 24], [60, 24]])),
43 node_type=node_type,
44 )
45 session.add(node)
46 session.flush()
47 cluster = Cluster(
48 name="Test community",
49 description="Test",
50 parent_node_id=node.id,
51 is_official_cluster=True,
52 small_community_features_enabled=small_community_features_enabled,
53 )
54 session.add(cluster)
55 session.flush()
56 return node.id
59def _make_node_admin(user_id: int, node_id: int):
60 with session_scope() as session:
61 cluster_id = session.execute(
62 select(Cluster.id).where(Cluster.parent_node_id == node_id).where(Cluster.is_official_cluster)
63 ).scalar_one()
64 session.add(ClusterSubscription(cluster_id=cluster_id, user_id=user_id, role=ClusterRole.admin))
67def _create_trip_directly(
68 user_id: int,
69 node_id: int,
70 from_date,
71 to_date,
72 *,
73 description: str = "Looking for a host!",
74 status=None,
75 same_gender_only: bool = False,
76) -> int:
77 with session_scope() as session:
78 trip = PublicTrip(
79 user_id=user_id,
80 node_id=node_id,
81 from_date=from_date,
82 to_date=to_date,
83 description=description,
84 status=status or PublicTripStatus.searching_for_host,
85 same_gender_only=same_gender_only,
86 )
87 session.add(trip)
88 session.flush()
89 return trip.id
92def test_create_public_trip(db):
93 user, token = generate_user()
94 node_id = _make_node()
96 from_date = today() + timedelta(days=5)
97 to_date = today() + timedelta(days=10)
99 with public_trips_session(token) as api:
100 res = api.CreatePublicTrip(
101 public_trips_pb2.CreatePublicTripReq(
102 community_id=node_id,
103 from_date=from_date.isoformat(),
104 to_date=to_date.isoformat(),
105 description=VALID_DESCRIPTION,
106 )
107 )
109 assert res.trip_id > 0
110 assert res.user.user_id == user.id
111 assert res.community_id == node_id
112 assert res.community_slug == "test-community"
113 assert res.community_name == "Test community"
114 assert res.from_date == from_date.isoformat()
115 assert res.to_date == to_date.isoformat()
116 assert res.description == VALID_DESCRIPTION
117 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST
119 with session_scope() as session:
120 trip = session.execute(select(PublicTrip).where(PublicTrip.id == res.trip_id)).scalar_one()
121 assert trip.user_id == user.id
122 assert trip.node_id == node_id
123 assert trip.status == PublicTripStatus.searching_for_host
126def test_create_public_trip_incomplete_profile(db):
127 _, token = generate_user(complete_profile=False)
128 node_id = _make_node()
130 with public_trips_session(token) as api:
131 with pytest.raises(grpc.RpcError) as e:
132 api.CreatePublicTrip(
133 public_trips_pb2.CreatePublicTripReq(
134 community_id=node_id,
135 from_date=(today() + timedelta(days=5)).isoformat(),
136 to_date=(today() + timedelta(days=10)).isoformat(),
137 description="Visiting town!",
138 )
139 )
140 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
141 assert e.value.details() == "You have to complete your profile before you can create a public trip."
144def test_create_public_trip_community_not_found(db):
145 _, token = generate_user()
147 with public_trips_session(token) as api:
148 with pytest.raises(grpc.RpcError) as e:
149 api.CreatePublicTrip(
150 public_trips_pb2.CreatePublicTripReq(
151 community_id=999999,
152 from_date=(today() + timedelta(days=5)).isoformat(),
153 to_date=(today() + timedelta(days=10)).isoformat(),
154 description="Visiting town!",
155 )
156 )
157 assert e.value.code() == grpc.StatusCode.NOT_FOUND
158 assert e.value.details() == "Community not found."
161def test_create_public_trip_not_enabled(db):
162 _, token = generate_user()
163 node_id = _make_node(small_community_features_enabled=False)
165 with public_trips_session(token) as api:
166 with pytest.raises(grpc.RpcError) as e:
167 api.CreatePublicTrip(
168 public_trips_pb2.CreatePublicTripReq(
169 community_id=node_id,
170 from_date=(today() + timedelta(days=5)).isoformat(),
171 to_date=(today() + timedelta(days=10)).isoformat(),
172 description="Visiting town!",
173 )
174 )
175 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
176 assert e.value.details() == "Public trips are not enabled in this community."
179@pytest.mark.parametrize(
180 "node_type",
181 [NodeType.region, NodeType.subregion, NodeType.locality, NodeType.sublocality],
182)
183def test_create_public_trip_allows_region_and_narrower(db, node_type):
184 _, token = generate_user()
185 node_id = _make_node(node_type=node_type)
187 with public_trips_session(token) as api:
188 res = api.CreatePublicTrip(
189 public_trips_pb2.CreatePublicTripReq(
190 community_id=node_id,
191 from_date=(today() + timedelta(days=5)).isoformat(),
192 to_date=(today() + timedelta(days=10)).isoformat(),
193 description=VALID_DESCRIPTION,
194 )
195 )
196 assert res.trip_id > 0
199def test_create_public_trip_in_past_uses_node_timezone(db):
200 # Default user geom resolves to America/New_York; the node's geom is in Europe/Helsinki.
201 # Simulate a moment where Helsinki has already rolled into the next day (2026-01-16)
202 # while NYC is still on 2026-01-15. A from_date of 2026-01-15 is "today" in NYC but
203 # "yesterday" in Helsinki, and must be rejected because the check uses the node's tz.
204 _, token = generate_user()
205 node_id = _make_node()
207 fake_today_by_tz = {"America/New_York": date(2026, 1, 15), "Europe/Helsinki": date(2026, 1, 16)}
209 with patch(
210 "couchers.servicers.public_trips.today_in_timezone",
211 side_effect=lambda tz: fake_today_by_tz[tz],
212 ):
213 with public_trips_session(token) as api:
214 with pytest.raises(grpc.RpcError) as e:
215 api.CreatePublicTrip(
216 public_trips_pb2.CreatePublicTripReq(
217 community_id=node_id,
218 from_date="2026-01-15",
219 to_date="2026-01-20",
220 description=VALID_DESCRIPTION,
221 )
222 )
223 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
226def test_create_public_trip_date_errors(db):
227 _, token = generate_user()
228 node_id = _make_node()
230 with public_trips_session(token) as api:
231 # from_date in the past
232 with pytest.raises(grpc.RpcError) as e:
233 api.CreatePublicTrip(
234 public_trips_pb2.CreatePublicTripReq(
235 community_id=node_id,
236 from_date=(today() - timedelta(days=2)).isoformat(),
237 to_date=(today() + timedelta(days=1)).isoformat(),
238 description="Visiting town!",
239 )
240 )
241 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
243 # from_date after to_date
244 with pytest.raises(grpc.RpcError) as e:
245 api.CreatePublicTrip(
246 public_trips_pb2.CreatePublicTripReq(
247 community_id=node_id,
248 from_date=(today() + timedelta(days=10)).isoformat(),
249 to_date=(today() + timedelta(days=5)).isoformat(),
250 description="Visiting town!",
251 )
252 )
253 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
255 # empty description
256 with pytest.raises(grpc.RpcError) as e:
257 api.CreatePublicTrip(
258 public_trips_pb2.CreatePublicTripReq(
259 community_id=node_id,
260 from_date=(today() + timedelta(days=5)).isoformat(),
261 to_date=(today() + timedelta(days=10)).isoformat(),
262 description=" ",
263 )
264 )
265 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
267 # invalid date format
268 with pytest.raises(grpc.RpcError) as e:
269 api.CreatePublicTrip(
270 public_trips_pb2.CreatePublicTripReq(
271 community_id=node_id,
272 from_date="not-a-date",
273 to_date=(today() + timedelta(days=10)).isoformat(),
274 description="Visiting town!",
275 )
276 )
277 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
280def test_create_public_trip_overlap(db):
281 user, token = generate_user()
282 node_id = _make_node()
284 _create_trip_directly(
285 user.id,
286 node_id,
287 today() + timedelta(days=5),
288 today() + timedelta(days=10),
289 )
291 with public_trips_session(token) as api:
292 # overlapping dates should fail
293 with pytest.raises(grpc.RpcError) as e:
294 api.CreatePublicTrip(
295 public_trips_pb2.CreatePublicTripReq(
296 community_id=node_id,
297 from_date=(today() + timedelta(days=8)).isoformat(),
298 to_date=(today() + timedelta(days=12)).isoformat(),
299 description=VALID_DESCRIPTION,
300 )
301 )
302 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
304 # non-overlapping dates should succeed
305 res = api.CreatePublicTrip(
306 public_trips_pb2.CreatePublicTripReq(
307 community_id=node_id,
308 from_date=(today() + timedelta(days=20)).isoformat(),
309 to_date=(today() + timedelta(days=25)).isoformat(),
310 description=VALID_DESCRIPTION,
311 )
312 )
313 assert res.trip_id > 0
316def test_create_public_trip_closed_trip_allows_new_overlap(db):
317 user, token = generate_user()
318 node_id = _make_node()
320 _create_trip_directly(
321 user.id,
322 node_id,
323 today() + timedelta(days=5),
324 today() + timedelta(days=10),
325 status=PublicTripStatus.closed,
326 )
328 with public_trips_session(token) as api:
329 # closed trips shouldn't block new overlapping ones
330 res = api.CreatePublicTrip(
331 public_trips_pb2.CreatePublicTripReq(
332 community_id=node_id,
333 from_date=(today() + timedelta(days=7)).isoformat(),
334 to_date=(today() + timedelta(days=12)).isoformat(),
335 description=VALID_DESCRIPTION,
336 )
337 )
338 assert res.trip_id > 0
341def test_get_public_trip(db):
342 user, token = generate_user()
343 node_id = _make_node()
344 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
346 with public_trips_session(token) as api:
347 res = api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=trip_id))
348 assert res.trip_id == trip_id
349 assert res.user.user_id == user.id
350 assert res.community_slug == "test-community"
351 assert res.community_name == "Test community"
354def test_get_public_trip_not_found(db):
355 _, token = generate_user()
356 with public_trips_session(token) as api:
357 with pytest.raises(grpc.RpcError) as e:
358 api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=999999))
359 assert e.value.code() == grpc.StatusCode.NOT_FOUND
362def test_list_public_trips(db):
363 traveler, _ = generate_user()
364 _, host_token = generate_user()
365 node_id = _make_node()
367 trip1 = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
368 trip2 = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=20), today() + timedelta(days=25))
370 with public_trips_session(host_token) as api:
371 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
372 returned_ids = {t.trip_id for t in res.public_trips}
373 assert returned_ids == {trip1, trip2}
374 assert all(t.community_slug == "test-community" for t in res.public_trips)
375 assert all(t.community_name == "Test community" for t in res.public_trips)
378def test_list_public_trips_filters_closed_and_past(db):
379 traveler, _ = generate_user()
380 _, host_token = generate_user()
381 node_id = _make_node()
383 active = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
384 # closed trip - should be hidden
385 _create_trip_directly(
386 traveler.id,
387 node_id,
388 today() + timedelta(days=15),
389 today() + timedelta(days=20),
390 status=PublicTripStatus.closed,
391 )
392 # past trip (to_date < today) - should be hidden
393 _create_trip_directly(traveler.id, node_id, today() - timedelta(days=10), today() - timedelta(days=1))
395 with public_trips_session(host_token) as api:
396 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
397 assert [t.trip_id for t in res.public_trips] == [active]
400def test_list_public_trips_hides_invisible_user(db):
401 traveler, _ = generate_user()
402 _, host_token = generate_user()
403 node_id = _make_node()
405 _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
407 # soft-delete the traveler
408 with session_scope() as session:
409 t = session.execute(select(User).where(User.id == traveler.id)).scalar_one()
410 t.deleted_at = now()
412 with public_trips_session(host_token) as api:
413 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
414 assert len(res.public_trips) == 0
417def test_list_public_trips_pagination(db):
418 traveler, _ = generate_user()
419 _, host_token = generate_user()
420 node_id = _make_node()
422 trip_ids = [
423 _create_trip_directly(
424 traveler.id, node_id, today() + timedelta(days=5 + i * 10), today() + timedelta(days=10 + i * 10)
425 )
426 for i in range(5)
427 ]
429 with public_trips_session(host_token) as api:
430 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id, page_size=2))
431 assert [t.trip_id for t in res.public_trips] == [trip_ids[4], trip_ids[3]]
432 assert res.next_page_token
434 res2 = api.ListPublicTrips(
435 public_trips_pb2.ListPublicTripsReq(community_id=node_id, page_size=2, page_token=res.next_page_token)
436 )
437 assert [t.trip_id for t in res2.public_trips] == [trip_ids[2], trip_ids[1]]
438 assert res2.next_page_token
440 res3 = api.ListPublicTrips(
441 public_trips_pb2.ListPublicTripsReq(community_id=node_id, page_size=2, page_token=res2.next_page_token)
442 )
443 assert [t.trip_id for t in res3.public_trips] == [trip_ids[0]]
444 assert not res3.next_page_token
447def test_list_public_trips_by_user_self_sees_all(db):
448 user, token = generate_user()
449 other, _ = generate_user()
450 node_id = _make_node()
452 mine_active = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
453 mine_closed = _create_trip_directly(
454 user.id,
455 node_id,
456 today() + timedelta(days=15),
457 today() + timedelta(days=20),
458 status=PublicTripStatus.closed,
459 )
460 mine_past = _create_trip_directly(user.id, node_id, today() - timedelta(days=10), today() - timedelta(days=1))
461 # other user's trip should not be returned
462 _create_trip_directly(other.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
464 with public_trips_session(token) as api:
465 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=user.id))
466 assert {t.trip_id for t in res.public_trips} == {mine_active, mine_closed, mine_past}
469def test_list_public_trips_by_user_other_filters_inactive_and_past(db):
470 traveler, _ = generate_user()
471 _, viewer_token = generate_user()
472 node_id = _make_node()
474 active = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
475 # closed trip - hidden from others
476 _create_trip_directly(
477 traveler.id,
478 node_id,
479 today() + timedelta(days=15),
480 today() + timedelta(days=20),
481 status=PublicTripStatus.closed,
482 )
483 # past trip - hidden from others
484 _create_trip_directly(traveler.id, node_id, today() - timedelta(days=10), today() - timedelta(days=1))
486 with public_trips_session(viewer_token) as api:
487 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id))
488 assert [t.trip_id for t in res.public_trips] == [active]
491def test_list_public_trips_by_user_invisible_user(db):
492 traveler, _ = generate_user()
493 _, viewer_token = generate_user()
494 node_id = _make_node()
496 _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
498 # soft-delete the traveler
499 with session_scope() as session:
500 t = session.execute(select(User).where(User.id == traveler.id)).scalar_one()
501 t.deleted_at = now()
503 with public_trips_session(viewer_token) as api:
504 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id))
505 assert len(res.public_trips) == 0
508def test_update_public_trip_close(db):
509 user, token = generate_user()
510 node_id = _make_node()
511 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
513 with public_trips_session(token) as api:
514 res = api.UpdatePublicTrip(
515 public_trips_pb2.UpdatePublicTripReq(
516 trip_id=trip_id,
517 status=public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED,
518 )
519 )
520 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED
522 with session_scope() as session:
523 trip = session.execute(select(PublicTrip).where(PublicTrip.id == trip_id)).scalar_one()
524 assert trip.status == PublicTripStatus.closed
527def test_update_public_trip_reopen(db):
528 user, token = generate_user()
529 node_id = _make_node()
530 trip_id = _create_trip_directly(
531 user.id,
532 node_id,
533 today() + timedelta(days=5),
534 today() + timedelta(days=10),
535 status=PublicTripStatus.closed,
536 )
538 with public_trips_session(token) as api:
539 res = api.UpdatePublicTrip(
540 public_trips_pb2.UpdatePublicTripReq(
541 trip_id=trip_id,
542 status=public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST,
543 )
544 )
545 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST
547 with session_scope() as session:
548 trip = session.execute(select(PublicTrip).where(PublicTrip.id == trip_id)).scalar_one()
549 assert trip.status == PublicTripStatus.searching_for_host
552def test_update_public_trip_cant_reopen_past_trip(db):
553 user, token = generate_user()
554 node_id = _make_node()
555 trip_id = _create_trip_directly(
556 user.id,
557 node_id,
558 today() - timedelta(days=10),
559 today() - timedelta(days=1),
560 status=PublicTripStatus.closed,
561 )
563 with public_trips_session(token) as api:
564 with pytest.raises(grpc.RpcError) as e:
565 api.UpdatePublicTrip(
566 public_trips_pb2.UpdatePublicTripReq(
567 trip_id=trip_id,
568 status=public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST,
569 )
570 )
571 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
574def test_update_public_trip_close_past_trip_allowed(db):
575 # Closing a trip whose dates are in the past is allowed, even though content edits are not.
576 user, token = generate_user()
577 node_id = _make_node()
578 trip_id = _create_trip_directly(user.id, node_id, today() - timedelta(days=10), today() - timedelta(days=1))
580 with public_trips_session(token) as api:
581 res = api.UpdatePublicTrip(
582 public_trips_pb2.UpdatePublicTripReq(
583 trip_id=trip_id,
584 status=public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED,
585 )
586 )
587 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED
590def test_update_public_trip_description_only(db):
591 user, token = generate_user()
592 node_id = _make_node()
593 trip_id = _create_trip_directly(
594 user.id,
595 node_id,
596 today() + timedelta(days=5),
597 today() + timedelta(days=10),
598 description="Original description",
599 )
601 updated = VALID_DESCRIPTION + " Updated plans."
603 with public_trips_session(token) as api:
604 res = api.UpdatePublicTrip(
605 public_trips_pb2.UpdatePublicTripReq(
606 trip_id=trip_id,
607 description=updated,
608 )
609 )
610 assert res.trip_id == trip_id
611 assert res.description == updated
612 # dates should be unchanged
613 assert res.from_date == (today() + timedelta(days=5)).isoformat()
614 assert res.to_date == (today() + timedelta(days=10)).isoformat()
616 with session_scope() as session:
617 trip = session.execute(select(PublicTrip).where(PublicTrip.id == trip_id)).scalar_one()
618 assert trip.description == updated
621def test_update_public_trip_dates(db):
622 user, token = generate_user()
623 node_id = _make_node()
624 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
626 new_from = today() + timedelta(days=7)
627 new_to = today() + timedelta(days=14)
629 with public_trips_session(token) as api:
630 res = api.UpdatePublicTrip(
631 public_trips_pb2.UpdatePublicTripReq(
632 trip_id=trip_id,
633 from_date=new_from.isoformat(),
634 to_date=new_to.isoformat(),
635 )
636 )
637 assert res.from_date == new_from.isoformat()
638 assert res.to_date == new_to.isoformat()
641def test_update_public_trip_not_owner(db):
642 user, _ = generate_user()
643 _, other_token = generate_user()
644 node_id = _make_node()
645 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
647 with public_trips_session(other_token) as api:
648 with pytest.raises(grpc.RpcError) as e:
649 api.UpdatePublicTrip(
650 public_trips_pb2.UpdatePublicTripReq(
651 trip_id=trip_id,
652 description="I don't own this!",
653 )
654 )
655 assert e.value.code() == grpc.StatusCode.NOT_FOUND
658def test_update_public_trip_in_past(db):
659 user, token = generate_user()
660 node_id = _make_node()
661 trip_id = _create_trip_directly(user.id, node_id, today() - timedelta(days=10), today() - timedelta(days=2))
663 with public_trips_session(token) as api:
664 with pytest.raises(grpc.RpcError) as e:
665 api.UpdatePublicTrip(
666 public_trips_pb2.UpdatePublicTripReq(
667 trip_id=trip_id,
668 description="Too late!",
669 )
670 )
671 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
674def test_update_public_trip_date_validation(db):
675 user, token = generate_user()
676 node_id = _make_node()
677 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
679 with public_trips_session(token) as api:
680 # from_date after to_date (using the stored to_date of today+10)
681 with pytest.raises(grpc.RpcError) as e:
682 api.UpdatePublicTrip(
683 public_trips_pb2.UpdatePublicTripReq(
684 trip_id=trip_id,
685 from_date=(today() + timedelta(days=20)).isoformat(),
686 )
687 )
688 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
690 # Empty description
691 with pytest.raises(grpc.RpcError) as e:
692 api.UpdatePublicTrip(
693 public_trips_pb2.UpdatePublicTripReq(
694 trip_id=trip_id,
695 description=" ",
696 )
697 )
698 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
701def test_create_public_trip_description_too_short(db):
702 _, token = generate_user()
703 node_id = _make_node()
705 with public_trips_session(token) as api:
706 with pytest.raises(grpc.RpcError) as e:
707 api.CreatePublicTrip(
708 public_trips_pb2.CreatePublicTripReq(
709 community_id=node_id,
710 from_date=(today() + timedelta(days=5)).isoformat(),
711 to_date=(today() + timedelta(days=10)).isoformat(),
712 description="Too short.",
713 )
714 )
715 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
716 assert "150" in (e.value.details() or "")
719def test_update_public_trip_description_too_short(db):
720 user, token = generate_user()
721 node_id = _make_node()
722 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
724 with public_trips_session(token) as api:
725 with pytest.raises(grpc.RpcError) as e:
726 api.UpdatePublicTrip(
727 public_trips_pb2.UpdatePublicTripReq(
728 trip_id=trip_id,
729 description="Too short.",
730 )
731 )
732 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
733 assert "150" in (e.value.details() or "")
736def test_same_gender_only_create_and_retrieve(db):
737 user, token = generate_user(gender="Woman")
738 node_id = _make_node()
740 from_date = today() + timedelta(days=5)
741 to_date = today() + timedelta(days=10)
743 with public_trips_session(token) as api:
744 res = api.CreatePublicTrip(
745 public_trips_pb2.CreatePublicTripReq(
746 community_id=node_id,
747 from_date=from_date.isoformat(),
748 to_date=to_date.isoformat(),
749 description=VALID_DESCRIPTION,
750 same_gender_only=True,
751 )
752 )
753 assert res.same_gender_only is True
755 with session_scope() as session:
756 trip = session.execute(select(PublicTrip).where(PublicTrip.id == res.trip_id)).scalar_one()
757 assert trip.same_gender_only is True
760def test_same_gender_only_visibility_list_and_get(db):
761 traveler, _ = generate_user(gender="Woman")
762 _, same_gender_token = generate_user(gender="Woman")
763 _, diff_gender_token = generate_user(gender="Man")
764 node_id = _make_node()
766 filtered_trip_id = _create_trip_directly(
767 traveler.id,
768 node_id,
769 today() + timedelta(days=5),
770 today() + timedelta(days=10),
771 same_gender_only=True,
772 )
773 open_trip_id = _create_trip_directly(
774 traveler.id,
775 node_id,
776 today() + timedelta(days=20),
777 today() + timedelta(days=25),
778 )
780 with public_trips_session(same_gender_token) as api:
781 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
782 assert {t.trip_id for t in res.public_trips} == {filtered_trip_id, open_trip_id}
784 get_res = api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=filtered_trip_id))
785 assert get_res.trip_id == filtered_trip_id
786 assert get_res.same_gender_only is True
788 with public_trips_session(diff_gender_token) as api:
789 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
790 assert [t.trip_id for t in res.public_trips] == [open_trip_id]
792 with pytest.raises(grpc.RpcError) as e:
793 api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=filtered_trip_id))
794 assert e.value.code() == grpc.StatusCode.NOT_FOUND
797def test_same_gender_only_moderator_bypass(db):
798 traveler, _ = generate_user(gender="Woman")
799 mod, mod_token = generate_user(gender="Man")
800 node_id = _make_node()
801 _make_node_admin(mod.id, node_id)
803 trip_id = _create_trip_directly(
804 traveler.id,
805 node_id,
806 today() + timedelta(days=5),
807 today() + timedelta(days=10),
808 same_gender_only=True,
809 )
811 with public_trips_session(mod_token) as api:
812 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
813 assert any(t.trip_id == trip_id for t in res.public_trips)
815 get_res = api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=trip_id))
816 assert get_res.trip_id == trip_id
819def test_same_gender_only_owner_always_sees_own_trips(db):
820 traveler, traveler_token = generate_user(gender="Woman")
821 _, diff_gender_token = generate_user(gender="Man")
822 node_id = _make_node()
824 trip_id = _create_trip_directly(
825 traveler.id,
826 node_id,
827 today() + timedelta(days=5),
828 today() + timedelta(days=10),
829 same_gender_only=True,
830 )
832 # Owner always sees their own trips (is_self path skips the gender filter)
833 with public_trips_session(traveler_token) as api:
834 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id))
835 assert any(t.trip_id == trip_id for t in res.public_trips)
837 # Different-gender viewer doesn't see it on the traveler's profile
838 with public_trips_session(diff_gender_token) as api:
839 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id))
840 assert not any(t.trip_id == trip_id for t in res.public_trips)
843def test_same_gender_only_update(db):
844 user, token = generate_user(gender="Woman")
845 node_id = _make_node()
846 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
848 with public_trips_session(token) as api:
849 res = api.UpdatePublicTrip(
850 public_trips_pb2.UpdatePublicTripReq(
851 trip_id=trip_id,
852 same_gender_only=True,
853 )
854 )
855 assert res.same_gender_only is True
857 res = api.UpdatePublicTrip(
858 public_trips_pb2.UpdatePublicTripReq(
859 trip_id=trip_id,
860 same_gender_only=False,
861 )
862 )
863 assert res.same_gender_only is False
865 with session_scope() as session:
866 trip = session.execute(select(PublicTrip).where(PublicTrip.id == trip_id)).scalar_one()
867 assert trip.same_gender_only is False
870def test_list_public_trips_by_user_ascending_order(db):
871 user, token = generate_user()
872 node_id = _make_node()
874 trip_near = _create_trip_directly(user.id, node_id, today() + timedelta(days=3), today() + timedelta(days=5))
875 trip_far = _create_trip_directly(user.id, node_id, today() + timedelta(days=20), today() + timedelta(days=25))
876 trip_mid = _create_trip_directly(user.id, node_id, today() + timedelta(days=10), today() + timedelta(days=12))
878 with public_trips_session(token) as api:
879 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=user.id, ascending=True))
880 trip_ids = [t.trip_id for t in res.public_trips]
881 assert trip_ids.index(trip_near) < trip_ids.index(trip_mid) < trip_ids.index(trip_far)
883 res_desc = api.ListPublicTripsByUser(
884 public_trips_pb2.ListPublicTripsByUserReq(user_id=user.id, ascending=False)
885 )
886 trip_ids_desc = [t.trip_id for t in res_desc.public_trips]
887 assert trip_ids_desc.index(trip_far) < trip_ids_desc.index(trip_mid) < trip_ids_desc.index(trip_near)
890def test_list_public_trips_by_user_status_filter(db):
891 user, token = generate_user()
892 node_id = _make_node()
894 active = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
895 closed = _create_trip_directly(
896 user.id, node_id, today() + timedelta(days=15), today() + timedelta(days=20), status=PublicTripStatus.closed
897 )
899 with public_trips_session(token) as api:
900 # Filter to active only
901 res = api.ListPublicTripsByUser(
902 public_trips_pb2.ListPublicTripsByUserReq(
903 user_id=user.id,
904 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST],
905 )
906 )
907 assert {t.trip_id for t in res.public_trips} == {active}
909 # Filter to closed only
910 res = api.ListPublicTripsByUser(
911 public_trips_pb2.ListPublicTripsByUserReq(
912 user_id=user.id,
913 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED],
914 )
915 )
916 assert {t.trip_id for t in res.public_trips} == {closed}
918 # No filter returns all
919 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=user.id))
920 assert {t.trip_id for t in res.public_trips} == {active, closed}
923def test_list_public_trips_by_user_status_filter_ignored_for_others(db):
924 traveler, _ = generate_user()
925 _, viewer_token = generate_user()
926 node_id = _make_node()
928 active = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
929 _create_trip_directly(
930 traveler.id, node_id, today() + timedelta(days=15), today() + timedelta(days=20), status=PublicTripStatus.closed
931 )
933 with public_trips_session(viewer_token) as api:
934 # status_filter is ignored for other users — always returns active+upcoming only
935 res = api.ListPublicTripsByUser(
936 public_trips_pb2.ListPublicTripsByUserReq(
937 user_id=traveler.id,
938 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED],
939 )
940 )
941 assert [t.trip_id for t in res.public_trips] == [active]
944def test_list_public_trips_by_user_offers_count_owner(db):
945 traveler, traveler_token = generate_user()
946 host, host_token = generate_user()
947 node_id = _make_node()
949 trip_id = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
951 with public_trips_session(traveler_token) as api:
952 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id))
953 trip = next(t for t in res.public_trips if t.trip_id == trip_id)
954 assert trip.HasField("offers_count")
955 assert trip.offers_count == 0
957 # Host creates an offer via a host request linked to the trip
958 with requests_session(host_token) as api:
959 api.CreateHostRequest(
960 requests_pb2.CreateHostRequestReq(
961 host_user_id=traveler.id,
962 from_date=(today() + timedelta(days=5)).isoformat(),
963 to_date=(today() + timedelta(days=10)).isoformat(),
964 text=_valid_request_text(),
965 public_trip_id=trip_id,
966 )
967 )
969 with public_trips_session(traveler_token) as api:
970 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id))
971 trip = next(t for t in res.public_trips if t.trip_id == trip_id)
972 assert trip.HasField("offers_count")
973 assert trip.offers_count == 1
976def test_list_public_trips_by_user_offers_count_not_set_for_others(db):
977 traveler, _ = generate_user()
978 _, viewer_token = generate_user()
979 node_id = _make_node()
981 _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
983 with public_trips_session(viewer_token) as api:
984 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id))
985 assert len(res.public_trips) == 1
986 assert not res.public_trips[0].HasField("offers_count")
987 assert not res.public_trips[0].HasField("offer_tally")
990def test_list_public_trips_by_user_offer_tally_owner(db):
991 traveler, traveler_token = generate_user()
992 hosts = [generate_user() for _ in range(5)]
993 node_id = _make_node()
995 trip_id = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
997 for _host, host_token in hosts:
998 with requests_session(host_token) as api:
999 api.CreateHostRequest(
1000 requests_pb2.CreateHostRequestReq(
1001 host_user_id=traveler.id,
1002 from_date=(today() + timedelta(days=5)).isoformat(),
1003 to_date=(today() + timedelta(days=10)).isoformat(),
1004 text=_valid_request_text(),
1005 public_trip_id=trip_id,
1006 )
1007 )
1009 # Set one offer per status: pending, accepted, confirmed, rejected, cancelled.
1010 with session_scope() as session:
1011 offers = (
1012 session.execute(
1013 select(HostRequest).where(HostRequest.public_trip_id == trip_id).order_by(HostRequest.conversation_id)
1014 )
1015 .scalars()
1016 .all()
1017 )
1018 offers[0].status = HostRequestStatus.pending
1019 offers[1].status = HostRequestStatus.accepted
1020 offers[2].status = HostRequestStatus.confirmed
1021 offers[3].status = HostRequestStatus.rejected
1022 offers[4].status = HostRequestStatus.cancelled
1024 with public_trips_session(traveler_token) as api:
1025 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id))
1026 trip = next(t for t in res.public_trips if t.trip_id == trip_id)
1027 assert trip.HasField("offer_tally")
1028 assert trip.offer_tally.pending == 1
1029 assert trip.offer_tally.accepted == 1
1030 assert trip.offer_tally.confirmed == 1
1031 assert trip.offer_tally.declined == 1
1032 # cancelled is excluded from offers_count and the tally
1033 assert trip.offers_count == 4
1036def test_viewer_host_request_id_reflects_viewers_own_offer(db):
1037 traveler, _ = generate_user()
1038 host, host_token = generate_user()
1039 _, other_token = generate_user()
1040 node_id = _make_node()
1042 trip_id = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
1044 # Before offering, the host sees 0.
1045 with public_trips_session(host_token) as api:
1046 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
1047 trip = next(t for t in res.public_trips if t.trip_id == trip_id)
1048 assert trip.viewer_host_request_id == 0
1050 # The host makes an offer on the trip.
1051 with requests_session(host_token) as api:
1052 create_res = api.CreateHostRequest(
1053 requests_pb2.CreateHostRequestReq(
1054 host_user_id=traveler.id,
1055 from_date=(today() + timedelta(days=5)).isoformat(),
1056 to_date=(today() + timedelta(days=10)).isoformat(),
1057 text=_valid_request_text(),
1058 public_trip_id=trip_id,
1059 )
1060 )
1061 host_request_id = create_res.host_request_id
1063 with public_trips_session(host_token) as api:
1064 # The offering host now sees their own host request id (List and Get).
1065 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
1066 trip = next(t for t in res.public_trips if t.trip_id == trip_id)
1067 assert trip.viewer_host_request_id == host_request_id
1069 get_res = api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=trip_id))
1070 assert get_res.viewer_host_request_id == host_request_id
1072 # A different viewer who hasn't offered still sees 0.
1073 with public_trips_session(other_token) as api:
1074 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
1075 trip = next(t for t in res.public_trips if t.trip_id == trip_id)
1076 assert trip.viewer_host_request_id == 0