Coverage for app/backend/src/tests/test_public_trips.py: 99%

610 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 17:13 +0000

1from datetime import UTC, date, datetime, timedelta 

2from unittest.mock import patch 

3 

4import grpc 

5import pytest 

6from sqlalchemy import select 

7 

8from couchers.constants import PUBLIC_TRIP_OFFER_MIN_LENGTH_UTF16 

9from couchers.db import session_scope 

10from couchers.models import ( 

11 Cluster, 

12 ClusterRole, 

13 ClusterSubscription, 

14 ModerationObjectType, 

15 ModerationQueueItem, 

16 ModerationState, 

17 ModerationTrigger, 

18 ModerationVisibility, 

19 Node, 

20 NodeType, 

21 User, 

22) 

23from couchers.models.host_requests import HostRequest, HostRequestStatus 

24from couchers.models.public_trips import PublicTrip, PublicTripStatus 

25from couchers.proto import public_trips_pb2, requests_pb2 

26from couchers.utils import create_polygon_lat_lng, now, to_multi, today 

27from tests.fixtures.db import generate_user 

28from tests.fixtures.misc import Moderator 

29from tests.fixtures.sessions import public_trips_session, requests_session 

30 

31 

32def _valid_trip_offer_text(text: str = "Offer to host") -> str: 

33 utf16_length = len(text.encode("utf-16-le")) // 2 

34 if utf16_length >= PUBLIC_TRIP_OFFER_MIN_LENGTH_UTF16: 34 ↛ 35line 34 didn't jump to line 35 because the condition on line 34 was never true

35 return text 

36 return text + "_" * (PUBLIC_TRIP_OFFER_MIN_LENGTH_UTF16 - utf16_length) 

37 

38 

39# 150+ utf-16 code units to satisfy PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16. 

40VALID_DESCRIPTION = ( 

41 "Visiting the area for a week for a music festival. I love meeting new people " 

42 "and would really appreciate local tips. Happy to help with tasks in exchange." 

43) 

44 

45 

46def _make_node(node_type: NodeType = NodeType.locality, small_community_features_enabled: bool = True) -> int: 

47 # Polygon inside the fake Europe/Helsinki timezone area so node.timezone resolves. 

48 with session_scope() as session: 

49 node = Node( 

50 geom=to_multi(create_polygon_lat_lng([[60, 24], [60, 26], [62, 26], [62, 24], [60, 24]])), 

51 node_type=node_type, 

52 ) 

53 session.add(node) 

54 session.flush() 

55 cluster = Cluster( 

56 name="Test community", 

57 description="Test", 

58 parent_node_id=node.id, 

59 is_official_cluster=True, 

60 small_community_features_enabled=small_community_features_enabled, 

61 ) 

62 session.add(cluster) 

63 session.flush() 

64 return node.id 

65 

66 

67def _make_node_admin(user_id: int, node_id: int): 

68 with session_scope() as session: 

69 cluster_id = session.execute( 

70 select(Cluster.id).where(Cluster.parent_node_id == node_id).where(Cluster.is_official_cluster) 

71 ).scalar_one() 

72 session.add(ClusterSubscription(cluster_id=cluster_id, user_id=user_id, role=ClusterRole.admin)) 

73 

74 

75def _create_trip_directly( 

76 user_id: int, 

77 node_id: int, 

78 from_date, 

79 to_date, 

80 *, 

81 description: str = "Looking for a host!", 

82 status=None, 

83 same_gender_only: bool = False, 

84 visibility: ModerationVisibility = ModerationVisibility.visible, 

85) -> int: 

86 with session_scope() as session: 

87 moderation_state = ModerationState( 

88 object_type=ModerationObjectType.public_trip, 

89 object_id=0, # placeholder, set after PublicTrip flush 

90 visibility=visibility, 

91 ) 

92 session.add(moderation_state) 

93 session.flush() 

94 trip = PublicTrip( 

95 user_id=user_id, 

96 node_id=node_id, 

97 from_date=from_date, 

98 to_date=to_date, 

99 description=description, 

100 status=status or PublicTripStatus.searching_for_host, 

101 same_gender_only=same_gender_only, 

102 moderation_state_id=moderation_state.id, 

103 ) 

104 session.add(trip) 

105 session.flush() 

106 moderation_state.object_id = trip.id 

107 return trip.id 

108 

109 

110def test_create_public_trip(db): 

111 user, token = generate_user() 

112 node_id = _make_node() 

113 

114 from_date = today() + timedelta(days=5) 

115 to_date = today() + timedelta(days=10) 

116 

117 with public_trips_session(token) as api: 

118 res = api.CreatePublicTrip( 

119 public_trips_pb2.CreatePublicTripReq( 

120 community_id=node_id, 

121 from_date=from_date.isoformat(), 

122 to_date=to_date.isoformat(), 

123 description=VALID_DESCRIPTION, 

124 ) 

125 ) 

126 

127 assert res.trip_id > 0 

128 assert res.user.user_id == user.id 

129 assert res.community_id == node_id 

130 assert res.community_slug == "test-community" 

131 assert res.community_name == "Test community" 

132 assert res.from_date == from_date.isoformat() 

133 assert res.to_date == to_date.isoformat() 

134 assert res.description == VALID_DESCRIPTION 

135 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST 

136 

137 with session_scope() as session: 

138 trip = session.execute(select(PublicTrip).where(PublicTrip.id == res.trip_id)).scalar_one() 

139 assert trip.user_id == user.id 

140 assert trip.node_id == node_id 

141 assert trip.status == PublicTripStatus.searching_for_host 

142 

143 

144def test_create_public_trip_incomplete_profile(db): 

145 _, token = generate_user(complete_profile=False) 

146 node_id = _make_node() 

147 

148 with public_trips_session(token) as api: 

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

