Coverage for app/backend/src/tests/test_public_trips.py: 99%
481 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-20 15:12 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-20 15:12 +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.public_trips import PublicTrip, PublicTripStatus
12from couchers.proto import public_trips_pb2, requests_pb2
13from couchers.utils import create_polygon_lat_lng, now, to_multi, today
14from tests.fixtures.db import generate_user
15from tests.fixtures.sessions import public_trips_session, requests_session
18def _valid_request_text(text: str = "Offer to host") -> str:
19 utf16_length = len(text.encode("utf-16-le")) // 2
20 if utf16_length >= HOST_REQUEST_MIN_LENGTH_UTF16: 20 ↛ 21line 20 didn't jump to line 21 because the condition on line 20 was never true
21 return text
22 return text + "_" * (HOST_REQUEST_MIN_LENGTH_UTF16 - utf16_length)
25@pytest.fixture(autouse=True)
26def _(testconfig):
27 pass
30# 150+ utf-16 code units to satisfy PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16.
31VALID_DESCRIPTION = (
32 "Visiting the area for a week for a music festival. I love meeting new people "
33 "and would really appreciate local tips. Happy to help with tasks in exchange."
34)
37def _make_node(node_type: NodeType = NodeType.locality, small_community_features_enabled: bool = True) -> int:
38 # Polygon inside the fake Europe/Helsinki timezone area so node.timezone resolves.
39 with session_scope() as session:
40 node = Node(
41 geom=to_multi(create_polygon_lat_lng([[60, 24], [60, 26], [62, 26], [62, 24], [60, 24]])),
42 node_type=node_type,
43 )
44 session.add(node)
45 session.flush()
46 cluster = Cluster(
47 name="Test community",
48 description="Test",
49 parent_node_id=node.id,
50 is_official_cluster=True,
51 small_community_features_enabled=small_community_features_enabled,
52 )
53 session.add(cluster)
54 session.flush()
55 return node.id
58def _make_node_admin(user_id: int, node_id: int):
59 with session_scope() as session:
60 cluster_id = session.execute(
61 select(Cluster.id).where(Cluster.parent_node_id == node_id).where(Cluster.is_official_cluster)
62 ).scalar_one()
63 session.add(ClusterSubscription(cluster_id=cluster_id, user_id=user_id, role=ClusterRole.admin))
66def _create_trip_directly(
67 user_id: int,
68 node_id: int,
69 from_date,
70 to_date,
71 *,
72 description: str = "Looking for a host!",
73 status=None,
74 same_gender_only: bool = False,
75) -> int:
76 with session_scope() as session:
77 trip = PublicTrip(
78 user_id=user_id,
79 node_id=node_id,
80 from_date=from_date,
81 to_date=to_date,
82 description=description,
83 status=status or PublicTripStatus.searching_for_host,
84 same_gender_only=same_gender_only,
85 )
86 session.add(trip)
87 session.flush()
88 return trip.id
91def test_create_public_trip(db):
92 user, token = generate_user()
93 node_id = _make_node()
95 from_date = today() + timedelta(days=5)
96 to_date = today() + timedelta(days=10)
98 with public_trips_session(token) as api:
99 res = api.CreatePublicTrip(
100 public_trips_pb2.CreatePublicTripReq(
101 community_id=node_id,
102 from_date=from_date.isoformat(),
103 to_date=to_date.isoformat(),
104 description=VALID_DESCRIPTION,
105 )
106 )
108 assert res.trip_id > 0
109 assert res.user.user_id == user.id
110 assert res.community_id == node_id
111 assert res.community_slug == "test-community"
112 assert res.community_name == "Test community"
113 assert res.from_date == from_date.isoformat()
114 assert res.to_date == to_date.isoformat()
115 assert res.description == VALID_DESCRIPTION
116 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST
118 with session_scope() as session:
119 trip = session.execute(select(PublicTrip).where(PublicTrip.id == res.trip_id)).scalar_one()
120 assert trip.user_id == user.id
121 assert trip.node_id == node_id
122 assert trip.status == PublicTripStatus.searching_for_host
125def test_create_public_trip_incomplete_profile(db):
126 _, token = generate_user(complete_profile=False)
127 node_id = _make_node()
129 with public_trips_session(token) as api:
130 with pytest.raises(grpc.RpcError) as e:
131 api.CreatePublicTrip(
132 public_trips_pb2.CreatePublicTripReq(
133 community_id=node_id,
134 from_date=(today() + timedelta(days=5)).isoformat(),
135 to_date=(today() + timedelta(days=10)).isoformat(),
136 description="Visiting town!",
137 )
138 )
139 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
140 assert e.value.details() == "You have to complete your profile before you can create a public trip."
143def test_create_public_trip_community_not_found(db):
144 _, token = generate_user()
146 with public_trips_session(token) as api:
147 with pytest.raises(grpc.RpcError) as e:
148 api.CreatePublicTrip(
149 public_trips_pb2.CreatePublicTripReq(
150 community_id=999999,
151 from_date=(today() + timedelta(days=5)).isoformat(),
152 to_date=(today() + timedelta(days=10)).isoformat(),
153 description="Visiting town!",
154 )
155 )
156 assert e.value.code() == grpc.StatusCode.NOT_FOUND
157 assert e.value.details() == "Community not found."
160def test_create_public_trip_not_enabled(db):
161 _, token = generate_user()
162 node_id = _make_node(small_community_features_enabled=False)
164 with public_trips_session(token) as api:
165 with pytest.raises(grpc.RpcError) as e:
166 api.CreatePublicTrip(
167 public_trips_pb2.CreatePublicTripReq(
168 community_id=node_id,
169 from_date=(today() + timedelta(days=5)).isoformat(),
170 to_date=(today() + timedelta(days=10)).isoformat(),
171 description="Visiting town!",
172 )
173 )
174 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
175 assert e.value.details() == "Public trips are not enabled in this community."
178@pytest.mark.parametrize(
179 "node_type",
180 [NodeType.region, NodeType.subregion, NodeType.locality, NodeType.sublocality],
181)
182def test_create_public_trip_allows_region_and_narrower(db, node_type):
183 _, token = generate_user()
184 node_id = _make_node(node_type=node_type)
186 with public_trips_session(token) as api:
187 res = api.CreatePublicTrip(
188 public_trips_pb2.CreatePublicTripReq(
189 community_id=node_id,
190 from_date=(today() + timedelta(days=5)).isoformat(),
191 to_date=(today() + timedelta(days=10)).isoformat(),
192 description=VALID_DESCRIPTION,
193 )
194 )
195 assert res.trip_id > 0
198def test_create_public_trip_in_past_uses_node_timezone(db):
199 # Default user geom resolves to America/New_York; the node's geom is in Europe/Helsinki.
200 # Simulate a moment where Helsinki has already rolled into the next day (2026-01-16)
201 # while NYC is still on 2026-01-15. A from_date of 2026-01-15 is "today" in NYC but
202 # "yesterday" in Helsinki, and must be rejected because the check uses the node's tz.
203 _, token = generate_user()
204 node_id = _make_node()
206 fake_today_by_tz = {"America/New_York": date(2026, 1, 15), "Europe/Helsinki": date(2026, 1, 16)}
208 with patch(
209 "couchers.servicers.public_trips.today_in_timezone",
210 side_effect=lambda tz: fake_today_by_tz[tz],
211 ):
212 with public_trips_session(token) as api:
213 with pytest.raises(grpc.RpcError) as e:
214 api.CreatePublicTrip(
215 public_trips_pb2.CreatePublicTripReq(
216 community_id=node_id,
217 from_date="2026-01-15",
218 to_date="2026-01-20",
219 description=VALID_DESCRIPTION,
220 )
221 )
222 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
225def test_create_public_trip_date_errors(db):
226 _, token = generate_user()
227 node_id = _make_node()
229 with public_trips_session(token) as api:
230 # from_date in the past
231 with pytest.raises(grpc.RpcError) as e:
232 api.CreatePublicTrip(
233 public_trips_pb2.CreatePublicTripReq(
234 community_id=node_id,
235 from_date=(today() - timedelta(days=2)).isoformat(),
236 to_date=(today() + timedelta(days=1)).isoformat(),
237 description="Visiting town!",
238 )
239 )
240 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
242 # from_date after to_date
243 with pytest.raises(grpc.RpcError) as e:
244 api.CreatePublicTrip(
245 public_trips_pb2.CreatePublicTripReq(
246 community_id=node_id,
247 from_date=(today() + timedelta(days=10)).isoformat(),
248 to_date=(today() + timedelta(days=5)).isoformat(),
249 description="Visiting town!",
250 )
251 )
252 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
254 # empty description
255 with pytest.raises(grpc.RpcError) as e:
256 api.CreatePublicTrip(
257 public_trips_pb2.CreatePublicTripReq(
258 community_id=node_id,
259 from_date=(today() + timedelta(days=5)).isoformat(),
260 to_date=(today() + timedelta(days=10)).isoformat(),
261 description=" ",
262 )
263 )
264 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
266 # invalid date format
267 with pytest.raises(grpc.RpcError) as e:
268 api.CreatePublicTrip(
269 public_trips_pb2.CreatePublicTripReq(
270 community_id=node_id,
271 from_date="not-a-date",
272 to_date=(today() + timedelta(days=10)).isoformat(),
273 description="Visiting town!",
274 )
275 )
276 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
279def test_create_public_trip_overlap(db):
280 user, token = generate_user()
281 node_id = _make_node()
283 _create_trip_directly(
284 user.id,
285 node_id,
286 today() + timedelta(days=5),
287 today() + timedelta(days=10),
288 )
290 with public_trips_session(token) as api:
291 # overlapping dates should fail
292 with pytest.raises(grpc.RpcError) as e:
293 api.CreatePublicTrip(
294 public_trips_pb2.CreatePublicTripReq(
295 community_id=node_id,
296 from_date=(today() + timedelta(days=8)).isoformat(),
297 to_date=(today() + timedelta(days=12)).isoformat(),
298 description=VALID_DESCRIPTION,
299 )
300 )
301 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
303 # non-overlapping dates should succeed
304 res = api.CreatePublicTrip(
305 public_trips_pb2.CreatePublicTripReq(
306 community_id=node_id,
307 from_date=(today() + timedelta(days=20)).isoformat(),
308 to_date=(today() + timedelta(days=25)).isoformat(),
309 description=VALID_DESCRIPTION,
310 )
311 )
312 assert res.trip_id > 0
315def test_create_public_trip_closed_trip_allows_new_overlap(db):
316 user, token = generate_user()
317 node_id = _make_node()
319 _create_trip_directly(
320 user.id,
321 node_id,
322 today() + timedelta(days=5),
323 today() + timedelta(days=10),
324 status=PublicTripStatus.closed,
325 )
327 with public_trips_session(token) as api:
328 # closed trips shouldn't block new overlapping ones
329 res = api.CreatePublicTrip(
330 public_trips_pb2.CreatePublicTripReq(
331 community_id=node_id,
332 from_date=(today() + timedelta(days=7)).isoformat(),
333 to_date=(today() + timedelta(days=12)).isoformat(),
334 description=VALID_DESCRIPTION,
335 )
336 )
337 assert res.trip_id > 0
340def test_get_public_trip(db):
341 user, token = generate_user()
342 node_id = _make_node()
343 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
345 with public_trips_session(token) as api:
346 res = api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=trip_id))
347 assert res.trip_id == trip_id
348 assert res.user.user_id == user.id
349 assert res.community_slug == "test-community"
350 assert res.community_name == "Test community"
353def test_get_public_trip_not_found(db):
354 _, token = generate_user()
355 with public_trips_session(token) as api:
356 with pytest.raises(grpc.RpcError) as e:
357 api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=999999))
358 assert e.value.code() == grpc.StatusCode.NOT_FOUND
361def test_list_public_trips(db):
362 traveler, _ = generate_user()
363 _, host_token = generate_user()
364 node_id = _make_node()
366 trip1 = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
367 trip2 = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=20), today() + timedelta(days=25))
369 with public_trips_session(host_token) as api:
370 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
371 returned_ids = {t.trip_id for t in res.public_trips}
372 assert returned_ids == {trip1, trip2}
373 assert all(t.community_slug == "test-community" for t in res.public_trips)
374 assert all(t.community_name == "Test community" for t in res.public_trips)
377def test_list_public_trips_filters_closed_and_past(db):
378 traveler, _ = generate_user()
379 _, host_token = generate_user()
380 node_id = _make_node()
382 active = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
383 # closed trip - should be hidden
384 _create_trip_directly(
385 traveler.id,
386 node_id,
387 today() + timedelta(days=15),
388 today() + timedelta(days=20),
389 status=PublicTripStatus.closed,
390 )
391 # past trip (to_date < today) - should be hidden
392 _create_trip_directly(traveler.id, node_id, today() - timedelta(days=10), today() - timedelta(days=1))
394 with public_trips_session(host_token) as api:
395 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
396 assert [t.trip_id for t in res.public_trips] == [active]
399def test_list_public_trips_hides_invisible_user(db):
400 traveler, _ = generate_user()
401 _, host_token = generate_user()
402 node_id = _make_node()
404 _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
406 # soft-delete the traveler
407 with session_scope() as session:
408 t = session.execute(select(User).where(User.id == traveler.id)).scalar_one()
409 t.deleted_at = now()
411 with public_trips_session(host_token) as api:
412 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
413 assert len(res.public_trips) == 0
416def test_list_public_trips_pagination(db):
417 traveler, _ = generate_user()
418 _, host_token = generate_user()
419 node_id = _make_node()
421 trip_ids = [
422 _create_trip_directly(
423 traveler.id, node_id, today() + timedelta(days=5 + i * 10), today() + timedelta(days=10 + i * 10)
424 )
425 for i in range(5)
426 ]
428 with public_trips_session(host_token) as api:
429 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id, page_size=2))
430 assert [t.trip_id for t in res.public_trips] == [trip_ids[4], trip_ids[3]]
431 assert res.next_page_token
433 res2 = api.ListPublicTrips(
434 public_trips_pb2.ListPublicTripsReq(community_id=node_id, page_size=2, page_token=res.next_page_token)
435 )
436 assert [t.trip_id for t in res2.public_trips] == [trip_ids[2], trip_ids[1]]
437 assert res2.next_page_token
439 res3 = api.ListPublicTrips(
440 public_trips_pb2.ListPublicTripsReq(community_id=node_id, page_size=2, page_token=res2.next_page_token)
441 )
442 assert [t.trip_id for t in res3.public_trips] == [trip_ids[0]]
443 assert not res3.next_page_token
446def test_list_public_trips_by_user_self_sees_all(db):
447 user, token = generate_user()
448 other, _ = generate_user()
449 node_id = _make_node()
451 mine_active = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
452 mine_closed = _create_trip_directly(
453 user.id,
454 node_id,
455 today() + timedelta(days=15),
456 today() + timedelta(days=20),
457 status=PublicTripStatus.closed,
458 )
459 mine_past = _create_trip_directly(user.id, node_id, today() - timedelta(days=10), today() - timedelta(days=1))
460 # other user's trip should not be returned
461 _create_trip_directly(other.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
463 with public_trips_session(token) as api:
464 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=user.id))
465 assert {t.trip_id for t in res.public_trips} == {mine_active, mine_closed, mine_past}
468def test_list_public_trips_by_user_other_filters_inactive_and_past(db):
469 traveler, _ = generate_user()
470 _, viewer_token = generate_user()
471 node_id = _make_node()
473 active = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
474 # closed trip - hidden from others
475 _create_trip_directly(
476 traveler.id,
477 node_id,
478 today() + timedelta(days=15),
479 today() + timedelta(days=20),
480 status=PublicTripStatus.closed,
481 )
482 # past trip - hidden from others
483 _create_trip_directly(traveler.id, node_id, today() - timedelta(days=10), today() - timedelta(days=1))
485 with public_trips_session(viewer_token) as api:
486 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id))
487 assert [t.trip_id for t in res.public_trips] == [active]
490def test_list_public_trips_by_user_invisible_user(db):
491 traveler, _ = generate_user()
492 _, viewer_token = generate_user()
493 node_id = _make_node()
495 _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
497 # soft-delete the traveler
498 with session_scope() as session:
499 t = session.execute(select(User).where(User.id == traveler.id)).scalar_one()
500 t.deleted_at = now()
502 with public_trips_session(viewer_token) as api:
503 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id))
504 assert len(res.public_trips) == 0
507def test_update_public_trip_close(db):
508 user, token = generate_user()
509 node_id = _make_node()
510 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
512 with public_trips_session(token) as api:
513 res = api.UpdatePublicTrip(
514 public_trips_pb2.UpdatePublicTripReq(
515 trip_id=trip_id,
516 status=public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED,
517 )
518 )
519 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED
521 with session_scope() as session:
522 trip = session.execute(select(PublicTrip).where(PublicTrip.id == trip_id)).scalar_one()
523 assert trip.status == PublicTripStatus.closed
526def test_update_public_trip_reopen(db):
527 user, token = generate_user()
528 node_id = _make_node()
529 trip_id = _create_trip_directly(
530 user.id,
531 node_id,
532 today() + timedelta(days=5),
533 today() + timedelta(days=10),
534 status=PublicTripStatus.closed,
535 )
537 with public_trips_session(token) as api:
538 res = api.UpdatePublicTrip(
539 public_trips_pb2.UpdatePublicTripReq(
540 trip_id=trip_id,
541 status=public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST,
542 )
543 )
544 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST
546 with session_scope() as session:
547 trip = session.execute(select(PublicTrip).where(PublicTrip.id == trip_id)).scalar_one()
548 assert trip.status == PublicTripStatus.searching_for_host
551def test_update_public_trip_cant_reopen_past_trip(db):
552 user, token = generate_user()
553 node_id = _make_node()
554 trip_id = _create_trip_directly(
555 user.id,
556 node_id,
557 today() - timedelta(days=10),
558 today() - timedelta(days=1),
559 status=PublicTripStatus.closed,
560 )
562 with public_trips_session(token) as api:
563 with pytest.raises(grpc.RpcError) as e:
564 api.UpdatePublicTrip(
565 public_trips_pb2.UpdatePublicTripReq(
566 trip_id=trip_id,
567 status=public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST,
568 )
569 )
570 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
573def test_update_public_trip_close_past_trip_allowed(db):
574 # Closing a trip whose dates are in the past is allowed, even though content edits are not.
575 user, token = generate_user()
576 node_id = _make_node()
577 trip_id = _create_trip_directly(user.id, node_id, today() - timedelta(days=10), today() - timedelta(days=1))
579 with public_trips_session(token) as api:
580 res = api.UpdatePublicTrip(
581 public_trips_pb2.UpdatePublicTripReq(
582 trip_id=trip_id,
583 status=public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED,
584 )
585 )
586 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED
589def test_update_public_trip_description_only(db):
590 user, token = generate_user()
591 node_id = _make_node()
592 trip_id = _create_trip_directly(
593 user.id,
594 node_id,
595 today() + timedelta(days=5),
596 today() + timedelta(days=10),
597 description="Original description",
598 )
600 updated = VALID_DESCRIPTION + " Updated plans."
602 with public_trips_session(token) as api:
603 res = api.UpdatePublicTrip(
604 public_trips_pb2.UpdatePublicTripReq(
605 trip_id=trip_id,
606 description=updated,
607 )
608 )
609 assert res.trip_id == trip_id
610 assert res.description == updated
611 # dates should be unchanged
612 assert res.from_date == (today() + timedelta(days=5)).isoformat()
613 assert res.to_date == (today() + timedelta(days=10)).isoformat()
615 with session_scope() as session:
616 trip = session.execute(select(PublicTrip).where(PublicTrip.id == trip_id)).scalar_one()
617 assert trip.description == updated
620def test_update_public_trip_dates(db):
621 user, token = generate_user()
622 node_id = _make_node()
623 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
625 new_from = today() + timedelta(days=7)
626 new_to = today() + timedelta(days=14)
628 with public_trips_session(token) as api:
629 res = api.UpdatePublicTrip(
630 public_trips_pb2.UpdatePublicTripReq(
631 trip_id=trip_id,
632 from_date=new_from.isoformat(),
633 to_date=new_to.isoformat(),
634 )
635 )
636 assert res.from_date == new_from.isoformat()
637 assert res.to_date == new_to.isoformat()
640def test_update_public_trip_not_owner(db):
641 user, _ = generate_user()
642 _, other_token = generate_user()
643 node_id = _make_node()
644 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
646 with public_trips_session(other_token) as api:
647 with pytest.raises(grpc.RpcError) as e:
648 api.UpdatePublicTrip(
649 public_trips_pb2.UpdatePublicTripReq(
650 trip_id=trip_id,
651 description="I don't own this!",
652 )
653 )
654 assert e.value.code() == grpc.StatusCode.NOT_FOUND
657def test_update_public_trip_in_past(db):
658 user, token = generate_user()
659 node_id = _make_node()
660 trip_id = _create_trip_directly(user.id, node_id, today() - timedelta(days=10), today() - timedelta(days=2))
662 with public_trips_session(token) as api:
663 with pytest.raises(grpc.RpcError) as e:
664 api.UpdatePublicTrip(
665 public_trips_pb2.UpdatePublicTripReq(
666 trip_id=trip_id,
667 description="Too late!",
668 )
669 )
670 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
673def test_update_public_trip_date_validation(db):
674 user, token = generate_user()
675 node_id = _make_node()
676 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
678 with public_trips_session(token) as api:
679 # from_date after to_date (using the stored to_date of today+10)
680 with pytest.raises(grpc.RpcError) as e:
681 api.UpdatePublicTrip(
682 public_trips_pb2.UpdatePublicTripReq(
683 trip_id=trip_id,
684 from_date=(today() + timedelta(days=20)).isoformat(),
685 )
686 )
687 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
689 # Empty description
690 with pytest.raises(grpc.RpcError) as e:
691 api.UpdatePublicTrip(
692 public_trips_pb2.UpdatePublicTripReq(
693 trip_id=trip_id,
694 description=" ",
695 )
696 )
697 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
700def test_create_public_trip_description_too_short(db):
701 _, token = generate_user()
702 node_id = _make_node()
704 with public_trips_session(token) as api:
705 with pytest.raises(grpc.RpcError) as e:
706 api.CreatePublicTrip(
707 public_trips_pb2.CreatePublicTripReq(
708 community_id=node_id,
709 from_date=(today() + timedelta(days=5)).isoformat(),
710 to_date=(today() + timedelta(days=10)).isoformat(),
711 description="Too short.",
712 )
713 )
714 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
715 assert "150" in (e.value.details() or "")
718def test_update_public_trip_description_too_short(db):
719 user, token = generate_user()
720 node_id = _make_node()
721 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
723 with public_trips_session(token) as api:
724 with pytest.raises(grpc.RpcError) as e:
725 api.UpdatePublicTrip(
726 public_trips_pb2.UpdatePublicTripReq(
727 trip_id=trip_id,
728 description="Too short.",
729 )
730 )
731 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
732 assert "150" in (e.value.details() or "")
735def test_same_gender_only_create_and_retrieve(db):
736 user, token = generate_user(gender="Woman")
737 node_id = _make_node()
739 from_date = today() + timedelta(days=5)
740 to_date = today() + timedelta(days=10)
742 with public_trips_session(token) as api:
743 res = api.CreatePublicTrip(
744 public_trips_pb2.CreatePublicTripReq(
745 community_id=node_id,
746 from_date=from_date.isoformat(),
747 to_date=to_date.isoformat(),
748 description=VALID_DESCRIPTION,
749 same_gender_only=True,
750 )
751 )
752 assert res.same_gender_only is True
754 with session_scope() as session:
755 trip = session.execute(select(PublicTrip).where(PublicTrip.id == res.trip_id)).scalar_one()
756 assert trip.same_gender_only is True
759def test_same_gender_only_visibility_list_and_get(db):
760 traveler, _ = generate_user(gender="Woman")
761 _, same_gender_token = generate_user(gender="Woman")
762 _, diff_gender_token = generate_user(gender="Man")
763 node_id = _make_node()
765 filtered_trip_id = _create_trip_directly(
766 traveler.id,
767 node_id,
768 today() + timedelta(days=5),
769 today() + timedelta(days=10),
770 same_gender_only=True,
771 )
772 open_trip_id = _create_trip_directly(
773 traveler.id,
774 node_id,
775 today() + timedelta(days=20),
776 today() + timedelta(days=25),
777 )
779 with public_trips_session(same_gender_token) as api:
780 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
781 assert {t.trip_id for t in res.public_trips} == {filtered_trip_id, open_trip_id}
783 get_res = api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=filtered_trip_id))
784 assert get_res.trip_id == filtered_trip_id
785 assert get_res.same_gender_only is True
787 with public_trips_session(diff_gender_token) as api:
788 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
789 assert [t.trip_id for t in res.public_trips] == [open_trip_id]
791 with pytest.raises(grpc.RpcError) as e:
792 api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=filtered_trip_id))
793 assert e.value.code() == grpc.StatusCode.NOT_FOUND
796def test_same_gender_only_moderator_bypass(db):
797 traveler, _ = generate_user(gender="Woman")
798 mod, mod_token = generate_user(gender="Man")
799 node_id = _make_node()
800 _make_node_admin(mod.id, node_id)
802 trip_id = _create_trip_directly(
803 traveler.id,
804 node_id,
805 today() + timedelta(days=5),
806 today() + timedelta(days=10),
807 same_gender_only=True,
808 )
810 with public_trips_session(mod_token) as api:
811 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
812 assert any(t.trip_id == trip_id for t in res.public_trips)
814 get_res = api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=trip_id))
815 assert get_res.trip_id == trip_id
818def test_same_gender_only_owner_always_sees_own_trips(db):
819 traveler, traveler_token = generate_user(gender="Woman")
820 _, diff_gender_token = generate_user(gender="Man")
821 node_id = _make_node()
823 trip_id = _create_trip_directly(
824 traveler.id,
825 node_id,
826 today() + timedelta(days=5),
827 today() + timedelta(days=10),
828 same_gender_only=True,
829 )
831 # Owner always sees their own trips (is_self path skips the gender filter)
832 with public_trips_session(traveler_token) as api:
833 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id))
834 assert any(t.trip_id == trip_id for t in res.public_trips)
836 # Different-gender viewer doesn't see it on the traveler's profile
837 with public_trips_session(diff_gender_token) as api:
838 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id))
839 assert not any(t.trip_id == trip_id for t in res.public_trips)
842def test_same_gender_only_update(db):
843 user, token = generate_user(gender="Woman")
844 node_id = _make_node()
845 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
847 with public_trips_session(token) as api:
848 res = api.UpdatePublicTrip(
849 public_trips_pb2.UpdatePublicTripReq(
850 trip_id=trip_id,
851 same_gender_only=True,
852 )
853 )
854 assert res.same_gender_only is True
856 res = api.UpdatePublicTrip(
857 public_trips_pb2.UpdatePublicTripReq(
858 trip_id=trip_id,
859 same_gender_only=False,
860 )
861 )
862 assert res.same_gender_only is False
864 with session_scope() as session:
865 trip = session.execute(select(PublicTrip).where(PublicTrip.id == trip_id)).scalar_one()
866 assert trip.same_gender_only is False
869def test_list_public_trips_by_user_ascending_order(db):
870 user, token = generate_user()
871 node_id = _make_node()
873 trip_near = _create_trip_directly(user.id, node_id, today() + timedelta(days=3), today() + timedelta(days=5))
874 trip_far = _create_trip_directly(user.id, node_id, today() + timedelta(days=20), today() + timedelta(days=25))
875 trip_mid = _create_trip_directly(user.id, node_id, today() + timedelta(days=10), today() + timedelta(days=12))
877 with public_trips_session(token) as api:
878 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=user.id, ascending=True))
879 trip_ids = [t.trip_id for t in res.public_trips]
880 assert trip_ids.index(trip_near) < trip_ids.index(trip_mid) < trip_ids.index(trip_far)
882 res_desc = api.ListPublicTripsByUser(
883 public_trips_pb2.ListPublicTripsByUserReq(user_id=user.id, ascending=False)
884 )
885 trip_ids_desc = [t.trip_id for t in res_desc.public_trips]
886 assert trip_ids_desc.index(trip_far) < trip_ids_desc.index(trip_mid) < trip_ids_desc.index(trip_near)
889def test_list_public_trips_by_user_status_filter(db):
890 user, token = generate_user()
891 node_id = _make_node()
893 active = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
894 closed = _create_trip_directly(
895 user.id, node_id, today() + timedelta(days=15), today() + timedelta(days=20), status=PublicTripStatus.closed
896 )
898 with public_trips_session(token) as api:
899 # Filter to active only
900 res = api.ListPublicTripsByUser(
901 public_trips_pb2.ListPublicTripsByUserReq(
902 user_id=user.id,
903 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST],
904 )
905 )
906 assert {t.trip_id for t in res.public_trips} == {active}
908 # Filter to closed only
909 res = api.ListPublicTripsByUser(
910 public_trips_pb2.ListPublicTripsByUserReq(
911 user_id=user.id,
912 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED],
913 )
914 )
915 assert {t.trip_id for t in res.public_trips} == {closed}
917 # No filter returns all
918 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=user.id))
919 assert {t.trip_id for t in res.public_trips} == {active, closed}
922def test_list_public_trips_by_user_status_filter_ignored_for_others(db):
923 traveler, _ = generate_user()
924 _, viewer_token = generate_user()
925 node_id = _make_node()
927 active = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
928 _create_trip_directly(
929 traveler.id, node_id, today() + timedelta(days=15), today() + timedelta(days=20), status=PublicTripStatus.closed
930 )
932 with public_trips_session(viewer_token) as api:
933 # status_filter is ignored for other users — always returns active+upcoming only
934 res = api.ListPublicTripsByUser(
935 public_trips_pb2.ListPublicTripsByUserReq(
936 user_id=traveler.id,
937 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED],
938 )
939 )
940 assert [t.trip_id for t in res.public_trips] == [active]
943def test_list_public_trips_by_user_offers_count_owner(db):
944 traveler, traveler_token = generate_user()
945 host, host_token = generate_user()
946 node_id = _make_node()
948 trip_id = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
950 with public_trips_session(traveler_token) as api:
951 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id))
952 trip = next(t for t in res.public_trips if t.trip_id == trip_id)
953 assert trip.HasField("offers_count")
954 assert trip.offers_count == 0
956 # Host creates an offer via a host request linked to the trip
957 with requests_session(host_token) as api:
958 api.CreateHostRequest(
959 requests_pb2.CreateHostRequestReq(
960 host_user_id=traveler.id,
961 from_date=(today() + timedelta(days=5)).isoformat(),
962 to_date=(today() + timedelta(days=10)).isoformat(),
963 text=_valid_request_text(),
964 public_trip_id=trip_id,
965 )
966 )
968 with public_trips_session(traveler_token) as api:
969 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id))
970 trip = next(t for t in res.public_trips if t.trip_id == trip_id)
971 assert trip.HasField("offers_count")
972 assert trip.offers_count == 1
975def test_list_public_trips_by_user_offers_count_not_set_for_others(db):
976 traveler, _ = generate_user()
977 _, viewer_token = generate_user()
978 node_id = _make_node()
980 _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
982 with public_trips_session(viewer_token) as api:
983 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id))
984 assert len(res.public_trips) == 1
985 assert not res.public_trips[0].HasField("offers_count")
988def test_viewer_host_request_id_reflects_viewers_own_offer(db):
989 traveler, _ = generate_user()
990 host, host_token = generate_user()
991 _, other_token = generate_user()
992 node_id = _make_node()
994 trip_id = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
996 # Before offering, the host sees 0.
997 with public_trips_session(host_token) as api:
998 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
999 trip = next(t for t in res.public_trips if t.trip_id == trip_id)
1000 assert trip.viewer_host_request_id == 0
1002 # The host makes an offer on the trip.
1003 with requests_session(host_token) as api:
1004 create_res = api.CreateHostRequest(
1005 requests_pb2.CreateHostRequestReq(
1006 host_user_id=traveler.id,
1007 from_date=(today() + timedelta(days=5)).isoformat(),
1008 to_date=(today() + timedelta(days=10)).isoformat(),
1009 text=_valid_request_text(),
1010 public_trip_id=trip_id,
1011 )
1012 )
1013 host_request_id = create_res.host_request_id
1015 with public_trips_session(host_token) as api:
1016 # The offering host now sees their own host request id (List and Get).
1017 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
1018 trip = next(t for t in res.public_trips if t.trip_id == trip_id)
1019 assert trip.viewer_host_request_id == host_request_id
1021 get_res = api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=trip_id))
1022 assert get_res.viewer_host_request_id == host_request_id
1024 # A different viewer who hasn't offered still sees 0.
1025 with public_trips_session(other_token) as api:
1026 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
1027 trip = next(t for t in res.public_trips if t.trip_id == trip_id)
1028 assert trip.viewer_host_request_id == 0