150 api.CreatePublicTrip( 

151 public_trips_pb2.CreatePublicTripReq( 

152 community_id=node_id, 

153 from_date=(today() + timedelta(days=5)).isoformat(), 

154 to_date=(today() + timedelta(days=10)).isoformat(), 

155 description="Visiting town!", 

156 ) 

157 ) 

158 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

159 assert e.value.details() == "You have to complete your profile before you can create a public trip." 

160 

161 

162def test_create_public_trip_community_not_found(db): 

163 _, token = generate_user() 

164 

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=999999, 

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.NOT_FOUND 

176 assert e.value.details() == "Community not found." 

177 

178 

179def test_create_public_trip_not_enabled(db): 

180 _, token = generate_user() 

181 node_id = _make_node(small_community_features_enabled=False) 

182 

183 with public_trips_session(token) as api: 

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

185 api.CreatePublicTrip( 

186 public_trips_pb2.CreatePublicTripReq( 

187 community_id=node_id, 

188 from_date=(today() + timedelta(days=5)).isoformat(), 

189 to_date=(today() + timedelta(days=10)).isoformat(), 

190 description="Visiting town!", 

191 ) 

192 ) 

193 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

194 assert e.value.details() == "Public trips are not enabled in this community." 

195 

196 

197@pytest.mark.parametrize( 

198 "node_type", 

199 [NodeType.region, NodeType.subregion, NodeType.locality, NodeType.sublocality], 

200) 

201def test_create_public_trip_allows_region_and_narrower(db, node_type): 

202 _, token = generate_user() 

203 node_id = _make_node(node_type=node_type) 

204 

205 with public_trips_session(token) as api: 

206 res = api.CreatePublicTrip( 

207 public_trips_pb2.CreatePublicTripReq( 

208 community_id=node_id, 

209 from_date=(today() + timedelta(days=5)).isoformat(), 

210 to_date=(today() + timedelta(days=10)).isoformat(), 

211 description=VALID_DESCRIPTION, 

212 ) 

213 ) 

214 assert res.trip_id > 0 

215 

216 

217def test_create_public_trip_in_past_uses_node_timezone(db): 

218 # Default user geom resolves to America/New_York; the node's geom is in Europe/Helsinki. 

219 # Simulate a moment where Helsinki has already rolled into the next day (2026-01-16) 

220 # while NYC is still on 2026-01-15. A from_date of 2026-01-15 is "today" in NYC but 

221 # "yesterday" in Helsinki, and must be rejected because the check uses the node's tz. 

222 _, token = generate_user() 

223 node_id = _make_node() 

224 

225 fake_today_by_tz = {"America/New_York": date(2026, 1, 15), "Europe/Helsinki": date(2026, 1, 16)} 

226 

227 with patch( 

228 "couchers.servicers.public_trips.today_in_timezone", 

229 side_effect=lambda tz: fake_today_by_tz[tz], 

230 ): 

231 with public_trips_session(token) as api: 

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

233 api.CreatePublicTrip( 

234 public_trips_pb2.CreatePublicTripReq( 

235 community_id=node_id, 

236 from_date="2026-01-15", 

237 to_date="2026-01-20", 

238 description=VALID_DESCRIPTION, 

239 ) 

240 ) 

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

242 

243 

244def test_create_public_trip_date_errors(db): 

245 _, token = generate_user() 

246 node_id = _make_node() 

247 

248 with public_trips_session(token) as api: 

249 # from_date in the past 

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

251 api.CreatePublicTrip( 

252 public_trips_pb2.CreatePublicTripReq( 

253 community_id=node_id, 

254 from_date=(today() - timedelta(days=2)).isoformat(), 

255 to_date=(today() + timedelta(days=1)).isoformat(), 

256 description="Visiting town!", 

257 ) 

258 ) 

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

260 

261 # from_date after to_date 

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

263 api.CreatePublicTrip( 

264 public_trips_pb2.CreatePublicTripReq( 

265 community_id=node_id, 

266 from_date=(today() + timedelta(days=10)).isoformat(), 

267 to_date=(today() + timedelta(days=5)).isoformat(), 

268 description="Visiting town!", 

269 ) 

270 ) 

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

272 

273 # empty description 

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

275 api.CreatePublicTrip( 

276 public_trips_pb2.CreatePublicTripReq( 

277 community_id=node_id, 

278 from_date=(today() + timedelta(days=5)).isoformat(), 

279 to_date=(today() + timedelta(days=10)).isoformat(), 

280 description=" ", 

281 ) 

282 ) 

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

284 

285 # invalid date format 

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

287 api.CreatePublicTrip( 

288 public_trips_pb2.CreatePublicTripReq( 

289 community_id=node_id, 

290 from_date="not-a-date", 

291 to_date=(today() + timedelta(days=10)).isoformat(), 

292 description="Visiting town!", 

293 ) 

294 ) 

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

296 

297 

298def test_create_public_trip_overlap(db): 

299 user, token = generate_user() 

300 node_id = _make_node() 

301 

302 _create_trip_directly( 

303 user.id, 

304 node_id, 

305 today() + timedelta(days=5), 

306 today() + timedelta(days=10), 

307 ) 

308 

309 with public_trips_session(token) as api: 

310 # overlapping dates should fail 

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

312 api.CreatePublicTrip( 

313 public_trips_pb2.CreatePublicTripReq( 

314 community_id=node_id, 

315 from_date=(today() + timedelta(days=8)).isoformat(), 

316 to_date=(today() + timedelta(days=12)).isoformat(), 

317 description=VALID_DESCRIPTION, 

318 ) 

319 ) 

320 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

321 

322 # non-overlapping dates should succeed 

323 res = api.CreatePublicTrip( 

324 public_trips_pb2.CreatePublicTripReq( 

325 community_id=node_id, 

326 from_date=(today() + timedelta(days=20)).isoformat(), 

327 to_date=(today() + timedelta(days=25)).isoformat(), 

328 description=VALID_DESCRIPTION, 

329 ) 

330 ) 

331 assert res.trip_id > 0 

332 

333 

334def test_create_public_trip_closed_trip_allows_new_overlap(db): 

335 user, token = generate_user() 

336 node_id = _make_node() 

337 

338 _create_trip_directly( 

339 user.id, 

340 node_id, 

341 today() + timedelta(days=5), 

342 today() + timedelta(days=10), 

343 status=PublicTripStatus.closed, 

344 ) 

345 

346 with public_trips_session(token) as api: 

347 # closed trips shouldn't block new overlapping ones 

348 res = api.CreatePublicTrip( 

349 public_trips_pb2.CreatePublicTripReq( 

350 community_id=node_id, 

351 from_date=(today() + timedelta(days=7)).isoformat(), 

352 to_date=(today() + timedelta(days=12)).isoformat(), 

353 description=VALID_DESCRIPTION, 

354 ) 

355 ) 

356 assert res.trip_id > 0 

357 

358 

359def test_get_public_trip(db): 

360 user, token = generate_user() 

361 node_id = _make_node() 

362 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

363 

364 with public_trips_session(token) as api: 

365 res = api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=trip_id)) 

366 assert res.trip_id == trip_id 

367 assert res.user.user_id == user.id 

368 assert res.community_slug == "test-community" 

369 assert res.community_name == "Test community" 

370 assert res.timezone == "Europe/Helsinki" 

371 

372 

373def test_get_public_trip_not_found(db): 

374 _, token = generate_user() 

375 with public_trips_session(token) as api: 

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

377 api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=999999)) 

378 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

379 

380 

381def test_list_public_trips(db): 

382 traveler, _ = generate_user() 

383 _, host_token = generate_user() 

384 node_id = _make_node() 

385 

386 trip1 = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

387 trip2 = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=20), today() + timedelta(days=25)) 

388 

389 with public_trips_session(host_token) as api: 

390 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id)) 

391 returned_ids = {t.trip_id for t in res.public_trips} 

392 assert returned_ids == {trip1, trip2} 

393 assert all(t.community_slug == "test-community" for t in res.public_trips) 

394 assert all(t.community_name == "Test community" for t in res.public_trips) 

395 

396 

397def test_list_public_trips_filters_closed_and_past(db): 

398 traveler, _ = generate_user() 

399 _, host_token = generate_user() 

400 node_id = _make_node() 

401 

402 active = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

403 # closed trip - should be hidden 

404 _create_trip_directly( 

405 traveler.id, 

406 node_id, 

407 today() + timedelta(days=15), 

408 today() + timedelta(days=20), 

409 status=PublicTripStatus.closed, 

410 ) 

411 # past trip (to_date < today) - should be hidden 

412 _create_trip_directly(traveler.id, node_id, today() - timedelta(days=10), today() - timedelta(days=1)) 

413 

414 with public_trips_session(host_token) as api: 

415 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id)) 

416 assert [t.trip_id for t in res.public_trips] == [active] 

417 

418 

419def test_list_public_trips_uses_timezone(db, frozen_timewarp): 

420 # 1999-12-31 23:30 UTC == 2000-01-01 01:30 Helsinki 

421 frozen_timewarp.freeze_at(datetime(1999, 12, 31, 23, 30, tzinfo=UTC)) 

422 traveler, _ = generate_user() 

423 _, host_token = generate_user() 

424 node_id = _make_node() # Europe/Helsinki timezone 

425 

426 # First trip is over in Europe/Helsinki, second trip is ongoing 

427 _create_trip_directly(traveler.id, node_id, date(1999, 12, 30), date(1999, 12, 31)) 

428 ongoing = _create_trip_directly(traveler.id, node_id, date(2000, 1, 1), date(2000, 1, 2)) 

429 

430 with public_trips_session(host_token) as api: 

431 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id)) 

432 assert [t.trip_id for t in res.public_trips] == [ongoing] 

433 assert res.public_trips[0].timezone == "Europe/Helsinki" 

434 

435 

436def test_list_public_trips_hides_invisible_user(db): 

437 traveler, _ = generate_user() 

438 _, host_token = generate_user() 

439 node_id = _make_node() 

440 

441 _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

442 

443 # soft-delete the traveler 

444 with session_scope() as session: 

445 t = session.execute(select(User).where(User.id == traveler.id)).scalar_one() 

446 t.deleted_at = now() 

447 

448 with public_trips_session(host_token) as api: 

449 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id)) 

450 assert len(res.public_trips) == 0 

451 

452 

453def test_list_public_trips_pagination(db): 

454 traveler, _ = generate_user() 

455 _, host_token = generate_user() 

456 node_id = _make_node() 

457 

458 trip_ids = [ 

459 _create_trip_directly( 

460 traveler.id, node_id, today() + timedelta(days=5 + i * 10), today() + timedelta(days=10 + i * 10) 

461 ) 

462 for i in range(5) 

463 ] 

464 

465 with public_trips_session(host_token) as api: 

466 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id, page_size=2)) 

467 assert [t.trip_id for t in res.public_trips] == [trip_ids[4], trip_ids[3]] 

468 assert res.next_page_token 

469 

470 res2 = api.ListPublicTrips( 

471 public_trips_pb2.ListPublicTripsReq(community_id=node_id, page_size=2, page_token=res.next_page_token) 

472 ) 

473 assert [t.trip_id for t in res2.public_trips] == [trip_ids[2], trip_ids[1]] 

474 assert res2.next_page_token 

475 

476 res3 = api.ListPublicTrips( 

477 public_trips_pb2.ListPublicTripsReq(community_id=node_id, page_size=2, page_token=res2.next_page_token) 

478 ) 

479 assert [t.trip_id for t in res3.public_trips] == [trip_ids[0]] 

480 assert not res3.next_page_token 

481 

482 

483def test_list_public_trips_by_user_self_sees_all(db): 

484 user, token = generate_user() 

485 other, _ = generate_user() 

486 node_id = _make_node() 

487 

488 mine_active = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

489 mine_closed = _create_trip_directly( 

490 user.id, 

491 node_id, 

492 today() + timedelta(days=15), 

493 today() + timedelta(days=20), 

494 status=PublicTripStatus.closed, 

495 ) 

496 mine_past = _create_trip_directly(user.id, node_id, today() - timedelta(days=10), today() - timedelta(days=1)) 

497 # other user's trip should not be returned 

498 _create_trip_directly(other.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

499 

500 with public_trips_session(token) as api: 

501 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=user.id)) 

502 assert {t.trip_id for t in res.public_trips} == {mine_active, mine_closed, mine_past} 

503 

504 

505def test_list_public_trips_by_user_other_filters_inactive_and_past(db): 

506 traveler, _ = generate_user() 

507 _, viewer_token = generate_user() 

508 node_id = _make_node() 

509 

510 active = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

511 # closed trip - hidden from others 

512 _create_trip_directly( 

513 traveler.id, 

514 node_id, 

515 today() + timedelta(days=15), 

516 today() + timedelta(days=20), 

517 status=PublicTripStatus.closed, 

518 ) 

519 # past trip - hidden from others 

520 _create_trip_directly(traveler.id, node_id, today() - timedelta(days=10), today() - timedelta(days=1)) 

521 

522 with public_trips_session(viewer_token) as api: 

523 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id)) 

524 assert [t.trip_id for t in res.public_trips] == [active] 

525 

526 

527def test_list_public_trips_by_user_invisible_user(db): 

528 traveler, _ = generate_user() 

529 _, viewer_token = generate_user() 

530 node_id = _make_node() 

531 

532 _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

533 

534 # soft-delete the traveler 

535 with session_scope() as session: 

536 t = session.execute(select(User).where(User.id == traveler.id)).scalar_one() 

537 t.deleted_at = now() 

538 

539 with public_trips_session(viewer_token) as api: 

540 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id)) 

541 assert len(res.public_trips) == 0 

542 

543 

544def test_update_public_trip_close(db): 

545 user, token = generate_user() 

546 node_id = _make_node() 

547 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

548 

549 with public_trips_session(token) as api: 

550 res = api.UpdatePublicTrip( 

551 public_trips_pb2.UpdatePublicTripReq( 

552 trip_id=trip_id, 

553 status=public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED, 

554 ) 

555 ) 

556 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED 

557 

558 with session_scope() as session: 

559 trip = session.execute(select(PublicTrip).where(PublicTrip.id == trip_id)).scalar_one() 

560 assert trip.status == PublicTripStatus.closed 

561 

562 

563def test_update_public_trip_reopen(db): 

564 user, token = generate_user() 

565 node_id = _make_node() 

566 trip_id = _create_trip_directly( 

567 user.id, 

568 node_id, 

569 today() + timedelta(days=5), 

570 today() + timedelta(days=10), 

571 status=PublicTripStatus.closed, 

572 ) 

573 

574 with public_trips_session(token) as api: 

575 res = api.UpdatePublicTrip( 

576 public_trips_pb2.UpdatePublicTripReq( 

577 trip_id=trip_id, 

578 status=public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST, 

579 ) 

580 ) 

581 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST 

582 

583 with session_scope() as session: 

584 trip = session.execute(select(PublicTrip).where(PublicTrip.id == trip_id)).scalar_one() 

585 assert trip.status == PublicTripStatus.searching_for_host 

586 

587 

588def test_update_public_trip_cant_reopen_past_trip(db): 

589 user, token = generate_user() 

590 node_id = _make_node() 

591 trip_id = _create_trip_directly( 

592 user.id, 

593 node_id, 

594 today() - timedelta(days=10), 

595 today() - timedelta(days=1), 

596 status=PublicTripStatus.closed, 

597 ) 

598 

599 with public_trips_session(token) as api: 

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

601 api.UpdatePublicTrip( 

602 public_trips_pb2.UpdatePublicTripReq( 

603 trip_id=trip_id, 

604 status=public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST, 

605 ) 

606 ) 

607 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

608 

609 

610def test_update_public_trip_close_past_trip_allowed(db): 

611 # Closing a trip whose dates are in the past is allowed, even though content edits are not. 

612 user, token = generate_user() 

613 node_id = _make_node() 

614 trip_id = _create_trip_directly(user.id, node_id, today() - timedelta(days=10), today() - timedelta(days=1)) 

615 

616 with public_trips_session(token) as api: 

617 res = api.UpdatePublicTrip( 

618 public_trips_pb2.UpdatePublicTripReq( 

619 trip_id=trip_id, 

620 status=public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED, 

621 ) 

622 ) 

623 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED 

624 

625 

626def test_update_public_trip_description_only(db): 

627 user, token = generate_user() 

628 node_id = _make_node() 

629 trip_id = _create_trip_directly( 

630 user.id, 

631 node_id, 

632 today() + timedelta(days=5), 

633 today() + timedelta(days=10), 

634 description="Original description", 

635 ) 

636 

637 updated = VALID_DESCRIPTION + " Updated plans." 

638 

639 with public_trips_session(token) as api: 

640 res = api.UpdatePublicTrip( 

641 public_trips_pb2.UpdatePublicTripReq( 

642 trip_id=trip_id, 

643 description=updated, 

644 ) 

645 ) 

646 assert res.trip_id == trip_id 

647 assert res.description == updated 

648 # dates should be unchanged 

649 assert res.from_date == (today() + timedelta(days=5)).isoformat() 

650 assert res.to_date == (today() + timedelta(days=10)).isoformat() 

651 

652 with session_scope() as session: 

653 trip = session.execute(select(PublicTrip).where(PublicTrip.id == trip_id)).scalar_one() 

654 assert trip.description == updated 

655 

656 

657def test_update_public_trip_dates(db): 

658 user, token = generate_user() 

659 node_id = _make_node() 

660 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

661 

662 new_from = today() + timedelta(days=7) 

663 new_to = today() + timedelta(days=14) 

664 

665 with public_trips_session(token) as api: 

666 res = api.UpdatePublicTrip( 

667 public_trips_pb2.UpdatePublicTripReq( 

668 trip_id=trip_id, 

669 from_date=new_from.isoformat(), 

670 to_date=new_to.isoformat(), 

671 ) 

672 ) 

673 assert res.from_date == new_from.isoformat() 

674 assert res.to_date == new_to.isoformat() 

675 

676 

677def test_update_public_trip_not_owner(db): 

678 user, _ = generate_user() 

679 _, other_token = generate_user() 

680 node_id = _make_node() 

681 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

682 

683 with public_trips_session(other_token) as api: 

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

685 api.UpdatePublicTrip( 

686 public_trips_pb2.UpdatePublicTripReq( 

687 trip_id=trip_id, 

688 description="I don't own this!", 

689 ) 

690 ) 

691 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

692 

693 

694def test_update_public_trip_in_past(db): 

695 user, token = generate_user() 

696 node_id = _make_node() 

697 trip_id = _create_trip_directly(user.id, node_id, today() - timedelta(days=10), today() - timedelta(days=2)) 

698 

699 with public_trips_session(token) as api: 

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

701 api.UpdatePublicTrip( 

702 public_trips_pb2.UpdatePublicTripReq( 

703 trip_id=trip_id, 

704 description="Too late!", 

705 ) 

706 ) 

707 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

708 

709 

710def test_update_public_trip_date_validation(db): 

711 user, token = generate_user() 

712 node_id = _make_node() 

713 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

714 

715 with public_trips_session(token) as api: 

716 # from_date after to_date (using the stored to_date of today+10) 

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

718 api.UpdatePublicTrip( 

719 public_trips_pb2.UpdatePublicTripReq( 

720 trip_id=trip_id, 

721 from_date=(today() + timedelta(days=20)).isoformat(), 

722 ) 

723 ) 

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

725 

726 # Empty description 

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

728 api.UpdatePublicTrip( 

729 public_trips_pb2.UpdatePublicTripReq( 

730 trip_id=trip_id, 

731 description=" ", 

732 ) 

733 ) 

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

735 

736 

737def test_create_public_trip_description_too_short(db): 

738 _, token = generate_user() 

739 node_id = _make_node() 

740 

741 with public_trips_session(token) as api: 

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

743 api.CreatePublicTrip( 

744 public_trips_pb2.CreatePublicTripReq( 

745 community_id=node_id, 

746 from_date=(today() + timedelta(days=5)).isoformat(), 

747 to_date=(today() + timedelta(days=10)).isoformat(), 

748 description="Too short.", 

749 ) 

750 ) 

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

752 assert "150" in (e.value.details() or "") 

753 

754 

755def test_update_public_trip_description_too_short(db): 

756 user, token = generate_user() 

757 node_id = _make_node() 

758 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

759 

760 with public_trips_session(token) as api: 

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

762 api.UpdatePublicTrip( 

763 public_trips_pb2.UpdatePublicTripReq( 

764 trip_id=trip_id, 

765 description="Too short.", 

766 ) 

767 ) 

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

769 assert "150" in (e.value.details() or "") 

770 

771 

772def test_same_gender_only_create_and_retrieve(db): 

773 user, token = generate_user(gender="Woman") 

774 node_id = _make_node() 

775 

776 from_date = today() + timedelta(days=5) 

777 to_date = today() + timedelta(days=10) 

778 

779 with public_trips_session(token) as api: 

780 res = api.CreatePublicTrip( 

781 public_trips_pb2.CreatePublicTripReq( 

782 community_id=node_id, 

783 from_date=from_date.isoformat(), 

784 to_date=to_date.isoformat(), 

785 description=VALID_DESCRIPTION, 

786 same_gender_only=True, 

787 ) 

788 ) 

789 assert res.same_gender_only is True 

790 

791 with session_scope() as session: 

792 trip = session.execute(select(PublicTrip).where(PublicTrip.id == res.trip_id)).scalar_one() 

793 assert trip.same_gender_only is True 

794 

795 

796def test_same_gender_only_visibility_list_and_get(db): 

797 traveler, _ = generate_user(gender="Woman") 

798 _, same_gender_token = generate_user(gender="Woman") 

799 _, diff_gender_token = generate_user(gender="Man") 

800 node_id = _make_node() 

801 

802 filtered_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 ) 

809 open_trip_id = _create_trip_directly( 

810 traveler.id, 

811 node_id, 

812 today() + timedelta(days=20), 

813 today() + timedelta(days=25), 

814 ) 

815 

816 with public_trips_session(same_gender_token) as api: 

817 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id)) 

818 assert {t.trip_id for t in res.public_trips} == {filtered_trip_id, open_trip_id} 

819 

820 get_res = api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=filtered_trip_id)) 

821 assert get_res.trip_id == filtered_trip_id 

822 assert get_res.same_gender_only is True 

823 

824 with public_trips_session(diff_gender_token) as api: 

825 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id)) 

826 assert [t.trip_id for t in res.public_trips] == [open_trip_id] 

827 

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

829 api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=filtered_trip_id)) 

830 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

831 

832 

833def test_same_gender_only_moderator_bypass(db): 

834 traveler, _ = generate_user(gender="Woman") 

835 mod, mod_token = generate_user(gender="Man") 

836 node_id = _make_node() 

837 _make_node_admin(mod.id, node_id) 

838 

839 trip_id = _create_trip_directly( 

840 traveler.id, 

841 node_id, 

842 today() + timedelta(days=5), 

843 today() + timedelta(days=10), 

844 same_gender_only=True, 

845 ) 

846 

847 with public_trips_session(mod_token) as api: 

848 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id)) 

849 assert any(t.trip_id == trip_id for t in res.public_trips) 

850 

851 get_res = api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=trip_id)) 

852 assert get_res.trip_id == trip_id 

853 

854 

855def test_same_gender_only_owner_always_sees_own_trips(db): 

856 traveler, traveler_token = generate_user(gender="Woman") 

857 _, diff_gender_token = generate_user(gender="Man") 

858 node_id = _make_node() 

859 

860 trip_id = _create_trip_directly( 

861 traveler.id, 

862 node_id, 

863 today() + timedelta(days=5), 

864 today() + timedelta(days=10), 

865 same_gender_only=True, 

866 ) 

867 

868 # Owner always sees their own trips (is_self path skips the gender filter) 

869 with public_trips_session(traveler_token) as api: 

870 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id)) 

871 assert any(t.trip_id == trip_id for t in res.public_trips) 

872 

873 # Different-gender viewer doesn't see it on the traveler's profile 

874 with public_trips_session(diff_gender_token) as api: 

875 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id)) 

876 assert not any(t.trip_id == trip_id for t in res.public_trips) 

877 

878 

879def test_same_gender_only_update(db): 

880 user, token = generate_user(gender="Woman") 

881 node_id = _make_node() 

882 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

883 

884 with public_trips_session(token) as api: 

885 res = api.UpdatePublicTrip( 

886 public_trips_pb2.UpdatePublicTripReq( 

887 trip_id=trip_id, 

888 same_gender_only=True, 

889 ) 

890 ) 

891 assert res.same_gender_only is True 

892 

893 res = api.UpdatePublicTrip( 

894 public_trips_pb2.UpdatePublicTripReq( 

895 trip_id=trip_id, 

896 same_gender_only=False, 

897 ) 

898 ) 

899 assert res.same_gender_only is False 

900 

901 with session_scope() as session: 

902 trip = session.execute(select(PublicTrip).where(PublicTrip.id == trip_id)).scalar_one() 

903 assert trip.same_gender_only is False 

904 

905 

906def test_list_public_trips_by_user_ascending_order(db): 

907 user, token = generate_user() 

908 node_id = _make_node() 

909 

910 trip_near = _create_trip_directly(user.id, node_id, today() + timedelta(days=3), today() + timedelta(days=5)) 

911 trip_far = _create_trip_directly(user.id, node_id, today() + timedelta(days=20), today() + timedelta(days=25)) 

912 trip_mid = _create_trip_directly(user.id, node_id, today() + timedelta(days=10), today() + timedelta(days=12)) 

913 

914 with public_trips_session(token) as api: 

915 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=user.id, ascending=True)) 

916 trip_ids = [t.trip_id for t in res.public_trips] 

917 assert trip_ids.index(trip_near) < trip_ids.index(trip_mid) < trip_ids.index(trip_far) 

918 

919 res_desc = api.ListPublicTripsByUser( 

920 public_trips_pb2.ListPublicTripsByUserReq(user_id=user.id, ascending=False) 

921 ) 

922 trip_ids_desc = [t.trip_id for t in res_desc.public_trips] 

923 assert trip_ids_desc.index(trip_far) < trip_ids_desc.index(trip_mid) < trip_ids_desc.index(trip_near) 

924 

925 

926def test_list_public_trips_by_user_status_filter(db): 

927 user, token = generate_user() 

928 node_id = _make_node() 

929 

930 active = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

931 closed = _create_trip_directly( 

932 user.id, node_id, today() + timedelta(days=15), today() + timedelta(days=20), status=PublicTripStatus.closed 

933 ) 

934 

935 with public_trips_session(token) as api: 

936 # Filter to active only 

937 res = api.ListPublicTripsByUser( 

938 public_trips_pb2.ListPublicTripsByUserReq( 

939 user_id=user.id, 

940 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST], 

941 ) 

942 ) 

943 assert {t.trip_id for t in res.public_trips} == {active} 

944 

945 # Filter to closed only 

946 res = api.ListPublicTripsByUser( 

947 public_trips_pb2.ListPublicTripsByUserReq( 

948 user_id=user.id, 

949 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED], 

950 ) 

951 ) 

952 assert {t.trip_id for t in res.public_trips} == {closed} 

953 

954 # No filter returns all 

955 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=user.id)) 

956 assert {t.trip_id for t in res.public_trips} == {active, closed} 

957 

958 

959def test_list_public_trips_by_user_status_filter_ignored_for_others(db): 

960 traveler, _ = generate_user() 

961 _, viewer_token = generate_user() 

962 node_id = _make_node() 

963 

964 active = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

965 _create_trip_directly( 

966 traveler.id, node_id, today() + timedelta(days=15), today() + timedelta(days=20), status=PublicTripStatus.closed 

967 ) 

968 

969 with public_trips_session(viewer_token) as api: 

970 # status_filter is ignored for other users — always returns active+upcoming only 

971 res = api.ListPublicTripsByUser( 

972 public_trips_pb2.ListPublicTripsByUserReq( 

973 user_id=traveler.id, 

974 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED], 

975 ) 

976 ) 

977 assert [t.trip_id for t in res.public_trips] == [active] 

978 

979 

980def test_list_public_trips_by_user_offers_count_owner(db, moderator: Moderator): 

981 traveler, traveler_token = generate_user() 

982 host, host_token = generate_user() 

983 node_id = _make_node() 

984 

985 trip_id = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

986 

987 with public_trips_session(traveler_token) as api: 

988 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id)) 

989 trip = next(t for t in res.public_trips if t.trip_id == trip_id) 

990 assert trip.HasField("offers_count") 

991 assert trip.offers_count == 0 

992 

993 # Host creates an offer via a host request linked to the trip 

994 with requests_session(host_token) as api: 

995 host_request_id = api.CreateHostRequest( 

996 requests_pb2.CreateHostRequestReq( 

997 host_user_id=traveler.id, 

998 from_date=(today() + timedelta(days=5)).isoformat(), 

999 to_date=(today() + timedelta(days=10)).isoformat(), 

1000 text=_valid_trip_offer_text(), 

1001 public_trip_id=trip_id, 

1002 ) 

1003 ).host_request_id 

1004 

1005 with public_trips_session(traveler_token) as api: 

1006 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id)) 

1007 trip = next(t for t in res.public_trips if t.trip_id == trip_id) 

1008 assert trip.HasField("offers_count") 

1009 assert trip.offers_count == 0 

1010 

1011 moderator.approve_host_request(host_request_id) 

1012 

1013 with public_trips_session(traveler_token) as api: 

1014 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id)) 

1015 trip = next(t for t in res.public_trips if t.trip_id == trip_id) 

1016 assert trip.HasField("offers_count") 

1017 assert trip.offers_count == 1 

1018 

1019 

1020def test_list_public_trips_by_user_offers_count_excludes_invisible_offers(db, moderator: Moderator): 

1021 """The count only covers offers the owner can open.""" 

1022 traveler, traveler_token = generate_user() 

1023 _hidden_host, hidden_host_token = generate_user() 

1024 banned_host, banned_host_token = generate_user() 

1025 node_id = _make_node() 

1026 

1027 trip_id = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

1028 

1029 offer_ids = [] 

1030 for token in (hidden_host_token, banned_host_token): 

1031 with requests_session(token) as api: 

1032 offer_ids.append( 

1033 api.CreateHostRequest( 

1034 requests_pb2.CreateHostRequestReq( 

1035 host_user_id=traveler.id, 

1036 from_date=(today() + timedelta(days=5)).isoformat(), 

1037 to_date=(today() + timedelta(days=10)).isoformat(), 

1038 text=_valid_trip_offer_text(), 

1039 public_trip_id=trip_id, 

1040 ) 

1041 ).host_request_id 

1042 ) 

1043 hidden_offer_id = offer_ids[0] 

1044 

1045 for offer_id in offer_ids: 

1046 moderator.approve_host_request(offer_id) 

1047 

1048 with public_trips_session(traveler_token) as api: 

1049 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id)) 

1050 assert next(t for t in res.public_trips if t.trip_id == trip_id).offers_count == 2 

1051 

1052 moderator.hide_host_request(hidden_offer_id) 

1053 with session_scope() as session: 

1054 session.execute(select(User).where(User.id == banned_host.id)).scalar_one().banned_at = now() 

1055 

1056 with public_trips_session(traveler_token) as api: 

1057 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id)) 

1058 assert next(t for t in res.public_trips if t.trip_id == trip_id).offers_count == 0 

1059 

1060 

1061def test_list_public_trips_by_user_offers_count_not_set_for_others(db): 

1062 traveler, _ = generate_user() 

1063 _, viewer_token = generate_user() 

1064 node_id = _make_node() 

1065 

1066 _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

1067 

1068 with public_trips_session(viewer_token) as api: 

1069 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id)) 

1070 assert len(res.public_trips) == 1 

1071 assert not res.public_trips[0].HasField("offers_count") 

1072 

1073 

1074def test_list_public_trips_by_user_offers_count_excludes_cancelled(db, moderator: Moderator): 

1075 traveler, traveler_token = generate_user() 

1076 hosts = [generate_user() for _ in range(5)] 

1077 node_id = _make_node() 

1078 

1079 trip_id = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

1080 

1081 for _host, host_token in hosts: 

1082 with requests_session(host_token) as api: 

1083 offer_id = api.CreateHostRequest( 

1084 requests_pb2.CreateHostRequestReq( 

1085 host_user_id=traveler.id, 

1086 from_date=(today() + timedelta(days=5)).isoformat(), 

1087 to_date=(today() + timedelta(days=10)).isoformat(), 

1088 text=_valid_trip_offer_text(), 

1089 public_trip_id=trip_id, 

1090 ) 

1091 ).host_request_id 

1092 moderator.approve_host_request(offer_id) 

1093 

1094 # Set one offer per status, so we cover every status the count has to consider. 

1095 with session_scope() as session: 

1096 offers = ( 

1097 session.execute( 

1098 select(HostRequest).where(HostRequest.public_trip_id == trip_id).order_by(HostRequest.conversation_id) 

1099 ) 

1100 .scalars() 

1101 .all() 

1102 ) 

1103 offers[0].status = HostRequestStatus.pending 

1104 offers[1].status = HostRequestStatus.accepted 

1105 offers[2].status = HostRequestStatus.confirmed 

1106 offers[3].status = HostRequestStatus.rejected 

1107 offers[4].status = HostRequestStatus.cancelled 

1108 

1109 with public_trips_session(traveler_token) as api: 

1110 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id)) 

1111 trip = next(t for t in res.public_trips if t.trip_id == trip_id) 

1112 # pending, accepted, confirmed and rejected all count; cancelled does not 

1113 assert trip.offers_count == 4 

1114 

1115 

1116def test_viewer_host_request_id_reflects_viewers_own_offer(db): 

1117 traveler, _ = generate_user() 

1118 host, host_token = generate_user() 

1119 _, other_token = generate_user() 

1120 node_id = _make_node() 

1121 

1122 trip_id = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

1123 

1124 # Before offering, the host sees 0. 

1125 with public_trips_session(host_token) as api: 

1126 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id)) 

1127 trip = next(t for t in res.public_trips if t.trip_id == trip_id) 

1128 assert trip.viewer_host_request_id == 0 

1129 

1130 # The host makes an offer on the trip. 

1131 with requests_session(host_token) as api: 

1132 create_res = api.CreateHostRequest( 

1133 requests_pb2.CreateHostRequestReq( 

1134 host_user_id=traveler.id, 

1135 from_date=(today() + timedelta(days=5)).isoformat(), 

1136 to_date=(today() + timedelta(days=10)).isoformat(), 

1137 text=_valid_trip_offer_text(), 

1138 public_trip_id=trip_id, 

1139 ) 

1140 ) 

1141 host_request_id = create_res.host_request_id 

1142 

1143 with public_trips_session(host_token) as api: 

1144 # The offering host now sees their own host request id (List and Get). 

1145 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id)) 

1146 trip = next(t for t in res.public_trips if t.trip_id == trip_id) 

1147 assert trip.viewer_host_request_id == host_request_id 

1148 

1149 get_res = api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=trip_id)) 

1150 assert get_res.viewer_host_request_id == host_request_id 

1151 

1152 # A different viewer who hasn't offered still sees 0. 

1153 with public_trips_session(other_token) as api: 

1154 res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id)) 

1155 trip = next(t for t in res.public_trips if t.trip_id == trip_id) 

1156 assert trip.viewer_host_request_id == 0 

1157 

1158 

1159def test_create_public_trip_creates_shadowed_moderation_state(db): 

1160 """Creating a trip creates a ModerationState (shadowed) and an initial-review queue item.""" 

1161 user, token = generate_user() 

1162 node_id = _make_node() 

1163 

1164 with public_trips_session(token) as api: 

1165 res = api.CreatePublicTrip( 

1166 public_trips_pb2.CreatePublicTripReq( 

1167 community_id=node_id, 

1168 from_date=(today() + timedelta(days=5)).isoformat(), 

1169 to_date=(today() + timedelta(days=10)).isoformat(), 

1170 description=VALID_DESCRIPTION, 

1171 ) 

1172 ) 

1173 

1174 with session_scope() as session: 

1175 trip = session.execute(select(PublicTrip).where(PublicTrip.id == res.trip_id)).scalar_one() 

1176 state = session.execute( 

1177 select(ModerationState).where(ModerationState.id == trip.moderation_state_id) 

1178 ).scalar_one() 

1179 assert state.object_type == ModerationObjectType.public_trip 

1180 assert state.object_id == trip.id 

1181 assert state.visibility == ModerationVisibility.shadowed 

1182 

1183 queue_item = session.execute( 

1184 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state.id) 

1185 ).scalar_one() 

1186 assert queue_item.trigger == ModerationTrigger.initial_review 

1187 assert queue_item.resolved_by_log_id is None 

1188 

1189 

1190def test_shadowed_public_trip_visible_to_author_hidden_from_others(db): 

1191 traveler, traveler_token = generate_user() 

1192 _, viewer_token = generate_user() 

1193 node_id = _make_node() 

1194 

1195 trip_id = _create_trip_directly( 

1196 traveler.id, 

1197 node_id, 

1198 today() + timedelta(days=5), 

1199 today() + timedelta(days=10), 

1200 visibility=ModerationVisibility.shadowed, 

1201 ) 

1202 

1203 # Author can fetch their own shadowed trip 

1204 with public_trips_session(traveler_token) as api: 

1205 res = api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=trip_id)) 

1206 assert res.trip_id == trip_id 

1207 

1208 # Author sees their shadowed trip in their own listing 

1209 with public_trips_session(traveler_token) as api: 

1210 res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id)) 

1211 assert any(t.trip_id == trip_id for t in res.public_trips) 

1212 

1213 # Other viewers can't see it 

1214 with public_trips_session(viewer_token) as api: 

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

1216 api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=trip_id)) 

1217 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

1218 

1219 list_res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id)) 

1220 assert not any(t.trip_id == trip_id for t in list_res.public_trips) 

1221 

1222 by_user_res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id)) 

1223 assert not any(t.trip_id == trip_id for t in by_user_res.public_trips) 

1224 

1225 

1226def test_hidden_public_trip_invisible_to_everyone(db): 

1227 traveler, traveler_token = generate_user() 

1228 _, viewer_token = generate_user() 

1229 node_id = _make_node() 

1230 

1231 trip_id = _create_trip_directly( 

1232 traveler.id, 

1233 node_id, 

1234 today() + timedelta(days=5), 

1235 today() + timedelta(days=10), 

1236 visibility=ModerationVisibility.hidden, 

1237 ) 

1238 

1239 # Even author can't read a hidden trip via the public API 

1240 with public_trips_session(traveler_token) as api: 

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

1242 api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=trip_id)) 

1243 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

1244 

1245 by_user_res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id)) 

1246 assert not any(t.trip_id == trip_id for t in by_user_res.public_trips) 

1247 

1248 with public_trips_session(viewer_token) as api: 

1249 list_res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id)) 

1250 assert not any(t.trip_id == trip_id for t in list_res.public_trips) 

1251 

1252 

1253def test_moderator_approval_makes_public_trip_visible(db, moderator): 

1254 user, token = generate_user() 

1255 _, viewer_token = generate_user() 

1256 node_id = _make_node() 

1257 

1258 with public_trips_session(token) as api: 

1259 res = api.CreatePublicTrip( 

1260 public_trips_pb2.CreatePublicTripReq( 

1261 community_id=node_id, 

1262 from_date=(today() + timedelta(days=5)).isoformat(), 

1263 to_date=(today() + timedelta(days=10)).isoformat(), 

1264 description=VALID_DESCRIPTION, 

1265 ) 

1266 ) 

1267 

1268 # Before approval (shadowed): hidden from other users 

1269 with public_trips_session(viewer_token) as api: 

1270 list_res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id)) 

1271 assert not any(t.trip_id == res.trip_id for t in list_res.public_trips) 

1272 

1273 moderator.approve_public_trip(res.trip_id) 

1274 

1275 # After approval: visible to everyone 

1276 with public_trips_session(viewer_token) as api: 

1277 list_res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id)) 

1278 assert any(t.trip_id == res.trip_id for t in list_res.public_trips) 

1279 

1280 get_res = api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=res.trip_id)) 

1281 assert get_res.trip_id == res.trip_id 

1282 

1283 # ... and the initial-review queue item is resolved, so the trip leaves the queue 

1284 with session_scope() as session: 

1285 trip = session.execute(select(PublicTrip).where(PublicTrip.id == res.trip_id)).scalar_one() 

1286 queue_item = session.execute( 

1287 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == trip.moderation_state_id) 

1288 ).scalar_one() 

1289 assert queue_item.resolved_by_log_id is not None